feat(discovery): bonjour beacons + bridge presence
This commit is contained in:
@@ -48,4 +48,4 @@
|
|||||||
--allman false
|
--allman false
|
||||||
|
|
||||||
# Exclusions
|
# 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
|
self.bridges = results.compactMap { result -> DiscoveredBridge? in
|
||||||
switch result.endpoint {
|
switch result.endpoint {
|
||||||
case let .service(name, _, _, _):
|
case let .service(name, _, _, _):
|
||||||
|
let decodedName = BonjourEscapeDecoder.decode(name)
|
||||||
return DiscoveredBridge(
|
return DiscoveredBridge(
|
||||||
name: name,
|
name: decodedName,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
||||||
default:
|
default:
|
||||||
@@ -74,35 +75,6 @@ final class BridgeDiscoveryModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
||||||
self.decodeBonjourEscapes(String(describing: endpoint))
|
BonjourEscapeDecoder.decode(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ actor BridgeSession {
|
|||||||
|
|
||||||
private(set) var state: State = .idle
|
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(
|
func connect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ final class NodeAppModel: ObservableObject {
|
|||||||
let screen = ScreenController()
|
let screen = ScreenController()
|
||||||
@Published var bridgeStatusText: String = "Not connected"
|
@Published var bridgeStatusText: String = "Not connected"
|
||||||
@Published var bridgeServerName: String?
|
@Published var bridgeServerName: String?
|
||||||
|
@Published var bridgeRemoteAddress: String?
|
||||||
|
@Published var connectedBridgeDebugID: String?
|
||||||
|
|
||||||
private let bridge = BridgeSession()
|
private let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
@@ -55,6 +57,8 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
self.bridgeStatusText = "Connecting…"
|
self.bridgeStatusText = "Connecting…"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
|
self.bridgeRemoteAddress = nil
|
||||||
|
self.connectedBridgeDebugID = BonjourEscapeDecoder.decode(String(describing: endpoint))
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
do {
|
do {
|
||||||
@@ -71,6 +75,11 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self?.bridgeStatusText = "Connected"
|
self?.bridgeStatusText = "Connected"
|
||||||
self?.bridgeServerName = serverName
|
self?.bridgeServerName = serverName
|
||||||
}
|
}
|
||||||
|
if let addr = await self.bridge.currentRemoteAddress() {
|
||||||
|
await MainActor.run {
|
||||||
|
self?.bridgeRemoteAddress = addr
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@@ -85,11 +94,15 @@ final class NodeAppModel: ObservableObject {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeStatusText = "Disconnected"
|
self.bridgeStatusText = "Disconnected"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
|
self.bridgeRemoteAddress = nil
|
||||||
|
self.connectedBridgeDebugID = nil
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
|
self.bridgeRemoteAddress = nil
|
||||||
|
self.connectedBridgeDebugID = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +114,8 @@ final class NodeAppModel: ObservableObject {
|
|||||||
Task { await self.bridge.disconnect() }
|
Task { await self.bridge.disconnect() }
|
||||||
self.bridgeStatusText = "Disconnected"
|
self.bridgeStatusText = "Disconnected"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
|
self.bridgeRemoteAddress = nil
|
||||||
|
self.connectedBridgeDebugID = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||||
|
|||||||
@@ -45,5 +45,6 @@ struct RootCanvas: View {
|
|||||||
.sheet(isPresented: self.$isShowingSettings) {
|
.sheet(isPresented: self.$isShowingSettings) {
|
||||||
SettingsTab()
|
SettingsTab()
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ final class ScreenController: ObservableObject {
|
|||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = .nonPersistent()
|
config.websiteDataStore = .nonPersistent()
|
||||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
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.contentInsetAdjustmentBehavior = .never
|
||||||
self.webView.scrollView.contentInset = .zero
|
self.webView.scrollView.contentInset = .zero
|
||||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ struct SettingsTab: View {
|
|||||||
@State private var connectStatus: String?
|
@State private var connectStatus: String?
|
||||||
@State private var isConnecting = false
|
@State private var isConnecting = false
|
||||||
@State private var didAutoConnect = false
|
@State private var didAutoConnect = false
|
||||||
@State private var isShowingBridgeList = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -34,16 +33,17 @@ struct SettingsTab: View {
|
|||||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||||
if let serverName = self.appModel.bridgeServerName {
|
if let serverName = self.appModel.bridgeServerName {
|
||||||
LabeledContent("Server", value: serverName)
|
LabeledContent("Server", value: serverName)
|
||||||
|
if let addr = self.appModel.bridgeRemoteAddress {
|
||||||
|
LabeledContent("Address", value: addr)
|
||||||
|
}
|
||||||
|
|
||||||
Button("Disconnect", role: .destructive) {
|
Button("Disconnect", role: .destructive) {
|
||||||
self.appModel.disconnectBridge()
|
self.appModel.disconnectBridge()
|
||||||
}
|
}
|
||||||
|
|
||||||
DisclosureGroup("Switch bridge", isExpanded: self.$isShowingBridgeList) {
|
self.bridgeList(showing: .availableOnly)
|
||||||
self.bridgeList(showConnectedRow: true)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.bridgeList(showConnectedRow: false)
|
self.bridgeList(showing: .all)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let connectStatus {
|
if let connectStatus {
|
||||||
@@ -88,22 +88,32 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||||
self.connectStatus = nil
|
self.connectStatus = nil
|
||||||
self.isShowingBridgeList = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func bridgeList(showConnectedRow: Bool) -> some View {
|
private func bridgeList(showing: BridgeListMode) -> some View {
|
||||||
if self.discovery.bridges.isEmpty {
|
if self.discovery.bridges.isEmpty {
|
||||||
Text("No bridges found yet.")
|
Text("No bridges found yet.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(self.discovery.bridges) { bridge in
|
let connectedID = self.appModel.connectedBridgeDebugID
|
||||||
let isConnected = self.isConnectedBridge(bridge)
|
let rows = self.discovery.bridges.filter { bridge in
|
||||||
if isConnected, !showConnectedRow {
|
let isConnected = bridge.debugID == connectedID
|
||||||
EmptyView()
|
switch showing {
|
||||||
} else {
|
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 {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(bridge.name)
|
Text(bridge.name)
|
||||||
@@ -114,29 +124,19 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if isConnected {
|
Button(self.isConnecting ? "…" : "Connect") {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Task { await self.connect(bridge) }
|
||||||
.foregroundStyle(.green)
|
|
||||||
.accessibilityLabel("Connected")
|
|
||||||
} else {
|
|
||||||
Button(self.isConnecting ? "…" : "Connect") {
|
|
||||||
Task { await self.connect(bridge) }
|
|
||||||
}
|
|
||||||
.disabled(self.isConnecting)
|
|
||||||
}
|
}
|
||||||
|
.disabled(self.isConnecting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isConnectedBridge(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> Bool {
|
private enum BridgeListMode: Equatable {
|
||||||
guard let serverName = self.appModel.bridgeServerName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
case all
|
||||||
!serverName.isEmpty
|
case availableOnly
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return bridge.name.localizedCaseInsensitiveContains(serverName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func keychainAccount() -> String {
|
private func keychainAccount() -> String {
|
||||||
|
|||||||
@@ -2,23 +2,69 @@ import AVFAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Speech
|
import Speech
|
||||||
|
|
||||||
enum SpeechAudioTapFactory {
|
private final class AudioBufferQueue: @unchecked Sendable {
|
||||||
static func makeAppendTap(requestBox: SpeechRequestBox) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
private let lock = NSLock()
|
||||||
{ buffer, _ in
|
private var buffers: [AVAudioPCMBuffer] = []
|
||||||
requestBox.append(buffer)
|
|
||||||
}
|
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 {
|
private extension AVAudioPCMBuffer {
|
||||||
let request: SFSpeechAudioBufferRecognitionRequest
|
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) {
|
if let src = self.floatChannelData, let dst = copy.floatChannelData {
|
||||||
self.request = request
|
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) {
|
if let src = self.int16ChannelData, let dst = copy.int16ChannelData {
|
||||||
self.request.append(buffer)
|
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 speechRecognizer: SFSpeechRecognizer?
|
||||||
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
private var recognitionTask: SFSpeechRecognitionTask?
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
|
private var tapQueue: AudioBufferQueue?
|
||||||
|
private var tapDrainTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var lastDispatched: String?
|
private var lastDispatched: String?
|
||||||
private var onCommand: (@Sendable (String) async -> Void)?
|
private var onCommand: (@Sendable (String) async -> Void)?
|
||||||
@@ -92,6 +140,11 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
self.isListening = false
|
self.isListening = false
|
||||||
self.statusText = "Off"
|
self.statusText = "Off"
|
||||||
|
|
||||||
|
self.tapDrainTask?.cancel()
|
||||||
|
self.tapDrainTask = nil
|
||||||
|
self.tapQueue?.clear()
|
||||||
|
self.tapQueue = nil
|
||||||
|
|
||||||
self.recognitionTask?.cancel()
|
self.recognitionTask?.cancel()
|
||||||
self.recognitionTask = nil
|
self.recognitionTask = nil
|
||||||
self.recognitionRequest = nil
|
self.recognitionRequest = nil
|
||||||
@@ -107,6 +160,10 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
private func startRecognition() throws {
|
private func startRecognition() throws {
|
||||||
self.recognitionTask?.cancel()
|
self.recognitionTask?.cancel()
|
||||||
self.recognitionTask = nil
|
self.recognitionTask = nil
|
||||||
|
self.tapDrainTask?.cancel()
|
||||||
|
self.tapDrainTask = nil
|
||||||
|
self.tapQueue?.clear()
|
||||||
|
self.tapQueue = nil
|
||||||
|
|
||||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||||
request.shouldReportPartialResults = true
|
request.shouldReportPartialResults = true
|
||||||
@@ -115,16 +172,33 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
let inputNode = self.audioEngine.inputNode
|
let inputNode = self.audioEngine.inputNode
|
||||||
inputNode.removeTap(onBus: 0)
|
inputNode.removeTap(onBus: 0)
|
||||||
|
|
||||||
let requestBox = SpeechRequestBox(request: request)
|
|
||||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
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()
|
self.audioEngine.prepare()
|
||||||
try self.audioEngine.start()
|
try self.audioEngine.start()
|
||||||
|
|
||||||
let handler = self.makeRecognitionResultHandler()
|
let handler = self.makeRecognitionResultHandler()
|
||||||
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
|
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 {
|
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||||
@@ -195,21 +269,17 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||||
await withCheckedContinuation { cont in
|
await withCheckedContinuation(isolation: nil) { cont in
|
||||||
AVAudioApplication.requestRecordPermission { ok in
|
AVAudioApplication.requestRecordPermission { ok in
|
||||||
Task { @MainActor in
|
cont.resume(returning: ok)
|
||||||
cont.resume(returning: ok)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||||
await withCheckedContinuation { cont in
|
await withCheckedContinuation(isolation: nil) { cont in
|
||||||
SFSpeechRecognizer.requestAuthorization { status 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 {
|
@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 {
|
@Published var webChatPort: Int {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
|
||||||
import CoreServices
|
import CoreServices
|
||||||
|
import Foundation
|
||||||
|
|
||||||
final class CanvasFileWatcher: @unchecked Sendable {
|
final class CanvasFileWatcher: @unchecked Sendable {
|
||||||
private let url: URL
|
private let url: URL
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
|
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
|
||||||
return CanvasResponse(mime: mime, data: data)
|
return CanvasResponse(mime: mime, data: data)
|
||||||
} catch {
|
} 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")
|
return self.html("Failed to read file.", title: "Canvas error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import AppKit
|
|||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import WebKit
|
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
import WebKit
|
||||||
|
|
||||||
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
|
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) {
|
func showCanvas(path: String? = nil) {
|
||||||
if case .panel(let anchorProvider) = self.presentation {
|
if case let .panel(anchorProvider) = self.presentation {
|
||||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
if let path {
|
if let path {
|
||||||
self.goto(path: path)
|
self.goto(path: path)
|
||||||
@@ -131,14 +131,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
func goto(path: String) {
|
func goto(path: String) {
|
||||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
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)")
|
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
|
||||||
self.webView.load(URLRequest(url: url))
|
self.webView.load(URLRequest(url: url))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: trimmed) else {
|
guard let url = CanvasScheme.makeURL(
|
||||||
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
||||||
|
path: trimmed)
|
||||||
|
else {
|
||||||
|
canvasWindowLogger
|
||||||
|
.error(
|
||||||
|
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
|
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
|
||||||
@@ -257,11 +264,15 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
let anchor = anchorProvider()
|
let anchor = anchorProvider()
|
||||||
let screen = NSScreen.screens.first { screen in
|
let screen = NSScreen.screens.first { screen in
|
||||||
guard let anchor else { return false }
|
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
|
} ?? NSScreen.main
|
||||||
|
|
||||||
// Base frame: restored frame (preferred), otherwise default top-right.
|
// 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:
|
// Apply agent placement as partial overrides:
|
||||||
// - If agent provides x/y, override origin.
|
// - 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?) {
|
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
||||||
guard let panel = self.window else { return }
|
guard let panel = self.window else { return }
|
||||||
let s = screen ?? panel.screen ?? NSScreen.main
|
let s = screen ?? panel.screen ?? NSScreen.main
|
||||||
let constrained: NSRect
|
let constrained: NSRect = if let s {
|
||||||
if let s {
|
panel.constrainFrameRect(frame, to: s)
|
||||||
constrained = panel.constrainFrameRect(frame, to: s)
|
|
||||||
} else {
|
} else {
|
||||||
constrained = frame
|
frame
|
||||||
}
|
}
|
||||||
panel.setFrame(constrained, display: false)
|
panel.setFrame(constrained, display: false)
|
||||||
self.persistFrameIfPanel()
|
self.persistFrameIfPanel()
|
||||||
@@ -371,11 +381,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
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? {
|
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 }
|
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])
|
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 }
|
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) {
|
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
|
||||||
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
|
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)
|
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)
|
userInfo: nil)
|
||||||
self.addTrackingArea(area)
|
self.addTrackingArea(area)
|
||||||
self.tracking = 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 CanvasDragHandleView: NSView {
|
||||||
}
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
self.window?.performDrag(with: event)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
window.setFrame(frame, display: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class CanvasChromeOverlayView: NSView {
|
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||||
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)
|
private final class CanvasResizeHandleView: NSView {
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
private var startPoint: NSPoint = .zero
|
||||||
|
private var startFrame: NSRect = .zero
|
||||||
|
|
||||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||||
// 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 }
|
override func mouseDown(with event: NSEvent) {
|
||||||
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
guard let window else { return }
|
||||||
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
|
_ = window.makeFirstResponder(self)
|
||||||
return nil
|
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() {
|
private final class CanvasChromeOverlayView: NSView {
|
||||||
self.onClose?()
|
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) {
|
override func mouseEntered(with _: NSEvent) {
|
||||||
NSAnimationContext.runAnimationGroup { ctx in
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ struct ContextMenuCardView: View {
|
|||||||
init(
|
init(
|
||||||
rows: [SessionRow],
|
rows: [SessionRow],
|
||||||
statusText: String? = nil,
|
statusText: String? = nil,
|
||||||
isLoading: Bool = false
|
isLoading: Bool = false)
|
||||||
) {
|
{
|
||||||
self.rows = rows
|
self.rows = rows
|
||||||
self.statusText = statusText
|
self.statusText = statusText
|
||||||
self.isLoading = isLoading
|
self.isLoading = isLoading
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ final class ControlChannel: ObservableObject {
|
|||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
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
|
self.state = .connected
|
||||||
return data
|
return data
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Foundation
|
|
||||||
import Darwin
|
import Darwin
|
||||||
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
|
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
|
||||||
/// without a launchd MachService. Listens on `controlSocketPath`.
|
/// without a launchd MachService. Listens on `controlSocketPath`.
|
||||||
final actor ControlSocketServer {
|
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 listenFD: Int32 = -1
|
||||||
private var acceptTask: Task<Void, Never>?
|
private var acceptTask: Task<Void, Never>?
|
||||||
@@ -60,7 +60,7 @@ final actor ControlSocketServer {
|
|||||||
}
|
}
|
||||||
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
|
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
|
||||||
let len = socklen_t(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)
|
close(fd)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ final actor ControlSocketServer {
|
|||||||
{
|
{
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
var addr = sockaddr()
|
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)
|
let client = accept(listenFD, &addr, &len)
|
||||||
if client < 0 {
|
if client < 0 {
|
||||||
if errno == EINTR { continue }
|
if errno == EINTR { continue }
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ final class CronJobsStore: ObservableObject {
|
|||||||
_ = try await self.request(
|
_ = try await self.request(
|
||||||
method: "cron.run",
|
method: "cron.run",
|
||||||
params: ["id": id, "mode": force ? "force" : "due"],
|
params: ["id": id, "mode": force ? "force" : "due"],
|
||||||
timeoutMs: 20_000)
|
timeoutMs: 20000)
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
self.lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ enum CronSchedule: Codable, Equatable {
|
|||||||
let kind = try container.decode(String.self, forKey: .kind)
|
let kind = try container.decode(String.self, forKey: .kind)
|
||||||
switch kind {
|
switch kind {
|
||||||
case "at":
|
case "at":
|
||||||
self = .at(atMs: try container.decode(Int.self, forKey: .atMs))
|
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
|
||||||
case "every":
|
case "every":
|
||||||
self = .every(
|
self = try .every(
|
||||||
everyMs: try container.decode(Int.self, forKey: .everyMs),
|
everyMs: container.decode(Int.self, forKey: .everyMs),
|
||||||
anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs))
|
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
|
||||||
case "cron":
|
case "cron":
|
||||||
self = .cron(
|
self = try .cron(
|
||||||
expr: try container.decode(String.self, forKey: .expr),
|
expr: container.decode(String.self, forKey: .expr),
|
||||||
tz: try container.decodeIfPresent(String.self, forKey: .tz))
|
tz: container.decodeIfPresent(String.self, forKey: .tz))
|
||||||
default:
|
default:
|
||||||
throw DecodingError.dataCorruptedError(
|
throw DecodingError.dataCorruptedError(
|
||||||
forKey: .kind,
|
forKey: .kind,
|
||||||
@@ -94,16 +94,16 @@ enum CronPayload: Codable, Equatable {
|
|||||||
let kind = try container.decode(String.self, forKey: .kind)
|
let kind = try container.decode(String.self, forKey: .kind)
|
||||||
switch kind {
|
switch kind {
|
||||||
case "systemEvent":
|
case "systemEvent":
|
||||||
self = .systemEvent(text: try container.decode(String.self, forKey: .text))
|
self = try .systemEvent(text: container.decode(String.self, forKey: .text))
|
||||||
case "agentTurn":
|
case "agentTurn":
|
||||||
self = .agentTurn(
|
self = try .agentTurn(
|
||||||
message: try container.decode(String.self, forKey: .message),
|
message: container.decode(String.self, forKey: .message),
|
||||||
thinking: try container.decodeIfPresent(String.self, forKey: .thinking),
|
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||||
timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||||
deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver),
|
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||||
channel: try container.decodeIfPresent(String.self, forKey: .channel),
|
channel: container.decodeIfPresent(String.self, forKey: .channel),
|
||||||
to: try container.decodeIfPresent(String.self, forKey: .to),
|
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||||
bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||||
default:
|
default:
|
||||||
throw DecodingError.dataCorruptedError(
|
throw DecodingError.dataCorruptedError(
|
||||||
forKey: .kind,
|
forKey: .kind,
|
||||||
@@ -209,4 +209,3 @@ struct CronListResponse: Codable {
|
|||||||
struct CronRunsResponse: Codable {
|
struct CronRunsResponse: Codable {
|
||||||
let entries: [CronRunLogEntry]
|
let entries: [CronRunLogEntry]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ struct CronSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||||
guard let newValue else { return }
|
guard let newValue else { return }
|
||||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var schedulerBanner: some View {
|
private var schedulerBanner: some View {
|
||||||
@@ -69,7 +69,8 @@ struct CronSettings: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer()
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -246,8 +247,8 @@ struct CronSettings: View {
|
|||||||
Toggle("Enabled", isOn: Binding(
|
Toggle("Enabled", isOn: Binding(
|
||||||
get: { job.enabled },
|
get: { job.enabled },
|
||||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
Button("Edit") {
|
Button("Edit") {
|
||||||
@@ -398,7 +399,7 @@ struct CronSettings: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||||
if (deliver ?? false) {
|
if deliver ?? false {
|
||||||
StatusPill(text: "deliver", tint: .secondary)
|
StatusPill(text: "deliver", tint: .secondary)
|
||||||
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||||
if let to, !to.isEmpty { StatusPill(text: to, 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 } }
|
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||||
@State private var scheduleKind: ScheduleKind = .every
|
@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 everyText: String = "1h"
|
||||||
@State private var cronExpr: String = "0 9 * * 3"
|
@State private var cronExpr: String = "0 9 * * 3"
|
||||||
@State private var cronTz: String = ""
|
@State private var cronTz: String = ""
|
||||||
@@ -696,7 +697,10 @@ private struct CronJobEditor: View {
|
|||||||
case .cron:
|
case .cron:
|
||||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if expr.isEmpty {
|
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)
|
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if tz.isEmpty {
|
if tz.isEmpty {
|
||||||
@@ -719,11 +723,17 @@ private struct CronJobEditor: View {
|
|||||||
|
|
||||||
if payload["kind"] as? String == "systemEvent" {
|
if payload["kind"] as? String == "systemEvent" {
|
||||||
if (payload["text"] as? String ?? "").isEmpty {
|
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" {
|
} else if payload["kind"] as? String == "agentTurn" {
|
||||||
if (payload["message"] as? String ?? "").isEmpty {
|
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 {
|
if self.postToMain {
|
||||||
root["isolation"] = [
|
root["isolation"] = [
|
||||||
"postToMain": true,
|
"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 {
|
} else if self.job != nil {
|
||||||
// Allow clearing isolation on edit.
|
// Allow clearing isolation on edit.
|
||||||
@@ -786,7 +797,7 @@ private struct CronJobEditor: View {
|
|||||||
let factor: Double = switch unit {
|
let factor: Double = switch unit {
|
||||||
case "ms": 1
|
case "ms": 1
|
||||||
case "s": 1000
|
case "s": 1000
|
||||||
case "m": 60_000
|
case "m": 60000
|
||||||
case "h": 3_600_000
|
case "h": 3_600_000
|
||||||
default: 86_400_000
|
default: 86_400_000
|
||||||
}
|
}
|
||||||
@@ -829,11 +840,25 @@ struct CronSettings_Previews: PreviewProvider {
|
|||||||
to: nil,
|
to: nil,
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"),
|
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.selectedJobId = "job-1"
|
||||||
store.runEntries = [
|
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)
|
return CronSettings(store: store)
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
|
|||||||
@@ -104,12 +104,18 @@ actor GatewayChannelActor {
|
|||||||
self.task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
self.task = 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
|
let waiters = self.connectWaiters
|
||||||
self.connectWaiters.removeAll()
|
self.connectWaiters.removeAll()
|
||||||
for waiter in waiters {
|
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.watchTicks()
|
||||||
}
|
}
|
||||||
await self.pushHandler?(.snapshot(ok))
|
await self.pushHandler?(.snapshot(ok))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func listen() {
|
private func listen() {
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ enum GatewayPayloadDecoding {
|
|||||||
-> T?
|
-> T?
|
||||||
{
|
{
|
||||||
guard let payload else { return nil }
|
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.
|
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
|
||||||
extension HelloOk: @unchecked Sendable {}
|
extension HelloOk: @unchecked Sendable {}
|
||||||
extension EventFrame: @unchecked Sendable {}
|
extension EventFrame: @unchecked Sendable {}
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ enum GatewayPush: Sendable {
|
|||||||
/// A detected sequence gap (`expected...received`) for event frames.
|
/// A detected sequence gap (`expected...received`) for event frames.
|
||||||
case seqGap(expected: Int, received: Int)
|
case seqGap(expected: Int, received: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum InstanceIdentity {
|
|||||||
private static var defaults: UserDefaults {
|
private static var defaults: UserDefaults {
|
||||||
UserDefaults(suiteName: suiteName) ?? .standard
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
}
|
}
|
||||||
|
|
||||||
static let instanceId: String = {
|
static let instanceId: String = {
|
||||||
let defaults = Self.defaults
|
let defaults = Self.defaults
|
||||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||||
|
|||||||
@@ -20,4 +20,3 @@ struct MasterDiscoveryMenu: View {
|
|||||||
.help("Discover Clawdis masters on your LAN")
|
.help("Discover Clawdis masters on your LAN")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
if let state {
|
if let state {
|
||||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||||
}
|
}
|
||||||
|
NodePairingApprovalPrompter.shared.start()
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
@@ -194,6 +195,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
WebChatManager.shared.close()
|
WebChatManager.shared.close()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
let hosting = NSHostingView(rootView: initial)
|
let hosting = NSHostingView(rootView: initial)
|
||||||
let size = hosting.fittingSize
|
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()
|
let item = NSMenuItem()
|
||||||
item.tag = self.tag
|
item.tag = self.tag
|
||||||
|
|||||||
@@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable {
|
|||||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
|
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)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.fill(.regularMaterial)
|
.fill(.regularMaterial))
|
||||||
)
|
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1)
|
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||||
)
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
self.controller.dismiss()
|
self.controller.dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ struct SessionTokenStats {
|
|||||||
static func formatKTokens(_ value: Int) -> String {
|
static func formatKTokens(_ value: Int) -> String {
|
||||||
if value < 1000 { return "\(value)" }
|
if value < 1000 { return "\(value)" }
|
||||||
let thousands = Double(value) / 1000
|
let thousands = Double(value) / 1000
|
||||||
let decimals = value >= 10_000 ? 0 : 1
|
let decimals = value >= 10000 ? 0 : 1
|
||||||
return String(format: "%.\(decimals)fk", thousands)
|
return String(format: "%.\(decimals)fk", thousands)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,9 @@ enum SessionLoader {
|
|||||||
let input = entry.inputTokens ?? 0
|
let input = entry.inputTokens ?? 0
|
||||||
let output = entry.outputTokens ?? 0
|
let output = entry.outputTokens ?? 0
|
||||||
let fallbackTotal = entry.totalTokens ?? input + output
|
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 total = max(fallbackTotal, promptTokens ?? 0)
|
||||||
let context = entry.contextTokens ?? defaults.contextTokens
|
let context = entry.contextTokens ?? defaults.contextTokens
|
||||||
let model = entry.model ?? defaults.model
|
let model = entry.model ?? defaults.model
|
||||||
|
|||||||
@@ -17,4 +17,3 @@ extension View {
|
|||||||
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
|
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable {
|
|||||||
nsView.isEmphasized = self.emphasized
|
nsView.isEmphasized = self.emphasized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ final class WebChatViewModel: ObservableObject {
|
|||||||
text: trimmed,
|
text: trimmed,
|
||||||
mimeType: nil,
|
mimeType: nil,
|
||||||
fileName: nil,
|
fileName: nil,
|
||||||
content: nil)
|
content: nil),
|
||||||
],
|
],
|
||||||
timestamp: Date().timeIntervalSince1970 * 1000)
|
timestamp: Date().timeIntervalSince1970 * 1000)
|
||||||
self.messages.append(userMessage)
|
self.messages.append(userMessage)
|
||||||
@@ -176,7 +176,7 @@ final class WebChatViewModel: ObservableObject {
|
|||||||
"type": att.type,
|
"type": att.type,
|
||||||
"mimeType": att.mimeType,
|
"mimeType": att.mimeType,
|
||||||
"fileName": att.fileName,
|
"fileName": att.fileName,
|
||||||
"content": att.data.base64EncodedString()
|
"content": att.data.base64EncodedString(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ final class WebChatViewModel: ObservableObject {
|
|||||||
"attachments": AnyCodable(attachmentsPayload as Any),
|
"attachments": AnyCodable(attachmentsPayload as Any),
|
||||||
"thinking": AnyCodable(self.thinkingLevel),
|
"thinking": AnyCodable(self.thinkingLevel),
|
||||||
"idempotencyKey": AnyCodable(runId),
|
"idempotencyKey": AnyCodable(runId),
|
||||||
"timeoutMs": AnyCodable(30_000)
|
"timeoutMs": AnyCodable(30000),
|
||||||
]
|
]
|
||||||
let data = try await GatewayConnection.shared.request(method: "chat.send", params: params)
|
let data = try await GatewayConnection.shared.request(method: "chat.send", params: params)
|
||||||
let response = try JSONDecoder().decode(ChatSendResponse.self, from: data)
|
let response = try JSONDecoder().decode(ChatSendResponse.self, from: data)
|
||||||
@@ -250,9 +250,9 @@ struct WebChatView: View {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
header
|
self.header
|
||||||
messageList
|
self.messageList
|
||||||
composer
|
self.composer
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
@@ -262,15 +262,14 @@ struct WebChatView: View {
|
|||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(red: 0.96, green: 0.97, blue: 1.0),
|
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,
|
startPoint: .top,
|
||||||
endPoint: .bottom)
|
endPoint: .bottom)
|
||||||
.opacity(0.35)
|
.opacity(0.35)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea())
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { self.viewModel.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
@@ -278,7 +277,8 @@ struct WebChatView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Clawd Web Chat")
|
Text("Clawd Web Chat")
|
||||||
.font(.title2.weight(.semibold))
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -295,8 +295,7 @@ struct WebChatView: View {
|
|||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.fill(Color(nsColor: .textBackgroundColor))
|
.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 {
|
private var messageList: some View {
|
||||||
@@ -311,14 +310,13 @@ struct WebChatView: View {
|
|||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.fill(Color(nsColor: .textBackgroundColor))
|
.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 {
|
private var composer: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
thinkingPicker
|
self.thinkingPicker
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
self.pickFiles()
|
self.pickFiles()
|
||||||
@@ -355,16 +353,14 @@ struct WebChatView: View {
|
|||||||
.strokeBorder(Color.secondary.opacity(0.2))
|
.strokeBorder(Color.secondary.opacity(0.2))
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.fill(Color(nsColor: .textBackgroundColor))
|
.fill(Color(nsColor: .textBackgroundColor)))
|
||||||
)
|
|
||||||
.overlay(
|
.overlay(
|
||||||
TextEditor(text: self.$viewModel.input)
|
TextEditor(text: self.$viewModel.input)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
.frame(minHeight: 96, maxHeight: 168)
|
.frame(minHeight: 96, maxHeight: 168)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8))
|
||||||
)
|
|
||||||
.frame(maxHeight: 180)
|
.frame(maxHeight: 180)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
@@ -388,8 +384,7 @@ struct WebChatView: View {
|
|||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.fill(Color(nsColor: .textBackgroundColor))
|
.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
|
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||||
self.handleDrop(providers)
|
self.handleDrop(providers)
|
||||||
}
|
}
|
||||||
@@ -471,8 +466,7 @@ private struct MessageBubble: View {
|
|||||||
.background(self.isUser ? Color.accentColor.opacity(0.12) : Color(nsColor: .textBackgroundColor))
|
.background(self.isUser ? Color.accentColor.opacity(0.12) : Color(nsColor: .textBackgroundColor))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
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))
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
@@ -482,7 +476,7 @@ private struct MessageBubble: View {
|
|||||||
|
|
||||||
private var primaryText: String? {
|
private var primaryText: String? {
|
||||||
self.message.content?
|
self.message.content?
|
||||||
.compactMap { $0.text }
|
.compactMap(\.text)
|
||||||
.joined(separator: "\n")
|
.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +502,7 @@ final class WebChatSwiftUIWindowController {
|
|||||||
self.presentation = presentation
|
self.presentation = presentation
|
||||||
let vm = WebChatViewModel(sessionKey: sessionKey)
|
let vm = WebChatViewModel(sessionKey: sessionKey)
|
||||||
self.hosting = NSHostingController(rootView: WebChatView(viewModel: vm))
|
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 {}
|
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 {
|
switch presentation {
|
||||||
case .window:
|
case .window:
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
|
|||||||
@@ -522,10 +522,10 @@ struct ClawdisCLI {
|
|||||||
switch request {
|
switch request {
|
||||||
case let .runShell(_, _, _, timeoutSec, _):
|
case let .runShell(_, _, _, timeoutSec, _):
|
||||||
// Allow longer for commands; still cap overall to a sane bound.
|
// 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:
|
default:
|
||||||
// Fail-fast so callers (incl. SSH tool calls) don't hang forever.
|
// 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 struct CronJob: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import Testing
|
|||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
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 cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
private let helloDelayMs: Int
|
private let helloDelayMs: Int
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import Testing
|
|||||||
private let requestSendDelayMs: Int
|
private let requestSendDelayMs: Int
|
||||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
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)
|
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
|
|
||||||
var state: URLSessionTask.State = .suspended
|
var state: URLSessionTask.State = .suspended
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import Testing
|
|||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
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 cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
|
|
||||||
var state: URLSessionTask.State = .suspended
|
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(-45), now: now) == "just now")
|
||||||
#expect(age(from: now.addingTimeInterval(-75), now: now) == "1 minute ago")
|
#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(-10 * 60), now: now) == "10m ago")
|
||||||
#expect(age(from: now.addingTimeInterval(-3_600), now: now) == "1 hour ago")
|
#expect(age(from: now.addingTimeInterval(-3600), now: now) == "1 hour ago")
|
||||||
#expect(age(from: now.addingTimeInterval(-5 * 3_600), now: now) == "5h ago")
|
#expect(age(from: now.addingTimeInterval(-5 * 3600), now: now) == "5h ago")
|
||||||
#expect(age(from: now.addingTimeInterval(-26 * 3_600), now: now) == "yesterday")
|
#expect(age(from: now.addingTimeInterval(-26 * 3600), now: now) == "yesterday")
|
||||||
#expect(age(from: now.addingTimeInterval(-3 * 86_400), now: now) == "3d ago")
|
#expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseSSHTargetSupportsUserPortAndDefaults() {
|
@Test func parseSSHTargetSupportsUserPortAndDefaults() {
|
||||||
|
|||||||
78
dist/protocol.schema.json
vendored
78
dist/protocol.schema.json
vendored
@@ -828,6 +828,84 @@
|
|||||||
"text"
|
"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": {
|
"CronJob": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"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.
|
- The **gateway** advertises itself (and/or its bridge) via Bonjour.
|
||||||
- Clients browse and show a “pick a master” list, then store the chosen endpoint.
|
- Clients browse and show a “pick a master” list, then store the chosen endpoint.
|
||||||
|
|
||||||
|
Troubleshooting and beacon details: `docs/bonjour.md`.
|
||||||
|
|
||||||
#### Current implementation
|
#### Current implementation
|
||||||
|
|
||||||
- Service types:
|
- Service types:
|
||||||
@@ -59,6 +61,8 @@ Disable/override:
|
|||||||
- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising.
|
- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising.
|
||||||
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
|
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
|
||||||
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bind/port.
|
- `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)
|
### 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 bridge is transport only; it forwards/scopes requests and enforces ACLs, but pairing decisions are made by the gateway.
|
||||||
|
|
||||||
The macOS UI (Swift) can:
|
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.
|
- Or ignore/dismiss (“Later”) and let CLI handle it.
|
||||||
|
|
||||||
## Implementation note
|
## Implementation note
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# iOS Node (internal) — Voice Trigger + Screen/Canvas
|
# 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
|
## Goals
|
||||||
- Build an **iOS app** that acts as a **remote node** for Clawdis:
|
- Build an **iOS app** that acts as a **remote node** for Clawdis:
|
||||||
@@ -43,8 +43,8 @@ Why:
|
|||||||
|
|
||||||
## Security plan (internal, but still robust)
|
## Security plan (internal, but still robust)
|
||||||
### Transport
|
### Transport
|
||||||
- Bridge listens on LAN and uses **TLS**.
|
- **Current (v0):** bridge is a LAN-facing **TCP** listener with token-based auth after pairing.
|
||||||
- Prefer **mutual authentication** (mTLS-like) or explicit public key pinning after pairing.
|
- **Next:** wrap the bridge in **TLS** and prefer key-pinned or mTLS-like auth after pairing.
|
||||||
|
|
||||||
### Pairing
|
### Pairing
|
||||||
- Bonjour discovery shows a candidate “Clawdis Bridge” on the LAN.
|
- Bonjour discovery shows a candidate “Clawdis Bridge” on the LAN.
|
||||||
@@ -53,7 +53,7 @@ Why:
|
|||||||
2) iOS connects to the bridge and requests pairing.
|
2) iOS connects to the bridge and requests pairing.
|
||||||
3) The bridge forwards the pairing request to the **Gateway** as a *pending request*.
|
3) The bridge forwards the pairing request to the **Gateway** as a *pending request*.
|
||||||
4) Approval can happen via:
|
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).
|
- **Terminal/CLI** (headless flows).
|
||||||
5) Once approved, the bridge returns a token to iOS; iOS stores it in Keychain.
|
5) Once approved, the bridge returns a token to iOS; iOS stores it in Keychain.
|
||||||
- Subsequent connections:
|
- Subsequent connections:
|
||||||
@@ -134,14 +134,13 @@ When iOS is backgrounded:
|
|||||||
|
|
||||||
## iOS app architecture (SwiftUI)
|
## iOS app architecture (SwiftUI)
|
||||||
### App structure
|
### App structure
|
||||||
- Tab bar:
|
- Single fullscreen Canvas surface (WKWebView).
|
||||||
- **Canvas/Screen** (WKWebView + overlay chrome)
|
- One settings entry point: a **gear button** that opens a settings sheet.
|
||||||
- **Voice** (status + last transcript + test)
|
- All navigation/mode selection is **agent-driven** (no local URL bar).
|
||||||
- **Settings** (node name, voice wake toggle, pairing state, debug)
|
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
- `BridgeDiscovery`: Bonjour browse + resolve (Network.framework `NWBrowser`)
|
- `BridgeDiscovery`: Bonjour browse + resolve (Network.framework `NWBrowser`)
|
||||||
- `BridgeConnection`: TLS session + pairing handshake + reconnect
|
- `BridgeConnection`: TCP session + pairing handshake + reconnect (TLS planned)
|
||||||
- `NodeRuntime`:
|
- `NodeRuntime`:
|
||||||
- Voice pipeline (wake-word + capture + forward)
|
- Voice pipeline (wake-word + capture + forward)
|
||||||
- Screen pipeline (WKWebView controller + snapshot + eval)
|
- 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 { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { startGatewayServer } from "./server.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 testSessionStorePath: string | undefined;
|
||||||
let testAllowFrom: string[] | undefined;
|
let testAllowFrom: string[] | undefined;
|
||||||
let testCronStorePath: 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 () => {
|
test("supports cron.add and cron.list", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
|
||||||
testCronStorePath = path.join(dir, "cron.json");
|
testCronStorePath = path.join(dir, "cron.json");
|
||||||
|
|||||||
@@ -666,7 +666,66 @@ export async function startGatewayServer(
|
|||||||
const started = await startNodeBridgeServer({
|
const started = await startNodeBridgeServer({
|
||||||
host: bridgeHost,
|
host: bridgeHost,
|
||||||
port: bridgePort,
|
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,
|
onEvent: handleBridgeEvent,
|
||||||
|
onPairRequested: (request) => {
|
||||||
|
broadcast("node.pair.requested", request, { dropIfSlow: true });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (started.port > 0) {
|
if (started.port > 0) {
|
||||||
bridge = started;
|
bridge = started;
|
||||||
@@ -680,9 +739,22 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const bonjour = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: port,
|
gatewayPort: port,
|
||||||
bridgePort: bridge?.port,
|
bridgePort: bridge?.port,
|
||||||
|
sshPort,
|
||||||
|
tailnetDns,
|
||||||
});
|
});
|
||||||
bonjourStop = bonjour.stop;
|
bonjourStop = bonjour.stop;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -126,4 +126,131 @@ describe("node bridge server", () => {
|
|||||||
|
|
||||||
await server.close();
|
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 {
|
import {
|
||||||
getPairedNode,
|
getPairedNode,
|
||||||
listNodePairing,
|
listNodePairing,
|
||||||
|
type NodePairingPendingRequest,
|
||||||
requestNodePairing,
|
requestNodePairing,
|
||||||
verifyNodeToken,
|
verifyNodeToken,
|
||||||
} from "../node-pairing.js";
|
} from "../node-pairing.js";
|
||||||
@@ -64,13 +65,24 @@ export type NodeBridgeServer = {
|
|||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NodeBridgeClientInfo = {
|
||||||
|
nodeId: string;
|
||||||
|
displayName?: string;
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
remoteIp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type NodeBridgeServerOpts = {
|
export type NodeBridgeServerOpts = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number; // 0 = ephemeral
|
port: number; // 0 = ephemeral
|
||||||
pairingBaseDir?: string;
|
pairingBaseDir?: string;
|
||||||
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
|
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
|
||||||
onAuthenticated?: (nodeId: string) => Promise<void> | void;
|
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||||
onDisconnected?: (nodeId: string) => Promise<void> | void;
|
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||||
|
onPairRequested?: (
|
||||||
|
request: NodePairingPendingRequest,
|
||||||
|
) => Promise<void> | void;
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,6 +121,7 @@ export async function startNodeBridgeServer(
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
let isAuthenticated = false;
|
let isAuthenticated = false;
|
||||||
let nodeId: string | null = null;
|
let nodeId: string | null = null;
|
||||||
|
let nodeInfo: NodeBridgeClientInfo | null = null;
|
||||||
const invokeWaiters = new Map<
|
const invokeWaiters = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -163,15 +176,22 @@ export async function startNodeBridgeServer(
|
|||||||
token,
|
token,
|
||||||
opts.pairingBaseDir,
|
opts.pairingBaseDir,
|
||||||
);
|
);
|
||||||
if (!verified.ok) {
|
if (!verified.ok || !verified.node) {
|
||||||
sendError("UNAUTHORIZED", "invalid token");
|
sendError("UNAUTHORIZED", "invalid token");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
connections.set(nodeId, socket);
|
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);
|
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
||||||
await opts.onAuthenticated?.(nodeId);
|
await opts.onAuthenticated?.(nodeInfo);
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForApproval = async (request: {
|
const waitForApproval = async (request: {
|
||||||
@@ -227,6 +247,9 @@ export async function startNodeBridgeServer(
|
|||||||
},
|
},
|
||||||
opts.pairingBaseDir,
|
opts.pairingBaseDir,
|
||||||
);
|
);
|
||||||
|
if (result.created) {
|
||||||
|
await opts.onPairRequested?.(result.request);
|
||||||
|
}
|
||||||
|
|
||||||
const wait = await waitForApproval(result.request);
|
const wait = await waitForApproval(result.request);
|
||||||
if (!wait.ok) {
|
if (!wait.ok) {
|
||||||
@@ -236,9 +259,16 @@ export async function startNodeBridgeServer(
|
|||||||
|
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
connections.set(nodeId, socket);
|
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: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
|
||||||
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
||||||
await opts.onAuthenticated?.(nodeId);
|
await opts.onAuthenticated?.(nodeInfo);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEvent = async (evt: BridgeEventFrame) => {
|
const handleEvent = async (evt: BridgeEventFrame) => {
|
||||||
@@ -319,9 +349,9 @@ export async function startNodeBridgeServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
const id = nodeId;
|
const info = nodeInfo;
|
||||||
stop();
|
stop();
|
||||||
if (id && isAuthenticated) void opts.onDisconnected?.(id);
|
if (info && isAuthenticated) void opts.onDisconnected?.(info);
|
||||||
});
|
});
|
||||||
socket.on("error", () => {
|
socket.on("error", () => {
|
||||||
// close handler will run after close
|
// close handler will run after close
|
||||||
|
|||||||
Reference in New Issue
Block a user