diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e465db48..a7f140abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 87eb6847a..8a8c31e8e 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -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? + private var recoveryTask: Task? + 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 { diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index a5ae9cbe4..117cde4e7 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -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)") + } } } diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 5ac5a37af..4dfc30fa4 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -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) } diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index f9509aae9..fd3fb7e69 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -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)