feat(discovery): bonjour beacons + bridge presence
This commit is contained in:
@@ -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
|
||||
|
||||
34
apps/ios/Sources/Bridge/BonjourEscapeDecoder.swift
Normal file
34
apps/ios/Sources/Bridge/BonjourEscapeDecoder.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,5 +45,6 @@ struct RootCanvas: View {
|
||||
.sheet(isPresented: self.$isShowingSettings) {
|
||||
SettingsTab()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -11,4 +11,3 @@ enum GatewayPush: Sendable {
|
||||
/// A detected sequence gap (`expected...received`) for event frames.
|
||||
case seqGap(expected: Int, received: Int)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -20,4 +20,3 @@ struct MasterDiscoveryMenu: View {
|
||||
.help("Discover Clawdis masters on your LAN")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable {
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift
Normal file
167
apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,4 +17,3 @@ extension View {
|
||||
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable {
|
||||
nsView.isEmphasized = self.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
78
dist/protocol.schema.json
vendored
78
dist/protocol.schema.json
vendored
@@ -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
64
docs/bonjour.md
Normal 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, 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`
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user