macos: control channel diagnostics and tunnel-based testing
This commit is contained in:
@@ -74,6 +74,13 @@ final class ControlChannel: ObservableObject {
|
||||
case remote(target: String, identity: String)
|
||||
}
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case degraded(String)
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
private var connection: NWConnection?
|
||||
private var sshProcess: Process?
|
||||
@@ -82,6 +89,10 @@ final class ControlChannel: ObservableObject {
|
||||
private var listenTask: Task<Void, Never>?
|
||||
private var mode: Mode = .local
|
||||
private var localPort: UInt16 = 18789
|
||||
private var pingTask: Task<Void, Never>?
|
||||
|
||||
@Published private(set) var state: ConnectionState = .disconnected
|
||||
@Published private(set) var lastPingMs: Double?
|
||||
|
||||
func configure(mode: Mode) async throws {
|
||||
if mode == self.mode, self.connection != nil { return }
|
||||
@@ -93,6 +104,8 @@ final class ControlChannel: ObservableObject {
|
||||
func disconnect() async {
|
||||
self.listenTask?.cancel()
|
||||
self.listenTask = nil
|
||||
self.pingTask?.cancel()
|
||||
self.pingTask = nil
|
||||
if let conn = self.connection {
|
||||
conn.cancel()
|
||||
}
|
||||
@@ -103,6 +116,7 @@ final class ControlChannel: ObservableObject {
|
||||
cont.resume(throwing: ControlChannelError.disconnected)
|
||||
}
|
||||
self.pending.removeAll()
|
||||
self.state = .disconnected
|
||||
}
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
@@ -111,6 +125,13 @@ final class ControlChannel: ObservableObject {
|
||||
return payload
|
||||
}
|
||||
|
||||
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||
try await self.ensureConnected()
|
||||
let data = try await self.request(method: "last-heartbeat")
|
||||
if data.isEmpty { return nil }
|
||||
return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data)
|
||||
}
|
||||
|
||||
private func request(method: String, params: [String: Any]? = nil) async throws -> Data {
|
||||
try await self.ensureConnected()
|
||||
let id = UUID().uuidString
|
||||
@@ -141,6 +162,8 @@ final class ControlChannel: ObservableObject {
|
||||
self.localPort = try self.startSSHTunnel(target: target, identity: identity)
|
||||
}
|
||||
|
||||
self.state = .connecting
|
||||
|
||||
let host = NWEndpoint.Host("127.0.0.1")
|
||||
let port = NWEndpoint.Port(rawValue: self.localPort)!
|
||||
let conn = NWConnection(host: host, port: port, using: .tcp)
|
||||
@@ -151,9 +174,12 @@ final class ControlChannel: ObservableObject {
|
||||
switch state {
|
||||
case .ready:
|
||||
cont.resume(returning: ())
|
||||
Task { @MainActor in self.state = .connected }
|
||||
case let .failed(err):
|
||||
Task { @MainActor in self.state = .degraded(err.localizedDescription) }
|
||||
cont.resume(throwing: err)
|
||||
case let .waiting(err):
|
||||
Task { @MainActor in self.state = .degraded(err.localizedDescription) }
|
||||
cont.resume(throwing: err)
|
||||
default:
|
||||
break
|
||||
@@ -165,6 +191,21 @@ final class ControlChannel: ObservableObject {
|
||||
self.listenTask = Task.detached { [weak self] in
|
||||
await self?.listen()
|
||||
}
|
||||
|
||||
self.pingTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||
let start = Date()
|
||||
_ = try await self.request(method: "ping")
|
||||
let ms = Date().timeIntervalSince(start) * 1000
|
||||
await MainActor.run { self.lastPingMs = ms; self.state = .connected }
|
||||
} catch {
|
||||
await MainActor.run { self.state = .degraded(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startSSHTunnel(target: String, identity: String) throws -> UInt16 {
|
||||
|
||||
@@ -135,31 +135,60 @@ struct GeneralSettings: View {
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
switch self.remoteStatus {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .checking:
|
||||
Text("Checking…").font(.caption).foregroundStyle(.secondary)
|
||||
switch self.remoteStatus {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .checking:
|
||||
Text("Checking…").font(.caption).foregroundStyle(.secondary)
|
||||
case .ok:
|
||||
Label("Ready", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
case let .failed(message):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
// Diagnostics
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Control channel")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(self.controlStatusLine)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
Text("Last ping: \(Int(ping)) ms")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
Text("Last heartbeat: \(hb.status) · \(ageText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: return "Connected"
|
||||
case .connecting: return "Connecting…"
|
||||
case .disconnected: return "Disconnected"
|
||||
case let .degraded(msg): return "Degraded: \(msg)"
|
||||
}
|
||||
}
|
||||
|
||||
private var cliInstaller: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
@@ -19,6 +19,14 @@ final class HeartbeatStore: ObservableObject {
|
||||
Task { @MainActor in self?.lastEvent = decoded }
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
if self.lastEvent == nil {
|
||||
if let evt = try? await ControlChannel.shared.lastHeartbeat() {
|
||||
self.lastEvent = evt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -58,6 +58,7 @@ private struct MenuContent: View {
|
||||
@ObservedObject private var relayManager = RelayProcessManager.shared
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
||||
@ObservedObject private var controlChannel = ControlChannel.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@@ -176,7 +177,10 @@ private struct MenuContent: View {
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
if let evt = self.heartbeatStore.lastEvent {
|
||||
if case .degraded = self.controlChannel.state {
|
||||
label = "Control channel disconnected"
|
||||
color = .red
|
||||
} else if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum VoiceWakeChime: Codable, Equatable {
|
||||
enum VoiceWakeChime: Codable, Equatable, Sendable {
|
||||
case none
|
||||
case system(name: String)
|
||||
case custom(displayName: String, bookmark: Data)
|
||||
|
||||
@@ -119,7 +119,7 @@ actor VoiceWakeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
private func stop(dismissOverlay: Bool = true) {
|
||||
self.captureTask?.cancel()
|
||||
self.captureTask = nil
|
||||
self.isCapturing = false
|
||||
@@ -135,6 +135,7 @@ actor VoiceWakeRuntime {
|
||||
self.currentConfig = nil
|
||||
self.logger.debug("voicewake runtime stopped")
|
||||
|
||||
guard dismissOverlay else { return }
|
||||
Task { @MainActor in
|
||||
VoiceWakeOverlayController.shared.dismiss()
|
||||
}
|
||||
@@ -298,7 +299,7 @@ actor VoiceWakeRuntime {
|
||||
private func restartRecognizer() {
|
||||
// Restart the recognizer so we listen for the next trigger with a clean buffer.
|
||||
let current = self.currentConfig
|
||||
self.stop()
|
||||
self.stop(dismissOverlay: false)
|
||||
if let current {
|
||||
Task { await self.start(with: current) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user