diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c38c47ac..71c842aa 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2172,8 +2172,7 @@ struct CMUXCLI { if let port = sshOptions.port { configureParams["port"] = port } - if let identityFile = sshOptions.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { + if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) { configureParams["identity_file"] = identityFile } if !remoteSSHOptions.isEmpty { @@ -2301,8 +2300,7 @@ struct CMUXCLI { if let port = options.port { parts += ["-p", String(port)] } - if let identityFile = options.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { + if let identityFile = normalizedSSHIdentityPath(options.identityFile) { parts += ["-i", identityFile] } for option in effectiveSSHOptions { @@ -2393,6 +2391,19 @@ fi "/tmp/cmux-ssh-\(getuid())-%C" } + private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? { + guard let rawPath else { return nil } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("~") { + let expanded = (trimmed as NSString).expandingTildeInPath + if !expanded.isEmpty { + return expanded + } + } + return trimmed + } + private func shellQuote(_ value: String) -> String { let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" if value.range(of: safePattern, options: .regularExpression) != nil { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1ff53ef5..a49aab50 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1567,11 +1567,10 @@ final class TerminalSurface: Identifiable, ObservableObject { self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil - var mergedEnvironment = additionalEnvironment - for (key, value) in initialEnvironmentOverrides { - mergedEnvironment[key] = value - } - self.initialEnvironmentOverrides = mergedEnvironment + self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment( + base: additionalEnvironment, + overrides: initialEnvironmentOverrides + ) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. @@ -1588,6 +1587,26 @@ final class TerminalSurface: Identifiable, ObservableObject { attachedView?.tabId = newTabId surfaceView.tabId = newTabId } + + private static func mergedNormalizedEnvironment( + base: [String: String], + overrides: [String: String] + ) -> [String: String] { + var merged: [String: String] = [:] + merged.reserveCapacity(base.count + overrides.count) + for (rawKey, value) in base { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + return merged + } + #if DEBUG private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log" private static let sizeLogPath = "/tmp/cmux-ghostty-size.log" @@ -1846,10 +1865,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } if !initialEnvironmentOverrides.isEmpty { - for (keyRaw, valueRaw) in initialEnvironmentOverrides { - let key = keyRaw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - env[key] = valueRaw + for (key, value) in initialEnvironmentOverrides { + env[key] = value } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 55422f4f..8e0fe902 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1265,7 +1265,7 @@ final class BrowserPanel: Panel, ObservableObject { private(set) var workspaceId: UUID /// The underlying web view - let webView: WKWebView + private(set) var webView: WKWebView /// Prevent the omnibar from auto-focusing for a short window after explicit programmatic focus. /// This avoids races where SwiftUI focus state steals first responder back from WebKit. @@ -1386,47 +1386,7 @@ final class BrowserPanel: Panel, ObservableObject { self.remoteProxyEndpoint = proxyEndpoint self.browserThemeMode = BrowserThemeSettings.mode() - // Configure web view - let config = WKWebViewConfiguration() - config.processPool = BrowserPanel.sharedProcessPool - // Keep data-store scoping at workspace granularity so remote proxy settings - // do not leak into local workspaces. - if #available(macOS 14.0, *) { - config.websiteDataStore = WKWebsiteDataStore(forIdentifier: workspaceId) - } else { - config.websiteDataStore = .default() - } - - // Enable developer extras (DevTools) - config.preferences.setValue(true, forKey: "developerExtrasEnabled") - - // Enable JavaScript - config.defaultWebpagePreferences.allowsContentJavaScript = true - // Keep browser console/error/dialog telemetry active from document start on every navigation. - config.userContentController.addUserScript( - WKUserScript( - source: Self.telemetryHookBootstrapScriptSource, - injectionTime: .atDocumentStart, - forMainFrameOnly: false - ) - ) - - // Set up web view - let webView = CmuxWebView(frame: .zero, configuration: config) - webView.allowsBackForwardNavigationGestures = true - - // Required for Web Inspector support on recent WebKit SDKs. - if #available(macOS 13.3, *) { - webView.isInspectable = true - } - - // Match the empty-page background to the terminal theme so newly-created browsers - // don't flash white before content loads. - webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() - - // Always present as Safari. - webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent - + let webView = Self.makeWebView(for: workspaceId) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } self.insecureHTTPAlertWindowProvider = { [weak webView] in @@ -1535,7 +1495,9 @@ final class BrowserPanel: Panel, ObservableObject { } func updateWorkspaceId(_ newWorkspaceId: UUID) { + guard workspaceId != newWorkspaceId else { return } workspaceId = newWorkspaceId + rebindWebViewDataStoreIfNeeded() } func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { @@ -1571,6 +1533,98 @@ final class BrowserPanel: Panel, ObservableObject { store.proxyConfigurations = [socks, connect] } + private static func makeWebView(for workspaceId: UUID) -> CmuxWebView { + let config = WKWebViewConfiguration() + config.processPool = BrowserPanel.sharedProcessPool + // Keep data-store scoping at workspace granularity so remote proxy settings + // do not leak into local workspaces. + if #available(macOS 14.0, *) { + config.websiteDataStore = WKWebsiteDataStore(forIdentifier: workspaceId) + } else { + config.websiteDataStore = .default() + } + + // Enable developer extras (DevTools) + config.preferences.setValue(true, forKey: "developerExtrasEnabled") + + // Enable JavaScript + config.defaultWebpagePreferences.allowsContentJavaScript = true + // Keep browser console/error/dialog telemetry active from document start on every navigation. + config.userContentController.addUserScript( + WKUserScript( + source: Self.telemetryHookBootstrapScriptSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) + + let webView = CmuxWebView(frame: .zero, configuration: config) + webView.allowsBackForwardNavigationGestures = true + + // Required for Web Inspector support on recent WebKit SDKs. + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + + // Match the empty-page background to the terminal theme so newly-created browsers + // don't flash white before content loads. + webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() + + // Always present as Safari. + webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + return webView + } + + private func rebindWebViewDataStoreIfNeeded() { + guard #available(macOS 14.0, *) else { return } + + let oldWebView = webView + let restoreURL = oldWebView.url ?? currentURL + let restorePageZoom = oldWebView.pageZoom + let shouldRestoreNavigation = shouldRenderWebView + && restoreURL?.absoluteString != blankURLString + + oldWebView.stopLoading() + oldWebView.navigationDelegate = nil + oldWebView.uiDelegate = nil + if let oldCmuxWebView = oldWebView as? CmuxWebView { + oldCmuxWebView.onContextMenuDownloadStateChanged = nil + } + BrowserWindowPortalRegistry.detach(webView: oldWebView) + oldWebView.removeFromSuperview() + + webViewObservers.removeAll() + cancellables.removeAll() + + let replacement = Self.makeWebView(for: workspaceId) + replacement.pageZoom = restorePageZoom + replacement.navigationDelegate = navigationDelegate + replacement.uiDelegate = uiDelegate + replacement.onContextMenuDownloadStateChanged = { [weak self] downloading in + if downloading { + self?.beginDownloadActivity() + } else { + self?.endDownloadActivity() + } + } + + objectWillChange.send() + webView = replacement + insecureHTTPAlertWindowProvider = { [weak self] in + self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + nativeCanGoBack = false + nativeCanGoForward = false + estimatedProgress = 0 + refreshNavigationAvailability() + setupObservers() + applyRemoteProxyConfigurationIfAvailable() + + if shouldRestoreNavigation, let restoreURL { + replacement.load(browserPreparedNavigationRequest(URLRequest(url: restoreURL))) + } + } + func triggerFlash() { focusFlashToken &+= 1 } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index b2507e20..31345f70 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -88,22 +88,40 @@ final class TerminalPanel: Panel, ObservableObject { initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { - var mergedEnvironment = additionalEnvironment - for (key, value) in initialEnvironmentOverrides { - mergedEnvironment[key] = value - } let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, initialCommand: initialCommand, - initialEnvironmentOverrides: mergedEnvironment + initialEnvironmentOverrides: Self.mergedNormalizedEnvironment( + base: additionalEnvironment, + overrides: initialEnvironmentOverrides + ) ) surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) } + private static func mergedNormalizedEnvironment( + base: [String: String], + overrides: [String: String] + ) -> [String: String] { + var merged: [String: String] = [:] + merged.reserveCapacity(base.count + overrides.count) + for (rawKey, value) in base { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + return merged + } + func updateTitle(_ newTitle: String) { let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty && title != trimmed { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index fce24d0a..c0f6f638 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1068,6 +1068,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { private let connection: NWConnection private let rpcClient: WorkspaceRemoteDaemonRPCClient private let queue: DispatchQueue + private let readQueue: DispatchQueue private let onClose: (UUID) -> Void private var isClosed = false @@ -1086,6 +1087,10 @@ private final class WorkspaceRemoteDaemonProxyTunnel { self.connection = connection self.rpcClient = rpcClient self.queue = queue + self.readQueue = DispatchQueue( + label: "com.cmux.remote-ssh.daemon-tunnel.proxy-read.\(UUID().uuidString)", + qos: .utility + ) self.onClose = onClose } @@ -1350,19 +1355,34 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } private func scheduleRemoteReadLoop() { - queue.async { [weak self] in - self?.pollRemoteOnce() + guard let streamID else { return } + readQueue.async { [weak self] in + self?.pollRemoteOnce(streamID: streamID) } } - private func pollRemoteOnce() { + private func pollRemoteOnce(streamID: String) { + let readResult: Result<(data: Data, eof: Bool), Error> + do { + readResult = .success(try rpcClient.readStream(streamID: streamID, maxBytes: 32768, timeoutMs: 250)) + } catch { + readResult = .failure(error) + } + + queue.async { [weak self] in + self?.handleRemoteReadResult(streamID: streamID, result: readResult) + } + } + + private func handleRemoteReadResult(streamID: String, result: Result<(data: Data, eof: Bool), Error>) { guard !isClosed else { return } - guard let streamID else { return } + guard self.streamID == streamID else { return } let readResult: (data: Data, eof: Bool) - do { - readResult = try rpcClient.readStream(streamID: streamID, maxBytes: 32768, timeoutMs: 250) - } catch { + switch result { + case .success(let value): + readResult = value + case .failure(let error): close(reason: "proxy.read failed: \(error.localizedDescription)") return } @@ -1869,6 +1889,7 @@ private final class WorkspaceRemoteSessionController { private let queueKey = DispatchSpecificKey() private weak var workspace: Workspace? private let configuration: WorkspaceRemoteConfiguration + private let controllerID: UUID private var isStopping = false private var proxyLease: WorkspaceRemoteProxyBroker.Lease? @@ -1879,9 +1900,10 @@ private final class WorkspaceRemoteSessionController { private var reconnectRetryCount = 0 private var reconnectWorkItem: DispatchWorkItem? - init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { + init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { self.workspace = workspace self.configuration = configuration + self.controllerID = controllerID queue.setSpecific(key: queueKey, value: ()) } @@ -1898,7 +1920,7 @@ private final class WorkspaceRemoteSessionController { stopAllLocked() return } - queue.sync { + queue.async { [self] in stopAllLocked() } } @@ -2050,8 +2072,10 @@ private final class WorkspaceRemoteSessionController { } private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + let controllerID = self.controllerID DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } workspace.applyRemoteConnectionStateUpdate( state, detail: detail, @@ -2068,6 +2092,7 @@ private final class WorkspaceRemoteSessionController { capabilities: [String] = [], remotePath: String? = nil ) { + let controllerID = self.controllerID let status = WorkspaceRemoteDaemonStatus( state: state, detail: detail, @@ -2078,6 +2103,7 @@ private final class WorkspaceRemoteSessionController { ) DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } workspace.applyRemoteDaemonStatusUpdate( status, target: workspace.remoteDisplayTarget ?? "remote host" @@ -2086,15 +2112,19 @@ private final class WorkspaceRemoteSessionController { } private func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + let controllerID = self.controllerID DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } workspace.applyRemoteProxyEndpointUpdate(endpoint) } } private func publishPortsSnapshotLocked() { + let controllerID = self.controllerID DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } workspace.applyRemotePortsSnapshot( detected: [], forwarded: [], @@ -2210,6 +2240,14 @@ private final class WorkspaceRemoteSessionController { } if process.isRunning { process.terminate() + let terminateDeadline = Date().addingTimeInterval(2.0) + while process.isRunning && Date() < terminateDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [ NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", ]) @@ -2227,12 +2265,20 @@ private final class WorkspaceRemoteSessionController { let version = Self.remoteDaemonVersion() let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - if try !remoteDaemonExistsLocked(remotePath: remotePath) { + let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath) + if !hadExistingBinary { let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) } - return try helloRemoteDaemonLocked(remotePath: remotePath) + var hello = try helloRemoteDaemonLocked(remotePath: remotePath) + if hadExistingBinary, !hello.capabilities.contains("proxy.stream") { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + + return hello } private func resolveRemotePlatformLocked() throws -> RemotePlatform { @@ -3033,6 +3079,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? + fileprivate var activeRemoteSessionControllerID: UUID? private var remoteLastErrorFingerprint: String? private var remoteLastDaemonErrorFingerprint: String? private var remoteLastPortConflictFingerprint: String? @@ -3215,6 +3262,7 @@ final class Workspace: Identifiable, ObservableObject { } deinit { + activeRemoteSessionControllerID = nil remoteSessionController?.stop() } @@ -3833,8 +3881,10 @@ final class Workspace: Identifiable, ObservableObject { remoteLastPortConflictFingerprint = nil recomputeListeningPorts() - remoteSessionController?.stop() + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil remoteSessionController = nil + previousController?.stop() applyRemoteProxyEndpointUpdate(nil) guard autoConnect else { @@ -3843,7 +3893,13 @@ final class Workspace: Identifiable, ObservableObject { } remoteConnectionState = .connecting - let controller = WorkspaceRemoteSessionController(workspace: self, configuration: configuration) + let controllerID = UUID() + let controller = WorkspaceRemoteSessionController( + workspace: self, + configuration: configuration, + controllerID: controllerID + ) + activeRemoteSessionControllerID = controllerID remoteSessionController = controller controller.start() } @@ -3854,8 +3910,10 @@ final class Workspace: Identifiable, ObservableObject { } func disconnectRemoteConnection(clearConfiguration: Bool = false) { - remoteSessionController?.stop() + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil remoteSessionController = nil + previousController?.stop() remoteDetectedPorts = [] remoteForwardedPorts = [] remotePortConflicts = [] diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 349be447..51a3f80f 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "io" + "math" "net" "strconv" "strings" @@ -500,6 +501,9 @@ func asInt(t *testing.T, value any, field string) int { case uint64: return int(typed) case float64: + if typed != math.Trunc(typed) { + t.Fatalf("%s should be integer-valued, got %v", field, typed) + } return int(typed) default: t.Fatalf("%s has unexpected type %T (%v)", field, value, value)