diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 4af54e79..f52d091e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -10,6 +10,13 @@ struct BrowserProxyEndpoint: Equatable { let port: Int } +struct BrowserRemoteWorkspaceStatus: Equatable { + let target: String + let connectionState: WorkspaceRemoteConnectionState + let heartbeatCount: Int + let lastHeartbeatAt: Date? +} + enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google case duckduckgo @@ -1305,6 +1312,9 @@ final class BrowserPanel: Panel, ObservableObject { /// Published loading state @Published private(set) var isLoading: Bool = false + /// Snapshot of remote SSH connection status for this panel's workspace. + @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? + /// Published download state for browser downloads (navigation + context menu). @Published private(set) var isDownloading: Bool = false @@ -1513,6 +1523,11 @@ final class BrowserPanel: Panel, ObservableObject { applyRemoteProxyConfigurationIfAvailable() } + func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { + guard remoteWorkspaceStatus != status else { return } + remoteWorkspaceStatus = status + } + private func applyRemoteProxyConfigurationIfAvailable() { guard #available(macOS 14.0, *) else { return } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ea282f33..82228842 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -729,19 +729,101 @@ struct BrowserPanelView: View { } }) } else { - Color(nsColor: browserChromeBackgroundColor) - .contentShape(Rectangle()) - .onTapGesture { - onRequestPanelFocus() - if addressBarFocused { - addressBarFocused = false - } + ZStack(alignment: .topLeading) { + Color(nsColor: browserChromeBackgroundColor) + if let status = panel.remoteWorkspaceStatus { + remoteWorkspaceBlankStateIndicator(status) + .padding(.top, 12) + .padding(.leading, 12) + .allowsHitTesting(false) } + } + .contentShape(Rectangle()) + .onTapGesture { + onRequestPanelFocus() + if addressBarFocused { + addressBarFocused = false + } + } } } .zIndex(0) } + @ViewBuilder + private func remoteWorkspaceBlankStateIndicator(_ status: BrowserRemoteWorkspaceStatus) -> some View { + HStack(spacing: 8) { + Image(systemName: remoteStatusSymbolName(status.connectionState)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(remoteStatusColor(status.connectionState)) + + VStack(alignment: .leading, spacing: 1) { + Text("SSH \(status.target)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.primary.opacity(0.92)) + .lineLimit(1) + Text(remoteStatusDetailText(status)) + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(Color.primary.opacity(0.72)) + .lineLimit(1) + } + .fixedSize(horizontal: true, vertical: false) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.85)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.primary.opacity(0.12), lineWidth: 1) + ) + } + } + + private func remoteStatusSymbolName(_ state: WorkspaceRemoteConnectionState) -> String { + switch state { + case .connected: + return "network" + case .connecting: + return "arrow.triangle.2.circlepath" + case .error: + return "exclamationmark.triangle.fill" + case .disconnected: + return "network.slash" + } + } + + private func remoteStatusColor(_ state: WorkspaceRemoteConnectionState) -> Color { + switch state { + case .connected: + return .green + case .connecting: + return .orange + case .error: + return .red + case .disconnected: + return .secondary + } + } + + private func remoteStatusDetailText(_ status: BrowserRemoteWorkspaceStatus) -> String { + switch status.connectionState { + case .connected: + guard let lastHeartbeat = status.lastHeartbeatAt else { + return "Connected, waiting for heartbeat" + } + let ageSeconds = max(0, Int(Date().timeIntervalSince(lastHeartbeat))) + return "Connected, heartbeat #\(status.heartbeatCount) \(ageSeconds)s ago" + case .connecting: + return "Connecting..." + case .error: + return "Connection error" + case .disconnected: + return "Disconnected" + } + } + private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index adf14f7a..9743e2f3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1948,6 +1948,10 @@ private final class WorkspaceRemoteSessionController { private var daemonRemotePath: String? private var reconnectRetryCount = 0 private var reconnectWorkItem: DispatchWorkItem? + private var heartbeatWorkItem: DispatchWorkItem? + private var heartbeatCount: Int = 0 + + private static let heartbeatInterval: TimeInterval = 3.0 init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { self.workspace = workspace @@ -1979,6 +1983,7 @@ private final class WorkspaceRemoteSessionController { reconnectWorkItem?.cancel() reconnectWorkItem = nil reconnectRetryCount = 0 + stopHeartbeatLocked(reset: true) proxyLease?.release() proxyLease = nil @@ -1994,6 +1999,7 @@ private final class WorkspaceRemoteSessionController { guard !isStopping else { return } reconnectWorkItem = nil + stopHeartbeatLocked(reset: true) let connectDetail: String let bootstrapDetail: String if reconnectRetryCount > 0 { @@ -2072,7 +2078,10 @@ private final class WorkspaceRemoteSessionController { reconnectWorkItem?.cancel() reconnectWorkItem = nil reconnectRetryCount = 0 - guard proxyEndpoint != endpoint else { return } + guard proxyEndpoint != endpoint else { + startHeartbeatLocked() + return + } proxyEndpoint = endpoint publishProxyEndpoint(endpoint) publishPortsSnapshotLocked() @@ -2080,8 +2089,10 @@ private final class WorkspaceRemoteSessionController { .connected, detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" ) + startHeartbeatLocked() case .error(let detail): proxyEndpoint = nil + stopHeartbeatLocked(reset: false) publishProxyEndpoint(nil) publishPortsSnapshotLocked() publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") @@ -2183,6 +2194,55 @@ private final class WorkspaceRemoteSessionController { } } + private func startHeartbeatLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease != nil else { return } + guard heartbeatWorkItem == nil else { return } + + heartbeatCount += 1 + publishHeartbeat(count: heartbeatCount, at: Date()) + scheduleNextHeartbeatLocked() + } + + private func scheduleNextHeartbeatLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease != nil else { return } + + heartbeatWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.heartbeatWorkItem = nil + guard !self.isStopping else { return } + guard self.daemonReady else { return } + guard self.proxyLease != nil else { return } + self.heartbeatCount += 1 + self.publishHeartbeat(count: self.heartbeatCount, at: Date()) + self.scheduleNextHeartbeatLocked() + } + heartbeatWorkItem = workItem + queue.asyncAfter(deadline: .now() + Self.heartbeatInterval, execute: workItem) + } + + private func stopHeartbeatLocked(reset: Bool) { + heartbeatWorkItem?.cancel() + heartbeatWorkItem = nil + if reset { + heartbeatCount = 0 + publishHeartbeat(count: 0, at: nil) + } + } + + private func publishHeartbeat(count: Int, at date: Date?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) + } + } + private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { @@ -3160,6 +3220,8 @@ final class Workspace: Identifiable, ObservableObject { @Published var remoteForwardedPorts: [Int] = [] @Published var remotePortConflicts: [Int] = [] @Published var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published var remoteHeartbeatCount: Int = 0 + @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? @@ -3170,6 +3232,11 @@ final class Workspace: Identifiable, ObservableObject { private static let remoteErrorStatusKey = "remote.error" private static let remotePortConflictStatusKey = "remote.port_conflicts" + private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { @@ -3478,6 +3545,25 @@ final class Workspace: Identifiable, ObservableObject { } panelSubscriptions[browserPanel.id] = subscription } + + private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { + guard let target = remoteDisplayTarget else { return nil } + return BrowserRemoteWorkspaceStatus( + target: target, + connectionState: remoteConnectionState, + heartbeatCount: remoteHeartbeatCount, + lastHeartbeatAt: remoteLastHeartbeatAt + ) + } + + private func applyBrowserRemoteWorkspaceStatusToPanels() { + let snapshot = browserRemoteWorkspaceStatusSnapshot() + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteWorkspaceStatus(snapshot) + } + } + // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -3906,6 +3992,14 @@ final class Workspace: Identifiable, ObservableObject { } func remoteStatusPayload() -> [String: Any] { + let heartbeatAgeSeconds: Any = { + guard let last = remoteLastHeartbeatAt else { return NSNull() } + return max(0, Date().timeIntervalSince(last)) + }() + let heartbeatTimestamp: Any = { + guard let last = remoteLastHeartbeatAt else { return NSNull() } + return Self.remoteHeartbeatDateFormatter.string(from: last) + }() var payload: [String: Any] = [ "enabled": remoteConfiguration != nil, "state": remoteConnectionState.rawValue, @@ -3915,6 +4009,11 @@ final class Workspace: Identifiable, ObservableObject { "forwarded_ports": remoteForwardedPorts, "conflicted_ports": remotePortConflicts, "detail": remoteConnectionDetail ?? NSNull(), + "heartbeat": [ + "count": remoteHeartbeatCount, + "last_seen_at": heartbeatTimestamp, + "age_seconds": heartbeatAgeSeconds, + ], ] if let endpoint = remoteProxyEndpoint { payload["proxy"] = [ @@ -3965,6 +4064,8 @@ final class Workspace: Identifiable, ObservableObject { remoteForwardedPorts = [] remotePortConflicts = [] remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil remoteConnectionDetail = nil remoteDaemonStatus = WorkspaceRemoteDaemonStatus() statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) @@ -3979,13 +4080,16 @@ final class Workspace: Identifiable, ObservableObject { remoteSessionController = nil previousController?.stop() applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() guard autoConnect else { remoteConnectionState = .disconnected + applyBrowserRemoteWorkspaceStatusToPanels() return } remoteConnectionState = .connecting + applyBrowserRemoteWorkspaceStatusToPanels() let controllerID = UUID() let controller = WorkspaceRemoteSessionController( workspace: self, @@ -4011,6 +4115,8 @@ final class Workspace: Identifiable, ObservableObject { remoteForwardedPorts = [] remotePortConflicts = [] remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil remoteConnectionState = .disconnected remoteConnectionDetail = nil remoteDaemonStatus = WorkspaceRemoteDaemonStatus() @@ -4023,6 +4129,7 @@ final class Workspace: Identifiable, ObservableObject { remoteConfiguration = nil } applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() recomputeListeningPorts() } @@ -4037,6 +4144,7 @@ final class Workspace: Identifiable, ObservableObject { ) { remoteConnectionState = state remoteConnectionDetail = detail + applyBrowserRemoteWorkspaceStatusToPanels() let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) if state == .error, let trimmedDetail, !trimmedDetail.isEmpty { @@ -4080,6 +4188,7 @@ final class Workspace: Identifiable, ObservableObject { fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { remoteDaemonStatus = status + applyBrowserRemoteWorkspaceStatusToPanels() guard status.state == .error else { remoteLastDaemonErrorFingerprint = nil return @@ -4101,6 +4210,13 @@ final class Workspace: Identifiable, ObservableObject { guard let browserPanel = panel as? BrowserPanel else { continue } browserPanel.setRemoteProxyEndpoint(endpoint) } + applyBrowserRemoteWorkspaceStatusToPanels() + } + + fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { + remoteHeartbeatCount = max(0, count) + remoteLastHeartbeatAt = lastSeenAt + applyBrowserRemoteWorkspaceStatusToPanels() } fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) { @@ -4522,6 +4638,7 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -4580,6 +4697,7 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -5037,6 +5155,7 @@ final class Workspace: Identifiable, ObservableObject { } else if let browserPanel = detached.panel as? BrowserPanel { browserPanel.updateWorkspaceId(id) browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) installBrowserPanelSubscription(browserPanel) } diff --git a/scripts/reload.sh b/scripts/reload.sh index f862610d..0b8e4644 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -392,6 +392,8 @@ fi OPEN_CLEAN_ENV=( env -u CMUX_SOCKET_PATH + -u CMUX_WORKSPACE_ID + -u CMUX_SURFACE_ID -u CMUX_TAB_ID -u CMUX_PANEL_ID -u CMUXD_UNIX_PATH diff --git a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py index cf55e61a..63162e76 100644 --- a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py +++ b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py @@ -101,6 +101,29 @@ def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float = raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}") +def _heartbeat_count(status: dict) -> int: + remote = status.get("remote") or {} + heartbeat = remote.get("heartbeat") or {} + raw = heartbeat.get("count") + try: + return int(raw or 0) + except Exception: # noqa: BLE001 + return 0 + + +def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict: + deadline = time.time() + timeout + last_status: dict = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + if _heartbeat_count(last_status) >= minimum_count: + return last_status + time.sleep(0.5) + raise cmuxError( + f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}" + ) + + def main() -> int: if not _docker_available(): print("SKIP: docker is not available") @@ -181,6 +204,36 @@ chmod 0644 "$HOME/.profile" detail = str(remote.get("detail") or "").lower() _must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}") + baseline_heartbeat = _heartbeat_count(status) + status = _wait_for_heartbeat_advance( + client, + workspace_id, + minimum_count=max(1, baseline_heartbeat + 1), + timeout=15.0, + ) + + opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {} + browser_surface_id = str(opened.get("surface_id") or "") + _must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}") + + after_open_heartbeat = _heartbeat_count(status) + status_after_blank_tab = _wait_for_heartbeat_advance( + client, + workspace_id, + minimum_count=after_open_heartbeat + 2, + timeout=20.0, + ) + remote_after_blank_tab = status_after_blank_tab.get("remote") or {} + _must( + str(remote_after_blank_tab.get("state") or "") == "connected", + f"remote should remain connected after blank browser open: {status_after_blank_tab}", + ) + heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {} + _must( + heartbeat_payload.get("last_seen_at") is not None, + f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}", + ) + try: client.close_workspace(workspace_id) except Exception: @@ -203,4 +256,3 @@ chmod 0644 "$HOME/.profile" if __name__ == "__main__": raise SystemExit(main()) -