fix(macos): clarify gateway error state
This commit is contained in:
@@ -67,6 +67,8 @@
|
|||||||
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
|
- 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).
|
- 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 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 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 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
|
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ final class ControlChannel {
|
|||||||
self.logger.info("control channel state -> connecting")
|
self.logger.info("control channel state -> connecting")
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
self.logger.info("control channel state -> disconnected")
|
self.logger.info("control channel state -> disconnected")
|
||||||
|
self.scheduleRecovery(reason: "disconnected")
|
||||||
case let .degraded(message):
|
case let .degraded(message):
|
||||||
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
||||||
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
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 let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||||
|
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var recoveryTask: Task<Void, Never>?
|
||||||
|
private var lastRecoveryAt: Date?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.startEventStream()
|
self.startEventStream()
|
||||||
@@ -231,7 +235,43 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
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 {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ final class HealthStore {
|
|||||||
guard !self.isRefreshing else { return }
|
guard !self.isRefreshing else { return }
|
||||||
self.isRefreshing = true
|
self.isRefreshing = true
|
||||||
defer { self.isRefreshing = false }
|
defer { self.isRefreshing = false }
|
||||||
|
let previousError = self.lastError
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try await ControlChannel.shared.health(timeout: 15)
|
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||||
@@ -121,13 +122,23 @@ final class HealthStore {
|
|||||||
self.snapshot = decoded
|
self.snapshot = decoded
|
||||||
self.lastSuccess = Date()
|
self.lastSuccess = Date()
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
|
if previousError != nil {
|
||||||
|
Self.logger.info("health refresh recovered")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.lastError = "health output not JSON"
|
self.lastError = "health output not JSON"
|
||||||
if onDemand { self.snapshot = nil }
|
if onDemand { self.snapshot = nil }
|
||||||
|
if previousError != self.lastError {
|
||||||
|
Self.logger.warning("health refresh failed: output not JSON")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
let desc = error.localizedDescription
|
||||||
|
self.lastError = desc
|
||||||
if onDemand { self.snapshot = nil }
|
if onDemand { self.snapshot = nil }
|
||||||
|
if previousError != desc {
|
||||||
|
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,10 @@ struct MenuContent: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(nil)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.layoutPriority(1)
|
||||||
}
|
}
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||||
let width = self.initialWidth(for: menu)
|
let width = self.initialWidth(for: menu)
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else { return }
|
||||||
menu.insertItem(self.makeMessageItem(
|
|
||||||
text: self.controlChannelStatusText,
|
|
||||||
symbolName: "wifi.slash",
|
|
||||||
width: width), at: insertIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let snapshot = self.cachedSnapshot else {
|
guard let snapshot = self.cachedSnapshot else {
|
||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
@@ -195,16 +189,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
menu.insertItem(topSeparator, at: cursor)
|
menu.insertItem(topSeparator, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else { return }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||||
menu.insertItem(
|
menu.insertItem(
|
||||||
@@ -265,36 +250,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return false
|
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 {
|
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||||
let view = AnyView(
|
let view = AnyView(
|
||||||
Label(text, systemImage: symbolName)
|
Label(text, systemImage: symbolName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
.truncationMode(.tail)
|
.lineLimit(nil)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.leading, 18)
|
.padding(.leading, 18)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
Reference in New Issue
Block a user