diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 66182756f..7b66afce4 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -99,8 +99,9 @@ final class ControlChannel: ObservableObject { self.state = .connected return payload } catch { - self.state = .degraded(error.localizedDescription) - throw error + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + throw ControlChannelError.badResponse(message) } } @@ -116,11 +117,41 @@ final class ControlChannel: ObservableObject { self.state = .connected return data } catch { - self.state = .degraded(error.localizedDescription) - throw error + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + throw ControlChannelError.badResponse(message) } } + private func friendlyGatewayMessage(_ error: Error) -> String { + // Map URLSession/WS errors into user-facing, actionable text. + if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription { + return desc + } + + if let urlError = error as? URLError { + let port = RelayEnvironment.gatewayPort() + switch urlError.code { + case .cancelled: + return "Gateway connection was closed; start the relay (localhost:\(port)) and retry." + case .cannotFindHost, .cannotConnectToHost: + return "Cannot reach gateway at localhost:\(port); ensure the relay is running." + case .networkConnectionLost: + return "Gateway connection dropped; relay likely restarted—retry." + case .timedOut: + return "Gateway request timed out; check relay on localhost:\(port)." + case .notConnectedToInternet: + return "No network connectivity; cannot reach gateway." + default: + break + } + } + + let nsError = error as NSError + let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription + return "Gateway error: \(detail)" + } + func sendSystemEvent(_ text: String) async throws { _ = try await self.request(method: "system-event", params: ["text": AnyHashable(text)]) } diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 0f11f4dde..7fe7af3e6 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -41,7 +41,12 @@ private actor GatewayChannelActor { self.task?.cancel(with: .goingAway, reason: nil) self.task = self.session.webSocketTask(with: self.url) self.task?.resume() - try await self.sendHello() + do { + try await self.sendHello() + } catch { + let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") + throw wrapped + } self.listen() self.connected = true self.backoffMs = 500 @@ -110,7 +115,8 @@ private actor GatewayChannelActor { } private func handleReceiveFailure(_ err: Error) async { - self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)") + let wrapped = self.wrap(err, context: "gateway receive") + self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") self.connected = false await self.scheduleReconnect() } @@ -177,13 +183,18 @@ private actor GatewayChannelActor { do { try await self.connect() } catch { - self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)") + let wrapped = self.wrap(error, context: "gateway reconnect") + self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)") await self.scheduleReconnect() } } func request(method: String, params: [String: AnyCodable]?) async throws -> Data { - try await self.connect() + do { + try await self.connect() + } catch { + throw self.wrap(error, context: "gateway connect") + } let id = UUID().uuidString let paramsObject = params?.reduce(into: [String: Any]()) { dict, entry in dict[entry.key] = entry.value.value @@ -202,7 +213,7 @@ private actor GatewayChannelActor { try await self.task?.send(.data(data)) } catch { self.pending.removeValue(forKey: id) - cont.resume(throwing: error) + cont.resume(throwing: self.wrap(error, context: "gateway send \(method)")) } } } @@ -221,6 +232,20 @@ private actor GatewayChannelActor { } return Data() } + + // Wrap low-level URLSession/WebSocket errors with context so UI can surface them. + private func wrap(_ error: Error, context: String) -> Error { + if let urlError = error as? URLError { + let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription + return NSError( + domain: urlError.errorDomain, + code: urlError.errorCode, + userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) + } + let ns = error as NSError + let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription + return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) + } } actor GatewayChannel {