fix(macos): clarify gateway error state

This commit is contained in:
Peter Steinberger
2026-01-02 13:48:19 +01:00
parent 5ecb65cbbe
commit f57f892409
5 changed files with 64 additions and 44 deletions

View File

@@ -67,6 +67,8 @@
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
- macOS menu: show multi-line gateway error details, avoid duplicate gateway status rows, and auto-recover the control channel on disconnect.
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b

View File

@@ -63,9 +63,11 @@ final class ControlChannel {
self.logger.info("control channel state -> connecting")
case .disconnected:
self.logger.info("control channel state -> disconnected")
self.scheduleRecovery(reason: "disconnected")
case let .degraded(message):
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
self.logger.info("control channel state -> \(detail, privacy: .public)")
self.scheduleRecovery(reason: message)
}
}
}
@@ -74,6 +76,8 @@ final class ControlChannel {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private var eventTask: Task<Void, Never>?
private var recoveryTask: Task<Void, Never>?
private var lastRecoveryAt: Date?
private init() {
self.startEventStream()
@@ -231,7 +235,43 @@ final class ControlChannel {
}
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
return "Gateway error: \(detail)"
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
return "Gateway error: \(trimmed)"
}
private func scheduleRecovery(reason: String) {
let now = Date()
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
guard self.recoveryTask == nil else { return }
self.lastRecoveryAt = now
self.recoveryTask = Task { [weak self] in
guard let self else { return }
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
guard mode != .unconfigured else {
self.recoveryTask = nil
return
}
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
self.logger.info(
"control channel recovery starting mode=\(String(describing: mode), privacy: .public) reason=\(reasonText, privacy: .public)")
if mode == .local {
GatewayProcessManager.shared.setActive(true)
}
do {
try await GatewayConnection.shared.refresh()
self.logger.info("control channel recovery finished")
} catch {
self.logger.error(
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
}
self.recoveryTask = nil
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {

View File

@@ -114,6 +114,7 @@ final class HealthStore {
guard !self.isRefreshing else { return }
self.isRefreshing = true
defer { self.isRefreshing = false }
let previousError = self.lastError
do {
let data = try await ControlChannel.shared.health(timeout: 15)
@@ -121,13 +122,23 @@ final class HealthStore {
self.snapshot = decoded
self.lastSuccess = Date()
self.lastError = nil
if previousError != nil {
Self.logger.info("health refresh recovered")
}
} else {
self.lastError = "health output not JSON"
if onDemand { self.snapshot = nil }
if previousError != self.lastError {
Self.logger.warning("health refresh failed: output not JSON")
}
}
} catch {
self.lastError = error.localizedDescription
let desc = error.localizedDescription
self.lastError = desc
if onDemand { self.snapshot = nil }
if previousError != desc {
Self.logger.error("health refresh failed \(desc, privacy: .public)")
}
}
}

View File

@@ -365,6 +365,10 @@ struct MenuContent: View {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
}
.padding(.top, 2)
}

View File

@@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
guard self.isControlChannelConnected else {
menu.insertItem(self.makeMessageItem(
text: self.controlChannelStatusText,
symbolName: "wifi.slash",
width: width), at: insertIndex)
return
}
guard self.isControlChannelConnected else { return }
guard let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem()
@@ -195,16 +189,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
menu.insertItem(topSeparator, at: cursor)
cursor += 1
guard self.isControlChannelConnected else {
menu.insertItem(
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
at: cursor)
cursor += 1
let separator = NSMenuItem.separator()
separator.tag = self.nodesTag
menu.insertItem(separator, at: cursor)
return
}
guard self.isControlChannelConnected else { return }
if let error = self.nodesStore.lastError?.nonEmpty {
menu.insertItem(
@@ -265,36 +250,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false
}
private var controlChannelStatusText: String {
switch ControlChannel.shared.state {
case .connected:
return "Connected"
case .connecting:
return "Connecting to gateway…"
case let .degraded(reason):
if self.shouldShowConnecting { return "Connecting to gateway…" }
return reason.nonEmpty ?? "No connection to gateway"
case .disconnected:
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
}
}
private var shouldShowConnecting: Bool {
switch GatewayProcessManager.shared.status {
case .starting, .running, .attachedExisting:
return true
case .stopped, .failed:
return false
}
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
let view = AnyView(
Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)