diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 3efa40449..6ae385d8a 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -75,9 +75,32 @@ enum DebugActions { static func restartGateway() { Task { @MainActor in - GatewayProcessManager.shared.stop() - try? await Task.sleep(nanoseconds: 300_000_000) - GatewayProcessManager.shared.setActive(true) + switch AppStateStore.shared.connectionMode { + case .local: + GatewayProcessManager.shared.stop() + // Kick the control channel + health check so the UI recovers immediately. + await GatewayConnection.shared.shutdown() + try? await Task.sleep(nanoseconds: 300_000_000) + GatewayProcessManager.shared.setActive(true) + Task { try? await ControlChannel.shared.configure(mode: .local) } + Task { await HealthStore.shared.refresh(onDemand: true) } + + case .remote: + // In remote mode, there is no local gateway to restart. "Restart Gateway" should + // reset the SSH control tunnel + reconnect so the menu recovers. + await RemoteTunnelManager.shared.stopAll() + await GatewayConnection.shared.shutdown() + do { + _ = try await RemoteTunnelManager.shared.ensureControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + } catch { + // ControlChannel will surface a degraded state; also refresh health to update the menu text. + Task { await HealthStore.shared.refresh(onDemand: true) } + } + } } } diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index ce4ea8fd9..63137df82 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -94,8 +94,24 @@ actor GatewayEndpointStore { switch self.state { case let .ready(_, url, token): return (url, token) - case let .unavailable(_, reason): - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) + case let .unavailable(mode, reason): + guard mode == .remote else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) + } + + // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), + // recreate it on demand so callers can recover without a manual reconnect. + do { + let forwarded = try await self.deps.ensureRemoteTunnel() + let token = self.deps.token() + let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! + self.setState(.ready(mode: .remote, url: url, token: token)) + return (url, token) + } catch { + let msg = "\(reason) (\(error.localizedDescription))" + self.setState(.unavailable(mode: .remote, reason: msg)) + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) + } } } @@ -111,10 +127,13 @@ actor GatewayEndpointStore { } switch next { case let .ready(mode, url, _): - self.logger.debug("resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)") + self.logger + .debug( + "resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)") case let .unavailable(mode, reason): - self.logger.debug("endpoint unavailable mode=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)") + self.logger + .debug( + "endpoint unavailable mode=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)") } } } -