feat(discovery): bonjour beacons + bridge presence

This commit is contained in:
Peter Steinberger
2025-12-13 04:28:12 +00:00
parent 3ee0e041fa
commit 1f37d94f9e
49 changed files with 1182 additions and 320 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Void, Never>?
@@ -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 {

View File

@@ -45,5 +45,6 @@ struct RootCanvas: View {
.sheet(isPresented: self.$isShowingSettings) {
SettingsTab()
}
.preferredColorScheme(.dark)
}
}

View File

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

View File

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

View File

@@ -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..<channels {
dst[ch].assign(from: src[ch], count: frames)
}
return copy
}
func append(_ buffer: AVAudioPCMBuffer) {
self.request.append(buffer)
if let src = self.int16ChannelData, let dst = copy.int16ChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].assign(from: src[ch], count: frames)
}
return copy
}
if let src = self.int32ChannelData, let dst = copy.int32ChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].assign(from: src[ch], count: frames)
}
return copy
}
return nil
}
}
@@ -32,6 +78,8 @@ final class VoiceWakeManager: NSObject, ObservableObject {
private var speechRecognizer: SFSpeechRecognizer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var tapQueue: AudioBufferQueue?
private var tapDrainTask: Task<Void, Never>?
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)
}
}
}

View File

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

View File

@@ -1,5 +1,5 @@
import Foundation
import CoreServices
import Foundation
final class CanvasFileWatcher: @unchecked Sendable {
private let url: URL

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Void, Never>?
@@ -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<sockaddr>(OpaquePointer($0)) }), len) != 0 {
if bind(fd, withUnsafePointer(to: &addr) { UnsafePointer<sockaddr>(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<sockaddr>.size)
var len = socklen_t(MemoryLayout<sockaddr>.size)
let client = accept(listenFD, &addr, &len)
if client < 0 {
if errno == EINTR { continue }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,4 +11,3 @@ enum GatewayPush: Sendable {
/// A detected sequence gap (`expected...received`) for event frames.
case seqGap(expected: Int, received: Int)
}

View File

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

View File

@@ -20,4 +20,3 @@ struct MasterDiscoveryMenu: View {
.help("Discover Clawdis masters on your LAN")
}
}

View File

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

View File

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

View File

@@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable {
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
}
}

View File

@@ -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<Void, Never>?
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
}
}

View File

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

View File

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

View File

@@ -17,4 +17,3 @@ extension View {
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
}
}

View File

@@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable {
nsView.isEmphasized = self.emphasized
}
}

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import Testing
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
private let helloDelayMs: Int

View File

@@ -8,7 +8,8 @@ import Testing
private let requestSendDelayMs: Int
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended

View File

@@ -7,7 +7,8 @@ import Testing
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended

View File

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

View File

@@ -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",

64
docs/bonjour.md Normal file
View File

@@ -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=<hostname>.local`
- `sshPort=<port>` (defaults to 22 when not overridden)
- `gatewayPort=<port>` (informational; the Gateway WS is typically loopback-only)
- `bridgePort=<port>` (only when bridge is enabled)
- `tailnetDns=<magicdns>` (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 `<instance>`):
- `dns-sd -L "<instance>" _clawdis-master._tcp local.`
- `dns-sd -L "<instance>" _clawdis-bridge._tcp local.`
If browsing shows instances but resolving fails, youre usually hitting a LAN policy / multicast issue.
## Common failure modes
- **Bonjour doesnt cross networks**: London/Vienna style setups require Tailnet (MagicDNS/IP) or SSH.
- **Multicast blocked**: some WiFi 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`

View File

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

View File

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

View File

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

View File

@@ -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> | void;
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
onPairRequested?: (request: unknown) => Promise<void> | 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");

View File

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

View File

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

View File

@@ -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<void>;
};
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> | void;
onAuthenticated?: (nodeId: string) => Promise<void> | void;
onDisconnected?: (nodeId: string) => Promise<void> | void;
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onPairRequested?: (
request: NodePairingPendingRequest,
) => Promise<void> | 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