From 9dd66980ff778a8720e7df4268eff2842e5442e2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:50:28 -0800 Subject: [PATCH 01/77] Add browser import flow with installed-browser detection --- Sources/Panels/BrowserPanel.swift | 1559 +++++++++++++++++++++++++ Sources/Panels/BrowserPanelView.swift | 66 +- Sources/cmuxApp.swift | 34 + cmuxTests/GhosttyConfigTests.swift | 84 ++ 4 files changed, 1742 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0e9b565e..d58fd21f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,6 +3,7 @@ import Combine import WebKit import AppKit import Bonsplit +import SQLite3 enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google @@ -598,6 +599,100 @@ final class BrowserHistoryStore: ObservableObject { return Array(ranked.prefix(limit)) } + @discardableResult + func mergeImportedEntries(_ importedEntries: [Entry]) -> Int { + loadIfNeeded() + guard !importedEntries.isEmpty else { return 0 } + + var mergedCount = 0 + for imported in importedEntries { + guard let parsedURL = URL(string: imported.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + continue + } + + if let host = parsedURL.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { continue } + } + + let urlString = parsedURL.absoluteString + guard urlString != "about:blank" else { continue } + let normalizedKey = normalizedHistoryKey(url: parsedURL) + + let importedTitle = imported.title?.trimmingCharacters(in: .whitespacesAndNewlines) + let importedLastVisited = imported.lastVisited + let importedVisitCount = max(1, imported.visitCount) + let importedTypedCount = max(0, imported.typedCount) + let importedLastTypedAt = imported.lastTypedAt + + if let idx = entries.firstIndex(where: { + if $0.url == urlString { return true } + guard let normalizedKey else { return false } + return normalizedHistoryKey(urlString: $0.url) == normalizedKey + }) { + var didMutate = false + if importedLastVisited > entries[idx].lastVisited { + entries[idx].lastVisited = importedLastVisited + didMutate = true + } + if importedVisitCount > entries[idx].visitCount { + entries[idx].visitCount = importedVisitCount + didMutate = true + } + if importedTypedCount > entries[idx].typedCount { + entries[idx].typedCount = importedTypedCount + didMutate = true + } + if let importedLastTypedAt { + if let existingLastTypedAt = entries[idx].lastTypedAt { + if importedLastTypedAt > existingLastTypedAt { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } else { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } + + let existingTitle = entries[idx].title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let incomingTitle = importedTitle ?? "" + if !incomingTitle.isEmpty, + (existingTitle.isEmpty || importedLastVisited >= entries[idx].lastVisited) { + if entries[idx].title != incomingTitle { + entries[idx].title = incomingTitle + didMutate = true + } + } + + if didMutate { + mergedCount += 1 + } + } else { + entries.append(Entry( + id: UUID(), + url: urlString, + title: importedTitle, + lastVisited: importedLastVisited, + visitCount: importedVisitCount, + typedCount: importedTypedCount, + lastTypedAt: importedLastTypedAt + )) + mergedCount += 1 + } + } + + guard mergedCount > 0 else { return 0 } + entries.sort(by: { $0.lastVisited > $1.lastVisited }) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + scheduleSave() + return mergedCount + } + func clearHistory() { loadIfNeeded() saveTask?.cancel() @@ -2774,3 +2869,1467 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } } } + +// MARK: - Browser Data Import + +enum BrowserImportScope: String, CaseIterable, Identifiable { + case cookiesOnly + case cookiesAndHistory + case everything + + var id: String { rawValue } + + var displayName: String { + switch self { + case .cookiesOnly: + return "Cookies only" + case .cookiesAndHistory: + return "Cookies + history" + case .everything: + return "Everything" + } + } + + var includesCookies: Bool { + switch self { + case .cookiesOnly, .cookiesAndHistory, .everything: + return true + } + } + + var includesHistory: Bool { + switch self { + case .cookiesOnly: + return false + case .cookiesAndHistory, .everything: + return true + } + } +} + +enum BrowserImportEngineFamily: String, Hashable { + case chromium + case firefox + case webkit +} + +struct BrowserImportBrowserDescriptor: Hashable { + let id: String + let displayName: String + let family: BrowserImportEngineFamily + let tier: Int + let bundleIdentifiers: [String] + let appNames: [String] + let dataRootRelativePaths: [String] + let dataArtifactRelativePaths: [String] + let supportsDataOnlyDetection: Bool +} + +struct InstalledBrowserCandidate: Identifiable, Hashable { + let descriptor: BrowserImportBrowserDescriptor + let homeDirectoryURL: URL + let appURL: URL? + let dataRootURL: URL? + let profileURLs: [URL] + let detectionSignals: [String] + let detectionScore: Int + + var id: String { descriptor.id } + var displayName: String { descriptor.displayName } + var family: BrowserImportEngineFamily { descriptor.family } +} + +enum InstalledBrowserDetector { + typealias BundleLookup = (String) -> URL? + + static let allBrowserDescriptors: [BrowserImportBrowserDescriptor] = [ + BrowserImportBrowserDescriptor( + id: "safari", + displayName: "Safari", + family: .webkit, + tier: 1, + bundleIdentifiers: ["com.apple.Safari"], + appNames: ["Safari.app"], + dataRootRelativePaths: ["Library/Safari"], + dataArtifactRelativePaths: [ + "Library/Safari/History.db", + "Library/Cookies/Cookies.binarycookies", + ], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "google-chrome", + displayName: "Google Chrome", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + dataRootRelativePaths: ["Library/Application Support/Google/Chrome"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "firefox", + displayName: "Firefox", + family: .firefox, + tier: 1, + bundleIdentifiers: ["org.mozilla.firefox"], + appNames: ["Firefox.app"], + dataRootRelativePaths: ["Library/Application Support/Firefox"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "arc", + displayName: "Arc", + family: .chromium, + tier: 1, + bundleIdentifiers: ["company.thebrowser.Browser", "company.thebrowser.arc"], + appNames: ["Arc.app"], + dataRootRelativePaths: ["Library/Application Support/Arc"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "brave", + displayName: "Brave", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.brave.Browser"], + appNames: ["Brave Browser.app"], + dataRootRelativePaths: ["Library/Application Support/BraveSoftware/Brave-Browser"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "microsoft-edge", + displayName: "Microsoft Edge", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.microsoft.edgemac", "com.microsoft.Edge"], + appNames: ["Microsoft Edge.app"], + dataRootRelativePaths: ["Library/Application Support/Microsoft Edge"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "zen", + displayName: "Zen Browser", + family: .firefox, + tier: 2, + bundleIdentifiers: ["app.zen-browser.zen", "app.zen-browser.Zen"], + appNames: ["Zen Browser.app", "Zen.app"], + dataRootRelativePaths: ["Library/Application Support/Zen", "Library/Application Support/zen"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "vivaldi", + displayName: "Vivaldi", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.vivaldi.Vivaldi"], + appNames: ["Vivaldi.app"], + dataRootRelativePaths: ["Library/Application Support/Vivaldi"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "opera", + displayName: "Opera", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.operasoftware.Opera"], + appNames: ["Opera.app"], + dataRootRelativePaths: [ + "Library/Application Support/com.operasoftware.Opera", + "Library/Application Support/Opera", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "opera-gx", + displayName: "Opera GX", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.operasoftware.OperaGX"], + appNames: ["Opera GX.app"], + dataRootRelativePaths: [ + "Library/Application Support/com.operasoftware.OperaGX", + "Library/Application Support/Opera GX Stable", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "orion", + displayName: "Orion", + family: .webkit, + tier: 2, + bundleIdentifiers: ["com.kagi.kagimacOS", "com.kagi.kagimacos", "com.kagi.orion"], + appNames: ["Orion.app"], + dataRootRelativePaths: ["Library/Application Support/Orion"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "dia", + displayName: "Dia", + family: .chromium, + tier: 2, + bundleIdentifiers: ["company.thebrowser.Dia", "company.thebrowser.dia"], + appNames: ["Dia.app"], + dataRootRelativePaths: ["Library/Application Support/Dia"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "perplexity-comet", + displayName: "Perplexity Comet", + family: .chromium, + tier: 3, + bundleIdentifiers: ["ai.perplexity.comet"], + appNames: ["Perplexity Comet.app", "Comet.app"], + dataRootRelativePaths: ["Library/Application Support/Comet"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "floorp", + displayName: "Floorp", + family: .firefox, + tier: 3, + bundleIdentifiers: ["one.ablaze.floorp"], + appNames: ["Floorp.app"], + dataRootRelativePaths: ["Library/Application Support/Floorp"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "waterfox", + displayName: "Waterfox", + family: .firefox, + tier: 3, + bundleIdentifiers: ["net.waterfox.waterfox"], + appNames: ["Waterfox.app"], + dataRootRelativePaths: ["Library/Application Support/Waterfox"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "sigmaos", + displayName: "SigmaOS", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.feralcat.sigmaos"], + appNames: ["SigmaOS.app"], + dataRootRelativePaths: ["Library/Application Support/SigmaOS"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "sidekick", + displayName: "Sidekick", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.meetsidekick.Sidekick", "com.pushplaylabs.sidekick"], + appNames: ["Sidekick.app"], + dataRootRelativePaths: ["Library/Application Support/Sidekick"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "helium", + displayName: "Helium", + family: .webkit, + tier: 3, + bundleIdentifiers: ["com.jadenGeller.Helium", "com.jaden.geller.helium"], + appNames: ["Helium.app"], + dataRootRelativePaths: ["Library/Application Support/Helium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "atlas", + displayName: "Atlas", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.atlas.browser"], + appNames: ["Atlas.app"], + dataRootRelativePaths: ["Library/Application Support/Atlas"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "ladybird", + displayName: "Ladybird", + family: .webkit, + tier: 3, + bundleIdentifiers: ["org.ladybird.Browser", "org.serenityos.ladybird"], + appNames: ["Ladybird.app"], + dataRootRelativePaths: ["Library/Application Support/Ladybird"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "chromium", + displayName: "Chromium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["org.chromium.Chromium"], + appNames: ["Chromium.app"], + dataRootRelativePaths: ["Library/Application Support/Chromium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "ungoogled-chromium", + displayName: "Ungoogled Chromium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["org.chromium.ungoogled"], + appNames: ["Ungoogled Chromium.app"], + dataRootRelativePaths: ["Library/Application Support/Chromium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: false + ), + ] + + static func detectInstalledBrowsers( + homeDirectoryURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), + bundleLookup: BundleLookup? = nil, + applicationSearchDirectories: [URL]? = nil, + fileManager: FileManager = .default + ) -> [InstalledBrowserCandidate] { + let lookup = bundleLookup ?? { bundleIdentifier in + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + } + let appSearchDirectories = applicationSearchDirectories ?? defaultApplicationSearchDirectories(homeDirectoryURL: homeDirectoryURL) + + let candidates = allBrowserDescriptors.compactMap { descriptor -> InstalledBrowserCandidate? in + let appDetection = detectApplication( + descriptor: descriptor, + appSearchDirectories: appSearchDirectories, + bundleLookup: lookup, + fileManager: fileManager + ) + + let dataDetection = detectData( + descriptor: descriptor, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + ) + + if appDetection.url == nil, + !descriptor.supportsDataOnlyDetection { + return nil + } + + let hasData = dataDetection.dataRootURL != nil || !dataDetection.profileURLs.isEmpty || !dataDetection.artifactHits.isEmpty + guard appDetection.url != nil || hasData else { + return nil + } + + var score = 0 + if appDetection.url != nil { + score += 80 + } + if dataDetection.dataRootURL != nil { + score += 24 + } + score += min(24, dataDetection.profileURLs.count * 6) + score += min(16, dataDetection.artifactHits.count * 4) + + var signals: [String] = [] + signals.append(contentsOf: appDetection.signals) + if let root = dataDetection.dataRootURL { + signals.append("data:\(root.lastPathComponent)") + } + if !dataDetection.profileURLs.isEmpty { + signals.append("profiles:\(dataDetection.profileURLs.count)") + } + if !dataDetection.artifactHits.isEmpty { + signals.append(contentsOf: dataDetection.artifactHits.map { "artifact:\($0)" }) + } + + return InstalledBrowserCandidate( + descriptor: descriptor, + homeDirectoryURL: homeDirectoryURL, + appURL: appDetection.url, + dataRootURL: dataDetection.dataRootURL, + profileURLs: dataDetection.profileURLs, + detectionSignals: signals, + detectionScore: score + ) + } + + return candidates.sorted { lhs, rhs in + if lhs.detectionScore != rhs.detectionScore { + return lhs.detectionScore > rhs.detectionScore + } + if lhs.descriptor.tier != rhs.descriptor.tier { + return lhs.descriptor.tier < rhs.descriptor.tier + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + static func summaryText(for browsers: [InstalledBrowserCandidate], limit: Int = 4) -> String { + guard !browsers.isEmpty else { return "No supported browsers detected." } + let names = browsers.map(\.displayName) + if names.count <= limit { + return "Detected: \(names.joined(separator: ", "))." + } + let shown = names.prefix(limit).joined(separator: ", ") + return "Detected: \(shown), +\(names.count - limit) more." + } + + private static func detectApplication( + descriptor: BrowserImportBrowserDescriptor, + appSearchDirectories: [URL], + bundleLookup: BundleLookup, + fileManager: FileManager + ) -> (url: URL?, signals: [String]) { + for bundleIdentifier in descriptor.bundleIdentifiers { + if let appURL = bundleLookup(bundleIdentifier) { + return (appURL, ["bundle:\(bundleIdentifier)"]) + } + } + + for appName in descriptor.appNames { + for directory in appSearchDirectories { + let appURL = directory.appendingPathComponent(appName, isDirectory: true) + if fileManager.fileExists(atPath: appURL.path) { + return (appURL, ["app:\(appName)"]) + } + } + } + + return (nil, []) + } + + private static func detectData( + descriptor: BrowserImportBrowserDescriptor, + homeDirectoryURL: URL, + fileManager: FileManager + ) -> (dataRootURL: URL?, profileURLs: [URL], artifactHits: [String]) { + var bestRootURL: URL? + var bestProfiles: [URL] = [] + var bestArtifacts: [String] = [] + + for relativePath in descriptor.dataRootRelativePaths { + let rootURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: true) + guard fileManager.fileExists(atPath: rootURL.path) else { continue } + + let profiles: [URL] + switch descriptor.family { + case .chromium: + profiles = chromiumProfileURLs(rootURL: rootURL, fileManager: fileManager) + case .firefox: + profiles = firefoxProfileURLs(rootURL: rootURL, fileManager: fileManager) + case .webkit: + profiles = [] + } + + let score = (profiles.count * 10) + 8 + let currentScore = (bestProfiles.count * 10) + (bestRootURL == nil ? 0 : 8) + if score > currentScore { + bestRootURL = rootURL + bestProfiles = profiles + } + } + + var artifactHits: [String] = [] + for relativePath in descriptor.dataArtifactRelativePaths { + let artifactURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: false) + if fileManager.fileExists(atPath: artifactURL.path) { + artifactHits.append(artifactURL.lastPathComponent) + } + } + + if !artifactHits.isEmpty { + bestArtifacts = artifactHits + if bestRootURL == nil, + let rootPath = descriptor.dataRootRelativePaths.first { + let rootURL = homeDirectoryURL.appendingPathComponent(rootPath, isDirectory: true) + if fileManager.fileExists(atPath: rootURL.path) { + bestRootURL = rootURL + } + } + } + + return ( + dataRootURL: bestRootURL, + profileURLs: dedupedCanonicalURLs(bestProfiles), + artifactHits: bestArtifacts + ) + } + + private static func chromiumProfileURLs( + rootURL: URL, + fileManager: FileManager + ) -> [URL] { + var profiles: [URL] = [] + if looksLikeChromiumProfile(rootURL: rootURL, fileManager: fileManager) { + profiles.append(rootURL) + } + + let children = (try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + let name = child.lastPathComponent + let isLikelyProfile = + name == "Default" || + name.hasPrefix("Profile ") || + name.hasPrefix("Guest Profile") || + name.hasPrefix("Person ") + if isLikelyProfile && looksLikeChromiumProfile(rootURL: child, fileManager: fileManager) { + profiles.append(child) + } + } + + profiles = dedupedCanonicalURLs(profiles) + return profiles.sorted { + profileRecency(for: $0, preferredFiles: ["History", "Cookies"], fileManager: fileManager) > + profileRecency(for: $1, preferredFiles: ["History", "Cookies"], fileManager: fileManager) + } + } + + private static func firefoxProfileURLs( + rootURL: URL, + fileManager: FileManager + ) -> [URL] { + var profiles = firefoxProfilesFromINI(rootURL: rootURL, fileManager: fileManager) + + let likelyProfileRoots = [ + rootURL.appendingPathComponent("Profiles", isDirectory: true), + rootURL, + ] + + for directory in likelyProfileRoots where fileManager.fileExists(atPath: directory.path) { + let children = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + if looksLikeFirefoxProfile(rootURL: child, fileManager: fileManager) { + profiles.append(child) + } + } + } + + profiles = dedupedCanonicalURLs(profiles) + return profiles.sorted { + profileRecency(for: $0, preferredFiles: ["places.sqlite", "cookies.sqlite"], fileManager: fileManager) > + profileRecency(for: $1, preferredFiles: ["places.sqlite", "cookies.sqlite"], fileManager: fileManager) + } + } + + private static func firefoxProfilesFromINI( + rootURL: URL, + fileManager: FileManager + ) -> [URL] { + let iniURL = rootURL.appendingPathComponent("profiles.ini", isDirectory: false) + guard let contents = try? String(contentsOf: iniURL, encoding: .utf8) else { + return [] + } + + var sections: [[String: String]] = [] + var current: [String: String] = [:] + + func flushCurrent() { + if !current.isEmpty { + sections.append(current) + current.removeAll() + } + } + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix(";") || trimmed.hasPrefix("#") { + continue + } + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + flushCurrent() + continue + } + guard let separator = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[.. Bool { + let historyURL = rootURL.appendingPathComponent("History", isDirectory: false) + let cookiesURL = rootURL.appendingPathComponent("Cookies", isDirectory: false) + return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) + } + + private static func looksLikeFirefoxProfile(rootURL: URL, fileManager: FileManager) -> Bool { + let historyURL = rootURL.appendingPathComponent("places.sqlite", isDirectory: false) + let cookiesURL = rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) + return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) + } + + private static func profileRecency( + for profileURL: URL, + preferredFiles: [String], + fileManager: FileManager + ) -> TimeInterval { + var latest: TimeInterval = 0 + for fileName in preferredFiles { + let url = profileURL.appendingPathComponent(fileName, isDirectory: false) + guard fileManager.fileExists(atPath: url.path), + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let date = values.contentModificationDate else { + continue + } + latest = max(latest, date.timeIntervalSince1970) + } + return latest + } + + private static func defaultApplicationSearchDirectories(homeDirectoryURL: URL) -> [URL] { + [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true), + URL(fileURLWithPath: "/Applications/Setapp", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications/Setapp", isDirectory: true), + ] + } + + private static func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { + var seen = Set() + var result: [URL] = [] + for url in urls { + let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path + if seen.insert(canonical).inserted { + result.append(url) + } + } + return result + } +} + +struct BrowserImportOutcome { + let browserName: String + let scope: BrowserImportScope + let domainFilters: [String] + let importedCookies: Int + let skippedCookies: Int + let importedHistoryEntries: Int + let warnings: [String] +} + +enum BrowserDataImporter { + private struct CookieImportResult { + var importedCount: Int = 0 + var skippedCount: Int = 0 + var warnings: [String] = [] + } + + private struct HistoryImportResult { + var importedCount: Int = 0 + var warnings: [String] = [] + } + + private struct HistoryRow { + let url: String + let title: String? + let visitCount: Int + let lastVisited: Date + } + + static func parseDomainFilters(_ raw: String) -> [String] { + var result: [String] = [] + var seen = Set() + let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")) + for token in raw.components(separatedBy: separators) { + var value = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value.hasPrefix("*.") { + value.removeFirst(2) + } + while value.hasPrefix(".") { + value.removeFirst() + } + guard !value.isEmpty else { continue } + guard seen.insert(value).inserted else { continue } + result.append(value) + } + return result + } + + static func importData( + from browser: InstalledBrowserCandidate, + scope: BrowserImportScope, + domainFilters: [String] + ) async -> BrowserImportOutcome { + var cookieResult = CookieImportResult() + if scope.includesCookies { + cookieResult = await importCookies(from: browser, domainFilters: domainFilters) + } + + var historyResult = HistoryImportResult() + if scope.includesHistory { + historyResult = await importHistory(from: browser, domainFilters: domainFilters) + } + + var warnings = cookieResult.warnings + warnings.append(contentsOf: historyResult.warnings) + if scope == .everything { + warnings.append("Bookmarks/settings import is not implemented yet; imported cookies and history only.") + } + + return BrowserImportOutcome( + browserName: browser.displayName, + scope: scope, + domainFilters: domainFilters, + importedCookies: cookieResult.importedCount, + skippedCookies: cookieResult.skippedCount, + importedHistoryEntries: historyResult.importedCount, + warnings: warnings + ) + } + + private static func importCookies( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> CookieImportResult { + switch browser.family { + case .firefox: + return await importFirefoxCookies(from: browser, domainFilters: domainFilters) + case .chromium: + return await importChromiumCookies(from: browser, domainFilters: domainFilters) + case .webkit: + if browser.descriptor.id == "safari" { + return CookieImportResult( + importedCount: 0, + skippedCount: 0, + warnings: [ + "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + ] + ) + } + return CookieImportResult( + importedCount: 0, + skippedCount: 0, + warnings: [ + "\(browser.displayName) cookie import is not implemented yet." + ] + ) + } + } + + private static func importHistory( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> HistoryImportResult { + switch browser.family { + case .firefox: + return await importFirefoxHistory(from: browser, domainFilters: domainFilters) + case .chromium: + return await importChromiumHistory(from: browser, domainFilters: domainFilters) + case .webkit: + return await importWebKitHistory(from: browser, domainFilters: domainFilters) + } + } + + private static func importFirefoxCookies( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> CookieImportResult { + let fileManager = FileManager.default + var cookies: [HTTPCookie] = [] + var warnings: [String] = [] + + let databaseURLs = browser.profileURLs.map { + $0.appendingPathComponent("cookies.sqlite", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: "SELECT host, name, value, path, expiry, isSecure FROM moz_cookies" + ) { statement in + let host = sqliteColumnText(statement, index: 0) ?? "" + let name = sqliteColumnText(statement, index: 1) ?? "" + let value = sqliteColumnText(statement, index: 2) ?? "" + let path = sqliteColumnText(statement, index: 3) ?? "/" + let expiry = sqliteColumnInt64(statement, index: 4) + let isSecure = sqliteColumnInt64(statement, index: 5) != 0 + + guard !name.isEmpty else { return } + guard domainMatches(host: host, filters: domainFilters) else { return } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: host, + .path: path.isEmpty ? "/" : path, + .name: name, + .value: value, + ] + if isSecure { + properties[.secure] = "TRUE" + } + if expiry > 0 { + properties[.expires] = Date(timeIntervalSince1970: TimeInterval(expiry)) + } + if let cookie = HTTPCookie(properties: properties) { + cookies.append(cookie) + } + } + } catch { + warnings.append("Failed reading Firefox cookies at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + } + } + + let dedupedCookies = dedupeCookies(cookies) + let importedCount = await setCookiesInStore(dedupedCookies) + return CookieImportResult(importedCount: importedCount, skippedCount: max(0, dedupedCookies.count - importedCount), warnings: warnings) + } + + private static func importChromiumCookies( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> CookieImportResult { + let fileManager = FileManager.default + var cookies: [HTTPCookie] = [] + var warnings: [String] = [] + var skippedEncryptedCookies = 0 + + let databaseURLs = browser.profileURLs.map { + $0.appendingPathComponent("Cookies", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: "SELECT host_key, name, value, path, expires_utc, is_secure, encrypted_value FROM cookies" + ) { statement in + let host = sqliteColumnText(statement, index: 0) ?? "" + let name = sqliteColumnText(statement, index: 1) ?? "" + let value = sqliteColumnText(statement, index: 2) ?? "" + let path = sqliteColumnText(statement, index: 3) ?? "/" + let expiresUTC = sqliteColumnInt64(statement, index: 4) + let isSecure = sqliteColumnInt64(statement, index: 5) != 0 + let encryptedLength = sqliteColumnBytes(statement, index: 6) + + guard !name.isEmpty else { return } + guard domainMatches(host: host, filters: domainFilters) else { return } + + let usableValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if usableValue.isEmpty && encryptedLength > 0 { + skippedEncryptedCookies += 1 + return + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: host, + .path: path.isEmpty ? "/" : path, + .name: name, + .value: usableValue, + ] + if isSecure { + properties[.secure] = "TRUE" + } + if let expiresDate = chromiumDate(fromWebKitMicroseconds: expiresUTC) { + properties[.expires] = expiresDate + } + if let cookie = HTTPCookie(properties: properties) { + cookies.append(cookie) + } + } + } catch { + warnings.append("Failed reading \(browser.displayName) cookies at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + } + } + + let dedupedCookies = dedupeCookies(cookies) + let importedCount = await setCookiesInStore(dedupedCookies) + if skippedEncryptedCookies > 0 { + warnings.append("Skipped \(skippedEncryptedCookies) encrypted cookies that require Keychain decryption.") + } + let skippedCount = max(0, dedupedCookies.count - importedCount) + skippedEncryptedCookies + return CookieImportResult(importedCount: importedCount, skippedCount: skippedCount, warnings: warnings) + } + + private static func importFirefoxHistory( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + let databaseURLs = browser.profileURLs.map { + $0.appendingPathComponent("places.sqlite", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT url, title, visit_count, last_visit_date + FROM moz_places + WHERE url LIKE 'http%' + ORDER BY last_visit_date DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitMicros = sqliteColumnInt64(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = firefoxDate(fromUnixMicroseconds: lastVisitMicros) ?? Date() + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append("Failed reading Firefox history at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + } + } + + let importedCount = await mergeHistoryRows(rows) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func importChromiumHistory( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + let databaseURLs = browser.profileURLs.map { + $0.appendingPathComponent("History", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT url, title, visit_count, last_visit_time + FROM urls + WHERE url LIKE 'http%' + ORDER BY last_visit_time DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitMicros = sqliteColumnInt64(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = chromiumDate(fromWebKitMicroseconds: lastVisitMicros) ?? Date() + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append("Failed reading \(browser.displayName) history at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + } + } + + let importedCount = await mergeHistoryRows(rows) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func importWebKitHistory( + from browser: InstalledBrowserCandidate, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + var candidateDatabaseURLs: [URL] = [] + if let dataRootURL = browser.dataRootURL { + candidateDatabaseURLs.append(dataRootURL.appendingPathComponent("History.db", isDirectory: false)) + } + if browser.descriptor.id == "safari" { + candidateDatabaseURLs.append( + browser.homeDirectoryURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Safari", isDirectory: true) + .appendingPathComponent("History.db", isDirectory: false) + ) + } + let uniqueURLs = dedupedCanonicalURLs(candidateDatabaseURLs).filter { fileManager.fileExists(atPath: $0.path) } + + if uniqueURLs.isEmpty { + return HistoryImportResult(importedCount: 0, warnings: ["No history database found for \(browser.displayName)."]) + } + + for databaseURL in uniqueURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT history_items.url, + history_items.title, + COUNT(history_visits.id) AS visit_count, + MAX(history_visits.visit_time) AS last_visit_time + FROM history_items + JOIN history_visits + ON history_items.id = history_visits.history_item + GROUP BY history_items.url + ORDER BY last_visit_time DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitReferenceSeconds = sqliteColumnDouble(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = Date(timeIntervalSinceReferenceDate: lastVisitReferenceSeconds) + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append("Failed reading \(browser.displayName) history at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + } + } + + let importedCount = await mergeHistoryRows(rows) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func mergeHistoryRows(_ rows: [HistoryRow]) async -> Int { + guard !rows.isEmpty else { return 0 } + return await MainActor.run { + let entries = rows.compactMap { row -> BrowserHistoryStore.Entry? in + guard let parsedURL = URL(string: row.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + let trimmedTitle = row.title?.trimmingCharacters(in: .whitespacesAndNewlines) + return BrowserHistoryStore.Entry( + id: UUID(), + url: parsedURL.absoluteString, + title: trimmedTitle, + lastVisited: row.lastVisited, + visitCount: max(1, row.visitCount) + ) + } + return BrowserHistoryStore.shared.mergeImportedEntries(entries) + } + } + + private static func setCookiesInStore(_ cookies: [HTTPCookie]) async -> Int { + guard !cookies.isEmpty else { return 0 } + let store = WKWebsiteDataStore.default().httpCookieStore + var importedCount = 0 + for cookie in cookies { + await withCheckedContinuation { continuation in + store.setCookie(cookie) { + importedCount += 1 + continuation.resume() + } + } + } + return importedCount + } + + private static func dedupeCookies(_ cookies: [HTTPCookie]) -> [HTTPCookie] { + var dedupedByKey: [String: HTTPCookie] = [:] + for cookie in cookies { + let key = "\(cookie.name.lowercased())|\(cookie.domain.lowercased())|\(cookie.path)" + if let existing = dedupedByKey[key] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + dedupedByKey[key] = cookie + } + } else { + dedupedByKey[key] = cookie + } + } + return Array(dedupedByKey.values) + } + + private static func domainMatches(host: String, filters: [String]) -> Bool { + if filters.isEmpty { return true } + var normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + while normalizedHost.hasPrefix(".") { + normalizedHost.removeFirst() + } + guard !normalizedHost.isEmpty else { return false } + for filter in filters { + if normalizedHost == filter { return true } + if normalizedHost.hasSuffix(".\(filter)") { return true } + } + return false + } + + private static func chromiumDate(fromWebKitMicroseconds rawValue: Int64) -> Date? { + guard rawValue > 0 else { return nil } + let unixSeconds = (Double(rawValue) / 1_000_000.0) - 11_644_473_600.0 + guard unixSeconds.isFinite else { return nil } + return Date(timeIntervalSince1970: unixSeconds) + } + + private static func firefoxDate(fromUnixMicroseconds rawValue: Int64) -> Date? { + guard rawValue > 0 else { return nil } + let seconds = Double(rawValue) / 1_000_000.0 + guard seconds.isFinite else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + private static func querySQLiteRows( + sourceDatabaseURL: URL, + sql: String, + rowHandler: (OpaquePointer) throws -> Void + ) throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-browser-import-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempRoot) } + + let snapshotURL = tempRoot.appendingPathComponent(sourceDatabaseURL.lastPathComponent, isDirectory: false) + try fileManager.copyItem(at: sourceDatabaseURL, to: snapshotURL) + + let walSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-wal") + let walSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-wal") + if fileManager.fileExists(atPath: walSourceURL.path) { + try? fileManager.copyItem(at: walSourceURL, to: walSnapshotURL) + } + let shmSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-shm") + let shmSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-shm") + if fileManager.fileExists(atPath: shmSourceURL.path) { + try? fileManager.copyItem(at: shmSourceURL, to: shmSnapshotURL) + } + + var database: OpaquePointer? + let openCode = sqlite3_open_v2(snapshotURL.path, &database, SQLITE_OPEN_READONLY, nil) + guard openCode == SQLITE_OK, let database else { + let message = sqliteMessage(from: database) ?? "unknown SQLite open failure" + sqlite3_close(database) + throw NSError(domain: "BrowserDataImporter", code: Int(openCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + defer { sqlite3_close(database) } + + var statement: OpaquePointer? + let prepareCode = sqlite3_prepare_v2(database, sql, -1, &statement, nil) + guard prepareCode == SQLITE_OK, let statement else { + let message = sqliteMessage(from: database) ?? "unknown SQLite prepare failure" + sqlite3_finalize(statement) + throw NSError(domain: "BrowserDataImporter", code: Int(prepareCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + defer { sqlite3_finalize(statement) } + + while true { + let stepCode = sqlite3_step(statement) + if stepCode == SQLITE_ROW { + try rowHandler(statement) + continue + } + if stepCode == SQLITE_DONE { + break + } + let message = sqliteMessage(from: database) ?? "unknown SQLite step failure" + throw NSError(domain: "BrowserDataImporter", code: Int(stepCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + } + + private static func sqliteMessage(from database: OpaquePointer?) -> String? { + guard let database, let cString = sqlite3_errmsg(database) else { return nil } + return String(cString: cString) + } + + private static func sqliteColumnText(_ statement: OpaquePointer, index: Int32) -> String? { + guard let cValue = sqlite3_column_text(statement, index) else { return nil } + return String(cString: cValue) + } + + private static func sqliteColumnInt64(_ statement: OpaquePointer, index: Int32) -> Int64 { + sqlite3_column_int64(statement, index) + } + + private static func sqliteColumnDouble(_ statement: OpaquePointer, index: Int32) -> Double { + sqlite3_column_double(statement, index) + } + + private static func sqliteColumnBytes(_ statement: OpaquePointer, index: Int32) -> Int { + Int(sqlite3_column_bytes(statement, index)) + } + + private static func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { + var seen = Set() + var result: [URL] = [] + for url in urls { + let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path + if seen.insert(canonical).inserted { + result.append(url) + } + } + return result + } +} + +@MainActor +final class BrowserDataImportCoordinator { + static let shared = BrowserDataImportCoordinator() + + private var importInProgress = false + + private init() {} + + func presentImportDialog() { + presentImportDialog(prefilledBrowsers: nil) + } + + private struct ImportSelection { + let browser: InstalledBrowserCandidate + let scope: BrowserImportScope + let domainFilters: [String] + } + + private func presentImportDialog(prefilledBrowsers: [InstalledBrowserCandidate]?) { + guard !importInProgress else { return } + let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() + guard !browsers.isEmpty else { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "No supported browsers detected" + alert.informativeText = "cmux could not find installed browser profiles to import from." + alert.addButton(withTitle: "OK") + alert.runModal() + return + } + + guard let selection = promptForSelection(browsers: browsers) else { return } + importInProgress = true + + let progressWindow = showProgressWindow( + title: "Importing Browser Data", + message: "Importing \(selection.scope.displayName.lowercased()) from \(selection.browser.displayName)…" + ) + + Task.detached(priority: .userInitiated) { + let outcome = await BrowserDataImporter.importData( + from: selection.browser, + scope: selection.scope, + domainFilters: selection.domainFilters + ) + + await MainActor.run { + self.hideProgressWindow(progressWindow) + self.presentOutcome(outcome) + self.importInProgress = false + } + } + } + + private func promptForSelection(browsers: [InstalledBrowserCandidate]) -> ImportSelection? { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Import Browser Data" + alert.informativeText = "Choose a browser and what to import." + alert.addButton(withTitle: "Import") + alert.addButton(withTitle: "Cancel") + + let browserPopup = NSPopUpButton(frame: .zero, pullsDown: false) + for browser in browsers { + browserPopup.addItem(withTitle: browser.displayName) + } + browserPopup.selectItem(at: 0) + + let scopePopup = NSPopUpButton(frame: .zero, pullsDown: false) + for scope in BrowserImportScope.allCases { + scopePopup.addItem(withTitle: scope.displayName) + scopePopup.item(at: scopePopup.numberOfItems - 1)?.representedObject = scope.rawValue + } + if let defaultIndex = BrowserImportScope.allCases.firstIndex(of: .cookiesAndHistory) { + scopePopup.selectItem(at: defaultIndex) + } + + let domainField = NSTextField(frame: .zero) + domainField.placeholderString = "Optional domains (comma or space separated)" + domainField.stringValue = "" + + let browserRow = NSStackView() + browserRow.orientation = .horizontal + browserRow.spacing = 8 + browserRow.alignment = .centerY + let browserLabel = NSTextField(labelWithString: "Browser") + browserLabel.alignment = .right + browserLabel.frame.size.width = 72 + browserRow.addArrangedSubview(browserLabel) + browserRow.addArrangedSubview(browserPopup) + + let scopeRow = NSStackView() + scopeRow.orientation = .horizontal + scopeRow.spacing = 8 + scopeRow.alignment = .centerY + let scopeLabel = NSTextField(labelWithString: "Import") + scopeLabel.alignment = .right + scopeLabel.frame.size.width = 72 + scopeRow.addArrangedSubview(scopeLabel) + scopeRow.addArrangedSubview(scopePopup) + + let domainRow = NSStackView() + domainRow.orientation = .horizontal + domainRow.spacing = 8 + domainRow.alignment = .centerY + let domainLabel = NSTextField(labelWithString: "Domains") + domainLabel.alignment = .right + domainLabel.frame.size.width = 72 + domainRow.addArrangedSubview(domainLabel) + domainRow.addArrangedSubview(domainField) + + let accessory = NSStackView() + accessory.orientation = .vertical + accessory.spacing = 8 + accessory.alignment = .leading + accessory.addArrangedSubview(browserRow) + accessory.addArrangedSubview(scopeRow) + accessory.addArrangedSubview(domainRow) + accessory.setFrameSize(NSSize(width: 420, height: 108)) + alert.accessoryView = accessory + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let browserIndex = max(0, min(browserPopup.indexOfSelectedItem, browsers.count - 1)) + let selectedBrowser = browsers[browserIndex] + let selectedScopeRaw = scopePopup.selectedItem?.representedObject as? String ?? BrowserImportScope.cookiesAndHistory.rawValue + let selectedScope = BrowserImportScope(rawValue: selectedScopeRaw) ?? .cookiesAndHistory + let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + + return ImportSelection( + browser: selectedBrowser, + scope: selectedScope, + domainFilters: domainFilters + ) + } + + private func showProgressWindow(title: String, message: String) -> NSWindow { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 122), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.title = title + window.isReleasedWhenClosed = false + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + + let content = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 122)) + + let spinner = NSProgressIndicator(frame: NSRect(x: 20, y: 50, width: 20, height: 20)) + spinner.style = .spinning + spinner.controlSize = .regular + spinner.startAnimation(nil) + content.addSubview(spinner) + + let titleLabel = NSTextField(labelWithString: message) + titleLabel.frame = NSRect(x: 52, y: 56, width: 340, height: 20) + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) + content.addSubview(titleLabel) + + let subtitleLabel = NSTextField(labelWithString: "This can take a few seconds for large profiles.") + subtitleLabel.frame = NSRect(x: 52, y: 34, width: 340, height: 16) + subtitleLabel.font = NSFont.systemFont(ofSize: 11) + subtitleLabel.textColor = .secondaryLabelColor + content.addSubview(subtitleLabel) + + window.contentView = content + + if let keyWindow = NSApp.keyWindow { + keyWindow.beginSheet(window, completionHandler: nil) + } else { + window.center() + window.makeKeyAndOrderFront(nil) + } + + return window + } + + private func hideProgressWindow(_ window: NSWindow) { + if let parent = window.sheetParent { + parent.endSheet(window) + } else { + window.orderOut(nil) + } + } + + private func presentOutcome(_ outcome: BrowserImportOutcome) { + var lines: [String] = [] + lines.append("Browser: \(outcome.browserName)") + lines.append("Scope: \(outcome.scope.displayName)") + lines.append("Imported cookies: \(outcome.importedCookies)") + if outcome.skippedCookies > 0 { + lines.append("Skipped cookies: \(outcome.skippedCookies)") + } + if outcome.scope.includesHistory { + lines.append("Imported history entries: \(outcome.importedHistoryEntries)") + } + if !outcome.domainFilters.isEmpty { + lines.append("Domain filter: \(outcome.domainFilters.joined(separator: ", "))") + } + if !outcome.warnings.isEmpty { + lines.append("") + lines.append("Warnings:") + for warning in outcome.warnings { + lines.append("- \(warning)") + } + } + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Browser data import complete" + alert.informativeText = lines.joined(separator: "\n") + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 9be61c8e..e7eea132 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -175,6 +175,7 @@ struct BrowserPanelView: View { @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @State private var latestRemoteSuggestions: [String] = [] + @State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = [] @State private var inlineCompletion: OmnibarInlineCompletion? @State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0) @State private var omnibarHasMarkedText: Bool = false @@ -304,6 +305,7 @@ struct BrowserPanelView: View { syncURLFromPanel() // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() + refreshEmptyStateImportBrowsers() BrowserHistoryStore.shared.loadIfNeeded() } .onChange(of: panel.focusFlashToken) { _ in @@ -320,6 +322,9 @@ struct BrowserPanelView: View { !isWebViewBlank() { addressBarFocused = false } + if isWebViewBlank() { + refreshEmptyStateImportBrowsers() + } } .onChange(of: forcedDarkModeEnabled) { _ in panel.setForcedDarkMode( @@ -644,7 +649,12 @@ struct BrowserPanelView: View { if addressBarFocused { addressBarFocused = false } - } + } + } + } + .overlay { + if isWebViewBlank() { + emptyBrowserStateOverlay } } .zIndex(0) @@ -693,6 +703,56 @@ struct BrowserPanelView: View { panel.acknowledgeAddressBarFocusRequest(requestId) } + private var emptyBrowserStateOverlay: some View { + VStack { + Spacer(minLength: 22) + + VStack(alignment: .leading, spacing: 10) { + Text("Start browsing") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + + Text("Search the web, enter a URL, or import cookies/history from another browser.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Button("Focus Address Bar") { + onRequestPanelFocus() + addressBarFocused = true + } + .buttonStyle(.bordered) + + Button("Import Browser Data…") { + refreshEmptyStateImportBrowsers() + BrowserDataImportCoordinator.shared.presentImportDialog() + } + .buttonStyle(.borderedProminent) + } + } + .padding(16) + .frame(maxWidth: 460, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.96)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.12), radius: 12, y: 4) + + Spacer() + } + .padding(.horizontal, 18) + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } @@ -710,6 +770,10 @@ struct BrowserPanelView: View { addressBarFocused = true } + private func refreshEmptyStateImportBrowsers() { + emptyStateImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() + } + private func openDevTools() { #if DEBUG dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 09b18c59..636a6969 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -470,6 +470,10 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } + Button("Import Browser Data…") { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + Button("Next Workspace") { (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() } @@ -2474,6 +2478,7 @@ struct SettingsView: View { @State private var showOpenAccessConfirmation = false @State private var pendingOpenAccessMode: SocketControlMode? @State private var browserHistoryEntryCount: Int = 0 + @State private var detectedImportBrowsers: [InstalledBrowserCandidate] = [] @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText @State private var socketPasswordDraft = "" @State private var socketPasswordStatusMessage: String? @@ -2521,6 +2526,10 @@ struct SettingsView: View { } } + private var browserImportSubtitle: String { + InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) + } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -2917,6 +2926,25 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow("Import Browser Data", subtitle: browserImportSubtitle) { + HStack(spacing: 8) { + Button("Import…") { + BrowserDataImportCoordinator.shared.presentImportDialog() + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button("Refresh") { + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + SettingsCardDivider() + SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { Button("Clear History…") { showClearBrowserHistoryConfirmation = true @@ -3042,6 +3070,7 @@ struct SettingsView: View { browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity) browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + refreshDetectedImportBrowsers() } .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in // Keep draft in sync with external changes unless the user has local unsaved edits. @@ -3103,6 +3132,7 @@ struct SettingsView: View { socketPasswordDraft = "" socketPasswordStatusMessage = nil socketPasswordStatusIsError = false + refreshDetectedImportBrowsers() KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } @@ -3110,6 +3140,10 @@ struct SettingsView: View { private func saveBrowserInsecureHTTPAllowlist() { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } + + private func refreshDetectedImportBrowsers() { + detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() + } } private struct SettingsTopOffsetPreferenceKey: PreferenceKey { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index effff6ad..c0aa5089 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -519,3 +519,87 @@ final class PostHogAnalyticsPropertiesTests: XCTestCase { XCTAssertNil(dailyProperties["app_build"]) } } + +final class BrowserInstallDetectorTests: XCTestCase { + func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + try createFile( + at: home + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"), + contents: Data() + ) + try createFile( + at: home + .appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"), + contents: Data() + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { bundleIdentifier in + if bundleIdentifier == "com.google.Chrome" { + return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true) + } + return nil + }, + applicationSearchDirectories: [] + ) + + guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else { + XCTFail("Expected Chrome to be detected") + return + } + guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else { + XCTFail("Expected Firefox to be detected from profile data") + return + } + + XCTAssertNotNil(chrome.appURL) + XCTAssertEqual(firefox.profileURLs.count, 1) + XCTAssertNil(firefox.appURL) + } + + func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + XCTAssertTrue(detected.isEmpty) + } + + func testUngoogledChromiumRequiresAppSignal() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + try createFile( + at: home + .appendingPathComponent("Library/Application Support/Chromium/Default/History"), + contents: Data() + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" })) + XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" })) + } + + private func makeTemporaryHome() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)") + } + + private func createFile(at url: URL, contents: Data) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + _ = FileManager.default.createFile(atPath: url.path, contents: contents) + } +} From e70ebe6df30eb539936a878cad49fce35ecce40d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:22:12 -0800 Subject: [PATCH 02/77] Tone down empty browser import overlay --- Sources/Panels/BrowserPanelView.swift | 42 ++++++++++----------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index e7eea132..c05b75fe 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -707,46 +707,36 @@ struct BrowserPanelView: View { VStack { Spacer(minLength: 22) - VStack(alignment: .leading, spacing: 10) { - Text("Start browsing") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - - Text("Search the web, enter a URL, or import cookies/history from another browser.") - .font(.system(size: 13)) + VStack(alignment: .leading, spacing: 8) { + Text("Import browser data") + .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 8) { - Button("Focus Address Bar") { - onRequestPanelFocus() - addressBarFocused = true - } - .buttonStyle(.bordered) - - Button("Import Browser Data…") { - refreshEmptyStateImportBrowsers() - BrowserDataImportCoordinator.shared.presentImportDialog() - } - .buttonStyle(.borderedProminent) + Button("Import…") { + refreshEmptyStateImportBrowsers() + BrowserDataImportCoordinator.shared.presentImportDialog() } + .buttonStyle(.bordered) + .controlSize(.small) } - .padding(16) - .frame(maxWidth: 460, alignment: .leading) + .padding(12) + .frame(maxWidth: 360, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor).opacity(0.96)) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.9)) ) .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1) + RoundedRectangle(cornerRadius: 12, style: .continuous).stroke( + Color(nsColor: .separatorColor).opacity(0.45), + lineWidth: 1 + ) ) - .shadow(color: Color.black.opacity(0.12), radius: 12, y: 4) + .shadow(color: Color.black.opacity(0.08), radius: 8, y: 3) Spacer() } From c1ffc178b863edce08f265e4925bbdd10cebd72f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:27:23 -0800 Subject: [PATCH 03/77] Make browser import a 2-step choice flow --- Sources/Panels/BrowserPanel.swift | 201 +++++++++++++++++++------- Sources/Panels/BrowserPanelView.swift | 2 +- Sources/cmuxApp.swift | 6 +- cmuxTests/GhosttyConfigTests.swift | 38 +++++ 4 files changed, 192 insertions(+), 55 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d58fd21f..a67cf4ab 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2874,6 +2874,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { enum BrowserImportScope: String, CaseIterable, Identifiable { case cookiesOnly + case historyOnly case cookiesAndHistory case everything @@ -2883,6 +2884,8 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { switch self { case .cookiesOnly: return "Cookies only" + case .historyOnly: + return "History only" case .cookiesAndHistory: return "Cookies + history" case .everything: @@ -2894,6 +2897,8 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { switch self { case .cookiesOnly, .cookiesAndHistory, .everything: return true + case .historyOnly: + return false } } @@ -2901,10 +2906,28 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { switch self { case .cookiesOnly: return false - case .cookiesAndHistory, .everything: + case .historyOnly, .cookiesAndHistory, .everything: return true } } + + static func fromSelection( + includeCookies: Bool, + includeHistory: Bool, + includeAdditionalData: Bool + ) -> BrowserImportScope? { + guard includeCookies || includeHistory else { return nil } + if includeAdditionalData { + return .everything + } + if includeCookies && includeHistory { + return .cookiesAndHistory + } + if includeCookies { + return .cookiesOnly + } + return .historyOnly + } } enum BrowserImportEngineFamily: String, Hashable { @@ -4134,14 +4157,20 @@ final class BrowserDataImportCoordinator { let domainFilters: [String] } + private enum ImportOptionsPromptResult { + case proceed(scope: BrowserImportScope, domainFilters: [String]) + case back + case cancel + } + private func presentImportDialog(prefilledBrowsers: [InstalledBrowserCandidate]?) { guard !importInProgress else { return } let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() guard !browsers.isEmpty else { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "No supported browsers detected" - alert.informativeText = "cmux could not find installed browser profiles to import from." + alert.messageText = "No importable browsers found" + alert.informativeText = "cmux could not find browser profiles to import from on this Mac." alert.addButton(withTitle: "OK") alert.runModal() return @@ -4171,84 +4200,154 @@ final class BrowserDataImportCoordinator { } private func promptForSelection(browsers: [InstalledBrowserCandidate]) -> ImportSelection? { + guard !browsers.isEmpty else { return nil } + var preselectedBrowser = browsers[0] + + while true { + guard let selectedBrowser = promptForBrowserSelection( + browsers: browsers, + preselectedBrowserID: preselectedBrowser.id + ) else { + return nil + } + preselectedBrowser = selectedBrowser + + switch promptForImportOptions(for: selectedBrowser) { + case .proceed(let scope, let domainFilters): + return ImportSelection( + browser: selectedBrowser, + scope: scope, + domainFilters: domainFilters + ) + case .back: + continue + case .cancel: + return nil + } + } + } + + private func promptForBrowserSelection( + browsers: [InstalledBrowserCandidate], + preselectedBrowserID: String + ) -> InstalledBrowserCandidate? { let alert = NSAlert() alert.alertStyle = .informational alert.messageText = "Import Browser Data" - alert.informativeText = "Choose a browser and what to import." - alert.addButton(withTitle: "Import") + alert.informativeText = "Step 1 of 2: Choose the browser to import from." + alert.addButton(withTitle: "Next") alert.addButton(withTitle: "Cancel") let browserPopup = NSPopUpButton(frame: .zero, pullsDown: false) for browser in browsers { browserPopup.addItem(withTitle: browser.displayName) } - browserPopup.selectItem(at: 0) - - let scopePopup = NSPopUpButton(frame: .zero, pullsDown: false) - for scope in BrowserImportScope.allCases { - scopePopup.addItem(withTitle: scope.displayName) - scopePopup.item(at: scopePopup.numberOfItems - 1)?.representedObject = scope.rawValue + if let index = browsers.firstIndex(where: { $0.id == preselectedBrowserID }) { + browserPopup.selectItem(at: index) + } else { + browserPopup.selectItem(at: 0) } - if let defaultIndex = BrowserImportScope.allCases.firstIndex(of: .cookiesAndHistory) { - scopePopup.selectItem(at: defaultIndex) - } - - let domainField = NSTextField(frame: .zero) - domainField.placeholderString = "Optional domains (comma or space separated)" - domainField.stringValue = "" let browserRow = NSStackView() browserRow.orientation = .horizontal browserRow.spacing = 8 browserRow.alignment = .centerY - let browserLabel = NSTextField(labelWithString: "Browser") + let browserLabel = NSTextField(labelWithString: "Source") browserLabel.alignment = .right - browserLabel.frame.size.width = 72 + browserLabel.frame.size.width = 80 browserRow.addArrangedSubview(browserLabel) browserRow.addArrangedSubview(browserPopup) - let scopeRow = NSStackView() - scopeRow.orientation = .horizontal - scopeRow.spacing = 8 - scopeRow.alignment = .centerY - let scopeLabel = NSTextField(labelWithString: "Import") - scopeLabel.alignment = .right - scopeLabel.frame.size.width = 72 - scopeRow.addArrangedSubview(scopeLabel) - scopeRow.addArrangedSubview(scopePopup) - - let domainRow = NSStackView() - domainRow.orientation = .horizontal - domainRow.spacing = 8 - domainRow.alignment = .centerY - let domainLabel = NSTextField(labelWithString: "Domains") - domainLabel.alignment = .right - domainLabel.frame.size.width = 72 - domainRow.addArrangedSubview(domainLabel) - domainRow.addArrangedSubview(domainField) + let hintLabel = NSTextField(wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers)) + hintLabel.font = NSFont.systemFont(ofSize: 11) + hintLabel.textColor = .secondaryLabelColor let accessory = NSStackView() accessory.orientation = .vertical accessory.spacing = 8 accessory.alignment = .leading accessory.addArrangedSubview(browserRow) - accessory.addArrangedSubview(scopeRow) - accessory.addArrangedSubview(domainRow) - accessory.setFrameSize(NSSize(width: 420, height: 108)) + accessory.addArrangedSubview(hintLabel) + accessory.setFrameSize(NSSize(width: 420, height: 72)) alert.accessoryView = accessory guard alert.runModal() == .alertFirstButtonReturn else { return nil } let browserIndex = max(0, min(browserPopup.indexOfSelectedItem, browsers.count - 1)) - let selectedBrowser = browsers[browserIndex] - let selectedScopeRaw = scopePopup.selectedItem?.representedObject as? String ?? BrowserImportScope.cookiesAndHistory.rawValue - let selectedScope = BrowserImportScope(rawValue: selectedScopeRaw) ?? .cookiesAndHistory - let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + return browsers[browserIndex] + } - return ImportSelection( - browser: selectedBrowser, - scope: selectedScope, - domainFilters: domainFilters - ) + private func promptForImportOptions(for browser: InstalledBrowserCandidate) -> ImportOptionsPromptResult { + while true { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Choose What to Import" + alert.informativeText = "Step 2 of 2: Pick data types from \(browser.displayName). Nothing is imported until you click Start Import." + alert.addButton(withTitle: "Start Import") + alert.addButton(withTitle: "Back") + alert.addButton(withTitle: "Cancel") + + let cookiesCheckbox = NSButton(checkboxWithTitle: "Cookies (site sign-ins)", target: nil, action: nil) + cookiesCheckbox.state = .on + + let historyCheckbox = NSButton(checkboxWithTitle: "History (visited pages)", target: nil, action: nil) + historyCheckbox.state = .on + + let domainField = NSTextField(frame: .zero) + domainField.placeholderString = "Optional domains only (e.g. github.com, openai.com)" + domainField.stringValue = "" + + let domainRow = NSStackView() + domainRow.orientation = .horizontal + domainRow.spacing = 8 + domainRow.alignment = .centerY + let domainLabel = NSTextField(labelWithString: "Limit to") + domainLabel.alignment = .right + domainLabel.frame.size.width = 80 + domainRow.addArrangedSubview(domainLabel) + domainRow.addArrangedSubview(domainField) + + let noteLabel = NSTextField( + wrappingLabelWithString: "Bookmarks and settings import is not available yet." + ) + noteLabel.font = NSFont.systemFont(ofSize: 11) + noteLabel.textColor = .secondaryLabelColor + + let accessory = NSStackView() + accessory.orientation = .vertical + accessory.spacing = 8 + accessory.alignment = .leading + accessory.addArrangedSubview(cookiesCheckbox) + accessory.addArrangedSubview(historyCheckbox) + accessory.addArrangedSubview(domainRow) + accessory.addArrangedSubview(noteLabel) + accessory.setFrameSize(NSSize(width: 440, height: 122)) + alert.accessoryView = accessory + + switch alert.runModal() { + case .alertFirstButtonReturn: + let includeCookies = cookiesCheckbox.state == .on + let includeHistory = historyCheckbox.state == .on + guard let scope = BrowserImportScope.fromSelection( + includeCookies: includeCookies, + includeHistory: includeHistory, + includeAdditionalData: false + ) else { + let validationAlert = NSAlert() + validationAlert.alertStyle = .warning + validationAlert.messageText = "Choose at least one data type" + validationAlert.informativeText = "Select Cookies, History, or both before starting import." + validationAlert.addButton(withTitle: "OK") + validationAlert.runModal() + continue + } + let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + return .proceed(scope: scope, domainFilters: domainFilters) + case .alertSecondButtonReturn: + return .back + default: + return .cancel + } + } } private func showProgressWindow(title: String, message: String) -> NSWindow { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c05b75fe..e11097d1 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -717,7 +717,7 @@ struct BrowserPanelView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - Button("Import…") { + Button("Choose What to Import…") { refreshEmptyStateImportBrowsers() BrowserDataImportCoordinator.shared.presentImportDialog() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 636a6969..5f200bc4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -470,7 +470,7 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } - Button("Import Browser Data…") { + Button("Import From Browser…") { BrowserDataImportCoordinator.shared.presentImportDialog() } @@ -2926,9 +2926,9 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow("Import Browser Data", subtitle: browserImportSubtitle) { + SettingsCardRow("Import From Browser", subtitle: browserImportSubtitle) { HStack(spacing: 8) { - Button("Import…") { + Button("Choose…") { BrowserDataImportCoordinator.shared.presentImportDialog() refreshDetectedImportBrowsers() } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index c0aa5089..994ccf25 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -603,3 +603,41 @@ final class BrowserInstallDetectorTests: XCTestCase { _ = FileManager.default.createFile(atPath: url.path, contents: contents) } } + +final class BrowserImportScopeTests: XCTestCase { + func testFromSelectionCookiesOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesOnly) + } + + func testFromSelectionHistoryOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .historyOnly) + } + + func testFromSelectionCookiesAndHistory() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesAndHistory) + } + + func testFromSelectionRejectsEmptySelection() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertNil(scope) + } +} From 3bce41955ff2ba923b810bc4ede6b1dd4e9d09bb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:32:18 -0800 Subject: [PATCH 04/77] Use single-window browser import wizard with close button --- Sources/Panels/BrowserPanel.swift | 396 ++++++++++++++++++++---------- 1 file changed, 267 insertions(+), 129 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a67cf4ab..65b138e5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4157,12 +4157,6 @@ final class BrowserDataImportCoordinator { let domainFilters: [String] } - private enum ImportOptionsPromptResult { - case proceed(scope: BrowserImportScope, domainFilters: [String]) - case back - case cancel - } - private func presentImportDialog(prefilledBrowsers: [InstalledBrowserCandidate]?) { guard !importInProgress else { return } let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() @@ -4201,130 +4195,99 @@ final class BrowserDataImportCoordinator { private func promptForSelection(browsers: [InstalledBrowserCandidate]) -> ImportSelection? { guard !browsers.isEmpty else { return nil } - var preselectedBrowser = browsers[0] - - while true { - guard let selectedBrowser = promptForBrowserSelection( - browsers: browsers, - preselectedBrowserID: preselectedBrowser.id - ) else { - return nil - } - preselectedBrowser = selectedBrowser - - switch promptForImportOptions(for: selectedBrowser) { - case .proceed(let scope, let domainFilters): - return ImportSelection( - browser: selectedBrowser, - scope: scope, - domainFilters: domainFilters - ) - case .back: - continue - case .cancel: - return nil - } - } + let wizard = ImportWizardWindowController(browsers: browsers) + return wizard.runModal() } - private func promptForBrowserSelection( - browsers: [InstalledBrowserCandidate], - preselectedBrowserID: String - ) -> InstalledBrowserCandidate? { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = "Import Browser Data" - alert.informativeText = "Step 1 of 2: Choose the browser to import from." - alert.addButton(withTitle: "Next") - alert.addButton(withTitle: "Cancel") - - let browserPopup = NSPopUpButton(frame: .zero, pullsDown: false) - for browser in browsers { - browserPopup.addItem(withTitle: browser.displayName) - } - if let index = browsers.firstIndex(where: { $0.id == preselectedBrowserID }) { - browserPopup.selectItem(at: index) - } else { - browserPopup.selectItem(at: 0) + @MainActor + private final class ImportWizardWindowController: NSObject, @preconcurrency NSWindowDelegate { + private enum Step { + case source + case dataTypes } - let browserRow = NSStackView() - browserRow.orientation = .horizontal - browserRow.spacing = 8 - browserRow.alignment = .centerY - let browserLabel = NSTextField(labelWithString: "Source") - browserLabel.alignment = .right - browserLabel.frame.size.width = 80 - browserRow.addArrangedSubview(browserLabel) - browserRow.addArrangedSubview(browserPopup) + private let browsers: [InstalledBrowserCandidate] - let hintLabel = NSTextField(wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers)) - hintLabel.font = NSFont.systemFont(ofSize: 11) - hintLabel.textColor = .secondaryLabelColor + private var step: Step = .source + private var didFinishModal = false + private(set) var selection: ImportSelection? - let accessory = NSStackView() - accessory.orientation = .vertical - accessory.spacing = 8 - accessory.alignment = .leading - accessory.addArrangedSubview(browserRow) - accessory.addArrangedSubview(hintLabel) - accessory.setFrameSize(NSSize(width: 420, height: 72)) - alert.accessoryView = accessory + private let panel: NSPanel - guard alert.runModal() == .alertFirstButtonReturn else { return nil } - let browserIndex = max(0, min(browserPopup.indexOfSelectedItem, browsers.count - 1)) - return browsers[browserIndex] - } + private let stepLabel = NSTextField(labelWithString: "") + private let sourcePopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let sourceContainer = NSStackView() + private let dataTypesContainer = NSStackView() + private let validationLabel = NSTextField(labelWithString: "") - private func promptForImportOptions(for browser: InstalledBrowserCandidate) -> ImportOptionsPromptResult { - while true { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = "Choose What to Import" - alert.informativeText = "Step 2 of 2: Pick data types from \(browser.displayName). Nothing is imported until you click Start Import." - alert.addButton(withTitle: "Start Import") - alert.addButton(withTitle: "Back") - alert.addButton(withTitle: "Cancel") + private let cookiesCheckbox = NSButton( + checkboxWithTitle: "Cookies (site sign-ins)", + target: nil, + action: nil + ) + private let historyCheckbox = NSButton( + checkboxWithTitle: "History (visited pages)", + target: nil, + action: nil + ) + private let domainField = NSTextField(frame: .zero) - let cookiesCheckbox = NSButton(checkboxWithTitle: "Cookies (site sign-ins)", target: nil, action: nil) - cookiesCheckbox.state = .on + private let backButton = NSButton(title: "Back", target: nil, action: nil) + private let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) + private let primaryButton = NSButton(title: "Next", target: nil, action: nil) - let historyCheckbox = NSButton(checkboxWithTitle: "History (visited pages)", target: nil, action: nil) - historyCheckbox.state = .on - - let domainField = NSTextField(frame: .zero) - domainField.placeholderString = "Optional domains only (e.g. github.com, openai.com)" - domainField.stringValue = "" - - let domainRow = NSStackView() - domainRow.orientation = .horizontal - domainRow.spacing = 8 - domainRow.alignment = .centerY - let domainLabel = NSTextField(labelWithString: "Limit to") - domainLabel.alignment = .right - domainLabel.frame.size.width = 80 - domainRow.addArrangedSubview(domainLabel) - domainRow.addArrangedSubview(domainField) - - let noteLabel = NSTextField( - wrappingLabelWithString: "Bookmarks and settings import is not available yet." + init(browsers: [InstalledBrowserCandidate]) { + self.browsers = browsers + self.panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false ) - noteLabel.font = NSFont.systemFont(ofSize: 11) - noteLabel.textColor = .secondaryLabelColor + super.init() + setupUI() + configureInitialState() + } - let accessory = NSStackView() - accessory.orientation = .vertical - accessory.spacing = 8 - accessory.alignment = .leading - accessory.addArrangedSubview(cookiesCheckbox) - accessory.addArrangedSubview(historyCheckbox) - accessory.addArrangedSubview(domainRow) - accessory.addArrangedSubview(noteLabel) - accessory.setFrameSize(NSSize(width: 440, height: 122)) - alert.accessoryView = accessory + func runModal() -> ImportSelection? { + panel.center() + panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) - switch alert.runModal() { - case .alertFirstButtonReturn: + let response = NSApp.runModal(for: panel) + if panel.isVisible { + panel.orderOut(nil) + } + + guard response == .OK else { return nil } + return selection + } + + func windowWillClose(_ notification: Notification) { + finishModal(with: .cancel) + } + + @objc + private func handleBack() { + guard step == .dataTypes else { return } + step = .source + validationLabel.isHidden = true + updateStepUI() + } + + @objc + private func handleCancel() { + finishModal(with: .cancel) + } + + @objc + private func handlePrimary() { + switch step { + case .source: + step = .dataTypes + validationLabel.isHidden = true + updateStepUI() + case .dataTypes: let includeCookies = cookiesCheckbox.state == .on let includeHistory = historyCheckbox.state == .on guard let scope = BrowserImportScope.fromSelection( @@ -4332,22 +4295,197 @@ final class BrowserDataImportCoordinator { includeHistory: includeHistory, includeAdditionalData: false ) else { - let validationAlert = NSAlert() - validationAlert.alertStyle = .warning - validationAlert.messageText = "Choose at least one data type" - validationAlert.informativeText = "Select Cookies, History, or both before starting import." - validationAlert.addButton(withTitle: "OK") - validationAlert.runModal() - continue + validationLabel.stringValue = "Select Cookies, History, or both before starting import." + validationLabel.isHidden = false + return } + + let selectedIndex = max(0, min(sourcePopup.indexOfSelectedItem, browsers.count - 1)) + let selectedBrowser = browsers[selectedIndex] let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) - return .proceed(scope: scope, domainFilters: domainFilters) - case .alertSecondButtonReturn: - return .back - default: - return .cancel + selection = ImportSelection( + browser: selectedBrowser, + scope: scope, + domainFilters: domainFilters + ) + finishModal(with: .OK) } } + + private func setupUI() { + panel.title = "Import Browser Data" + panel.isReleasedWhenClosed = false + panel.delegate = self + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 300)) + contentView.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = contentView + + let titleLabel = NSTextField(labelWithString: "Import Browser Data") + titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .semibold) + + stepLabel.font = NSFont.systemFont(ofSize: 15, weight: .medium) + stepLabel.textColor = .secondaryLabelColor + + setupSourceContainer() + setupDataTypesContainer() + + validationLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + validationLabel.textColor = .systemRed + validationLabel.isHidden = true + validationLabel.lineBreakMode = .byWordWrapping + validationLabel.maximumNumberOfLines = 2 + + backButton.target = self + backButton.action = #selector(handleBack) + backButton.bezelStyle = .rounded + + cancelButton.target = self + cancelButton.action = #selector(handleCancel) + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + + primaryButton.target = self + primaryButton.action = #selector(handlePrimary) + primaryButton.bezelStyle = .rounded + primaryButton.keyEquivalent = "\r" + + let buttonSpacer = NSView(frame: .zero) + + let buttonRow = NSStackView(views: [buttonSpacer, backButton, cancelButton, primaryButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 8 + buttonRow.alignment = .centerY + buttonRow.translatesAutoresizingMaskIntoConstraints = false + buttonSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + buttonSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let contentStack = NSStackView( + views: [titleLabel, stepLabel, sourceContainer, dataTypesContainer, validationLabel] + ) + contentStack.orientation = .vertical + contentStack.spacing = 10 + contentStack.alignment = .leading + contentStack.translatesAutoresizingMaskIntoConstraints = false + + guard let panelContent = panel.contentView else { return } + panelContent.addSubview(contentStack) + panelContent.addSubview(buttonRow) + + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 18), + contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), + contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + + buttonRow.topAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 14), + buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), + buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -16), + ]) + } + + private func setupSourceContainer() { + for browser in browsers { + sourcePopup.addItem(withTitle: browser.displayName) + } + sourcePopup.selectItem(at: 0) + + let sourceLabel = NSTextField(labelWithString: "Source") + sourceLabel.alignment = .right + sourceLabel.frame.size.width = 80 + + let sourceRow = NSStackView(views: [sourceLabel, sourcePopup]) + sourceRow.orientation = .horizontal + sourceRow.spacing = 8 + sourceRow.alignment = .centerY + + let detectedLabel = NSTextField( + wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers) + ) + detectedLabel.font = NSFont.systemFont(ofSize: 12) + detectedLabel.textColor = .secondaryLabelColor + detectedLabel.maximumNumberOfLines = 2 + detectedLabel.preferredMaxLayoutWidth = 500 + + sourceContainer.orientation = .vertical + sourceContainer.spacing = 10 + sourceContainer.alignment = .leading + sourceContainer.addArrangedSubview(sourceRow) + sourceContainer.addArrangedSubview(detectedLabel) + } + + private func setupDataTypesContainer() { + cookiesCheckbox.state = .on + historyCheckbox.state = .on + + domainField.placeholderString = "Optional domains only (e.g. github.com, openai.com)" + domainField.stringValue = "" + + let domainLabel = NSTextField(labelWithString: "Limit to") + domainLabel.alignment = .right + domainLabel.frame.size.width = 80 + + let domainRow = NSStackView(views: [domainLabel, domainField]) + domainRow.orientation = .horizontal + domainRow.spacing = 8 + domainRow.alignment = .centerY + + let noteLabel = NSTextField( + wrappingLabelWithString: "Bookmarks and settings import is not available yet." + ) + noteLabel.font = NSFont.systemFont(ofSize: 12) + noteLabel.textColor = .secondaryLabelColor + noteLabel.maximumNumberOfLines = 2 + noteLabel.preferredMaxLayoutWidth = 500 + + dataTypesContainer.orientation = .vertical + dataTypesContainer.spacing = 8 + dataTypesContainer.alignment = .leading + dataTypesContainer.addArrangedSubview(cookiesCheckbox) + dataTypesContainer.addArrangedSubview(historyCheckbox) + dataTypesContainer.addArrangedSubview(domainRow) + dataTypesContainer.addArrangedSubview(noteLabel) + } + + private func configureInitialState() { + step = .source + updateStepUI() + } + + private func updateStepUI() { + switch step { + case .source: + stepLabel.stringValue = "Step 1 of 2: Choose the browser to import from." + sourceContainer.isHidden = false + dataTypesContainer.isHidden = true + backButton.isHidden = true + primaryButton.title = "Next" + case .dataTypes: + let selectedBrowserName = selectedBrowser().displayName + stepLabel.stringValue = "Step 2 of 2: Choose what to import from \(selectedBrowserName)." + sourceContainer.isHidden = true + dataTypesContainer.isHidden = false + backButton.isHidden = false + primaryButton.title = "Start Import" + } + } + + private func selectedBrowser() -> InstalledBrowserCandidate { + let selectedIndex = max(0, min(sourcePopup.indexOfSelectedItem, browsers.count - 1)) + return browsers[selectedIndex] + } + + private func finishModal(with response: NSApplication.ModalResponse) { + guard !didFinishModal else { return } + didFinishModal = true + + if NSApp.modalWindow == panel { + NSApp.stopModal(withCode: response) + } + panel.orderOut(nil) + } } private func showProgressWindow(title: String, message: String) -> NSWindow { From e4e53a96061760bb2742dcf048aab2e0e7718fdd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:33:12 -0800 Subject: [PATCH 05/77] Mention extensions not yet supported in import note --- Sources/Panels/BrowserPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 65b138e5..9542325a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4433,7 +4433,7 @@ final class BrowserDataImportCoordinator { domainRow.alignment = .centerY let noteLabel = NSTextField( - wrappingLabelWithString: "Bookmarks and settings import is not available yet." + wrappingLabelWithString: "Bookmarks, settings, and extensions import are not available yet." ) noteLabel.font = NSFont.systemFont(ofSize: 12) noteLabel.textColor = .secondaryLabelColor From 19b59cae37fd80666247290c7d1b18ab5d08fd48 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:54:26 -0700 Subject: [PATCH 06/77] Reapply "Merge pull request #239 from manaflow-ai/issue-151-ssh-remote-port-proxying" This reverts commit f7cbbad4342fb1cafb520aedb079cdf4a5730225. --- .github/workflows/ci.yml | 21 + .github/workflows/nightly.yml | 50 + .github/workflows/release.yml | 41 + CLAUDE.md | 2 + CLI/cmux.swift | 1257 +++++- Resources/Localizable.xcstrings | 306 ++ .../cmux-zsh-integration.zsh | 101 + Sources/ContentView.swift | 222 +- Sources/GhosttyTerminalView.swift | 136 +- Sources/Panels/BrowserPanel.swift | 116 +- Sources/Panels/TerminalPanel.swift | 31 +- Sources/SocketControlSettings.swift | 46 + Sources/TabManager.swift | 253 +- Sources/TerminalController.swift | 1344 +++--- Sources/WindowToolbarController.swift | 2 +- Sources/Workspace.swift | 4012 ++++++++++++++++- Sources/cmuxApp.swift | 13 + TODO.md | 15 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 68 + cmuxTests/GhosttyConfigTests.swift | 98 + .../TabManagerSessionSnapshotTests.swift | 49 + ...erminalControllerSocketSecurityTests.swift | 214 + daemon/remote/README.md | 82 + daemon/remote/cmd/cmuxd-remote/cli.go | 721 +++ daemon/remote/cmd/cmuxd-remote/cli_test.go | 696 +++ daemon/remote/cmd/cmuxd-remote/main.go | 1034 +++++ daemon/remote/cmd/cmuxd-remote/main_test.go | 531 +++ daemon/remote/go.mod | 3 + docs/remote-daemon-spec.md | 214 + scripts/build_remote_daemon_release_assets.sh | 140 + scripts/ghosttykit-checksums.txt | 1 + scripts/release_asset_guard.js | 11 +- scripts/release_asset_guard.test.js | 12 +- scripts/reload.sh | 118 +- tests/fixtures/ssh-remote/Dockerfile | 20 + tests/fixtures/ssh-remote/run.sh | 38 + tests/fixtures/ssh-remote/sshd_config | 31 + tests/fixtures/ssh-remote/ws_echo.py | 132 + tests/test_cli_version_flag.py | 18 +- tests/test_remote_daemon_release_assets.sh | 65 + ...est_sidebar_copy_ssh_error_context_menu.py | 79 + ..._cli_global_flags_and_v1_error_contract.py | 100 + ...est_pane_resize_preserves_ls_scrollback.py | 304 ++ ...t_pane_resize_preserves_visible_content.py | 263 ++ tests_v2/test_rename_tab_cli_parity.py | 29 +- ...t_ssh_remote_browser_move_rebinds_proxy.py | 297 ++ tests_v2/test_ssh_remote_cli_metadata.py | 630 +++ tests_v2/test_ssh_remote_cli_relay.py | 392 ++ .../test_ssh_remote_daemon_resize_stdio.py | 188 + ..._remote_docker_bootstrap_nonlogin_shell.py | 258 ++ tests_v2/test_ssh_remote_docker_forwarding.py | 742 +++ tests_v2/test_ssh_remote_docker_reconnect.py | 612 +++ ...ote_interactive_cmux_command_regression.py | 249 + ...remote_last_surface_clears_remote_state.py | 259 ++ .../test_ssh_remote_proxy_bind_conflict.py | 246 + ...ssh_remote_resize_scrollback_regression.py | 357 ++ ...sh_remote_second_session_mux_regression.py | 175 + tests_v2/test_ssh_remote_shell_integration.py | 577 +++ .../test_ssh_remote_shortcuts_stay_remote.py | 281 ++ tests_v2/test_workspace_create_initial_env.py | 86 + 60 files changed, 17139 insertions(+), 1249 deletions(-) create mode 100644 cmuxTests/TabManagerSessionSnapshotTests.swift create mode 100644 cmuxTests/TerminalControllerSocketSecurityTests.swift create mode 100644 daemon/remote/README.md create mode 100644 daemon/remote/cmd/cmuxd-remote/cli.go create mode 100644 daemon/remote/cmd/cmuxd-remote/cli_test.go create mode 100644 daemon/remote/cmd/cmuxd-remote/main.go create mode 100644 daemon/remote/cmd/cmuxd-remote/main_test.go create mode 100644 daemon/remote/go.mod create mode 100644 docs/remote-daemon-spec.md create mode 100755 scripts/build_remote_daemon_release_assets.sh create mode 100644 tests/fixtures/ssh-remote/Dockerfile create mode 100644 tests/fixtures/ssh-remote/run.sh create mode 100644 tests/fixtures/ssh-remote/sshd_config create mode 100644 tests/fixtures/ssh-remote/ws_echo.py create mode 100755 tests/test_remote_daemon_release_assets.sh create mode 100644 tests/test_sidebar_copy_ssh_error_context_menu.py create mode 100644 tests_v2/test_cli_global_flags_and_v1_error_contract.py create mode 100644 tests_v2/test_pane_resize_preserves_ls_scrollback.py create mode 100644 tests_v2/test_pane_resize_preserves_visible_content.py create mode 100644 tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py create mode 100644 tests_v2/test_ssh_remote_cli_metadata.py create mode 100644 tests_v2/test_ssh_remote_cli_relay.py create mode 100644 tests_v2/test_ssh_remote_daemon_resize_stdio.py create mode 100644 tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py create mode 100644 tests_v2/test_ssh_remote_docker_forwarding.py create mode 100644 tests_v2/test_ssh_remote_docker_reconnect.py create mode 100644 tests_v2/test_ssh_remote_interactive_cmux_command_regression.py create mode 100644 tests_v2/test_ssh_remote_last_surface_clears_remote_state.py create mode 100644 tests_v2/test_ssh_remote_proxy_bind_conflict.py create mode 100644 tests_v2/test_ssh_remote_resize_scrollback_regression.py create mode 100644 tests_v2/test_ssh_remote_second_session_mux_regression.py create mode 100755 tests_v2/test_ssh_remote_shell_integration.py create mode 100644 tests_v2/test_ssh_remote_shortcuts_stay_remote.py create mode 100644 tests_v2/test_workspace_create_initial_env.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22933f48..e7b821d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,27 @@ jobs: - name: Validate GhosttyKit checksum verification run: ./tests/test_ci_ghosttykit_checksum_verification.sh + - name: Validate release asset guard + run: node scripts/release_asset_guard.test.js + + remote-daemon-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + + - name: Run remote daemon tests + working-directory: daemon/remote + run: go test ./... + + - name: Validate remote daemon release assets + run: ./tests/test_remote_daemon_release_assets.sh + web-typecheck: runs-on: ubuntu-latest defaults: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5c46f0a3..c175487d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,6 +20,8 @@ concurrency: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -142,6 +144,11 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -240,6 +247,7 @@ jobs: NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV" ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg" @@ -284,6 +292,24 @@ jobs: echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" + - name: Build remote daemon nightly assets and inject manifest + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + run: | + set -euo pipefail + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$NIGHTLY_REMOTE_DAEMON_VERSION" \ + --release-tag "nightly" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + for APP_PLIST in \ + "build-arm/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" \ + "build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" + do + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + done + - name: Import signing cert if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: @@ -427,6 +453,18 @@ jobs: ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml + - name: Attest remote daemon nightly assets + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload branch nightly artifacts if: needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -437,6 +475,12 @@ jobs: cmux-nightly-universal-macos*.dmg appcast.xml appcast-universal.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json if-no-files-found: error - name: Move nightly tag to built commit @@ -472,6 +516,12 @@ jobs: cmux-nightly-universal-macos.dmg appcast.xml appcast-universal.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json overwrite_files: true - name: Cleanup keychain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a58f07f..bce4327c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -114,6 +116,12 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key if: steps.guard_release_assets.outputs.skip_all != 'true' env: @@ -134,6 +142,21 @@ jobs: -clonedSourcePackagesDirPath .spm-cache \ CODE_SIGNING_ALLOWED=NO build + - name: Build remote daemon release assets and inject manifest + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" + APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$APP_VERSION" \ + --release-tag "$GITHUB_REF_NAME" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + - name: Run CLI version memory guard regression if: steps.guard_release_assets.outputs.skip_all != 'true' run: | @@ -268,6 +291,18 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml + - name: Attest remote daemon release assets + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -275,6 +310,12 @@ jobs: files: | cmux-macos.dmg appcast.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json generate_release_notes: true overwrite_files: false diff --git a/CLAUDE.md b/CLAUDE.md index 0fcbfce3..8a8e4e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,8 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Untagged Debug app: `/tmp/cmux-debug.log` - Tagged Debug app (`./scripts/reload.sh --tag `): `/tmp/cmux-debug-.log` - `reload.sh` writes the current path to `/tmp/cmux-last-debug-log-path` +- `reload.sh` writes the selected dev CLI path to `/tmp/cmux-last-cli-path` +- `reload.sh` updates `/tmp/cmux-cli` and `$HOME/.local/bin/cmux-dev` to that CLI - Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift` - Free function `dlog("message")` — logs with timestamp and appends to file in real time diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8346d1ab..6329c5d4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,12 @@ import Foundation +import CryptoKit import Darwin +#if canImport(LocalAuthentication) +import LocalAuthentication +#endif +#if canImport(Security) +import Security +#endif #if canImport(Sentry) import Sentry #endif @@ -415,17 +422,22 @@ enum CLIIDFormat: String { } private enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" private static let directoryName = "cmux" private static let fileName = "socket-control-password" - static func resolve(explicit: String?) -> String? { + static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit) { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - return loadFromFile() + if let filePassword = loadFromFile() { + return filePassword + } + return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { @@ -449,6 +461,83 @@ private enum SocketPasswordResolver { } return normalized(value) } + + private static func keychainServices(socketPath: String) -> [String] { + guard let scope = keychainScope(socketPath: socketPath) else { + return [service] + } + return ["\(service).\(scope)"] + } + + private static func keychainScope(socketPath: String) -> String? { + if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } + } + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start.. String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func loadFromKeychain(socketPath: String) -> String? { + for service in keychainServices(socketPath: socketPath) { + let authContext = LAContext() + authContext.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + // Never trigger keychain UI from CLI commands; fail fast instead. + kSecUseAuthenticationContext as String: authContext, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { + continue + } + guard status == errSecSuccess else { + continue + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + continue + } + return password + } + return nil + } } private enum CLISocketPathSource { @@ -619,6 +708,10 @@ final class SocketClient { self.path = path } + var socketPath: String { + path + } + func connect() throws { if socketFD >= 0 { return } @@ -791,6 +884,53 @@ final class SocketClient { struct CMUXCLI { let args: [String] + private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func pathIsSocket(_ path: String) -> Bool { + var st = stat() + guard lstat(path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + + private static func debugSocketPathFromHintFile() -> String? { +#if DEBUG + guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { + return nil + } + guard let hinted = normalizedEnvValue(raw), + hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasSuffix(".sock"), + pathIsSocket(hinted) else { + return nil + } + return hinted +#else + return nil +#endif + } + + private static func defaultSocketPath(environment: [String: String]) -> String { + if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { + return explicit + } +#if DEBUG + if let hinted = debugSocketPathFromHintFile() { + return hinted + } + return "/tmp/cmux-debug.sock" +#else + return "/tmp/cmux.sock" +#endif + } + func run() throws { let processEnv = ProcessInfo.processInfo.environment let envSocketPath: String? = { @@ -891,6 +1031,11 @@ struct CMUXCLI { return } + if command == "remote-daemon-status" { + try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput) + return + } + // If the argument looks like a path (not a known command), open a workspace there. if looksLikePath(command) { try openPath(command, socketPath: resolvedSocketPath) @@ -970,7 +1115,11 @@ struct CMUXCLI { } defer { client.close() } - try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg) + try authenticateClientIfNeeded( + client, + explicitPassword: socketPasswordArg, + socketPath: resolvedSocketPath + ) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) @@ -1115,14 +1264,27 @@ struct CMUXCLI { let selected = (ws["selected"] as? Bool) == true let handle = textHandle(ws, idFormat: idFormat) let title = (ws["title"] as? String) ?? "" + let remoteTag: String = { + guard let remote = ws["remote"] as? [String: Any], + (remote["enabled"] as? Bool) == true else { + return "" + } + let state = (remote["state"] as? String) ?? "unknown" + return " [ssh:\(state)]" + }() let prefix = selected ? "* " : " " let selTag = selected ? " [selected]" : "" let titlePart = title.isEmpty ? "" : " \(title)" - print("\(prefix)\(handle)\(titlePart)\(selTag)") + print("\(prefix)\(handle)\(titlePart)\(remoteTag)\(selTag)") } } } + case "ssh": + try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "ssh-session-end": + try runSSHSessionEnd(commandArgs: commandArgs, client: client) + case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd") @@ -1571,109 +1733,6 @@ struct CMUXCLI { throw error } - case "set-status": - let (icon, r1) = parseOption(commandArgs, name: "--icon") - let (color, r2) = parseOption(r1, name: "--color") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - guard r3.count >= 2 else { - throw CLIError(message: "set-status requires and ") - } - let key = r3[0] - let value = r3.dropFirst().joined(separator: " ") - guard !value.isEmpty else { - throw CLIError(message: "set-status requires a non-empty value") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_status \(key) \(socketQuote(value))" - if let icon { socketCmd += " --icon=\(socketQuote(icon))" } - if let color { socketCmd += " --color=\(socketQuote(color))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-status": - let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace") - guard let key = csRemaining.first else { - throw CLIError(message: "clear-status requires a ") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client) - print(response) - - case "list-status": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("list_status --tab=\(wsId)", client: client) - print(response) - - case "set-progress": - let (label, spR1) = parseOption(commandArgs, name: "--label") - let (wsFlag, spR2) = parseOption(spR1, name: "--workspace") - guard let valueStr = spR2.first else { - throw CLIError(message: "set-progress requires a progress value (0.0-1.0)") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_progress \(valueStr)" - if let label { socketCmd += " --label=\(socketQuote(label))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-progress": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client) - print(response) - - case "log": - let (level, r1) = parseOption(commandArgs, name: "--level") - let (source, r2) = parseOption(r1, name: "--source") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - // Strip leading "--" separator if present - let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3 - let message = positional.joined(separator: " ") - guard !message.isEmpty else { - throw CLIError(message: "log requires a message") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "log" - if let level { socketCmd += " --level=\(level)" } - if let source { socketCmd += " --source=\(socketQuote(source))" } - socketCmd += " --tab=\(wsId) -- \(socketQuote(message))" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-log": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_log --tab=\(wsId)", client: client) - print(response) - - case "list-log": - let (limitStr, r1) = parseOption(commandArgs, name: "--limit") - let (wsFlag, _) = parseOption(r1, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "list_log" - if let limitStr { socketCmd += " --limit=\(limitStr)" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "sidebar-state": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client) - print(response) - case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) @@ -2091,17 +2150,32 @@ struct CMUXCLI { guard connected else { throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") } - try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + pollClient, + explicitPassword: explicitPassword, + socketPath: socketPath + ) return pollClient } try client.connect() - try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) return client } - private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws { - if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) { + private func authenticateClientIfNeeded( + _ client: SocketClient, + explicitPassword: String?, + socketPath: String + ) throws { + if let socketPassword = SocketPasswordResolver.resolve( + explicit: explicitPassword, + socketPath: socketPath + ) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { @@ -2126,14 +2200,6 @@ struct CMUXCLI { process.waitUntilExit() } - private func sendV1Command(_ command: String, client: SocketClient) throws -> String { - let response = try client.send(command: command) - if response.hasPrefix("ERROR:") { - throw CLIError(message: response) - } - return response - } - private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -2142,6 +2208,14 @@ struct CMUXCLI { return .refs } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -2788,6 +2862,807 @@ struct CMUXCLI { windowOverride: windowOverride ) } + private struct SSHCommandOptions { + let destination: String + let port: Int? + let identityFile: String? + let workspaceName: String? + let sshOptions: [String] + let extraArguments: [String] + let localSocketPath: String + let remoteRelayPort: Int + } + + private struct RemoteDaemonManifest: Decodable { + struct Entry: Decodable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } + } + + private func generateRemoteRelayPort() -> Int { + // Random port in the ephemeral range (49152-65535) + Int.random(in: 49152...65535) + } + + private func randomHex(byteCount: Int) throws -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw CLIError(message: "failed to generate SSH relay credential") + } + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private func runSSH( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + // Use the socket path from this invocation (supports --socket overrides). + let localSocketPath = client.socketPath + let remoteRelayPort = generateRemoteRelayPort() + let relayID = UUID().uuidString.lowercased() + let relayToken = try randomHex(byteCount: 32) + let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) + prepareSSHTerminfoIfNeeded(sshOptions) + let sshCommand = buildSSHCommandText(sshOptions) + let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + let sshStartupCommand = buildSSHStartupCommand( + sshCommand: sshCommand, + shellFeatures: shellFeaturesValue, + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteSSHOptions = effectiveSSHOptions( + sshOptions.sshOptions, + remoteRelayPort: sshOptions.remoteRelayPort + ) + + cliDebugLog( + "cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " + + "relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " + + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "workspaceName=\(sshOptions.workspaceName?.replacingOccurrences(of: " ", with: "_") ?? "nil") " + + "extraArgs=\(sshOptions.extraArguments.count)" + ) + + let workspaceCreateParams: [String: Any] = [ + "initial_command": sshStartupCommand, + ] + + let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) + guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + let workspaceWindowId = (workspaceCreate["window_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + cliDebugLog( + "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + + "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" + ) + let configuredPayload: [String: Any] + do { + if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceName.isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": workspaceName, + ]) + } + + var configureParams: [String: Any] = [ + "workspace_id": workspaceId, + "destination": sshOptions.destination, + "auto_connect": true, + ] + if let port = sshOptions.port { + configureParams["port"] = port + } + if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) { + configureParams["identity_file"] = identityFile + } + if !remoteSSHOptions.isEmpty { + configureParams["ssh_options"] = remoteSSHOptions + } + if sshOptions.remoteRelayPort > 0 { + configureParams["relay_port"] = sshOptions.remoteRelayPort + configureParams["relay_id"] = relayID + configureParams["relay_token"] = relayToken + configureParams["local_socket_path"] = sshOptions.localSocketPath + } + configureParams["terminal_startup_command"] = sshStartupCommand + + cliDebugLog( + "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + + "target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" + ) + configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + var selectParams: [String: Any] = ["workspace_id": workspaceId] + if let workspaceWindowId, !workspaceWindowId.isEmpty { + selectParams["window_id"] = workspaceWindowId + } + _ = try client.sendV2(method: "workspace.select", params: selectParams) + let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown" + cliDebugLog( + "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" + ) + } catch { + cliDebugLog( + "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" + ) + do { + _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + } catch { + let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n" + FileHandle.standardError.write(Data(warning.utf8)) + } + throw error + } + + var payload = configuredPayload + + payload["ssh_command"] = sshCommand + payload["ssh_startup_command"] = sshStartupCommand + payload["ssh_env_overrides"] = [ + "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, + ] + payload["remote_relay_port"] = remoteRelayPort + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? workspaceId + let remote = payload["remote"] as? [String: Any] + let state = (remote?["state"] as? String) ?? "unknown" + print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state)") + } + } + + private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { + var destination: String? + var port: Int? + var identityFile: String? + var workspaceName: String? + var sshOptions: [String] = [] + var extraArguments: [String] = [] + + var passthrough = false + var index = 0 + while index < commandArgs.count { + let arg = commandArgs[index] + if passthrough { + extraArguments.append(arg) + index += 1 + continue + } + + switch arg { + case "--": + passthrough = true + index += 1 + case "--port": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --port requires a value") + } + guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else { + throw CLIError(message: "ssh: --port must be 1-65535") + } + port = parsed + index += 2 + case "--identity": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --identity requires a path") + } + identityFile = commandArgs[index + 1] + index += 2 + case "--name": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --name requires a workspace title") + } + workspaceName = commandArgs[index + 1] + index += 2 + case "--ssh-option": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --ssh-option requires a value") + } + let value = commandArgs[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + sshOptions.append(value) + } + index += 2 + default: + if arg.hasPrefix("--") { + throw CLIError(message: "ssh: unknown flag '\(arg)'") + } + if destination == nil { + if arg.hasPrefix("-") { + throw CLIError( + message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." + ) + } + destination = arg + } else { + extraArguments.append(arg) + } + index += 1 + } + } + + guard let destination else { + throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)") + } + return SSHCommandOptions( + destination: destination, + port: port, + identityFile: identityFile, + workspaceName: workspaceName, + sshOptions: sshOptions, + extraArguments: extraArguments, + localSocketPath: localSocketPath, + remoteRelayPort: remoteRelayPort + ) + } + + private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { + var parts = baseSSHArguments(options) + let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + + if options.extraArguments.isEmpty { + // No explicit remote command provided. Use RemoteCommand to bootstrap + // the relay wrapper and then hand off to an interactive shell. + if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { + parts.append("-tt") + } + if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { + parts += [ + "-o", + "RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))", + ] + } + parts.append(options.destination) + } else { + parts.append(options.destination) + parts.append(contentsOf: options.extraArguments) + } + return parts.map(shellQuote).joined(separator: " ") + } + + private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { + var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort) + if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") { + merged.append("StrictHostKeyChecking=accept-new") + } + return merged + } + + private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { + let relayExport = remoteRelayPort > 0 + ? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)" + : nil + let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures) + let innerCommand = [ + remoteEnvExports, + "export PATH=\"$HOME/.cmux/bin:$PATH\"", + relayExport, + "exec \"${SHELL:-/bin/zsh}\" -i", + ] + .compactMap { $0 } + .joined(separator: "; ") + + let outerCommand = [ + "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", + "case \"${CMUX_LOGIN_SHELL##*/}\" in", + " zsh|bash)", + " exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))", + " ;;", + " *)", + remoteEnvExports, + " export PATH=\"$HOME/.cmux/bin:$PATH\"", + relayExport, + " exec \"$CMUX_LOGIN_SHELL\" -i", + " ;;", + "esac", + ] + .compactMap { $0 } + .joined(separator: "; ") + + return outerCommand + } + + private func interactiveRemoteShellExports(shellFeatures: String) -> String { + let environment = ProcessInfo.processInfo.environment + let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty" + let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export TERM=\(shellQuote(term))", + "export COLORTERM=\(shellQuote(colorTerm))", + "export TERM_PROGRAM=\(shellQuote(termProgram))", + ] + if !termProgramVersion.isEmpty { + exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") + } + if !trimmedShellFeatures.isEmpty { + exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") + } + return exports.joined(separator: "; ") + } + + private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { + let effectiveSSHOptions = effectiveSSHOptions( + options.sshOptions, + remoteRelayPort: options.remoteRelayPort + ) + var parts: [String] = ["ssh"] + if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") { + parts += ["-o", "SetEnv COLORTERM=truecolor"] + } + if !hasSSHOptionKey(effectiveSSHOptions, key: "SendEnv") { + parts += ["-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + } + if let port = options.port { + parts += ["-p", String(port)] + } + if let identityFile = normalizedSSHIdentityPath(options.identityFile) { + parts += ["-i", identityFile] + } + for option in effectiveSSHOptions { + parts += ["-o", option] + } + return parts + } + + private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) { + guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return } + + var args = baseSSHArguments(options) + args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination] + let installScript = """ + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + """ + args.append(installScript) + + _ = runProcess( + executablePath: "/usr/bin/ssh", + arguments: Array(args.dropFirst()), + stdinText: terminfoSource + ) + } + + private func localXtermGhosttyTerminfoSource() -> String? { + let result = runProcess( + executablePath: "/usr/bin/infocmp", + arguments: ["-0", "-x", "xterm-ghostty"] + ) + guard result.status == 0 else { return nil } + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return output.isEmpty ? nil : output + } + + private func sshOptionsWithControlSocketDefaults( + _ options: [String], + remoteRelayPort: Int? = nil + ) -> [String] { + var merged: [String] = [] + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + merged.append(trimmed) + } + if !hasSSHOptionKey(merged, key: "ControlMaster") { + merged.append("ControlMaster=auto") + } + if !hasSSHOptionKey(merged, key: "ControlPersist") { + merged.append("ControlPersist=600") + } + if !hasSSHOptionKey(merged, key: "ControlPath") { + merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))") + } + return merged + } + + private func scopedGhosttyShellFeaturesValue() -> String { + let rawExisting = ProcessInfo.processInfo.environment["GHOSTTY_SHELL_FEATURES"] ?? "" + var seen: Set = [] + var merged: [String] = [] + + for token in rawExisting.split(separator: ",") { + let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !feature.isEmpty else { continue } + if seen.insert(feature).inserted { + merged.append(feature) + } + } + + for required in ["ssh-env", "ssh-terminfo"] { + if seen.insert(required).inserted { + merged.append(required) + } + } + + return merged.joined(separator: ",") + } + + private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String { + let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty + ? "" + : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" + let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) + let script = [ + shellFeaturesBootstrap, + "CMUX_SSH_SESSION_ENDED=0", + "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", + "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", + "command \(sshCommand)", + "trap - EXIT HUP INT TERM", + "cmux_ssh_session_end", + "exec ${SHELL:-/bin/zsh} -l", + ] + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: "\n") + return "/bin/zsh -ilc \(shellQuote(script))" + } + + private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { + [ + "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", + "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", + "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "elif command -v cmux >/dev/null 2>&1", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "fi", + ].joined(separator: " ") + } + + private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { + guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), + let relayPort = Int(relayPortRaw), + relayPort > 0 else { + throw CLIError(message: "ssh-session-end requires --relay-port ") + } + let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + guard let workspaceRaw, + let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), + !workspaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") + } + guard let surfaceRaw, + let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), + !surfaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") + } + _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "relay_port": relayPort, + ]) + } + + private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { + let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines) + let info = resolvedVersionInfo() + let manifest = remoteDaemonManifest() + let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch) + let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch) + let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path) + let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil + let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch) + let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased()) + let releaseTag = manifest?.releaseTag ?? "unknown" + let assetName = entry?.assetName ?? "unknown" + let downloadURL = entry?.downloadURL ?? "unknown" + let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown" + let checksumsURL = manifest?.checksumsURL ?? "unknown" + let downloadCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(assetName)" + let downloadChecksumsCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(checksumsAssetName)" + let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing" + let signerWorkflow = releaseTag == "nightly" + ? "manaflow-ai/cmux/.github/workflows/nightly.yml" + : "manaflow-ai/cmux/.github/workflows/release.yml" + let verifyCommand = "gh attestation verify ./\(assetName) --repo manaflow-ai/cmux --signer-workflow \(signerWorkflow)" + + let payload: [String: Any] = [ + "app_version": remoteDaemonVersionString(from: info), + "build": info["CFBundleVersion"] ?? NSNull(), + "commit": info["CMUXCommit"] ?? NSNull(), + "manifest_present": manifest != nil, + "release_tag": releaseTag, + "release_url": manifest?.releaseURL ?? NSNull(), + "target_goos": platform.goOS, + "target_goarch": platform.goArch, + "asset_name": assetName, + "download_url": downloadURL, + "checksums_asset_name": checksumsAssetName, + "checksums_url": checksumsURL, + "expected_sha256": entry?.sha256 ?? NSNull(), + "cache_path": cacheURL.path, + "cache_exists": cacheExists, + "cache_sha256": cacheSHA ?? NSNull(), + "cache_verified": cacheVerified, + "dev_local_build_fallback": ProcessInfo.processInfo.environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1", + "download_command": downloadCommand, + "download_checksums_command": downloadChecksumsCommand, + "checksum_verify_command": checksumVerifyCommand, + "attestation_verify_command": verifyCommand, + ] + + if jsonOutput { + print(jsonString(payload)) + return + } + + print("app version: \(payload["app_version"] as? String ?? "unknown")") + if let build = payload["build"] as? String { + print("build: \(build)") + } + if let commit = payload["commit"] as? String { + print("commit: \(commit)") + } + print("manifest: \(manifest != nil ? "present" : "missing")") + print("platform: \(platform.goOS)/\(platform.goArch)") + print("release: \(releaseTag)") + print("asset: \(assetName)") + print("download url: \(downloadURL)") + print("checksums asset: \(checksumsAssetName)") + print("checksums: \(checksumsURL)") + if let expectedSHA = entry?.sha256 { + print("expected sha256: \(expectedSHA)") + } + print("cache: \(cacheURL.path)") + print("cache exists: \(cacheExists ? "yes" : "no")") + if let cacheSHA { + print("cache sha256: \(cacheSHA)") + } + print("cache verified: \(cacheVerified ? "yes" : "no")") + print("download command: \(downloadCommand)") + print("download checksums: \(downloadChecksumsCommand)") + print("verify checksum: \(checksumVerifyCommand)") + print("attestation verify: \(verifyCommand)") + if manifest == nil { + print("note: this build has no embedded remote daemon manifest. Set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.") + } + } + + private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) { + let normalizedOS = requestedOS? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedArch = requestedArch? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS()) + let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch()) + return (goOS, goArch) + } + + private func hostGoOS() -> String { +#if os(macOS) + return "darwin" +#elseif os(Linux) + return "linux" +#else + return "unknown" +#endif + } + + private func hostGoArch() -> String { +#if arch(arm64) + return "arm64" +#elseif arch(x86_64) + return "amd64" +#else + return "unknown" +#endif + } + + private func remoteDaemonManifest() -> RemoteDaemonManifest? { + for plistURL in candidateInfoPlistURLs() { + guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any], + let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String, + let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), + let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else { + continue + } + return manifest + } + return nil + } + + private func remoteDaemonVersionString(from info: [String: String]) -> String { + info["CFBundleShortVersionString"] ?? "dev" + } + + private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL { + let root: URL + do { + root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } catch { + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + return root + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() + if token == loweredKey { + return true + } + } + return false + } + + private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { + if let remoteRelayPort, remoteRelayPort > 0 { + return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" + } + return "/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 { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private func sshOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private func cliDebugLog(_ message: @autoclosure () -> String) { +#if DEBUG + let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let path: String? = { + if let trimmedExplicit, !trimmedExplicit.isEmpty { + return trimmedExplicit + } + guard let marker = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8) else { + return nil + } + let trimmedMarker = marker.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedMarker.isEmpty ? nil : trimmedMarker + }() + guard let path else { return } + let timestamp = ISO8601DateFormatter().string(from: Date()) + let line = "\(timestamp) [cmux-cli] \(message())\n" + guard let data = line.data(using: .utf8) else { return } + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + guard let handle = FileHandle(forWritingAtPath: path) else { return } + defer { try? handle.close() } + do { + try handle.seekToEnd() + try handle.write(contentsOf: data) + } catch { + return + } +#endif + } + + private func runProcess( + executablePath: String, + arguments: [String], + stdinText: String? = nil + ) -> (status: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdinPipe: Pipe? + if stdinText != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + stdinPipe = nil + } + + do { + try process.run() + } catch { + return (1, "", String(describing: error)) + } + + if let stdinText, let stdinPipe { + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + } + + process.waitUntilExit() + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, stdout, stderr) + } private func runBrowserCommand( commandArgs: [String], @@ -2941,7 +3816,6 @@ struct CMUXCLI { return lines.joined(separator: "\n") } - func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -3126,13 +4000,7 @@ struct CMUXCLI { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - let fallback: String - if let value = payload["value"] { - fallback = displayBrowserValue(value) - } else { - fallback = "OK" - } - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3865,8 +4733,7 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK" - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3880,8 +4747,7 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK" - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -4457,7 +5323,7 @@ struct CMUXCLI { new-terminal-right | new-browser-right reload | duplicate pin | unpin - mark-read | mark-unread + mark-unread Flags: --action Action name (required if not positional) @@ -4476,18 +5342,21 @@ struct CMUXCLI { return """ Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] - Rename a tab (surface). Defaults to the focused tab, using: - 1) explicit --tab/--surface - 2) $CMUX_TAB_ID / $CMUX_SURFACE_ID - 3) focused tab in the resolved workspace context + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 4) currently focused tab (optionally within --workspace) Flags: --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) - --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) --surface <id|ref> Alias for --tab - --title <text> New title (or pass trailing title) + --title <text> Explicit title (or use trailing positional title) - Example: + Examples: cmux rename-tab "build logs" cmux rename-tab --tab tab:3 "staging server" cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" @@ -4516,6 +5385,35 @@ struct CMUXCLI { Example: cmux list-workspaces """ + case "ssh": + return """ + Usage: cmux ssh <destination> [flags] [-- <remote-command-args>] + + Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. + cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. + + Flags: + --name <title> Optional workspace title + --port <n> SSH port + --identity <path> SSH identity file path + --ssh-option <opt> Extra SSH -o option (repeatable) + + Example: + cmux ssh dev@my-host + cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 + cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no + """ + case "remote-daemon-status": + return """ + Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] + + Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, + and the GitHub attestation verification command for a target platform. + + Example: + cmux remote-daemon-status + cmux remote-daemon-status --os linux --arch arm64 + """ case "new-split": return """ Usage: cmux new-split <left|right|up|down> [flags] @@ -5330,20 +6228,6 @@ struct CMUXCLI { return true } - /// Escape and quote a string for safe embedding in a v1 socket command. - /// The socket tokenizer treats `\` and `"` as special inside quoted strings, - /// so both must be escaped before wrapping in double quotes. Newlines and - /// carriage returns must also be escaped since the socket protocol uses - /// newline as the message terminator. - private func socketQuote(_ s: String) -> String { - let escaped = s - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - return "\"\(escaped)\"" - } - private func parseOption(_ args: [String], name: String) -> (String?, [String]) { var remaining: [String] = [] var value: String? @@ -6580,7 +7464,11 @@ struct CMUXCLI { do { try client.connect() - try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) defer { client.close() } let payload = try client.sendV2(method: "system.identify") @@ -7621,7 +8509,7 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") print(response) } else { print("OK") @@ -7680,7 +8568,7 @@ struct CMUXCLI { ) } - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -7915,8 +8803,7 @@ struct CMUXCLI { ] let session = firstString(in: object, keys: ["session_id", "sessionId"]) let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" - let dedupedMessage = dedupeBranchContextLines(message) - let normalizedMessage = normalizedSingleLine(dedupedMessage) + let normalizedMessage = normalizedSingleLine(message) let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) @@ -7949,42 +8836,6 @@ struct CMUXCLI { return ("Attention", body) } - private func dedupeBranchContextLines(_ value: String) -> String { - let lines = value.components(separatedBy: .newlines) - guard lines.count > 1 else { return value } - - var lastIndexByPath: [String: Int] = [:] - for (index, line) in lines.enumerated() { - guard let path = branchContextPath(from: line) else { continue } - lastIndexByPath[path] = index - } - guard !lastIndexByPath.isEmpty else { return value } - - let deduped = lines.enumerated().compactMap { index, line -> String? in - guard let path = branchContextPath(from: line) else { return line } - return lastIndexByPath[path] == index ? line : nil - } - return deduped.joined(separator: "\n") - } - - private func branchContextPath(from line: String) -> String? { - let parts = line.split(separator: "•", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { return nil } - - let branch = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let path = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !branch.isEmpty, !path.isEmpty else { return nil } - - let looksLikePath = path.hasPrefix("/") || path.hasPrefix("~") || path.hasPrefix(".") || path.contains("/") - guard looksLikePath else { return nil } - - let trimmedQuotes = path.trimmingCharacters(in: CharacterSet(charactersIn: "`'\"")) - let expanded = NSString(string: trimmedQuotes).expandingTildeInPath - let standardized = NSString(string: expanded).standardizingPath - let normalized = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized.isEmpty ? nil : normalized - } - private func firstString(in object: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = object[key] else { continue } @@ -8305,8 +9156,6 @@ struct CMUXCLI { appendIfExisting(current.appendingPathComponent("Info.plist")) } - // Local dev fallback: resolve version from the repo's app Info.plist - // when running a standalone cmux-cli binary from build/Debug. let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), @@ -8394,12 +9243,12 @@ struct CMUXCLI { --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: - version welcome shortcuts feedback [--email <email> --body <text> [--image <path> ...]] claude-teams [claude-args...] ping + version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows @@ -8412,6 +9261,8 @@ struct CMUXCLI { workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] list-workspaces new-workspace [--cwd <path>] [--command <text>] + ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [-- <remote-command-args>] + remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] @@ -8444,18 +9295,6 @@ struct CMUXCLI { list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] - - # sidebar metadata commands - set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>] - clear-status <key> [--workspace <id|ref>] - list-status [--workspace <id|ref>] - set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>] - clear-progress [--workspace <id|ref>] - log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message> - clear-log [--workspace <id|ref>] - list-log [--limit <n>] [--workspace <id|ref>] - sidebar-state [--workspace <id|ref>] - set-app-focus <active|inactive|clear> simulate-app-active diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b7c73485..137f9f92 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -25452,6 +25452,57 @@ } } }, + "contextMenu.copyError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copyErrors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Errors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copySshError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy SSH Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSHエラーをコピー" + } + } + } + }, "contextMenu.moveDown": { "extractionState": "manual", "localizations": { @@ -42792,6 +42843,40 @@ } } }, + "settings.app.showSSH": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show SSH in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにSSHを表示" + } + } + } + }, + "settings.app.showSSH.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the SSH target for remote workspaces in its own row." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペースのSSHターゲットを専用の行に表示します。" + } + } + } + }, "settings.app.showPorts.subtitle": { "extractionState": "manual", "localizations": { @@ -61336,6 +61421,227 @@ } } }, + "sidebar.remote.badge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + } + } + }, + "remote.status.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続済み" + } + } + } + }, + "remote.status.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中" + } + } + } + }, + "remote.status.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切断済み" + } + } + } + }, + "remote.status.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } + } + }, + "sidebar.remote.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + } + } + }, + "sidebar.remote.subtitleFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH ワークスペース" + } + } + } + }, + "sidebar.remote.help.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH connected to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ に接続済み" + } + } + } + }, + "sidebar.remote.help.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH connecting to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ に接続中" + } + } + } + }, + "sidebar.remote.help.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の SSH エラー" + } + } + } + }, + "sidebar.remote.help.errorWithDetail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error for %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の SSH エラー: %@" + } + } + } + }, + "sidebar.remote.help.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH disconnected from %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ から切断済み" + } + } + } + }, + "sidebar.remote.help.targetFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートホスト" + } + } + } + }, "sidebar.workspace.moveDownAction": { "extractionState": "manual", "localizations": { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 821f3d19..45a99aaf 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -57,6 +57,102 @@ typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 +typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 +typeset -g _CMUX_WINCH_GUARD_INSTALLED=0 + +_cmux_ensure_ghostty_preexec_strips_both_marks() { + local fn_name="$1" + (( $+functions[$fn_name] )) || return 0 + + local old_strip new_strip updated + old_strip=$'PS1=${PS1//$\'%{\\e]133;A;cl=line\\a%}\'}' + new_strip=$'PS1=${PS1//$\'%{\\e]133;A;redraw=last;cl=line\\a%}\'}' + updated="${functions[$fn_name]}" + + if [[ "$updated" == *"$new_strip"* && "$updated" != *"$old_strip"* ]]; then + updated="${updated/$new_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + return 0 + fi + if [[ "$updated" == *"$old_strip"* && "$updated" != *"$new_strip"* ]]; then + updated="${updated/$old_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi +} + +_cmux_patch_ghostty_semantic_redraw() { + (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) && return 0 + + local old_frag new_frag + old_frag='133;A;cl=line' + new_frag='133;A;redraw=last;cl=line' + + # Patch both deferred and live hook definitions, depending on init timing. + if (( $+functions[_ghostty_deferred_init] )); then + functions[_ghostty_deferred_init]="${functions[_ghostty_deferred_init]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_precmd] )); then + functions[_ghostty_precmd]="${functions[_ghostty_precmd]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_preexec] )); then + functions[_ghostty_preexec]="${functions[_ghostty_preexec]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + + # Keep legacy + redraw-aware strip lines so prompts created before patching + # are still cleared by preexec. + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_deferred_init + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_preexec +} +_cmux_patch_ghostty_semantic_redraw + +_cmux_prompt_wrap_guard() { + local cmd_start="$1" + local pwd="$2" + [[ -n "$cmd_start" && "$cmd_start" != 0 ]] || return 0 + + local cols="${COLUMNS:-0}" + (( cols > 0 )) || return 0 + + local budget=$(( cols - 24 )) + (( budget < 20 )) && budget=20 + (( ${#pwd} >= budget )) || return 0 + + # Keep a spacer line between command output and a wrapped prompt so + # resize-driven prompt redraw cannot overwrite the command tail. + builtin print -r -- "" +} + +_cmux_install_winch_guard() { + (( _CMUX_WINCH_GUARD_INSTALLED )) && return 0 + + # Respect user-defined WINCH handlers (function-based or trap-based). + local existing_winch_trap="" + existing_winch_trap="$(trap -p WINCH 2>/dev/null || true)" + if (( $+functions[TRAPWINCH] )) || [[ -n "$existing_winch_trap" ]]; then + _CMUX_WINCH_GUARD_INSTALLED=1 + return 0 + fi + + TRAPWINCH() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + # Keep a spacer line so prompt redraw during resize cannot clobber the + # tail of command output that was rendered immediately above the prompt. + builtin print -r -- "" + return 0 + } + + _CMUX_WINCH_GUARD_INSTALLED=1 +} +_cmux_install_winch_guard _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). @@ -385,6 +481,9 @@ _cmux_precmd() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 + # Handle cases where Ghostty integration initializes after this file. + _cmux_patch_ghostty_semantic_redraw + if [[ -z "$_CMUX_TTY_NAME" ]]; then local t t="$(tty 2>/dev/null || true)" @@ -399,6 +498,8 @@ _cmux_precmd() { local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + _cmux_prompt_wrap_guard "$cmd_start" "$pwd" + # Post-wake socket writes can occasionally leave a probe process wedged. # If one probe is stale, clear the guard so fresh async probes can resume. if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4f3c0725..29fdf434 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -74,6 +74,47 @@ func cmuxAccentColor() -> Color { Color(nsColor: cmuxAccentNSColor()) } +struct SidebarRemoteErrorCopyEntry: Equatable { + let workspaceTitle: String + let target: String + let detail: String +} + +enum SidebarRemoteErrorCopySupport { + static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1 { + return String(localized: "contextMenu.copyError", defaultValue: "Copy Error") + } + return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors") + } + + static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1, let entry = entries.first { + return "SSH error (\(entry.target)): \(entry.detail)" + } + + return entries.enumerated().map { index, entry in + "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" + }.joined(separator: "\n") + } + + static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("SSH error") else { return nil } + + if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) { + return (String(match.1), String(match.2)) + } + if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) { + guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil } + return (fallbackTarget, String(match.1)) + } + return nil + } +} + func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { cmuxAccentNSColor(for: colorScheme) } @@ -1929,6 +1970,7 @@ struct ContentView: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -7296,6 +7338,7 @@ struct VerticalTabsSidebar: View { #endif draggedTabId = nil } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -9489,6 +9532,7 @@ private struct TabItemView: View, Equatable { @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -9591,12 +9635,84 @@ private struct TabItemView: View, Equatable { ) } + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let target = tab.remoteDisplayTarget ?? "unknown" + return "SSH error (\(target)): \(trimmedDetail)" + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + return statusValue + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH")) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.62)) + .textCase(.uppercase) + + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + var body: some View { let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest) ? tab.sidebarOrderedPanelIds() : nil @@ -9700,7 +9816,7 @@ private struct TabItemView: View, Equatable { .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } - if let subtitle = latestNotificationSubtitle { + if let subtitle = effectiveSubtitle { Text(subtitle) .font(.system(size: 10)) .foregroundColor(activeSecondaryColor(0.8)) @@ -9709,6 +9825,8 @@ private struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } + remoteWorkspaceSection + if sidebarShowMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() @@ -9968,6 +10086,16 @@ private struct TabItemView: View, Equatable { let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned + let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } + let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) let pinLabel = shouldPin ? contextMenuLabel( multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), @@ -10017,6 +10145,24 @@ private struct TabItemView: View, Equatable { } } + if !remoteTargetWorkspaces.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.reconnectRemoteConnection() + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) + + Button(disconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + } + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { if tab.customColor != nil { Button { @@ -10049,6 +10195,12 @@ private struct TabItemView: View, Equatable { } } + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + Divider() Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { @@ -10324,6 +10476,62 @@ private struct TabItemView: View, Equatable { } } + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { guard let app = AppDelegate.shared else { return } let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } @@ -10525,6 +10733,18 @@ private struct TabItemView: View, Equatable { } } + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 65e50eaa..5b4db687 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2333,7 +2333,8 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? - private let additionalEnvironment: [String: String] + private let initialCommand: String? + private let initialEnvironmentOverrides: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -2401,6 +2402,8 @@ final class TerminalSurface: Identifiable, ObservableObject { context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, workingDirectory: String? = nil, + initialCommand: String? = nil, + initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { self.id = UUID() @@ -2408,7 +2411,12 @@ final class TerminalSurface: Identifiable, ObservableObject { self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) - self.additionalEnvironment = additionalEnvironment + let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) + self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil + 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. @@ -2426,6 +2434,25 @@ final class TerminalSurface: Identifiable, ObservableObject { 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 + } + func isAttached(to view: GhosttyNSView) -> Bool { attachedView === view && surface != nil } @@ -2784,6 +2811,10 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path, + !bundledCLIPath.isEmpty { + env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath + } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { env["CMUX_BUNDLE_ID"] = bundleId } @@ -2851,8 +2882,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if !additionalEnvironment.isEmpty { - for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { + if !initialEnvironmentOverrides.isEmpty { + for (key, value) in initialEnvironmentOverrides { env[key] = value } } @@ -2880,15 +2911,31 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if let workingDirectory, !workingDirectory.isEmpty { - workingDirectory.withCString { cWorkingDir in - surfaceConfig.working_directory = cWorkingDir + let createWithCommandAndWorkingDirectory = { [self] in + if let initialCommand, !initialCommand.isEmpty { + initialCommand.withCString { cCommand in + surfaceConfig.command = cCommand + if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir + createSurface() + } + } else { + createSurface() + } + } + } else if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir + createSurface() + } + } else { createSurface() } - } else { - createSurface() } + createWithCommandAndWorkingDirectory() + if surface == nil { surfaceCallbackContext?.release() surfaceCallbackContext = nil @@ -3038,6 +3085,7 @@ final class TerminalSurface: Identifiable, ObservableObject { dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, + let surface, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { @@ -5637,6 +5685,7 @@ final class GhosttySurfaceScrollView: NSView { private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 + private var pendingAutomaticFirstResponderApply = false // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. /// Tracks whether keyboard focus should go to the search field or the terminal @@ -6244,7 +6293,7 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") #endif - self.applyFirstResponderIfNeeded() + self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, @@ -6267,7 +6316,9 @@ final class GhosttySurfaceScrollView: NSView { #endif } }) - if window.isKeyWindow { applyFirstResponderIfNeeded() } + if window.isKeyWindow { + scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") + } } func attachSurface(_ terminalSurface: TerminalSurface) { @@ -6684,7 +6735,7 @@ final class GhosttySurfaceScrollView: NSView { window.makeFirstResponder(nil) } } else { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") } } @@ -6711,7 +6762,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setActive") } else { resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } @@ -7073,6 +7124,20 @@ final class GhosttySurfaceScrollView: NSView { return fr === surfaceView || fr.isDescendant(of: surfaceView) } + private func scheduleAutomaticFirstResponderApply(reason: String) { + guard !pendingAutomaticFirstResponderApply else { return } + pendingAutomaticFirstResponderApply = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pendingAutomaticFirstResponderApply = false +#if DEBUG + let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") +#endif + self.applyFirstResponderIfNeeded() + } + } + private func reassertTerminalSurfaceFocus(reason: String) { guard let terminalSurface = surfaceView.terminalSurface else { return } #if DEBUG @@ -7648,35 +7713,15 @@ final class GhosttySurfaceScrollView: NSView { /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult private func synchronizeCoreSurface() -> Bool { - let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) + // Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight + // over terminal columns during split churn. The width can flap by one scrollbar gutter, + // which redraws the shell prompt multiple times on Cmd+D. Favor stable columns. + let width = max(0, scrollView.contentSize.width) let height = surfaceView.frame.height guard width > 0, height > 0 else { return false } return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } - /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. - private func overlayScrollbarInsetWidth() -> CGFloat { - guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 } - - // If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction. - let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width) - if alreadyReserved > 0.5 { return 0 } - - let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) - guard let verticalScroller = scrollView.verticalScroller else { return fallback } - - let measuredWidth = verticalScroller.frame.width - if measuredWidth > 0 { - return max(measuredWidth, fallback) - } - - let controlSizeWidth = NSScroller.scrollerWidth( - for: verticalScroller.controlSize, - scrollerStyle: .overlay - ) - return max(controlSizeWidth, fallback) - } - private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, @@ -8190,6 +8235,12 @@ struct GhosttyTerminalView: NSViewRepresentable { } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() + func portalBindingStillLive() -> Bool { + terminalSurface.canAcceptPortalBinding( + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + } let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { @@ -8228,6 +8279,7 @@ struct GhosttyTerminalView: NSViewRepresentable { reason: "didMoveToWindow" ) else { return } guard host.window != nil else { return } + guard portalBindingStillLive() else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, @@ -8251,6 +8303,7 @@ struct GhosttyTerminalView: NSViewRepresentable { bounds: host.bounds, reason: "geometryChanged" ) else { return } + guard portalBindingStillLive() else { return } let hostId = ObjectIdentifier(host) if host.window != nil, (coordinator.lastBoundHostId != hostId || @@ -8280,6 +8333,7 @@ struct GhosttyTerminalView: NSViewRepresentable { } if host.window != nil, hostOwnsPortalNow { + let portalBindingLive = portalBindingStillLive() let hostId = ObjectIdentifier(host) let geometryRevision = host.geometryRevision let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) @@ -8290,7 +8344,7 @@ struct GhosttyTerminalView: NSViewRepresentable { previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority - if shouldBindNow { + if portalBindingLive && shouldBindNow { #if DEBUG if portalEntryMissing { dlog( @@ -8310,11 +8364,11 @@ struct GhosttyTerminalView: NSViewRepresentable { ) coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { TerminalWindowPortalRegistry.synchronizeForAnchor(host) coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - } else if hostOwnsPortalNow { + } else if hostOwnsPortalNow, portalBindingStillLive() { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. @@ -8344,7 +8398,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isBoundToCurrentHost: isBoundToCurrentHost ) - if shouldApplyImmediateHostedState { + if portalBindingStillLive() && shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b9f1ca1b..b2927a8a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,6 +3,19 @@ import Combine import WebKit import AppKit import Bonsplit +import Network + +struct BrowserProxyEndpoint: Equatable { + let host: String + let port: Int +} + +struct BrowserRemoteWorkspaceStatus: Equatable { + let target: String + let connectionState: WorkspaceRemoteConnectionState + let heartbeatCount: Int + let lastHeartbeatAt: Date? +} enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { @@ -1255,6 +1268,14 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + private static let remoteLoopbackHosts: Set<String> = [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + ] + /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() @@ -1773,6 +1794,8 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 + private var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsTransitionTargetVisible: Bool? @@ -2015,15 +2038,24 @@ final class BrowserPanel: Panel, ObservableObject { return instanceID == webViewInstanceID } - init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { + init( + workspaceId: UUID, + initialURL: URL? = nil, + bypassInsecureHTTPHostOnce: String? = nil, + proxyEndpoint: BrowserProxyEndpoint? = nil, + isRemoteWorkspace: Bool = false + ) { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") + self.remoteProxyEndpoint = proxyEndpoint self.browserThemeMode = BrowserThemeSettings.mode() let webView = Self.makeWebView() self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } + let _ = isRemoteWorkspace + applyRemoteProxyConfigurationIfAvailable() // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() @@ -2103,6 +2135,40 @@ final class BrowserPanel: Panel, ObservableObject { } } + func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + guard remoteProxyEndpoint != endpoint else { return } + remoteProxyEndpoint = endpoint + applyRemoteProxyConfigurationIfAvailable() + } + + func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { + guard remoteWorkspaceStatus != status else { return } + remoteWorkspaceStatus = status + } + + private func applyRemoteProxyConfigurationIfAvailable() { + guard #available(macOS 14.0, *) else { return } + + let store = webView.configuration.websiteDataStore + guard let endpoint = remoteProxyEndpoint else { + store.proxyConfigurations = [] + return + } + + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, + endpoint.port > 0 && endpoint.port <= 65535, + let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { + store.proxyConfigurations = [] + return + } + + let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) + let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) + let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) + store.proxyConfigurations = [socks, connect] + } + private func beginDownloadActivity() { let apply = { self.activeDownloadCount += 1 @@ -2599,6 +2665,7 @@ final class BrowserPanel: Panel, ObservableObject { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } + let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request) // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -2606,7 +2673,35 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - browserLoadRequest(request, in: webView) + browserLoadRequest(effectiveRequest, in: webView) + } + + private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest { + guard remoteProxyEndpoint != nil else { return request } + guard let url = request.url else { return request } + guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } + + var rewrittenRequest = request + rewrittenRequest.url = rewrittenURL +#if DEBUG + dlog( + "browser.remoteProxy.rewrite " + + "panel=\(id.uuidString.prefix(5)) " + + "from=\(url.absoluteString) " + + "to=\(rewrittenURL.absoluteString)" + ) +#endif + return rewrittenRequest + } + + private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } + guard remoteLoopbackHosts.contains(host) else { return nil } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = remoteLoopbackProxyAliasHost + return components?.url } /// Navigate with smart URL/search detection @@ -3481,6 +3576,16 @@ extension BrowserPanel { applyPageZoom(1.0) } + func currentPageZoomFactor() -> CGFloat { + webView.pageZoom + } + + @discardableResult + func setPageZoomFactor(_ pageZoom: CGFloat) -> Bool { + let clamped = max(minPageZoom, min(maxPageZoom, pageZoom)) + return applyPageZoom(clamped) + } + /// Take a snapshot of the web view func takeSnapshot(completion: @escaping (NSImage?) -> Void) { let config = WKSnapshotConfiguration() @@ -4295,6 +4400,13 @@ extension BrowserPanel { return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" } + func hideBrowserPortalView(source: String) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: source + ) + } + } #endif diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index f9d197a3..43a5f32b 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -84,20 +84,45 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, - additionalEnvironment: [String: String] = [:], - portOrdinal: Int = 0 + portOrdinal: Int = 0, + initialCommand: String? = nil, + initialEnvironmentOverrides: [String: String] = [:], + additionalEnvironment: [String: String] = [:] ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, - additionalEnvironment: additionalEnvironment + initialCommand: initialCommand, + 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/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 6a12a955..efe8cfa8 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -406,6 +406,18 @@ struct SocketControlSettings { ) -> String { let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild) + if let taggedDebugPath = taggedDebugSocketPath( + bundleIdentifier: bundleIdentifier, + environment: environment + ) { + if isTruthy(environment[allowSocketPathOverrideKey]), + let override = environment["CMUX_SOCKET_PATH"], + !override.isEmpty { + return override + } + return taggedDebugPath + } + guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { return fallback } @@ -422,6 +434,9 @@ struct SocketControlSettings { } static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String { + if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) { + return taggedDebugPath + } if bundleIdentifier == "com.cmuxterm.app.nightly" { return "/tmp/cmux-nightly.sock" } @@ -454,6 +469,37 @@ struct SocketControlSettings { || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") } + static func taggedDebugSocketPath( + bundleIdentifier: String?, + environment: [String: String] + ) -> String? { + let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") { + let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1)) + let slug = suffix + .replacingOccurrences(of: ".", with: "-") + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + if !slug.isEmpty { + return "/tmp/cmux-debug-\(slug).sock" + } + } + + let tag = launchTag(environment: environment)? + .lowercased() + .replacingOccurrences(of: ".", with: "-") + .replacingOccurrences(of: "_", with: "-") + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + + guard bundleId == baseDebugBundleIdentifier, + let tag, + !tag.isEmpty else { + return nil + } + return "/tmp/cmux-debug-\(tag).sock" + } + static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.staging" diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 764b15ce..2455e8d5 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -30,11 +30,20 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { var description: String { switch self { case .top: - return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.") + return String( + localized: "workspace.placement.top.description", + defaultValue: "Insert new workspaces at the top of the list." + ) case .afterCurrent: - return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.") + return String( + localized: "workspace.placement.afterCurrent.description", + defaultValue: "Insert new workspaces directly after the active workspace." + ) case .end: - return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.") + return String( + localized: "workspace.placement.end.description", + defaultValue: "Append new workspaces to the bottom of the list." + ) } } } @@ -72,9 +81,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") + return "Left Rail" case .solidFill: - return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") + return "Solid Fill" } } } @@ -732,36 +741,25 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - if selectedTerminalPanel?.searchState != nil { return true } - if focusedBrowserPanel?.searchState != nil { return true } - return false + selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil } var canUseSelectionForFind: Bool { - if focusedBrowserPanel != nil { return false } - return selectedTerminalPanel?.hasSelection() == true + selectedTerminalPanel?.hasSelection() == true } func startSearch() { - if let browser = focusedBrowserPanel { - browser.startFind() + if let panel = selectedTerminalPanel { + if panel.searchState == nil { + panel.searchState = TerminalSurface.SearchState() + } + NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) + NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) + _ = panel.performBindingAction("start_search") return } - guard let panel = selectedTerminalPanel else { -#if DEBUG - dlog("find.startSearch SKIPPED no selectedTerminalPanel") -#endif - return - } - let wasNil = panel.searchState == nil - if wasNil { - panel.searchState = TerminalSurface.SearchState() - } -#if DEBUG - dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))") -#endif - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("start_search") + + focusedBrowserPanel?.startFind() } func searchSelection() { @@ -769,27 +767,27 @@ class TabManager: ObservableObject { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } -#if DEBUG - dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") -#endif + NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") } func findNext() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findNext() + if let panel = selectedTerminalPanel { + _ = panel.performBindingAction("search:next") return } - _ = selectedTerminalPanel?.performBindingAction("search:next") + + focusedBrowserPanel?.findNext() } func findPrevious() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findPrevious() + if let panel = selectedTerminalPanel { + _ = panel.performBindingAction("search:previous") return } - _ = selectedTerminalPanel?.performBindingAction("search:previous") + + focusedBrowserPanel?.findPrevious() } @discardableResult @@ -799,27 +797,26 @@ class TabManager: ObservableObject { } func hideFind() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.hideFind() + if let panel = selectedTerminalPanel { + panel.searchState = nil return } -#if DEBUG - dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") -#endif - selectedTerminalPanel?.searchState = nil + + focusedBrowserPanel?.hideFind() } @discardableResult func addWorkspace( workingDirectory overrideWorkingDirectory: String? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:], select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil, autoWelcomeIfNeeded: Bool = true ) -> Workspace { sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) - let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) - let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() + let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -827,7 +824,9 @@ class TabManager: ObservableObject { title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + initialTerminalCommand: initialTerminalCommand, + initialTerminalEnvironment: initialTerminalEnvironment ) wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex(placementOverride: placementOverride) @@ -836,17 +835,8 @@ class TabManager: ObservableObject { } else { tabs.append(newWorkspace) } - if let explicitWorkingDirectory, - let terminalPanel = newWorkspace.focusedTerminalPanel { - scheduleInitialWorkspaceGitMetadataRefresh( - workspaceId: newWorkspace.id, - panelId: terminalPanel.id, - directory: explicitWorkingDirectory - ) - } if eagerLoadTerminal { - requestBackgroundWorkspaceLoad(for: newWorkspace.id) - newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() + newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() } if select { selectedTabId = newWorkspace.id @@ -1162,16 +1152,6 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } - func moveTabToTopForNotification(_ tabId: UUID) { - guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } - let pinnedCount = tabs.filter { $0.isPinned }.count - guard index != pinnedCount else { return } - let tab = tabs[index] - guard !tab.isPinned else { return } - tabs.remove(at: index) - tabs.insert(tab, at: pinnedCount) - } - func moveTabsToTop(_ tabIds: Set<UUID>) { guard !tabIds.isEmpty else { return } let selectedTabs = tabs.filter { tabIds.contains($0.id) } @@ -1184,6 +1164,16 @@ class TabManager: ObservableObject { tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned } + func moveTabToTopForNotification(_ tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let pinnedCount = tabs.filter { $0.isPinned }.count + guard index != pinnedCount else { return } + let tab = tabs[index] + guard !tab.isPinned else { return } + tabs.remove(at: index) + tabs.insert(tab, at: pinnedCount) + } + @discardableResult func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } @@ -1269,22 +1259,23 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } - guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - clearInitialWorkspaceGitProbe(workspaceId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) - unwireClosedBrowserTracking(for: workspace) workspace.teardownAllPanels() + workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) - tabs.remove(at: index) + if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { + tabs.remove(at: index) - if selectedTabId == workspace.id { - // Keep the "focused index" stable when possible: - // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). - // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). - let newIndex = min(index, max(0, tabs.count - 1)) - selectedTabId = tabs[newIndex].id + if selectedTabId == workspace.id { + // Keep the "focused index" stable when possible: + // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). + // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). + let newIndex = min(index, max(0, tabs.count - 1)) + selectedTabId = tabs[newIndex].id + } } } @@ -1293,7 +1284,6 @@ class TabManager: ObservableObject { @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } - clearInitialWorkspaceGitProbe(workspaceId: tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -1355,13 +1345,9 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = if count == 1 { - String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)") - } else { - String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)") - } + let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" guard confirmClose( - title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + title: "Close other tabs?", message: message, acceptCmdD: false ) else { return } @@ -1401,8 +1387,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") // macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save"). // We only opt into this for the "close last workspace => close window" path to avoid @@ -1463,15 +1449,15 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return String(localized: "tab.untitled", defaultValue: "Untitled Tab") + return "Untitled Tab" } private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { let willCloseWindow = tabs.count <= 1 if workspaceNeedsConfirmClose(workspace), !confirmClose( - title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), - message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), + title: "Close workspace?", + message: "This will close the workspace and all of its panels.", acceptCmdD: willCloseWindow ) { return @@ -1512,8 +1498,8 @@ class TabManager: ObservableObject { let needsConfirm = workspaceNeedsConfirmClose(tab) if needsConfirm { let message = willCloseWindow - ? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.") - : String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.") + ? "This will close the last tab and close the window." + : "This will close the last tab and close its workspace." #if DEBUG dlog( "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + @@ -1521,7 +1507,7 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + title: "Close tab?", message: message, acceptCmdD: willCloseWindow ) else { @@ -1553,8 +1539,8 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), + title: "Close tab?", + message: "This will close the current tab.", acceptCmdD: false ) else { #if DEBUG @@ -1592,8 +1578,8 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: surfaceId), terminalPanel.needsConfirmClose() { guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), + title: "Close tab?", + message: "This will close the current tab.", acceptCmdD: false ) else { return } } @@ -1860,32 +1846,28 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + guard AppFocusState.isAppActive() else { return } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } + if let tab = tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: panelId) } @discardableResult func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { - dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) - } - - @discardableResult - private func dismissNotificationIfActive( - tabId: UUID, - surfaceId: UUID?, - triggerFlash: Bool - ) -> Bool { guard selectedTabId == tabId else { return false } guard AppFocusState.isAppActive() else { return false } guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } - if triggerFlash, - let panelId = surfaceId, + if let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } @@ -2184,24 +2166,9 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=terminal dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) -#if DEBUG - dlog( - "split.create.result kind=terminal dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif + _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } /// Create a new browser split from the currently focused panel. @@ -2210,30 +2177,14 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=browser dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() - let createdPanelId = newBrowserSplit( + return newBrowserSplit( tabId: selectedTabId, fromPanelId: focusedPanelId, orientation: direction.orientation, insertFirst: direction.insertFirst, url: url ) -#if DEBUG - dlog( - "split.create.result kind=browser dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif - return createdPanelId } /// Refresh Bonsplit right-side action button tooltips for all workspaces. @@ -2334,21 +2285,12 @@ class TabManager: ObservableObject { /// Returns the new panel's ID (which is also the surface ID for terminals) func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - let createdPanel = tab.newTerminalSplit( + return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, insertFirst: direction.insertFirst, focus: focus )?.id -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.newSurface result dir=\(directionLabel) " + - "tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " + - "created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)" - ) -#endif - return createdPanel } /// Move focus in the specified direction @@ -2949,7 +2891,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") + terminal.surface.forceRefresh() } } @@ -3927,15 +3869,6 @@ enum SplitDirection { var insertFirst: Bool { self == .left || self == .up } - - var debugLabel: String { - switch self { - case .left: return "left" - case .right: return "right" - case .up: return "up" - case .down: return "down" - } - } } /// Resize direction for backwards compatibility diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6a708ae2..001a40ba 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -201,6 +201,28 @@ class TerminalController { return socketCommandFocusAllowanceStack.last ?? false } + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { if isV2 { return focusIntentV2Methods.contains(commandKey) @@ -225,27 +247,26 @@ class TerminalController { return body() } - private func socketCommandAllowsInAppFocusMutations() -> Bool { - Self.allowsInAppFocusMutationsForActiveSocketCommand() - } - - private func v2FocusAllowed(requested: Bool = true) -> Bool { - requested && socketCommandAllowsInAppFocusMutations() - } - - private func v2MaybeFocusWindow(for tabManager: TabManager) { - guard socketCommandAllowsInAppFocusMutations(), - let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - - private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { - guard socketCommandAllowsInAppFocusMutations() else { return } - if tabManager.selectedTabId != workspace.id { - tabManager.selectWorkspace(workspace) +#if DEBUG + static func debugSocketCommandPolicySnapshot( + commandKey: String, + isV2: Bool + ) -> (insideSuppressed: Bool, insideAllowsFocus: Bool, outsideSuppressed: Bool, outsideAllowsFocus: Bool) { + var insideSuppressed = false + var insideAllowsFocus = false + _ = Self.shared.withSocketCommandPolicy(commandKey: commandKey, isV2: isV2) { + insideSuppressed = Self.shouldSuppressSocketCommandActivation() + insideAllowsFocus = Self.socketCommandAllowsInAppFocusMutations() + return 0 } + return ( + insideSuppressed: insideSuppressed, + insideAllowsFocus: insideAllowsFocus, + outsideSuppressed: Self.shouldSuppressSocketCommandActivation(), + outsideAllowsFocus: Self.socketCommandAllowsInAppFocusMutations() + ) } +#endif nonisolated static func shouldReplaceStatusEntry( current: SidebarStatusEntry?, @@ -312,33 +333,6 @@ class TerminalController { return currentSorted != nextSorted } - private struct SocketSurfaceKey: Hashable { - let workspaceId: UUID - let panelId: UUID - } - - private final class SocketFastPathState: @unchecked Sendable { - private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") - private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] - private let maxTrackedDirectories = 4096 - - func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { - let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) - return queue.sync { - if lastReportedDirectories[key] == directory { - return false - } - if lastReportedDirectories.count >= maxTrackedDirectories { - lastReportedDirectories.removeAll(keepingCapacity: true) - } - lastReportedDirectories[key] = directory - return true - } - } - } - - private static let socketFastPathState = SocketFastPathState() - nonisolated static func explicitSocketScope( options: [String: String] ) -> (workspaceId: UUID, panelId: UUID)? { @@ -362,6 +356,36 @@ class TerminalController { return trimmed } + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -735,14 +759,7 @@ class TerminalController { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } let validSurfaceIds = Set(workspace.panels.keys) guard validSurfaceIds.contains(panelId) else { return } - let nextPorts = Array(Set(ports)).sorted() - let currentPorts = workspace.surfaceListeningPorts[panelId] ?? [] - guard currentPorts != nextPorts else { return } - if nextPorts.isEmpty { - workspace.surfaceListeningPorts.removeValue(forKey: panelId) - } else { - workspace.surfaceListeningPorts[panelId] = nextPorts - } + workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports workspace.recomputeListeningPorts() } } @@ -1229,7 +1246,7 @@ class TerminalController { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. - // Other modes allow external clients and apply separate auth controls. + // In allowAll mode (env-var only), skip the ancestry check. if accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. @@ -1300,11 +1317,7 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { switch cmd { case "ping": return "PONG" @@ -1622,25 +1635,13 @@ class TerminalController { case "refresh_surfaces": return refreshSurfaces() - case "surface_health": - return surfaceHealth(args) + case "surface_health": + return surfaceHealth(args) - default: - return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + default: + return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + } } - } - - #if DEBUG - if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.hasPrefix("OK") ? "ok" : "err" - dlog( - "socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } // MARK: - V2 JSON Socket Protocol @@ -1675,11 +1676,7 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: method, isV2: true) { + return withSocketCommandPolicy(commandKey: method, isV2: true) { switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1736,6 +1733,16 @@ class TerminalController { return v2Result(id: id, self.v2WorkspacePrevious(params: params)) case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) + case "workspace.remote.configure": + return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) + case "workspace.remote.reconnect": + return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) + case "workspace.remote.disconnect": + return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) + case "workspace.remote.status": + return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) + case "workspace.remote.terminal_session_end": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) // Settings case "settings.open": @@ -2064,22 +2071,10 @@ class TerminalController { return v2Result(id: id, self.v2DebugScreenshot(params: params)) #endif - default: - return v2Error(id: id, code: "method_not_found", message: "Unknown method") + default: + return v2Error(id: id, code: "method_not_found", message: "Unknown method") + } } - } - - #if DEBUG - if method == "workspace.create" || method == "surface.send_text" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.contains("\"ok\":true") ? "ok" : "err" - dlog( - "socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } private func v2Capabilities() -> [String: Any] { @@ -2106,6 +2101,11 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "workspace.remote.configure", + "workspace.remote.reconnect", + "workspace.remote.disconnect", + "workspace.remote.status", + "workspace.remote.terminal_session_end", "settings.open", "feedback.open", "feedback.submit", @@ -2689,6 +2689,42 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func v2StringArray(_ params: [String: Any], _ key: String) -> [String]? { + if let raw = params[key] as? [String] { + let normalized = raw + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized + } + if let raw = params[key] as? [Any] { + let normalized = raw + .compactMap { $0 as? String } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized + } + if let single = v2String(params, key) { + return [single] + } + return nil + } + + private func v2StringMap(_ params: [String: Any], _ key: String) -> [String: String]? { + guard let raw = params[key] else { return nil } + if let dict = raw as? [String: String] { + return dict + } + if let anyDict = raw as? [String: Any] { + var out: [String: String] = [:] + for (k, value) in anyDict { + guard let stringValue = value as? String else { continue } + out[k] = stringValue + } + return out + } + return nil + } + private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { guard let action = v2String(params, key) else { return nil } return action.lowercased().replacingOccurrences(of: "-", with: "_") @@ -2751,6 +2787,40 @@ class TerminalController { return nil } + private func v2HasNonNullParam(_ params: [String: Any], _ key: String) -> Bool { + guard let raw = params[key] else { return false } + return !(raw is NSNull) + } + + private func v2StrictInt(_ params: [String: Any], _ key: String) -> Int? { + v2StrictIntAny(params[key]) + } + + private func v2StrictIntAny(_ raw: Any?) -> Int? { + guard let raw else { return nil } + + if let numberValue = raw as? NSNumber { + if CFGetTypeID(numberValue) == CFBooleanGetTypeID() { + return nil + } + let doubleValue = numberValue.doubleValue + guard doubleValue.isFinite, floor(doubleValue) == doubleValue else { + return nil + } + return Int(exactly: doubleValue) + } + + if let intValue = raw as? Int { + return intValue + } + + if let stringValue = raw as? String { + return Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + return nil + } + private func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? { guard let s = v2String(params, key) else { return nil } return PanelType(rawValue: s.lowercased()) @@ -2834,9 +2904,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // Keep active routing stable unless this command is explicitly focus-intent. - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // The new window should become key, but setActiveTabManager defensively. + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -2877,7 +2946,9 @@ class TerminalController { "index": index, "title": ws.title, "selected": ws.id == tabManager.selectedTabId, - "pinned": ws.isPinned + "pinned": ws.isPinned, + "listening_ports": ws.listeningPorts, + "remote": ws.remoteStatusPayload() ] } } @@ -2894,8 +2965,22 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } + let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) + let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil + + let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil + + let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] + let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + result[key] = pair.value + } let cwd: String? - if let raw = params["cwd"] { + if let workingDirectory { + cwd = workingDirectory + } else if let raw = params["cwd"] { guard let str = raw as? String else { return .err(code: "invalid_params", message: "cwd must be a string", data: nil) } @@ -2906,23 +2991,16 @@ class TerminalController { var newId: UUID? let shouldFocus = v2FocusAllowed() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif v2MainSync { let ws = tabManager.addWorkspace( workingDirectory: cwd, + initialTerminalCommand: initialCommand, + initialTerminalEnvironment: initialEnv, select: shouldFocus, eagerLoadTerminal: !shouldFocus ) newId = ws.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif guard let newId else { return .err(code: "internal_error", message: "Failed to create workspace", data: nil) @@ -2946,8 +3024,12 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // If this workspace belongs to another window, bring it forward so focus is visible. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectWorkspace(ws) success = true } } @@ -2970,8 +3052,20 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } var wsId: UUID? + var wsPayload: [String: Any]? v2MainSync { wsId = tabManager.selectedTabId + if let wsId, let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { + wsPayload = [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "title": workspace.title, + "selected": true, + "pinned": workspace.isPinned, + "listening_ports": workspace.listeningPorts, + "remote": workspace.remoteStatusPayload(), + ] + } } guard let wsId else { return .err(code: "not_found", message: "No workspace selected", data: nil) @@ -2981,7 +3075,8 @@ class TerminalController { "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), + "workspace": wsPayload ?? NSNull() ]) } private func v2WorkspaceClose(params: [String: Any]) -> V2CallResult { @@ -3020,7 +3115,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -3140,7 +3235,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3162,7 +3260,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3184,7 +3285,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) v2MainSync { guard let before = tabManager.selectedTabId else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3198,6 +3302,277 @@ class TerminalController { return result } + private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + guard let destination = v2String(params, "destination") else { + return .err(code: "invalid_params", message: "Missing destination", data: nil) + } + + var sshPort: Int? + if v2HasNonNullParam(params, "port") { + guard let parsedPort = v2StrictInt(params, "port"), + parsedPort > 0, + parsedPort <= 65535 else { + return .err(code: "invalid_params", message: "port must be 1-65535", data: nil) + } + sshPort = parsedPort + } + + // Internal deterministic test hook: pin the local proxy listener port to force bind conflicts. + var localProxyPort: Int? + if v2HasNonNullParam(params, "local_proxy_port") { + guard let parsedLocalProxyPort = v2StrictInt(params, "local_proxy_port"), + parsedLocalProxyPort > 0, + parsedLocalProxyPort <= 65535 else { + return .err(code: "invalid_params", message: "local_proxy_port must be 1-65535", data: nil) + } + localProxyPort = parsedLocalProxyPort + } + + let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) + let sshOptions = v2StringArray(params, "ssh_options") ?? [] + let autoConnect = v2Bool(params, "auto_connect") ?? true + var relayPort: Int? + if v2HasNonNullParam(params, "relay_port") { + guard let parsedRelayPort = v2StrictInt(params, "relay_port"), + parsedRelayPort > 0, + parsedRelayPort <= 65535 else { + return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) + } + relayPort = parsedRelayPort + } + let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) + let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) + let localSocketPath = v2RawString(params, "local_socket_path") + let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if relayPort != nil { + guard let relayID, !relayID.isEmpty else { + return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) + } + guard let relayToken, + relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) + } + } + +#if DEBUG + dlog( + "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + + "target=\(destination) port=\(sshPort.map(String.init) ?? "nil") " + + "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + + "localSocket=\(localSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? localSocketPath! : "nil") " + + "sshOptions=\(sshOptions.joined(separator: "|"))" + ) +#endif + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + let config = WorkspaceRemoteConfiguration( + destination: destination, + port: sshPort, + identityFile: identityFile?.isEmpty == true ? nil : identityFile, + sshOptions: sshOptions, + localProxyPort: localProxyPort, + relayPort: relayPort, + relayID: relayID?.isEmpty == true ? nil : relayID, + relayToken: relayToken?.isEmpty == true ? nil : relayToken, + localSocketPath: localSocketPath, + terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand + ) + workspace.configureRemoteConnection(config, autoConnect: autoConnect) + + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + let clearConfiguration = v2Bool(params, "clear") ?? false + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + guard workspace.remoteConfiguration != nil else { + result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + return + } + + workspace.reconnectRemoteConnection() + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let relayPort = v2StrictInt(params, "relay_port"), + relayPort > 0, + relayPort <= 65535 else { + return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + ]) + + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -3360,7 +3735,7 @@ class TerminalController { "close_left", "close_right", "close_others", "new_terminal_right", "new_browser_right", "reload", "duplicate", - "pin", "unpin", "mark_read", "mark_unread" + "pin", "unpin", "mark_unread" ] var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ @@ -3373,7 +3748,6 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - let allowFocusMutation = v2FocusAllowed() let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { @@ -3475,10 +3849,6 @@ class TerminalController { workspace.setPanelPinned(panelId: surfaceId, pinned: false) finish(["pinned": false]) - case "mark_read", "mark_as_read": - workspace.markPanelRead(surfaceId) - finish() - case "mark_unread", "mark_as_unread": workspace.markPanelUnread(surfaceId) finish() @@ -3503,7 +3873,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: allowFocusMutation + focus: true ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -3524,7 +3894,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3551,7 +3921,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3662,7 +4032,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -3741,8 +4111,15 @@ class TerminalController { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + // Make sure the workspace is selected so focus effects apply to the visible UI. + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -3770,8 +4147,13 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { @@ -3783,12 +4165,7 @@ class TerminalController { return } - if let newId = tabManager.newSplit( - tabId: ws.id, - surfaceId: targetSurfaceId, - direction: direction, - focus: v2FocusAllowed() - ) { + if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -3961,7 +4338,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -4072,15 +4449,16 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return } if focus { - v2MaybeFocusWindow(for: targetTabManager) - v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) + _ = app.focusMainWindow(windowId: targetWindowId) + setActiveTabManager(targetTabManager) + targetTabManager.selectWorkspace(targetWorkspace) } result = .ok([ @@ -4267,21 +4645,13 @@ class TerminalController { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() queued = true } - #if DEBUG +#if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 dlog( "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) - #endif - result = .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "queued": queued, - "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) - ]) +#endif + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result } @@ -4309,7 +4679,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) return } @@ -4429,41 +4799,87 @@ class TerminalController { private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" } - let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT - let topLeft = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ) - let bottomRight = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ) - let selection = ghostty_selection_s( - top_left: topLeft, - bottom_right: bottomRight, - rectangle: true - ) - var text = ghostty_text_s() + func readSelectionText(pointTag: ghostty_point_tag_e) -> String? { + let topLeft = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: false + ) - guard ghostty_surface_read_text(surface, selection, &text) else { - return "ERROR: Failed to read terminal text" - } - defer { - ghostty_surface_free_text(surface, &text) + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { + return nil + } + defer { + ghostty_surface_free_text(surface, &text) + } + + guard let ptr = text.text, text.text_len > 0 else { + return "" + } + let rawData = Data(bytes: ptr, count: Int(text.text_len)) + return String(decoding: rawData, as: UTF8.self) } - let rawData: Data - if let ptr = text.text, text.text_len > 0 { - rawData = Data(bytes: ptr, count: Int(text.text_len)) + var output: String + if includeScrollback { + func candidateScore(_ text: String) -> (lines: Int, bytes: Int) { + let lines = text.isEmpty ? 0 : text.split(separator: "\n", omittingEmptySubsequences: false).count + return (lines, text.utf8.count) + } + + // Read all available regions and pick the most complete candidate. + // Different point tags can lose different rows around resize/reflow boundaries. + let screen = readSelectionText(pointTag: GHOSTTY_POINT_SCREEN) + let history = readSelectionText(pointTag: GHOSTTY_POINT_SURFACE) + let active = readSelectionText(pointTag: GHOSTTY_POINT_ACTIVE) + + var candidates: [String] = [] + if let screen { + candidates.append(screen) + } + if history != nil || active != nil { + var merged = history ?? "" + if let active { + if !merged.isEmpty, !merged.hasSuffix("\n"), !active.isEmpty { + merged.append("\n") + } + merged.append(active) + } + candidates.append(merged) + } + + if let best = candidates.max(by: { lhs, rhs in + let left = candidateScore(lhs) + let right = candidateScore(rhs) + if left.lines != right.lines { + return left.lines < right.lines + } + return left.bytes < right.bytes + }) { + output = best + } else { + return "ERROR: Failed to read terminal text" + } } else { - rawData = Data() + guard let viewport = readSelectionText(pointTag: GHOSTTY_POINT_VIEWPORT) else { + return "ERROR: Failed to read terminal text" + } + output = viewport } - var output = String(decoding: rawData, as: UTF8.self) if let lineLimit { output = tailTerminalLines(output, maxLines: lineLimit) } @@ -4472,152 +4888,21 @@ class TerminalController { return "OK \(base64)" } - private struct PasteboardItemSnapshot { - let representations: [(type: NSPasteboard.PasteboardType, data: Data)] - } - - nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let url = URL(string: trimmed), - url.isFileURL, - !url.path.isEmpty { - return url.path - } - return trimmed.hasPrefix("/") ? trimmed : nil - } - - nonisolated static func shouldRemoveExportedScreenFile( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let standardizedFile = fileURL.standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return standardizedFile.path.hasPrefix(temporary.path + "/") - } - - nonisolated static func shouldRemoveExportedScreenDirectory( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let directory = fileURL.deletingLastPathComponent().standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return directory.path.hasPrefix(temporary.path + "/") - } - - private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { - guard let items = pasteboard.pasteboardItems else { return [] } - return items.map { item in - let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in - guard let data = item.data(forType: type) else { return nil } - return (type: type, data: data) - } - return PasteboardItemSnapshot(representations: representations) - } - } - - private func restorePasteboardItems( - _ snapshots: [PasteboardItemSnapshot], - to pasteboard: NSPasteboard - ) { - _ = pasteboard.clearContents() - guard !snapshots.isEmpty else { return } - - let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in - guard !snapshot.representations.isEmpty else { return nil } - let item = NSPasteboardItem() - for representation in snapshot.representations { - item.setData(representation.data, forType: representation.type) - } - return item - } - guard !restoredItems.isEmpty else { return } - _ = pasteboard.writeObjects(restoredItems) - } - - private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { - if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], - let firstURL = urls.first, - firstURL.isFileURL { - return firstURL.path - } - if let value = pasteboard.string(forType: .string) { - return value - } - return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) - } - - private func readTerminalTextFromVTExportForSnapshot( - terminalPanel: TerminalPanel, - lineLimit: Int? - ) -> String? { - // read_text strips style state; VT export keeps ANSI escape sequences. - let pasteboard = NSPasteboard.general - let snapshot = snapshotPasteboardItems(pasteboard) - defer { - restorePasteboardItems(snapshot, to: pasteboard) - } - - let initialChangeCount = pasteboard.changeCount - guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { - return nil - } - guard pasteboard.changeCount != initialChangeCount else { - return nil - } - guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { - return nil - } - - let fileURL = URL(fileURLWithPath: exportedPath) - defer { - if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL) - if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) - } - } - } - - guard let data = try? Data(contentsOf: fileURL), - var output = String(data: data, encoding: .utf8) else { - return nil - } - if let lineLimit { - output = tailTerminalLines(output, maxLines: lineLimit) - } - return output - } - - func readTerminalTextForSnapshot( + func readTerminalTextForSessionSnapshot( terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil ) -> String? { - if includeScrollback, - let vtOutput = readTerminalTextFromVTExportForSnapshot( - terminalPanel: terminalPanel, - lineLimit: lineLimit - ) { - return vtOutput - } - let response = readTerminalTextBase64( terminalPanel: terminalPanel, includeScrollback: includeScrollback, lineLimit: lineLimit ) guard response.hasPrefix("OK ") else { return nil } - let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - if base64.isEmpty { - return "" - } - guard let data = Data(base64Encoded: base64), - let decoded = String(data: data, encoding: .utf8) else { - return nil - } - return decoded + let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !payload.isEmpty else { return "" } + guard let data = Data(base64Encoded: payload) else { return nil } + return String(decoding: data, as: UTF8.self) } private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { @@ -4632,9 +4917,14 @@ class TerminalController { return } - // Only explicit focus-intent commands may mutate selection state. - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // Ensure the flash is visible in the active UI. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { @@ -4715,8 +5005,13 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) @@ -5036,7 +5331,7 @@ class TerminalController { if sourcePaneUUID == targetPaneUUID { return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -5119,7 +5414,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -5160,7 +5455,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace(select: focus) + let destinationWorkspace = tabManager.addWorkspace() guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -5168,7 +5463,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -5181,12 +5476,16 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) return } + + if !focus { + tabManager.selectWorkspace(sourceWorkspace) + } let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -6096,16 +6395,11 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit( - from: sourceSurfaceId, - orientation: .horizontal, - url: url, - focus: v2FocusAllowed() - ) + createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) } guard let browserPanelId = createdPanel?.id else { @@ -7456,8 +7750,13 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -8496,7 +8795,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -8541,7 +8840,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -9640,7 +9939,6 @@ class TerminalController { Available commands: ping - Check if server is running - auth <password> - Authenticate this connection (required in password mode) list_workspaces - List all workspaces with IDs new_workspace - Create a new workspace select_workspace <id|index> - Select workspace by ID or index (0-based) @@ -9755,37 +10053,6 @@ class TerminalController { } #if DEBUG - private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { - let snakeCase = action.rawValue.replacingOccurrences( - of: "([a-z0-9])([A-Z])", - with: "$1_$2", - options: .regularExpression - ) - return snakeCase.lowercased() - } - - private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { - let normalized = rawName - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "-", with: "_") - - for action in KeyboardShortcutSettings.Action.allCases { - let snakeCaseName = debugShortcutName(for: action) - if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { - return action - } - } - return nil - } - - private func debugShortcutSupportedNames() -> String { - KeyboardShortcutSettings.Action.allCases - .map(debugShortcutName(for:)) - .sorted() - .joined(separator: ", ") - } - private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -9793,15 +10060,29 @@ class TerminalController { return "ERROR: Usage: set_shortcut <name> <combo|clear>" } - let name = parts[0] + let name = parts[0].lowercased() let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard let action = debugShortcutAction(named: name) else { - return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" + let defaultsKey: String? + switch name { + case "focus_left", "focusleft": + defaultsKey = KeyboardShortcutSettings.focusLeftKey + case "focus_right", "focusright": + defaultsKey = KeyboardShortcutSettings.focusRightKey + case "focus_up", "focusup": + defaultsKey = KeyboardShortcutSettings.focusUpKey + case "focus_down", "focusdown": + defaultsKey = KeyboardShortcutSettings.focusDownKey + default: + defaultsKey = nil + } + + guard let defaultsKey else { + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: action.defaultsKey) + UserDefaults.standard.removeObject(forKey: defaultsKey) return "OK" } @@ -9819,13 +10100,12 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: action.defaultsKey) + UserDefaults.standard.set(data, forKey: defaultsKey) return "OK" } private func prepareWindowForSyntheticInput(_ window: NSWindow?) { guard let window else { return } - // Keep socket-driven input simulation focused on the intended window without // paying repeated activation/order-front costs for every synthetic key event. if !NSApp.isActive { @@ -9964,22 +10244,7 @@ class TerminalController { return } - // If workspace handoff temporarily leaves a non-terminal first responder, - // route debug typing to the selected terminal's focused panel directly. - if let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let panelId = tab.focusedPanelId, - let terminalPanel = tab.terminalPanel(for: panelId), - !terminalPanel.hostedView.isSurfaceViewFirstResponder() { - // Match Enter semantics expected by tests/debug tooling when bypassing AppKit. - let directText = text.replacingOccurrences(of: "\n", with: "\r") - terminalPanel.surface.sendText(directText) - result = "OK" - return - } - - // Fall back to the responder-chain insertText action. + // Fall back to the responder chain insertText action. (fr as? NSResponder)?.insertText(text) result = "OK" } @@ -10572,10 +10837,6 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { - case "esc", "escape": - storedKey = "\u{1b}" - keyCode = UInt16(kVK_Escape) - charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -10596,10 +10857,6 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey - case "backspace", "delete", "del": - storedKey = "\u{7f}" - keyCode = UInt16(kVK_Delete) - charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } @@ -10738,8 +10995,7 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -10759,7 +11015,6 @@ class TerminalController { guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" } var ok = false - let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), @@ -10767,11 +11022,9 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: focus) - if focus { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) - } + dstTM.attachWorkspace(ws, select: true) + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) ok = true } @@ -10797,19 +11050,10 @@ class TerminalController { var newTabId: UUID? let focus = socketCommandAllowsInAppFocusMutations() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif DispatchQueue.main.sync { let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus) newTabId = workspace.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -10853,12 +11097,7 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit( - tabId: tabId, - surfaceId: targetSurface, - direction: direction, - focus: socketCommandAllowsInAppFocusMutations() - ) { + if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { result = "OK \(newPanelId.uuidString)" } } @@ -11861,29 +12100,6 @@ class TerminalController { } } - private func sendSocketText(_ text: String, surface: ghostty_surface_t) { - let chunks = Self.socketTextChunks(text) - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - for chunk in chunks { - switch chunk { - case .text(let value): - sendTextEvent(surface: surface, text: value) - case .control(let scalar): - _ = handleControlScalar(scalar, surface: surface) - } - } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - if elapsedMs >= 8 || chunks.count > 1 { - dlog( - "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" - ) - } - #endif - } - private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: @@ -11986,6 +12202,15 @@ class TerminalController { return } + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { + error = "ERROR: Surface not ready" + return + } + // Unescape common escape sequences // Note: \n is converted to \r for terminal (Enter key sends \r) let unescaped = text @@ -11993,11 +12218,13 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -12005,6 +12232,29 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendSocketText(_ text: String, surface: ghostty_surface_t) { + let chunks = Self.socketTextChunks(text) +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime +#endif + for chunk in chunks { + switch chunk { + case .text(let value): + sendTextEvent(surface: surface, text: value) + case .control(let scalar): + _ = handleControlScalar(scalar, surface: surface) + } + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + if elapsedMs >= 8 || chunks.count > 1 { + dlog( + "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" + ) + } +#endif + } + private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) @@ -12106,18 +12356,20 @@ class TerminalController { var success = false DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } + guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -12138,7 +12390,11 @@ class TerminalController { return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { error = "ERROR: Surface not ready" return } @@ -12160,11 +12416,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { + guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { error = "ERROR: Surface not found" return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { error = "ERROR: Surface not ready" return } @@ -12182,7 +12438,6 @@ class TerminalController { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create browser panel" DispatchQueue.main.sync { @@ -12192,12 +12447,7 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit( - from: focusedPanelId, - orientation: .horizontal, - url: url, - focus: shouldFocus - )?.id { + if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -12599,7 +12849,6 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" DispatchQueue.main.sync { @@ -12611,20 +12860,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit( - from: focusedPanelId, - orientation: orientation, - insertFirst: insertFirst, - url: url, - focus: shouldFocus - )?.id + newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id } else { - newPanelId = tab.newTerminalSplit( - from: focusedPanelId, - orientation: orientation, - insertFirst: insertFirst, - focus: shouldFocus - )?.id + newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id } if let id = newPanelId { @@ -13201,9 +13439,6 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else { - return - } tab.progress = SidebarProgressState(value: clamped, label: label) } return result @@ -13216,9 +13451,7 @@ class TerminalController { result = "ERROR: Tab not found" return } - if tab.progress != nil { - tab.progress = nil - } + tab.progress = nil } return result } @@ -13226,7 +13459,7 @@ class TerminalController { private func reportGitBranch(_ args: String) -> String { let parsed = parseOptions(args) guard let branch = parsed.positional.first else { - return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" + return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]" } let isDirty = parsed.options["status"]?.lowercased() == "dirty" @@ -13253,35 +13486,7 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty) + tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) } return result } @@ -13305,42 +13510,13 @@ class TerminalController { } return "OK" } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + result = "ERROR: Tab not found" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.clearPanelGitBranch(panelId: surfaceId) + tab.gitBranch = nil } return result } @@ -13483,7 +13659,6 @@ class TerminalController { } ports.append(port) } - let normalizedPorts = Array(Set(ports)).sorted() var result = "OK" DispatchQueue.main.sync { @@ -13520,43 +13695,20 @@ class TerminalController { return } - guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else { - return - } - - tab.surfaceListeningPorts[surfaceId] = normalizedPorts + tab.surfaceListeningPorts[surfaceId] = ports tab.recomputeListeningPorts() } return result } private func reportPwd(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]" } - let directory = Self.normalizeReportedDirectory(parsed.positional.joined(separator: " ")) - - // Shell integration provides explicit UUID handles for cwd updates. - // Keep this hot path off-main and drop no-op reports before scheduling UI work. - if let scope = Self.explicitSocketScope(options: parsed.options) { - guard Self.socketFastPathState.shouldPublishDirectory( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - directory: directory - ) else { - return "OK" - } - DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } - tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory) - } - return "OK" - } - - guard let tabManager else { return "ERROR: TabManager not available" } - + let directory = parsed.positional.joined(separator: " ") var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -13623,15 +13775,11 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil { - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeValue(forKey: surfaceId) } else { - if !tab.surfaceListeningPorts.isEmpty { - tab.surfaceListeningPorts.removeAll() - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeAll() } + tab.recomputeListeningPorts() } return result } @@ -13642,17 +13790,6 @@ class TerminalController { return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]" } - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to avoid sync-hopping on every report. - if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.registerTTY( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - ttyName: ttyName - ) - return "OK" - } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -13686,7 +13823,6 @@ class TerminalController { return } - guard tab.surfaceTTYNames[surfaceId] != ttyName else { return } tab.surfaceTTYNames[surfaceId] = ttyName PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) } @@ -13694,22 +13830,15 @@ class TerminalController { } private func portsKick(_ args: String) -> String { - let parsed = parseOptions(args) - - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to keep prompt hooks from blocking UI work. - if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) - return "OK" - } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { + let parsed = parseOptions(args) result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } + let parsed = parseOptions(args) let panelArg = parsed.options["panel"] ?? parsed.options["surface"] let surfaceId: UUID if let panelArg { @@ -13933,7 +14062,6 @@ class TerminalController { var panelType: PanelType = .terminal var paneArg: String? = nil var url: URL? = nil - let shouldFocus = socketCommandAllowsInAppFocusMutations() let parts = args.split(separator: " ") for part in parts { @@ -13978,9 +14106,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id } if let id = newPanelId { diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 52d9ff26..462b036f 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -94,7 +94,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { let text: String if let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { - let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) + let title = tab.title.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" } else { text = "Cmd: —" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1bc7e1ed..496ebeb2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,9 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CryptoKit +import Darwin +import Network import CoreText func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { @@ -104,7 +107,49 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } +struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { + struct Entry: Decodable, Equatable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } +} + extension Workspace { + nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey + + nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) + } + + nonisolated static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( + version: version, + goOS: goOS, + goArch: goArch, + fileManager: fileManager + ) + } + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -312,7 +357,7 @@ extension Workspace { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } let capturedScrollback = includeScrollback - ? TerminalController.shared.readTerminalTextForSnapshot( + ? TerminalController.shared.readTerminalTextForSessionSnapshot( terminalPanel: terminalPanel, includeScrollback: true, lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal @@ -336,17 +381,17 @@ extension Workspace { browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.webView.pageZoom), + pageZoom: Double(browserPanel.currentPageZoomFactor()), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil case .markdown: - guard let mdPanel = panel as? MarkdownPanel else { return nil } + guard let markdownPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) } return SessionPanelSnapshot( @@ -523,18 +568,7 @@ extension Workspace { applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: - guard let filePath = snapshot.markdown?.filePath else { - return nil - } - guard let markdownPanel = newMarkdownSurface( - inPane: paneId, - filePath: filePath, - focus: false - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) - return markdownPanel.id + return nil } } @@ -580,7 +614,7 @@ extension Workspace { let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { - browserPanel.webView.pageZoom = pageZoom + _ = browserPanel.setPageZoomFactor(pageZoom) } if browserSnapshot.developerToolsVisible { @@ -613,6 +647,3239 @@ extension Workspace { } } +final class WorkspaceRemoteDaemonPendingCallRegistry { + final class PendingCall { + let id: Int + fileprivate let semaphore = DispatchSemaphore(value: 0) + fileprivate var response: [String: Any]? + fileprivate var failureMessage: String? + + fileprivate init(id: Int) { + self.id = id + } + } + + enum WaitOutcome { + case response([String: Any]) + case failure(String) + case missing + case timedOut + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") + private var nextRequestID = 1 + private var pendingCalls: [Int: PendingCall] = [:] + + func reset() { + queue.sync { + nextRequestID = 1 + pendingCalls.removeAll(keepingCapacity: false) + } + } + + func register() -> PendingCall { + queue.sync { + let call = PendingCall(id: nextRequestID) + nextRequestID += 1 + pendingCalls[call.id] = call + return call + } + } + + @discardableResult + func resolve(id: Int, payload: [String: Any]) -> Bool { + queue.sync { + guard let pendingCall = pendingCalls[id] else { return false } + pendingCall.response = payload + pendingCall.semaphore.signal() + return true + } + } + + func failAll(_ message: String) { + queue.sync { + let calls = Array(pendingCalls.values) + for call in calls { + guard call.response == nil, call.failureMessage == nil else { continue } + call.failureMessage = message + call.semaphore.signal() + } + } + } + + func remove(_ call: PendingCall) { + queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + } + + func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { + if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { + queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + // A response can win the race immediately before timeout cleanup removes the call. + // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. + _ = call.semaphore.wait(timeout: .now()) + return .timedOut + } + + return queue.sync { + guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { + return .missing + } + if let failure = pendingCall.failureMessage { + return .failure(failure) + } + guard let response = pendingCall.response else { + return .missing + } + return .response(response) + } + } +} + +private final class WorkspaceRemoteDaemonRPCClient { + private static let maxStdoutBufferBytes = 256 * 1024 + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let onUnexpectedTermination: (String) -> Void + private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") + private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") + private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() + + private var process: Process? + private var stdinHandle: FileHandle? + private var stdoutHandle: FileHandle? + private var stderrHandle: FileHandle? + private var isClosed = true + private var shouldReportTermination = true + + private var stdoutBuffer = Data() + private var stderrBuffer = "" + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUnexpectedTermination: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.onUnexpectedTermination = onUnexpectedTermination + } + + func start() throws { + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStdoutData(data) + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStderrData(data) + } + } + process.terminationHandler = { [weak self] terminated in + self?.stateQueue.async { + self?.handleProcessTermination(terminated) + } + } + + do { + try process.run() + } catch { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", + ]) + } + + stateQueue.sync { + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutHandle = stdoutPipe.fileHandleForReading + self.stderrHandle = stderrPipe.fileHandleForReading + self.isClosed = false + self.shouldReportTermination = true + self.stdoutBuffer = Data() + self.stderrBuffer = "" + } + pendingCalls.reset() + + do { + let hello = try call(method: "hello", params: [:], timeout: 8.0) + let capabilities = (hello["capabilities"] as? [String]) ?? [] + guard capabilities.contains("proxy.stream") else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + ]) + } + } catch { + stop(suppressTerminationCallback: true) + throw error + } + } + + func stop() { + stop(suppressTerminationCallback: true) + } + + func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { + let result = try call( + method: "proxy.open", + params: [ + "host": host, + "port": port, + "timeout_ms": timeoutMs, + ], + timeout: 12.0 + ) + let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !streamID.isEmpty else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "proxy.open missing stream_id", + ]) + } + return streamID + } + + func writeStream(streamID: String, data: Data) throws { + _ = try call( + method: "proxy.write", + params: [ + "stream_id": streamID, + "data_base64": data.base64EncodedString(), + ], + timeout: 8.0 + ) + } + + func readStream(streamID: String, maxBytes: Int = 32768, timeoutMs: Int = 250) throws -> (data: Data, eof: Bool) { + let result = try call( + method: "proxy.read", + params: [ + "stream_id": streamID, + "max_bytes": maxBytes, + "timeout_ms": timeoutMs, + ], + timeout: max(2.0, TimeInterval(timeoutMs) / 1000.0 + 2.0) + ) + let encoded = (result["data_base64"] as? String) ?? "" + let decoded = encoded.isEmpty ? Data() : (Data(base64Encoded: encoded) ?? Data()) + let eof = (result["eof"] as? Bool) ?? false + return (decoded, eof) + } + + func closeStream(streamID: String) { + _ = try? call( + method: "proxy.close", + params: ["stream_id": streamID], + timeout: 4.0 + ) + } + + private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + let pendingCall = pendingCalls.register() + let requestID = pendingCall.id + + let payload: Data + do { + payload = try Self.encodeJSON([ + "id": requestID, + "method": method, + "params": params, + ]) + } catch { + pendingCalls.remove(pendingCall) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", + ]) + } + + do { + try writeQueue.sync { + try writePayload(payload) + } + } catch { + pendingCalls.remove(pendingCall) + throw error + } + + let response: [String: Any] + switch pendingCalls.wait(for: pendingCall, timeout: timeout) { + case .timedOut: + stop(suppressTerminationCallback: false) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", + ]) + case .failure(let failure): + throw NSError(domain: "cmux.remote.daemon.rpc", code: 12, userInfo: [ + NSLocalizedDescriptionKey: failure, + ]) + case .missing: + throw NSError(domain: "cmux.remote.daemon.rpc", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", + ]) + case .response(let pendingResponse): + response = pendingResponse + } + + let ok = (response["ok"] as? Bool) ?? false + if ok { + return (response["result"] as? [String: Any]) ?? [:] + } + + let errorObject = (response["error"] as? [String: Any]) ?? [:] + let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" + let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" + throw NSError(domain: "cmux.remote.daemon.rpc", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", + ]) + } + + private func writePayload(_ payload: Data) throws { + let stdinHandle: FileHandle = stateQueue.sync { + self.stdinHandle ?? FileHandle.nullDevice + } + if stdinHandle === FileHandle.nullDevice { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "daemon transport is not connected", + ]) + } + do { + try stdinHandle.write(contentsOf: payload) + try stdinHandle.write(contentsOf: Data([0x0A])) + } catch { + stop(suppressTerminationCallback: false) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 16, userInfo: [ + NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", + ]) + } + } + + private func consumeStdoutData(_ data: Data) { + guard !data.isEmpty else { + signalPendingFailureLocked("daemon transport closed stdout") + return + } + + stdoutBuffer.append(data) + if stdoutBuffer.count > Self.maxStdoutBufferBytes { + stdoutBuffer.removeAll(keepingCapacity: false) + signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") + process?.terminate() + return + } + while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { + var lineData = Data(stdoutBuffer[..<newlineIndex]) + stdoutBuffer.removeSubrange(...newlineIndex) + + if let carriageIndex = lineData.lastIndex(of: 0x0D), carriageIndex == lineData.index(before: lineData.endIndex) { + lineData.remove(at: carriageIndex) + } + guard !lineData.isEmpty else { continue } + + guard let payload = try? JSONSerialization.jsonObject(with: lineData, options: []) as? [String: Any] else { + continue + } + + let responseID: Int = { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return -1 + }() + guard responseID >= 0 else { continue } + _ = pendingCalls.resolve(id: responseID, payload: payload) + } + } + + private func consumeStderrData(_ data: Data) { + guard !data.isEmpty else { return } + guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } + stderrBuffer.append(chunk) + if stderrBuffer.count > 8192 { + stderrBuffer.removeFirst(stderrBuffer.count - 8192) + } + } + + private func handleProcessTermination(_ process: Process) { + let shouldNotify: Bool = { + guard self.process === process else { return false } + return !isClosed && shouldReportTermination + }() + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" + + isClosed = true + self.process = nil + stdinHandle = nil + stdoutHandle?.readabilityHandler = nil + stdoutHandle = nil + stderrHandle?.readabilityHandler = nil + stderrHandle = nil + signalPendingFailureLocked(detail) + + guard shouldNotify else { return } + onUnexpectedTermination(detail) + } + + private func stop(suppressTerminationCallback: Bool) { + let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" + let shouldNotify = !suppressTerminationCallback && !isClosed + shouldReportTermination = !suppressTerminationCallback + if isClosed { + return (nil, nil, nil, nil, false, detail) + } + + isClosed = true + signalPendingFailureLocked("daemon transport stopped") + let capturedProcess = process + let capturedStdin = stdinHandle + let capturedStdout = stdoutHandle + let capturedStderr = stderrHandle + + process = nil + stdinHandle = nil + stdoutHandle = nil + stderrHandle = nil + return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) + } + + captured.2?.readabilityHandler = nil + captured.3?.readabilityHandler = nil + try? captured.1?.close() + try? captured.2?.close() + try? captured.3?.close() + if let process = captured.0, process.isRunning { + process.terminate() + } + if captured.4 { + onUnexpectedTermination(captured.5) + } + } + + private func signalPendingFailureLocked(_ message: String) { + pendingCalls.failAll(message) + } + + private static func encodeJSON(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: []) + } + + private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { + let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" + // Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup. + let command = "sh -c \(shellSingleQuoted(script))" + return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command] + } + + private static let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + + private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + if batchMode { + args += ["-o", "BatchMode=yes"] + // Batch helpers should reuse an existing ControlPath if one was configured, + // but must never try to negotiate a new master connection. + args += ["-o", "ControlMaster=no"] + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let token = sshOptionKey(option) + if token == loweredKey { + return true + } + } + return false + } + + private static func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static func backgroundSSHOptions(_ options: [String]) -> [String] { + normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private static func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private static func bestErrorLine(stderr: String) -> String? { + let lines = stderr + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } +} + +private final class WorkspaceRemoteDaemonProxyTunnel { + private final class ProxySession { + private static let maxHandshakeBytes = 64 * 1024 + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + + private enum HandshakeProtocol { + case undecided + case socks5 + case connect + } + + private enum SocksStage { + case greeting + case request + } + + private struct SocksRequest { + let host: String + let port: Int + let command: UInt8 + let consumedBytes: Int + } + + let id = UUID() + + 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 + private var protocolKind: HandshakeProtocol = .undecided + private var socksStage: SocksStage = .greeting + private var handshakeBuffer = Data() + private var streamID: String? + private var localInputEOF = false + + init( + connection: NWConnection, + rpcClient: WorkspaceRemoteDaemonRPCClient, + queue: DispatchQueue, + onClose: @escaping (UUID) -> Void + ) { + 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 + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .failed(let error): + self.close(reason: "proxy client connection failed: \(error)") + case .cancelled: + self.close(reason: nil) + default: + break + } + } + connection.start(queue: queue) + receiveNext() + } + + func stop() { + close(reason: nil) + } + + private func receiveNext() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in + guard let self, !self.isClosed else { return } + + if let data, !data.isEmpty { + if self.streamID == nil { + if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { + self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") + return + } + self.handshakeBuffer.append(data) + self.processHandshakeBuffer() + } else { + self.forwardToRemote(data) + } + } + + if isComplete { + // Treat local EOF as a half-close: keep remote read loop alive so we can + // drain upstream response bytes (for example curl closing write-side after + // sending an HTTP request through SOCKS/CONNECT). + self.localInputEOF = true + if self.streamID == nil { + self.close(reason: nil) + } + return + } + if let error { + self.close(reason: "proxy client receive error: \(error)") + return + } + + self.receiveNext() + } + } + + private func processHandshakeBuffer() { + guard !isClosed else { return } + while streamID == nil { + switch protocolKind { + case .undecided: + guard let first = handshakeBuffer.first else { return } + protocolKind = (first == 0x05) ? .socks5 : .connect + case .socks5: + if !processSocksHandshakeStep() { + return + } + case .connect: + if !processConnectHandshakeStep() { + return + } + } + } + } + + private func processSocksHandshakeStep() -> Bool { + switch socksStage { + case .greeting: + guard handshakeBuffer.count >= 2 else { return false } + let methodCount = Int(handshakeBuffer[1]) + let total = 2 + methodCount + guard handshakeBuffer.count >= total else { return false } + + let methods = [UInt8](handshakeBuffer[2..<total]) + handshakeBuffer = Data(handshakeBuffer.dropFirst(total)) + socksStage = .request + + if !methods.contains(0x00) { + sendAndClose(Data([0x05, 0xFF])) + return false + } + sendLocal(Data([0x05, 0x00])) + return true + + case .request: + let request: SocksRequest + do { + guard let parsed = try parseSocksRequest(from: handshakeBuffer) else { return false } + request = parsed + } catch { + sendAndClose(Data([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + let pending = handshakeBuffer.count > request.consumedBytes + ? Data(handshakeBuffer[request.consumedBytes...]) + : Data() + handshakeBuffer = Data() + guard request.command == 0x01 else { + sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + openRemoteStream( + host: request.host, + port: request.port, + successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + pendingPayload: pending + ) + return false + } + } + + private func parseSocksRequest(from data: Data) throws -> SocksRequest? { + let bytes = [UInt8](data) + guard bytes.count >= 4 else { return nil } + guard bytes[0] == 0x05 else { + throw NSError(domain: "cmux.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) + } + + let command = bytes[1] + let addressType = bytes[3] + var cursor = 4 + let host: String + + switch addressType { + case 0x01: + guard bytes.count >= cursor + 4 + 2 else { return nil } + let octets = bytes[cursor..<(cursor + 4)].map { String($0) } + host = octets.joined(separator: ".") + cursor += 4 + + case 0x03: + guard bytes.count >= cursor + 1 else { return nil } + let length = Int(bytes[cursor]) + cursor += 1 + guard bytes.count >= cursor + length + 2 else { return nil } + let hostData = Data(bytes[cursor..<(cursor + length)]) + host = String(data: hostData, encoding: .utf8) ?? "" + cursor += length + + case 0x04: + guard bytes.count >= cursor + 16 + 2 else { return nil } + var address = in6_addr() + withUnsafeMutableBytes(of: &address) { target in + for i in 0..<16 { + target[i] = bytes[cursor + i] + } + } + var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let pointer = withUnsafePointer(to: &address) { + inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) + } + host = pointer != nil ? String(cString: text) : "" + cursor += 16 + + default: + throw NSError(domain: "cmux.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) + } + + guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw NSError(domain: "cmux.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) + } + guard bytes.count >= cursor + 2 else { return nil } + let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) + cursor += 2 + + guard port > 0 && port <= 65535 else { + throw NSError(domain: "cmux.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) + } + + return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) + } + + private func processConnectHandshakeStep() -> Bool { + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard let headerRange = handshakeBuffer.range(of: marker) else { return false } + + let headerData = Data(handshakeBuffer[..<headerRange.upperBound]) + let pending = headerRange.upperBound < handshakeBuffer.count + ? Data(handshakeBuffer[headerRange.upperBound...]) + : Data() + handshakeBuffer = Data() + guard let headerText = String(data: headerData, encoding: .utf8) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + let firstLine = headerText.components(separatedBy: "\r\n").first ?? "" + let parts = firstLine.split(whereSeparator: \.isWhitespace).map(String.init) + guard parts.count >= 2, parts[0].uppercased() == "CONNECT" else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + openRemoteStream( + host: host, + port: port, + successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), + failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), + pendingPayload: pending + ) + return false + } + + private func openRemoteStream( + host: String, + port: Int, + successResponse: Data, + failureResponse: Data, + pendingPayload: Data + ) { + guard !isClosed else { return } + do { + let targetHost = Self.normalizedProxyTargetHost(host) + let streamID = try rpcClient.openStream(host: targetHost, port: port) + self.streamID = streamID + connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if !pendingPayload.isEmpty { + self.forwardToRemote(pendingPayload, allowAfterEOF: true) + } + self.scheduleRemoteReadLoop() + }) + } catch { + sendAndClose(failureResponse) + } + } + + private func forwardToRemote(_ data: Data, allowAfterEOF: Bool = false) { + guard !isClosed else { return } + guard !localInputEOF || allowAfterEOF else { return } + guard let streamID else { return } + do { + try rpcClient.writeStream(streamID: streamID, data: data) + } catch { + close(reason: "proxy.write failed: \(error.localizedDescription)") + } + } + + private func scheduleRemoteReadLoop() { + guard let streamID else { return } + readQueue.async { [weak self] in + self?.pollRemoteOnce(streamID: streamID) + } + } + + 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 self.streamID == streamID else { return } + + let readResult: (data: Data, eof: Bool) + switch result { + case .success(let value): + readResult = value + case .failure(let error): + close(reason: "proxy.read failed: \(error.localizedDescription)") + return + } + + if !readResult.data.isEmpty { + connection.send(content: readResult.data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if readResult.eof { + self.close(reason: nil) + } else { + self.scheduleRemoteReadLoop() + } + }) + return + } + + if readResult.eof { + close(reason: nil) + } else { + scheduleRemoteReadLoop() + } + } + + private func close(reason: String?) { + guard !isClosed else { return } + isClosed = true + + let streamID = self.streamID + self.streamID = nil + + if let streamID { + rpcClient.closeStream(streamID: streamID) + } + connection.cancel() + onClose(id) + } + + private func sendLocal(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + } + }) + } + + private func sendAndClose(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] _ in + self?.close(reason: nil) + }) + } + + private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { + let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("[") { + guard let closing = trimmed.firstIndex(of: "]") else { return nil } + let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) + let portStart = trimmed.index(after: closing) + guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil } + let portString = String(trimmed[trimmed.index(after: portStart)...]) + guard let port = Int(portString), port > 0, port <= 65535 else { return nil } + return (host, port) + } + + guard let colon = trimmed.lastIndex(of: ":") else { return nil } + let host = String(trimmed[..<colon]) + let portString = String(trimmed[trimmed.index(after: colon)...]) + guard !host.isEmpty else { return nil } + guard let port = Int(portString), port > 0, port <= 65535 else { return nil } + return (host, port) + } + + private static func normalizedProxyTargetHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. + // Resolve it back to true loopback before dialing from the remote daemon. + if normalized == remoteLoopbackProxyAliasHost { + return "127.0.0.1" + } + return host + } + + private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { + var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + if closeAfterResponse { + text += "Connection: close\r\n" + } + text += "\r\n" + return Data(text.utf8) + } + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let localPort: Int + private let onFatalError: (String) -> Void + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var rpcClient: WorkspaceRemoteDaemonRPCClient? + private var sessions: [UUID: ProxySession] = [:] + private var isStopped = false + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + localPort: Int, + onFatalError: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.localPort = localPort + self.onFatalError = onFatalError + } + + func start() throws { + var capturedError: Error? + queue.sync { + guard !isStopped else { + capturedError = NSError(domain: "cmux.remote.proxy", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "proxy tunnel already stopped", + ]) + return + } + do { + let client = WorkspaceRemoteDaemonRPCClient( + configuration: configuration, + remotePath: remotePath + ) { [weak self] detail in + self?.queue.async { + self?.failLocked("Remote daemon transport failed: \(detail)") + } + } + try client.start() + + let listener = try Self.makeLoopbackListener(port: localPort) + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleListenerStateLocked(state) + } + } + + self.rpcClient = client + self.listener = listener + listener.start(queue: queue) + } catch { + capturedError = error + stopLocked(notify: false) + } + } + if let capturedError { + throw capturedError + } + } + + func stop() { + queue.sync { + stopLocked(notify: false) + } + } + + private func handleListenerStateLocked(_ state: NWListener.State) { + guard !isStopped else { return } + switch state { + case .failed(let error): + failLocked("Local proxy listener failed: \(error)") + default: + break + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + guard let rpcClient else { + connection.cancel() + return + } + + let session = ProxySession( + connection: connection, + rpcClient: rpcClient, + queue: queue + ) { [weak self] id in + self?.queue.async { + self?.sessions.removeValue(forKey: id) + } + } + sessions[session.id] = session + session.start() + } + + private func failLocked(_ detail: String) { + guard !isStopped else { return } + stopLocked(notify: false) + onFatalError(detail) + } + + private func stopLocked(notify: Bool) { + guard !isStopped else { return } + isStopped = true + + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + + rpcClient?.stop() + rpcClient = nil + } + + private static func makeLoopbackListener(port: Int) throws -> NWListener { + guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + throw NSError(domain: "cmux.remote.proxy", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "invalid local proxy port \(port)", + ]) + } + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) + return try NWListener(using: parameters) + } +} + +private final class WorkspaceRemoteProxyBroker { + enum Update { + case connecting + case ready(BrowserProxyEndpoint) + case error(String) + } + + final class Lease { + private let key: String + private let subscriberID: UUID + private weak var broker: WorkspaceRemoteProxyBroker? + private var isReleased = false + + fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { + self.key = key + self.subscriberID = subscriberID + self.broker = broker + } + + func release() { + guard !isReleased else { return } + isReleased = true + broker?.release(key: key, subscriberID: subscriberID) + } + + deinit { + release() + } + } + + private final class Entry { + let configuration: WorkspaceRemoteConfiguration + var remotePath: String + var tunnel: WorkspaceRemoteDaemonProxyTunnel? + var endpoint: BrowserProxyEndpoint? + var restartWorkItem: DispatchWorkItem? + var subscribers: [UUID: (Update) -> Void] = [:] + + init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { + self.configuration = configuration + self.remotePath = remotePath + } + } + + static let shared = WorkspaceRemoteProxyBroker() + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) + private var entries: [String: Entry] = [:] + + func acquire( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUpdate: @escaping (Update) -> Void + ) -> Lease { + queue.sync { + let key = Self.transportKey(for: configuration) + let subscriberID = UUID() + let entry: Entry + if let existing = entries[key] { + entry = existing + if existing.remotePath != remotePath { + existing.remotePath = remotePath + if existing.tunnel != nil { + stopEntryRuntimeLocked(existing) + notifyLocked(existing, update: .connecting) + } + } + } else { + entry = Entry(configuration: configuration, remotePath: remotePath) + entries[key] = entry + } + + entry.subscribers[subscriberID] = onUpdate + if let endpoint = entry.endpoint { + onUpdate(.ready(endpoint)) + } else { + onUpdate(.connecting) + } + + if entry.tunnel == nil, entry.restartWorkItem == nil { + startEntryLocked(key: key, entry: entry) + } + + return Lease(key: key, subscriberID: subscriberID, broker: self) + } + } + + private func release(key: String, subscriberID: UUID) { + queue.async { [weak self] in + guard let self, let entry = self.entries[key] else { return } + entry.subscribers.removeValue(forKey: subscriberID) + guard entry.subscribers.isEmpty else { return } + self.teardownEntryLocked(key: key, entry: entry) + } + } + + private func startEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + + let localPort: Int + if let forcedLocalPort = entry.configuration.localProxyPort { + // Internal deterministic test hook used by docker regressions to force bind conflicts. + localPort = forcedLocalPort + } else { + guard let allocatedPort = Self.allocateLoopbackPort() else { + notifyLocked( + entry, + update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: 3.0))") + ) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + return + } + localPort = allocatedPort + } + + do { + let tunnel = WorkspaceRemoteDaemonProxyTunnel( + configuration: entry.configuration, + remotePath: entry.remotePath, + localPort: localPort + ) { [weak self] detail in + self?.queue.async { + self?.handleTunnelFailureLocked(key: key, detail: detail) + } + } + try tunnel.start() + entry.tunnel = tunnel + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) + entry.endpoint = endpoint + notifyLocked(entry, update: .ready(endpoint)) + } catch { + stopEntryRuntimeLocked(entry) + let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))")) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + } + } + + private func handleTunnelFailureLocked(key: String, detail: String) { + guard let entry = entries[key], entry.tunnel != nil else { return } + stopEntryRuntimeLocked(entry) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))")) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + } + + private func scheduleRestartLocked(key: String, entry: Entry, delay: TimeInterval) { + guard !entry.subscribers.isEmpty else { + teardownEntryLocked(key: key, entry: entry) + return + } + guard entry.restartWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + guard let self, let currentEntry = self.entries[key] else { return } + currentEntry.restartWorkItem = nil + guard !currentEntry.subscribers.isEmpty else { + self.teardownEntryLocked(key: key, entry: currentEntry) + return + } + self.notifyLocked(currentEntry, update: .connecting) + self.startEntryLocked(key: key, entry: currentEntry) + } + + entry.restartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func teardownEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + stopEntryRuntimeLocked(entry) + entries.removeValue(forKey: key) + } + + private func stopEntryRuntimeLocked(_ entry: Entry) { + entry.tunnel?.stop() + entry.tunnel = nil + entry.endpoint = nil + } + + private func notifyLocked(_ entry: Entry, update: Update) { + for callback in entry.subscribers.values { + callback(update) + } + } + + private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { + let destination = configuration.destination.trimmingCharacters(in: .whitespacesAndNewlines) + let port = configuration.port.map(String.init) ?? "" + let identity = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let localProxyPort = configuration.localProxyPort.map(String.init) ?? "" + let options = configuration.sshOptions + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\u{1f}") + return [destination, port, identity, options, localProxyPort].joined(separator: "\u{1e}") + } + + private static func allocateLoopbackPort() -> Int? { + for _ in 0..<8 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size)) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(0) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) + } + } + guard bindResult == 0 else { continue } + + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout<sockaddr_in>.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + getsockname(fd, sockaddrPtr, &len) + } + } + guard nameResult == 0 else { continue } + + let port = Int(UInt16(bigEndian: bound.sin_port)) + if port > 0 && port <= 65535 { + return port + } + } + return nil + } + + private static func retrySuffix(delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry in \(seconds)s)" + } +} + +private final class WorkspaceRemoteCLIRelayServer { + private final class Session { + private enum Phase { + case awaitingAuth + case awaitingCommand + case forwarding + case closed + } + + private let connection: NWConnection + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue: DispatchQueue + private let onClose: () -> Void + private let challengeProtocol = "cmux-relay-auth" + private let challengeVersion = 1 + private let minimumFailureDelay: TimeInterval = 0.05 + private let maximumFrameBytes = 16 * 1024 + + private var buffer = Data() + private var phase: Phase = .awaitingAuth + private var challengeNonce = "" + private var challengeSentAt = Date() + private var isClosed = false + + init( + connection: NWConnection, + localSocketPath: String, + relayID: String, + relayToken: Data, + queue: DispatchQueue, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleState(state) + } + } + connection.start(queue: queue) + } + + func stop() { + close() + } + + private func handleState(_ state: NWConnection.State) { + guard !isClosed else { return } + switch state { + case .ready: + sendChallenge() + receive() + case .failed, .cancelled: + close() + default: + break + } + } + + private func sendChallenge() { + challengeSentAt = Date() + challengeNonce = Self.randomHex(byteCount: 16) + let challenge: [String: Any] = [ + "protocol": challengeProtocol, + "version": challengeVersion, + "relay_id": relayID, + "nonce": challengeNonce, + ] + sendJSONLine(challenge) { _ in } + } + + private func receive() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + if error != nil { + self.close() + return + } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > self.maximumFrameBytes { + self.sendFailureAndClose() + return + } + self.processBufferedLines() + } + if isComplete { + self.close() + return + } + if !self.isClosed { + self.receive() + } + } + } + } + + private func processBufferedLines() { + while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(...newlineIndex) + let line = String(data: lineData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch phase { + case .awaitingAuth: + handleAuthLine(line) + case .awaitingCommand: + handleCommandLine(Data(lineData) + Data([0x0A])) + case .forwarding, .closed: + return + } + } + } + + private func handleAuthLine(_ line: String) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let receivedRelayID = object["relay_id"] as? String, + receivedRelayID == relayID, + let macHex = object["mac"] as? String, + let receivedMAC = Self.hexData(from: macHex) + else { + sendFailureAndClose() + return + } + + let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) + let expectedMAC = Self.authMAC(token: relayToken, message: message) + guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { + sendFailureAndClose() + return + } + + phase = .awaitingCommand + sendJSONLine(["ok": true]) { [weak self] _ in + self?.queue.async { + self?.processBufferedLines() + } + } + } + + private func handleCommandLine(_ commandLine: Data) { + guard !commandLine.isEmpty else { + sendFailureAndClose() + return + } + phase = .forwarding + DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in + let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + queue.async { [weak self] in + guard let self else { return } + switch result { + case .success(let response): + self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.queue.async { + self?.close() + } + }) + case .failure: + self.sendFailureAndClose() + } + } + } + } + + private func sendFailureAndClose() { + let elapsed = Date().timeIntervalSince(challengeSentAt) + let delay = max(0, minimumFailureDelay - elapsed) + phase = .closed + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendJSONLine(["ok": false]) { [weak self] _ in + self?.queue.async { + self?.close() + } + } + } + } + + private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { + guard !isClosed else { + completion(nil) + return + } + guard let payload = try? JSONSerialization.data(withJSONObject: object) else { + completion(nil) + return + } + connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) + } + + private func close() { + guard !isClosed else { return } + isClosed = true + phase = .closed + connection.stateUpdateHandler = nil + connection.cancel() + onClose() + } + + private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { + Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + } + + private static func authMAC(token: Data, message: Data) -> Data { + let key = SymmetricKey(data: token) + let code = HMAC<SHA256>.authenticationCode(for: message, using: key) + return Data(code) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + fileprivate static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { return nil } + data.append(byte) + cursor = next + } + return data + } + + private static func randomHex(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "cmux.remote.relay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "failed to create local relay socket", + ]) + } + defer { Darwin.close(fd) } + + var timeout = timeval(tv_sec: 15, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: "cmux.remote.relay", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "local relay socket path is too long", + ]) + } + let sunPathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 + withUnsafeMutableBytes(of: &address) { rawBuffer in + let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) + pathBytes.withUnsafeBytes { pathBuffer in + destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) + } + } + + let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + guard connectResult == 0 else { + throw NSError(domain: "cmux.remote.relay", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "failed to connect to local cmux socket", + ]) + } + + try request.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var bytesRemaining = rawBuffer.count + var pointer = baseAddress + while bytesRemaining > 0 { + let written = Darwin.write(fd, pointer, bytesRemaining) + if written <= 0 { + throw NSError(domain: "cmux.remote.relay", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "failed to write relay request", + ]) + } + bytesRemaining -= written + pointer = pointer.advanced(by: written) + } + } + _ = shutdown(fd, SHUT_WR) + + var response = Data() + var scratch = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &scratch, scratch.count) + if count > 0 { + response.append(scratch, count: count) + continue + } + if count == 0 { + break + } + + if errno == EAGAIN || errno == EWOULDBLOCK { + if !response.isEmpty { + break + } + throw NSError(domain: "cmux.remote.relay", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local cmux response", + ]) + } + throw NSError(domain: "cmux.remote.relay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "failed to read local cmux response", + ]) + } + return response + } + } + + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var sessions: [UUID: Session] = [:] + private var isStopped = false + private(set) var localPort: Int? + + init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { + throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "invalid relay token", + ]) + } + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + } + + func start() throws -> Int { + var capturedError: Error? + var boundPort: Int = 0 + queue.sync { + do { + if let localPort { + boundPort = localPort + return + } + let listener = try Self.makeLoopbackListener() + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { _ in } + listener.start(queue: queue) + guard let tcpPort = listener.port?.rawValue else { + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + self.listener = listener + self.localPort = Int(tcpPort) + boundPort = Int(tcpPort) + } catch { + capturedError = error + } + } + if let capturedError { + throw capturedError + } + return boundPort + } + + func stop() { + queue.sync { + guard !isStopped else { return } + isStopped = true + listener?.newConnectionHandler = nil + listener?.stateUpdateHandler = nil + listener?.cancel() + listener = nil + localPort = nil + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + let sessionID = UUID() + let session = Session( + connection: connection, + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken, + queue: queue + ) { [weak self] in + self?.sessions.removeValue(forKey: sessionID) + } + sessions[sessionID] = session + session.start() + } + + private static func makeLoopbackListener() throws -> NWListener { + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) + return try NWListener(using: parameters) + } +} + +private final class WorkspaceRemoteSessionController { + private struct CommandResult { + let status: Int32 + let stdout: String + let stderr: String + } + + private struct RemotePlatform { + let goOS: String + let goArch: String + } + + private struct DaemonHello { + let name: String + let version: String + let capabilities: [String] + let remotePath: String + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) + private let queueKey = DispatchSpecificKey<Void>() + private weak var workspace: Workspace? + private let configuration: WorkspaceRemoteConfiguration + private let controllerID: UUID + + private var isStopping = false + private var proxyLease: WorkspaceRemoteProxyBroker.Lease? + private var proxyEndpoint: BrowserProxyEndpoint? + private var daemonReady = false + private var daemonBootstrapVersion: String? + private var daemonRemotePath: String? + private var reverseRelayProcess: Process? + private var cliRelayServer: WorkspaceRemoteCLIRelayServer? + private var reverseRelayStderrPipe: Pipe? + private var reverseRelayRestartWorkItem: DispatchWorkItem? + private var reverseRelayStderrBuffer = "" + 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 + self.configuration = configuration + self.controllerID = controllerID + queue.setSpecific(key: queueKey, value: ()) + } + + func start() { + debugLog("remote.session.start \(debugConfigSummary())") + queue.async { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + self.beginConnectionAttemptLocked() + } + } + + func stop() { + if DispatchQueue.getSpecific(key: queueKey) != nil { + stopAllLocked() + return + } + queue.async { [self] in + stopAllLocked() + } + } + + private func stopAllLocked() { + debugLog("remote.session.stop \(debugConfigSummary())") + isStopping = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + stopHeartbeatLocked(reset: true) + stopReverseRelayLocked() + + proxyLease?.release() + proxyLease = nil + proxyEndpoint = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + } + + private func beginConnectionAttemptLocked() { + guard !isStopping else { return } + + debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") + reconnectWorkItem = nil + stopHeartbeatLocked(reset: true) + let connectDetail: String + let bootstrapDetail: String + if reconnectRetryCount > 0 { + connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" + } else { + connectDetail = "Connecting to \(configuration.displayTarget)" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" + } + publishState(.connecting, detail: connectDetail) + publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) + do { + let hello = try bootstrapDaemonLocked() + guard hello.capabilities.contains("proxy.stream") else { + throw NSError(domain: "cmux.remote.daemon", code: 43, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + ]) + } + daemonReady = true + daemonBootstrapVersion = hello.version + daemonRemotePath = hello.remotePath + publishDaemonStatus( + .ready, + detail: "Remote daemon ready", + version: hello.version, + name: hello.name, + capabilities: hello.capabilities, + remotePath: hello.remotePath + ) + prepareRemoteCLISessionLocked(remotePath: hello.remotePath) + startReverseRelayLocked(remotePath: hello.remotePath) + startProxyLocked() + } catch { + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + let nextRetry = scheduleReconnectLocked(delay: 4.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) + let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + } + } + + private func startProxyLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease == nil else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let nextRetry = scheduleReconnectLocked(delay: 4.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) + let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + return + } + + let lease = WorkspaceRemoteProxyBroker.shared.acquire( + configuration: configuration, + remotePath: remotePath + ) { [weak self] update in + self?.queue.async { + self?.handleProxyBrokerUpdateLocked(update) + } + } + proxyLease = lease + } + + private func prepareRemoteCLISessionLocked(remotePath: String) { + createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) + } + + private func startReverseRelayLocked(remotePath: String) { + guard !isStopping else { return } + guard daemonReady else { return } + guard let relayPort = configuration.relayPort, relayPort > 0, + let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayID.isEmpty, + let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayToken.isEmpty, + let localSocketPath = configuration.localSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localSocketPath.isEmpty else { + return + } + guard reverseRelayProcess == nil else { return } + + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + do { + let relayServer = try ensureCLIRelayServerLocked( + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken + ) + let localRelayPort = try relayServer.start() + Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } + } + + try process.run() + reverseRelayProcess = process + cliRelayServer = relayServer + reverseRelayStderrPipe = stderrPipe + reverseRelayStderrBuffer = "" + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget)" + ) + + queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + guard self.reverseRelayProcess === process, process.isRunning else { return } + self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath) + do { + try self.writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) + } catch { + self.debugLog("remote.relay.auth.error \(error.localizedDescription)") + self.stopReverseRelayLocked() + self.scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + self.writeRemoteSocketAddrLocked(relayPort: relayPort) + } + } catch { + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(error.localizedDescription)" + ) + cliRelayServer?.stop() + cliRelayServer = nil + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + } + + private func handleReverseRelayTerminationLocked(process: Process) { + guard reverseRelayProcess === process else { return } + let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + guard !isStopping else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let detail = stderrDetail ?? "status=\(process.terminationStatus)" + debugLog("remote.relay.exit \(detail)") + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + + private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { + guard !isStopping else { return } + reverseRelayRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reverseRelayRestartWorkItem = nil + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + guard self.daemonReady else { return } + self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) + } + reverseRelayRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func stopReverseRelayLocked() { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if let reverseRelayProcess, reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + reverseRelayStderrBuffer = "" + cliRelayServer?.stop() + cliRelayServer = nil + removeRemoteRelayMetadataLocked() + } + + private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { + guard !isStopping else { return } + switch update { + case .connecting: + debugLog("remote.proxy.connecting \(debugConfigSummary())") + if proxyEndpoint == nil { + publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") + } + case .ready(let endpoint): + debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + guard proxyEndpoint != endpoint else { + startHeartbeatLocked() + return + } + proxyEndpoint = endpoint + publishProxyEndpoint(endpoint) + publishPortsSnapshotLocked() + publishState( + .connected, + detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" + ) + startHeartbeatLocked() + case .error(let detail): + debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") + proxyEndpoint = nil + stopHeartbeatLocked(reset: false) + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") + guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } + + proxyLease?.release() + proxyLease = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + + let nextRetry = scheduleReconnectLocked(delay: 2.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 2.0) + publishDaemonStatus( + .error, + detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" + ) + } + } + + @discardableResult + private func scheduleReconnectLocked(delay: TimeInterval) -> Int { + guard !isStopping else { return reconnectRetryCount } + reconnectWorkItem?.cancel() + reconnectRetryCount += 1 + let retryNumber = reconnectRetryCount + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reconnectWorkItem = nil + guard !self.isStopping else { return } + guard self.proxyLease == nil else { return } + self.beginConnectionAttemptLocked() + } + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + return retryNumber + } + + 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, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func publishDaemonStatus( + _ state: WorkspaceRemoteDaemonState, + detail: String?, + version: String? = nil, + name: String? = nil, + capabilities: [String] = [], + remotePath: String? = nil + ) { + let controllerID = self.controllerID + let status = WorkspaceRemoteDaemonStatus( + state: state, + detail: detail, + version: version, + name: name, + capabilities: capabilities, + remotePath: remotePath + ) + 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" + ) + } + } + + 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: [], + conflicts: [], + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + 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 reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { + // `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still + // attach to an existing master and exit immediately with its status. + // `-S none` forces a standalone transport for the reverse relay. + var args: [String] = ["-N", "-T", "-S", "none"] + args += sshCommonArguments(batchMode: true) + args += [ + "-o", "ExitOnForwardFailure=no", + "-o", "RequestTTY=no", + "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", + configuration.destination, + ] + return args + } + + private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" + private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" + + private func sshCommonArguments(batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + if batchMode { + args += ["-o", "BatchMode=yes"] + args += ["-o", "ControlMaster=no"] + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let token = sshOptionKey(option) + if token == loweredKey { + return true + } + } + return false + } + + private func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private func backgroundSSHOptions(_ options: [String]) -> [String] { + let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + return normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/ssh", + arguments: arguments, + stdin: stdin, + timeout: timeout + ) + } + + private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/scp", + arguments: arguments, + stdin: nil, + timeout: timeout + ) + } + + private func runProcess( + executable: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectory: URL? = nil, + stdin: Data?, + timeout: TimeInterval + ) throws -> CommandResult { + debugLog( + "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let environment { + process.environment = environment + } + if let currentDirectory { + process.currentDirectoryURL = currentDirectory + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + if stdin != nil { + process.standardInput = Pipe() + } else { + process.standardInput = FileHandle.nullDevice + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") + var stdoutData = Data() + var stderrData = Data() + let captureGroup = DispatchGroup() + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stdoutHandle.readDataToEndOfFile() + captureQueue.sync { + stdoutData = data + } + captureGroup.leave() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stderrHandle.readDataToEndOfFile() + captureQueue.sync { + stderrData = data + } + captureGroup.leave() + } + + do { + try process.run() + } catch { + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + debugLog( + "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "error=\(error.localizedDescription)" + ) + throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", + ]) + } + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + + if let stdin, let pipe = process.standardInput as? Pipe { + pipe.fileHandleForWriting.write(stdin) + try? pipe.fileHandleForWriting.close() + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + 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() + } + debugLog( + "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", + ]) + } + + _ = captureGroup.wait(timeout: .now() + 2.0) + try? stdoutHandle.close() + try? stderrHandle.close() + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + debugLog( + "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + + "stderr=\(Self.debugLogSnippet(stderr))" + ) + return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + private func bootstrapDaemonLocked() throws -> DaemonHello { + debugLog("remote.bootstrap.begin \(debugConfigSummary())") + let platform = try resolveRemotePlatformLocked() + let version = Self.remoteDaemonVersion() + let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + debugLog( + "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + + "version=\(version) remotePath=\(remotePath)" + ) + + let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath) + debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") + if !hadExistingBinary { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + } + + var hello = try helloRemoteDaemonLocked(remotePath: remotePath) + if hadExistingBinary, !hello.capabilities.contains("proxy.stream") { + debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + + debugLog( + "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" + ) + return hello + } + + private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRemotePath.isEmpty else { return } + + let script = """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + ln -sf "$HOME/.cmux/bin/cmuxd-remote-current" "$HOME/.cmux/bin/cmux" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.wrapper.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.wrapper.error \(error.localizedDescription)") + } + } + + private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { + if let cliRelayServer { + return cliRelayServer + } + let relayServer = try WorkspaceRemoteCLIRelayServer( + localSocketPath: localSocketPath, + relayID: relayID, + relayTokenHex: relayToken + ) + cliRelayServer = relayServer + return relayServer + } + + private func writeRemoteSocketAddrLocked(relayPort: Int) { + let script = """ + mkdir -p "$HOME/.cmux" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.socketAddr.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.socketAddr.error \(error.localizedDescription)") + } + } + + private func writeRemoteRelayDaemonPathLocked(remotePath: String) { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRemotePath.isEmpty else { return } + + let script = """ + mkdir -p "$HOME/.cmux/relay" + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.daemonPath.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.daemonPath.error \(error.localizedDescription)") + } + } + + private func writeRemoteRelayAuthLocked(relayPort: Int, relayID: String, relayToken: String) throws { + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + let script = """ + umask 077 + mkdir -p "$HOME/.cmux/relay" + chmod 700 "$HOME/.cmux/relay" + cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' + \(authPayload) + CMUXRELAYAUTH + chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote relay auth: \(detail)", + ]) + } + } + + private func removeRemoteRelayMetadataLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let script = """ + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + } catch { + debugLog("remote.relay.cleanup.error \(error.localizedDescription)") + } + } + + private func resolveRemotePlatformLocked() throws -> RemotePlatform { + let script = """ + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$(uname -s)" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$(uname -m)" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", + ]) + } + + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } + let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } + guard let unameOS, let unameArch else { + throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "remote platform probe returned invalid output", + ]) + } + + guard let goOS = Self.mapUnameOS(unameOS), + let goArch = Self.mapUnameArch(unameArch) else { + throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", + ]) + } + + return RemotePlatform(goOS: goOS, goArch: goArch) + } + + private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool { + let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { return false } + return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" + } + + static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" + + static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } + let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { + remoteDaemonManifest(from: Bundle.main.infoDictionary) + } + + private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { + let appSupportRoot = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheRoot = appSupportRoot + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + return cacheRoot + } + + static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try remoteDaemonCacheRoot(fileManager: fileManager) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private static func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" + } + + private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } + guard let path = environment["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + } + + private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String) throws -> URL { + guard let url = URL(string: entry.downloadURL) else { + throw NSError(domain: "cmux.remote.daemon", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", + ]) + } + + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) + let fileManager = FileManager.default + try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let request = NSMutableURLRequest(url: url) + request.timeoutInterval = 60 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + + let semaphore = DispatchSemaphore(value: 0) + var downloadedURL: URL? + var downloadError: Error? + session.downloadTask(with: request as URLRequest) { localURL, response, error in + defer { semaphore.signal() } + if let error { + downloadError = error + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + downloadError = NSError(domain: "cmux.remote.daemon", code: 26, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", + ]) + return + } + downloadedURL = localURL + }.resume() + _ = semaphore.wait(timeout: .now() + 75.0) + session.finishTasksAndInvalidate() + + if let downloadError { + throw downloadError + } + guard let downloadedURL else { + throw NSError(domain: "cmux.remote.daemon", code: 27, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download did not produce a file", + ]) + } + + let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) + guard downloadedSHA == entry.sha256.lowercased() else { + throw NSError(domain: "cmux.remote.daemon", code: 28, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", + ]) + } + + let tempURL = cacheURL.deletingLastPathComponent() + .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.moveItem(at: downloadedURL, to: tempURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: cacheURL) + try fileManager.moveItem(at: tempURL, to: cacheURL) + return cacheURL + } + + private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), + FileManager.default.isExecutableFile(atPath: explicitBinary.path) { + debugLog("remote.build.explicit path=\(explicitBinary.path)") + return explicitBinary + } + + if let manifest = Self.remoteDaemonManifest(), + manifest.appVersion == version, + let entry = manifest.entry(goOS: goOS, goArch: goArch) { + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) + if FileManager.default.fileExists(atPath: cacheURL.path) { + let cachedSHA = try Self.sha256Hex(forFile: cacheURL) + if cachedSHA == entry.sha256.lowercased(), + FileManager.default.isExecutableFile(atPath: cacheURL.path) { + debugLog("remote.build.cached path=\(cacheURL.path)") + return cacheURL + } + try? FileManager.default.removeItem(at: cacheURL) + } + let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion) + debugLog("remote.build.downloaded path=\(downloadedURL.path)") + return downloadedURL + } + + guard Self.allowLocalDaemonBuildFallback() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "this build does not include a verified cmuxd-remote manifest for \(goOS)-\(goArch). Use a release/nightly build, or set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + ]) + } + + guard let repoRoot = Self.findRepoRoot() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only cmuxd-remote build fallback", + ]) + } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + let goModPath = daemonRoot.appendingPathComponent("go.mod").path + guard FileManager.default.fileExists(atPath: goModPath) else { + throw NSError(domain: "cmux.remote.daemon", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", + ]) + } + guard let goBinary = Self.which("go") else { + throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "go is required for the dev-only cmuxd-remote build fallback", + ]) + } + + let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + + var env = ProcessInfo.processInfo.environment + env["GOOS"] = goOS + env["GOARCH"] = goArch + env["CGO_ENABLED"] = "0" + let ldflags = "-s -w -X main.version=\(version)" + let result = try runProcess( + executable: goBinary, + arguments: ["build", "-trimpath", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"], + environment: env, + currentDirectory: daemonRoot, + stdin: nil, + timeout: 90 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [ + NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)", + ]) + } + guard FileManager.default.isExecutableFile(atPath: output.path) else { + throw NSError(domain: "cmux.remote.daemon", code: 24, userInfo: [ + NSLocalizedDescriptionKey: "cmuxd-remote build output is not executable", + ]) + } + debugLog("remote.build.output path=\(output.path)") + return output + } + + private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { + let remoteDirectory = (remotePath as NSString).deletingLastPathComponent + let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" + debugLog( + "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" + ) + + let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))" + let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) + guard mkdirResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", + ]) + } + + let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + var scpArgs: [String] = ["-q"] + if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { + scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] + } + scpArgs += ["-o", "ControlMaster=no"] + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] + let scpResult = try scpExec(arguments: scpArgs, timeout: 45) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)", + ]) + } + + let finalizeScript = """ + chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ + mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) + """ + let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))" + let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) + guard finalizeResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", + ]) + } + } + + private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { + let request = #"{"id":1,"method":"hello","params":{}}"# + let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [ + NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", + ]) + } + + let responseLine = result.stdout + .split(separator: "\n") + .map(String.init) + .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" + guard !responseLine.isEmpty, + let data = responseLine.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + throw NSError(domain: "cmux.remote.daemon", code: 41, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", + ]) + } + + if let ok = payload["ok"] as? Bool, !ok { + let errorMessage: String = { + if let errorObject = payload["error"] as? [String: Any], + let message = errorObject["message"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + return "hello call failed" + }() + throw NSError(domain: "cmux.remote.daemon", code: 42, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", + ]) + } + + let resultObject = payload["result"] as? [String: Any] ?? [:] + let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let capabilities = (resultObject["capabilities"] as? [String]) ?? [] + return DaemonHello( + name: (name?.isEmpty == false ? name! : "cmuxd-remote"), + version: (version?.isEmpty == false ? version! : "dev"), + capabilities: capabilities, + remotePath: remotePath + ) + } + + private func debugLog(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + private func debugConfigSummary() -> String { + let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" + return + "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + + "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + + "localSocket=\(configuration.localSocketPath ?? "nil") " + + "controlPath=\(controlPath)" + } + + private func debugShellCommand(executable: String, arguments: [String]) -> String { + ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) + .map(Self.shellSingleQuoted) + .joined(separator: " ") + } + + private static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { + let normalized = text + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return "\"\"" } + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private static func mapUnameOS(_ raw: String) -> String? { + switch raw.lowercased() { + case "linux": + return "linux" + case "darwin": + return "darwin" + case "freebsd": + return "freebsd" + default: + return nil + } + } + + private static func mapUnameArch(_ raw: String) -> String? { + switch raw.lowercased() { + case "x86_64", "amd64": + return "amd64" + case "aarch64", "arm64": + return "arm64" + case "armv7l": + return "arm" + default: + return nil + } + } + + private static func remoteDaemonVersion() -> String { + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let bundleVersion, !bundleVersion.isEmpty { + return bundleVersion + } + return "dev" + } + + private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { + ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" + } + + private static func killOrphanedRelayProcesses(relayPort: Int, destination: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):127\\.0\\.0\\.1:[0-9]+.*\(destination)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + } catch { + // Best effort cleanup only. + } + } + + private static func which(_ executable: String) -> String? { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + for component in path.split(separator: ":") { + let candidate = String(component) + "/" + executable + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private static func findRepoRoot() -> URL? { + var candidates: [URL] = [] + let compileTimeRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Sources + .deletingLastPathComponent() // repo root + candidates.append(compileTimeRoot) + let environment = ProcessInfo.processInfo.environment + if let envRoot = environment["CMUX_REMOTE_DAEMON_SOURCE_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + if let envRoot = environment["CMUXTERM_REPO_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) + if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { + candidates.append(executable) + candidates.append(executable.deletingLastPathComponent()) + candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) + } + + let fm = FileManager.default + for base in candidates { + var cursor = base.standardizedFileURL + for _ in 0..<10 { + let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path + if fm.fileExists(atPath: marker) { + return cursor + } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { + break + } + cursor = parent + } + } + return nil + } + + private static func bestErrorLine(stderr: String, stdout: String = "") -> String? { + if let stderrLine = meaningfulErrorLine(in: stderr) { + return stderrLine + } + if let stdoutLine = meaningfulErrorLine(in: stdout) { + return stdoutLine + } + return nil + } + + private static func meaningfulErrorLine(in text: String) -> String? { + let lines = text + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } + + private static func retrySuffix(retry: Int, delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry \(retry) in \(seconds)s)" + } + + private static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote daemon transport failed") + || lowered.contains("daemon transport closed stdout") + || lowered.contains("daemon transport exited") + || lowered.contains("daemon transport is not connected") + || lowered.contains("daemon transport stopped") + } + +} + enum SidebarLogLevel: String { case info case progress @@ -638,6 +3905,58 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum WorkspaceRemoteConnectionState: String { + case disconnected + case connecting + case connected + case error +} + +enum WorkspaceRemoteDaemonState: String { + case unavailable + case bootstrapping + case ready + case error +} + +struct WorkspaceRemoteDaemonStatus: Equatable { + var state: WorkspaceRemoteDaemonState = .unavailable + var detail: String? + var version: String? + var name: String? + var capabilities: [String] = [] + var remotePath: String? + + func payload() -> [String: Any] { + [ + "state": state.rawValue, + "detail": detail ?? NSNull(), + "version": version ?? NSNull(), + "name": name ?? NSNull(), + "capabilities": capabilities, + "remote_path": remotePath ?? NSNull(), + ] + } +} + +struct WorkspaceRemoteConfiguration: Equatable { + let destination: String + let port: Int? + let identityFile: String? + let sshOptions: [String] + let localProxyPort: Int? + let relayPort: Int? + let relayID: String? + let relayToken: String? + let localSocketPath: String? + let terminalStartupCommand: String? + + var displayTarget: String { + guard let port else { return destination } + return "\(destination):\(port)" + } +} + enum SidebarPullRequestStatus: String { case open case merged @@ -996,10 +4315,44 @@ final class Workspace: Identifiable, ObservableObject { @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] + @Published var remoteConfiguration: WorkspaceRemoteConfiguration? + @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected + @Published var remoteConnectionDetail: String? + @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() + @Published var remoteDetectedPorts: [Int] = [] + @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] = [] + @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] + private var remoteSessionController: WorkspaceRemoteSessionController? + fileprivate var activeRemoteSessionControllerID: UUID? + private var remoteLastErrorFingerprint: String? + private var remoteLastDaemonErrorFingerprint: String? + private var remoteLastPortConflictFingerprint: String? + private var activeRemoteTerminalSurfaceIds: Set<UUID> = [] + + 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 { + let lowered = detail.lowercased() + return lowered.contains("remote proxy") + || lowered.contains("proxy_unavailable") + || lowered.contains("local daemon proxy") + || lowered.contains("proxy failure") + || lowered.contains("daemon transport") + } + var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } @@ -1018,10 +4371,10 @@ final class Workspace: Identifiable, ObservableObject { private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down")) + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") ) } @@ -1092,24 +4445,18 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" ) } } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundColor.alphaComponent, - reason: reason - ) - } - init( title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0, - configTemplate: ghostty_surface_config_s? = nil + configTemplate: ghostty_surface_config_s? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:] ) { self.id = UUID() self.portOrdinal = portOrdinal @@ -1154,7 +4501,9 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: initialTerminalCommand, + initialEnvironmentOverrides: initialTerminalEnvironment ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle @@ -1205,6 +4554,11 @@ final class Workspace: Identifiable, ObservableObject { } } + deinit { + activeRemoteSessionControllerID = nil + remoteSessionController?.stop() + } + func refreshSplitButtonTooltips() { let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration @@ -1337,8 +4691,8 @@ final class Workspace: Identifiable, ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self, weak markdownPanel] newTitle in - guard let self = self, - let markdownPanel = markdownPanel, + guard let self, + let markdownPanel, let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } guard let existing = self.bonsplitController.tab(tabId) else { return } @@ -1356,6 +4710,24 @@ final class Workspace: Identifiable, ObservableObject { panelSubscriptions[markdownPanel.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)? { @@ -1790,7 +5162,7 @@ final class Workspace: Identifiable, ObservableObject { } func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + let unique = Set(surfaceListeningPorts.values.flatMap { $0 }).union(remoteForwardedPorts) let next = unique.sorted() if listeningPorts != next { listeningPorts = next @@ -1874,6 +5246,337 @@ final class Workspace: Identifiable, ObservableObject { } } + var isRemoteWorkspace: Bool { + remoteConfiguration != nil + } + + var remoteDisplayTarget: String? { + remoteConfiguration?.displayTarget + } + + var hasActiveRemoteTerminalSessions: Bool { + activeRemoteTerminalSessionCount > 0 + } + + 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, + "connected": remoteConnectionState == .connected, + "active_terminal_sessions": activeRemoteTerminalSessionCount, + "daemon": remoteDaemonStatus.payload(), + "detected_ports": remoteDetectedPorts, + "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"] = [ + "state": "ready", + "host": endpoint.host, + "port": endpoint.port, + "schemes": ["socks5", "http_connect"], + "url": "socks5://\(endpoint.host):\(endpoint.port)", + ] + } else { + let proxyState: String + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } + payload["proxy"] = [ + "state": proxyState, + "host": NSNull(), + "port": NSNull(), + "schemes": ["socks5", "http_connect"], + "url": NSNull(), + "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), + ] + } + if let remoteConfiguration { + payload["destination"] = remoteConfiguration.destination + payload["port"] = remoteConfiguration.port ?? NSNull() + payload["identity_file"] = remoteConfiguration.identityFile ?? NSNull() + payload["ssh_options"] = remoteConfiguration.sshOptions + payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + } else { + payload["destination"] = NSNull() + payload["port"] = NSNull() + payload["identity_file"] = NSNull() + payload["ssh_options"] = [] + payload["local_proxy_port"] = NSNull() + } + return payload + } + + func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { + remoteConfiguration = configuration + seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + recomputeListeningPorts() + + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + 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, + configuration: configuration, + controllerID: controllerID + ) + activeRemoteSessionControllerID = controllerID + remoteSessionController = controller + controller.start() + } + + func reconnectRemoteConnection() { + guard let configuration = remoteConfiguration else { return } + configureRemoteConnection(configuration, autoConnect: true) + } + + func disconnectRemoteConnection(clearConfiguration: Bool = false) { + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + activeRemoteTerminalSurfaceIds.removeAll() + activeRemoteTerminalSessionCount = 0 + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionState = .disconnected + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + if clearConfiguration { + remoteConfiguration = nil + } + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + recomputeListeningPorts() + } + + private func clearRemoteConfigurationIfWorkspaceBecameLocal() { + guard panels.isEmpty, remoteConfiguration != nil else { return } + disconnectRemoteConnection(clearConfiguration: true) + } + + private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return + } + guard activeRemoteTerminalSurfaceIds.isEmpty else { return } + let terminalIds = panels.compactMap { panelId, panel in + panel is TerminalPanel ? panelId : nil + } + guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + trackRemoteTerminalSurface(initialPanelId) + } + + private func trackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + + private func untrackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() + } + + private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } + if !hasBrowserPanels { + disconnectRemoteConnection(clearConfiguration: true) + } + } + + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return + } + untrackRemoteTerminalSurface(surfaceId) + } + + func teardownRemoteConnection() { + disconnectRemoteConnection(clearConfiguration: true) + } + + fileprivate func applyRemoteConnectionStateUpdate( + _ state: WorkspaceRemoteConnectionState, + detail: String?, + target: String + ) { + remoteConnectionState = state + remoteConnectionDetail = detail + applyBrowserRemoteWorkspaceStatusToPanels() + + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + if state == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let proxyOnlyError = Self.isProxyOnlyRemoteError(trimmedDetail) + let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" + let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" + let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" + let logSource = proxyOnlyError ? "remote-proxy" : "remote" + statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( + key: Self.remoteErrorStatusKey, + value: "\(statusPrefix) (\(target)): \(trimmedDetail)", + icon: statusIcon, + color: nil, + timestamp: Date() + ) + + let fingerprint = "connection:\(trimmedDetail)" + if remoteLastErrorFingerprint != fingerprint { + remoteLastErrorFingerprint = fingerprint + appendSidebarLog( + message: "\(statusPrefix) (\(target)): \(trimmedDetail)", + level: .error, + source: logSource + ) + AppDelegate.shared?.notificationStore?.addNotification( + tabId: id, + surfaceId: nil, + title: notificationTitle, + subtitle: target, + body: trimmedDetail + ) + } + return + } + + if state != .error { + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + remoteLastErrorFingerprint = nil + } + } + + fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { + remoteDaemonStatus = status + applyBrowserRemoteWorkspaceStatusToPanels() + guard status.state == .error else { + remoteLastDaemonErrorFingerprint = nil + return + } + let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" + let fingerprint = "daemon:\(trimmedDetail)" + guard remoteLastDaemonErrorFingerprint != fingerprint else { return } + remoteLastDaemonErrorFingerprint = fingerprint + appendSidebarLog( + message: "Remote daemon error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote-daemon" + ) + } + + fileprivate func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { + remoteProxyEndpoint = endpoint + for panel in panels.values { + 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) { + remoteDetectedPorts = detected + remoteForwardedPorts = forwarded + remotePortConflicts = conflicts + recomputeListeningPorts() + + if conflicts.isEmpty { + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastPortConflictFingerprint = nil + return + } + + let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") + statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( + key: Self.remotePortConflictStatusKey, + value: "SSH port conflicts (\(target)): \(conflictsList)", + icon: "exclamationmark.triangle.fill", + color: nil, + timestamp: Date() + ) + + let fingerprint = conflicts.map(String.init).joined(separator: ",") + guard remoteLastPortConflictFingerprint != fingerprint else { return } + remoteLastPortConflictFingerprint = fingerprint + appendSidebarLog( + message: "Port conflicts while forwarding \(target): \(conflictsList)", + level: .warning, + source: "remote-forward" + ) + } + + private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + if logEntries.count > limit { + logEntries.removeFirst(logEntries.count - limit) + } + } + // MARK: - Panel Operations private func seedTerminalInheritanceFontPoints( @@ -2060,26 +5763,21 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - - // Inherit working directory: prefer the source panel's reported cwd, - // fall back to the workspace's current directory. - let splitWorkingDirectory: String? = panelDirectories[panelId] - ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil : currentDirectory) -#if DEBUG - dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") -#endif + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit @@ -2105,6 +5803,9 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -2147,6 +5848,7 @@ final class Workspace: Identifiable, ObservableObject { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create new terminal panel let newPanel = TerminalPanel( @@ -2154,11 +5856,15 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: workingDirectory, - additionalEnvironment: startupEnvironment, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, + additionalEnvironment: startupEnvironment ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit @@ -2172,6 +5878,9 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -2190,6 +5899,12 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } + private func remoteTerminalStartupCommand() -> String? { + guard hasActiveRemoteTerminalSessions else { return nil } + return remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + /// Create a new browser panel split @discardableResult func newBrowserSplit( @@ -2213,7 +5928,12 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } // Create browser panel - let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) + let browserPanel = BrowserPanel( + workspaceId: id, + initialURL: url, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace + ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -2257,6 +5977,7 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -2278,7 +5999,9 @@ final class Workspace: Identifiable, ObservableObject { let browserPanel = BrowserPanel( workspaceId: id, initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -2314,13 +6037,11 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } - // MARK: - Markdown Panel Creation - - /// Create a new markdown panel split from an existing panel. func newMarkdownSplit( from panelId: UUID, orientation: SplitOrientation, @@ -2328,7 +6049,6 @@ final class Workspace: Identifiable, ObservableObject { filePath: String, focus: Bool = true ) -> MarkdownPanel? { - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { @@ -2341,12 +6061,10 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } - // Create markdown panel let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel panelTitles[markdownPanel.id] = markdownPanel.displayTitle - // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: markdownPanel.displayTitle, icon: markdownPanel.displayIcon, @@ -2358,8 +6076,6 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = markdownPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the markdown tab already present in the new pane. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { @@ -2369,7 +6085,6 @@ final class Workspace: Identifiable, ObservableObject { return nil } - // Suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() @@ -2386,11 +6101,9 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } - /// Create a new markdown surface (tab) in the specified pane. @discardableResult func newMarkdownSurface( inPane paneId: PaneID, @@ -2418,8 +6131,6 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = markdownPanel.id - - // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) @@ -2427,7 +6138,6 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } @@ -2455,29 +6165,12 @@ final class Workspace: Identifiable, ObservableObject { /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { -#if DEBUG - let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId) - dlog( - "surface.close.request panel=\(panelId.uuidString.prefix(5)) " + - "force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } // Close the tab in bonsplit (this triggers delegate callback) - let closed = bonsplitController.closeTab(tabId) -#if DEBUG - dlog( - "surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)" - ) -#endif - return closed + return bonsplitController.closeTab(tabId) } // Mapping can transiently drift during split-tree mutations. If the target panel is @@ -2509,38 +6202,12 @@ final class Workspace: Identifiable, ObservableObject { dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0) " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + "closed=\(closed ? 1 : 0)" ) #endif return closed } -#if DEBUG - private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String { - guard let panel else { return "panelState=missing" } - if let terminal = panel as? TerminalPanel { - let hosted = terminal.hostedView - let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height) - let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height) - let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0 - return - "panelState=terminal panel=\(panelId.uuidString.prefix(5)) " + - "surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " + - "inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " + - "hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" - } - if let browser = panel as? BrowserPanel { - let webView = browser.webView - let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height) - return - "panelState=browser panel=\(panelId.uuidString.prefix(5)) " + - "webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)" - } - return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))" - } -#endif - func paneId(forPanelId panelId: UUID) -> PaneID? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } return bonsplitController.allPaneIds.first { paneId in @@ -2748,7 +6415,6 @@ final class Workspace: Identifiable, ObservableObject { in: bonsplitController.treeSnapshot() ) let resolvedURL = browserPanel.currentURL - ?? browserPanel.webView.url ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( @@ -2947,6 +6613,8 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.updateWorkspaceId(id) } else if let browserPanel = detached.panel as? BrowserPanel { browserPanel.updateWorkspaceId(id) + browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) installBrowserPanelSubscription(browserPanel) } @@ -3206,6 +6874,10 @@ final class Workspace: Identifiable, ObservableObject { ) } + if let browserPanel = panels[panelId] as? BrowserPanel { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + } + if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { scheduleTerminalFirstResponderReassert(panelId: panelId) @@ -3405,7 +7077,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerNotificationDismissFlash() + terminalPanel.triggerFlash() } func triggerDebugFlash(panelId: UUID) { @@ -3425,16 +7097,10 @@ final class Workspace: Identifiable, ObservableObject { } } - /// Hide all browser portal views for this workspace. - /// Called before the workspace is unmounted so a portal-hosted WKWebView - /// cannot remain visible after this workspace stops being selected. func hideAllBrowserPortalViews() { for panel in panels.values { guard let browser = panel as? BrowserPanel else { continue } - BrowserWindowPortalRegistry.hide( - webView: browser.webView, - source: "workspaceRetire" - ) + browser.hideBrowserPortalView(source: "workspaceRetire") } } @@ -3577,11 +7243,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - let geometryChanged = hostedView.reconcileGeometryNow() + hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if geometryChanged, terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") + if terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh() } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3879,9 +7545,9 @@ final class Workspace: Identifiable, ObservableObject { let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } - let geometryChanged = panel.hostedView.reconcileGeometryNow() - if geometryChanged, panel.surface.surface != nil { - panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") + panel.hostedView.reconcileGeometryNow() + if panel.surface.surface != nil { + panel.surface.forceRefresh() } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3948,15 +7614,15 @@ final class Workspace: Identifiable, ObservableObject { let panel = panels[panelId] else { return } let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab") - alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.") + alert.messageText = "Rename Tab" + alert.informativeText = "Enter a custom name for this tab." let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) - input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") + input.placeholderString = "Tab name" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -3985,24 +7651,24 @@ final class Workspace: Identifiable, ObservableObject { ) var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), + ("New Workspace in Current Window", .newWorkspaceInCurrentWindow), + ("Selected Workspace in New Window", .selectedWorkspaceInNewWindow), ] options.append(contentsOf: workspaceTargets.map { target in (target.label, .existingWorkspace(target.workspaceId)) }) let alert = NSAlert() - alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.") + alert.messageText = "Move Tab" + alert.informativeText = "Choose a destination for this tab." let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) for option in options { popup.addItem(withTitle: option.title) } popup.selectItem(at: 0) alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Move") + alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) @@ -4048,9 +7714,9 @@ final class Workspace: Identifiable, ObservableObject { if !moved { let failure = NSAlert() failure.alertStyle = .warning - failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + failure.messageText = "Move Failed" + failure.informativeText = "cmux could not move this tab to the selected destination." + failure.addButton(withTitle: "OK") _ = failure.runModal() } } @@ -4117,11 +7783,11 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { let alert = NSAlert() - alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") - alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") + alert.messageText = "Close tab?" + alert.informativeText = "This will close the current tab." alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") // Prefer a sheet if we can find a window, otherwise fall back to modal. if let window = NSApp.keyWindow ?? NSApp.mainWindow { @@ -4581,11 +8247,7 @@ extension Workspace: BonsplitDelegate { // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG - dlog( - "surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " + - "panels=\(panels.count) panes=\(controller.allPaneIds.count)" - ) + NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif scheduleTerminalGeometryReconcile() if !isDetaching { @@ -4594,15 +8256,11 @@ extension Workspace: BonsplitDelegate { return } + #if DEBUG + NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") + #endif + let panel = panels[panelId] -#if DEBUG - dlog( - "surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panel))" - ) -#endif if isDetaching, let panel { let browserPanel = panel as? BrowserPanel @@ -4630,6 +8288,7 @@ extension Workspace: BonsplitDelegate { } panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) @@ -4647,18 +8306,13 @@ extension Workspace: BonsplitDelegate { if lastTerminalConfigInheritancePanelId == panelId { lastTerminalConfigInheritancePanelId = nil } + clearRemoteConfigurationIfWorkspaceBecameLocal() // Keep the workspace invariant for normal close paths. // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { if isDetaching { -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace" - ) -#endif scheduleTerminalGeometryReconcile() return } @@ -4672,13 +8326,6 @@ extension Workspace: BonsplitDelegate { } scheduleTerminalGeometryReconcile() scheduleFocusReconcile() -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " + - "replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)" - ) -#endif return } @@ -4700,15 +8347,6 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } -#if DEBUG - let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " + - "focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)" - ) -#endif scheduleTerminalGeometryReconcile() if !isDetaching { scheduleFocusReconcile() @@ -4793,23 +8431,12 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] let shouldScheduleFocusReconcile = !isDetachingCloseTransaction -#if DEBUG - dlog( - "surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " + - "closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)" - ) -#endif if !closedPanelIds.isEmpty { for panelId in closedPanelIds { -#if DEBUG - dlog( - "surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif panels[panelId]?.close() panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) @@ -4827,6 +8454,7 @@ extension Workspace: BonsplitDelegate { let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { @@ -4840,12 +8468,6 @@ extension Workspace: BonsplitDelegate { if shouldScheduleFocusReconcile { scheduleFocusReconcile() } -#if DEBUG - dlog( - "surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " + - "remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)" - ) -#endif } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e2c65a34..36bc6a05 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3086,6 +3086,7 @@ struct SettingsView: View { private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -3697,6 +3698,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), + subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") + ) { + Toggle("", isOn: $sidebarShowSSH) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") @@ -4382,6 +4394,7 @@ struct SettingsView: View { sidebarShowPullRequest = true openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + sidebarShowSSH = true sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true diff --git a/TODO.md b/TODO.md index 7538404a..5453b8f5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,18 @@ # TODO +## Issue 151: Remote SSH (Living Execution) +- [x] `cmux ssh` creates remote workspace metadata and does not require `--name` +- [x] Remote daemon bootstrap/upload/start path with `cmuxd-remote serve --stdio` +- [x] Reconnect/disconnect controls (CLI/API/context menu) + improved error surfacing +- [x] Retry count/time surfaced in remote daemon/probe error details +- [ ] Remove automatic remote service port mirroring (`ssh -L` from detected remote listening ports) +- [ ] Add transport-scoped proxy broker (SOCKS5 + HTTP CONNECT) for remote traffic +- [ ] Extend `cmuxd-remote` RPC beyond `hello/ping` with proxy stream methods (`proxy.open|close`) +- [ ] Auto-wire WKWebView in remote workspaces to proxy via `WKWebsiteDataStore.proxyConfigurations` +- [ ] Add browser proxy e2e tests (remote egress IP, websocket, reconnect continuity) +- [ ] Implement PTY resize coordinator with tmux semantics (`smallest screen wins`) +- [ ] Add resize tests for multi-attachment sessions (attach/detach/reconnect transitions) + ## Socket API / Agent - [x] Add window handles + `window.list/current/focus/create/close` for multi-window socket control (v2) + v1 equivalents (`list_windows`, etc) + CLI support. - [x] Add surface move/reorder commands (move between panes, reorder within pane, move across workspaces/windows). @@ -41,7 +54,7 @@ - [ ] OpenCode integration ## Browser -- [ ] Per-WKWebView local proxy for full network request/response inspection (URL, method, headers, body, status, timing) +- [ ] Per-WKWebView proxy observability/inspection once remote proxy path is shipped (URL, method, headers, body, status, timing) ## Bugs - [ ] **P0** Terminal title updates are suppressed when workspace is not focused (e.g. Claude Code loading indicator doesn't update in sidebar until you switch to that tab) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 580466bd..bbe59232 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4782,6 +4782,74 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } + + func testParsedTargetAndDetailParsesCanonicalStatusValue() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error (devbox:22): failed to bootstrap daemon" + ) + XCTAssertEqual(parsed?.target, "devbox:22") + XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon") + } + + func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error: connection refused", + fallbackTarget: "fallback-host" + ) + XCTAssertEqual(parsed?.target, "fallback-host") + XCTAssertEqual(parsed?.detail, "connection refused") + } + + func testParsedTargetAndDetailIgnoresNonSSHStatusValues() { + XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good")) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 26d3a789..d7a4b136 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -659,6 +659,104 @@ final class WindowTransparencyDecisionTests: XCTestCase { } } +final class WorkspaceRemoteDaemonManifestTests: XCTestCase { + func testParsesEmbeddedRemoteDaemonManifestJSON() throws { + let manifestJSON = """ + { + "schemaVersion": 1, + "appVersion": "0.62.0", + "releaseTag": "v0.62.0", + "releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0", + "checksumsAssetName": "cmuxd-remote-checksums.txt", + "checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt", + "entries": [ + { + "goOS": "linux", + "goArch": "amd64", + "assetName": "cmuxd-remote-linux-amd64", + "downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64", + "sha256": "abc123" + } + ] + } + """ + + let manifest = Workspace.remoteDaemonManifest(from: [ + Workspace.remoteDaemonManifestInfoKey: manifestJSON, + ]) + + XCTAssertEqual(manifest?.releaseTag, "v0.62.0") + XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64") + } + + func testRemoteDaemonCachePathIsVersionedByPlatform() throws { + let url = try Workspace.remoteDaemonCachedBinaryURL( + version: "0.62.0", + goOS: "linux", + goArch: "arm64" + ) + + XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/")) + XCTAssertEqual(url.lastPathComponent, "cmuxd-remote") + } +} + +final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { + func testSupportsMultiplePendingCallsResolvedOutOfOrder() { + let registry = WorkspaceRemoteDaemonPendingCallRegistry() + let first = registry.register() + let second = registry.register() + + XCTAssertTrue(registry.resolve(id: second.id, payload: [ + "ok": true, + "result": ["stream_id": "second"], + ])) + + switch registry.wait(for: second, timeout: 0.1) { + case .response(let response): + XCTAssertEqual(response["ok"] as? Bool, true) + XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "second") + default: + XCTFail("second pending call should complete independently") + } + + XCTAssertTrue(registry.resolve(id: first.id, payload: [ + "ok": true, + "result": ["stream_id": "first"], + ])) + + switch registry.wait(for: first, timeout: 0.1) { + case .response(let response): + XCTAssertEqual(response["ok"] as? Bool, true) + XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "first") + default: + XCTFail("first pending call should remain pending until its own response arrives") + } + } + + func testFailAllSignalsEveryPendingCall() { + let registry = WorkspaceRemoteDaemonPendingCallRegistry() + let first = registry.register() + let second = registry.register() + + registry.failAll("daemon transport stopped") + + switch registry.wait(for: first, timeout: 0.1) { + case .failure(let message): + XCTAssertEqual(message, "daemon transport stopped") + default: + XCTFail("first pending call should receive shared failure") + } + + switch registry.wait(for: second, timeout: 0.1) { + case .failure(let message): + XCTAssertEqual(message, "daemon transport stopped") + default: + XCTFail("second pending call should receive shared failure") + } + } +} + final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift new file mode 100644 index 00000000..af954ee2 --- /dev/null +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TabManagerSessionSnapshotTests: XCTestCase { + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + firstWorkspace.setCustomTitle("First") + + let secondWorkspace = manager.addWorkspace(select: true) + secondWorkspace.setCustomTitle("Second") + XCTAssertEqual(manager.tabs.count, 2) + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.workspaces.count, 2) + XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.tabs.count, 2) + XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id) + XCTAssertEqual(restored.tabs[0].customTitle, "First") + XCTAssertEqual(restored.tabs[1].customTitle, "Second") + } + + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { + let manager = TabManager() + let emptySnapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ) + + manager.restoreSessionSnapshot(emptySnapshot) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(manager.selectedTabId) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift new file mode 100644 index 00000000..ccf3f116 --- /dev/null +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -0,0 +1,214 @@ +import XCTest +import AppKit +import Darwin + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSocketSecurityTests: XCTestCase { + private func makeSocketPath(_ name: String) -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock") + .path + } + + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testPasswordModeRejectsUnauthenticatedCommands() throws { + let socketPath = makeSocketPath("password-mode") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let pingOnly = try sendCommands(["ping"], to: socketPath) + XCTAssertEqual(pingOnly.count, 1) + XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:")) + XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG")) + + let wrongAuthThenPing = try sendCommands( + ["auth not-the-password", "ping"], + to: socketPath + ) + XCTAssertEqual(wrongAuthThenPing.count, 2) + XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:")) + XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) + } + + func testSocketCommandPolicyDistinguishesFocusIntent() throws { +#if DEBUG + let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "ping", + isV2: false + ) + XCTAssertTrue(nonFocus.insideSuppressed) + XCTAssertFalse(nonFocus.insideAllowsFocus) + XCTAssertFalse(nonFocus.outsideSuppressed) + XCTAssertFalse(nonFocus.outsideAllowsFocus) + + let focusV1 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "focus_window", + isV2: false + ) + XCTAssertTrue(focusV1.insideSuppressed) + XCTAssertTrue(focusV1.insideAllowsFocus) + XCTAssertFalse(focusV1.outsideSuppressed) + + let focusV2 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.select", + isV2: true + ) + XCTAssertTrue(focusV2.insideSuppressed) + XCTAssertTrue(focusV2.insideAllowsFocus) + XCTAssertFalse(focusV2.outsideSuppressed) +#else + throw XCTSkip("Socket command policy snapshot helper is debug-only.") +#endif + } + + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + usleep(20_000) + } + XCTFail("Timed out waiting for socket at \(path)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + + private func socketMode(at path: String) throws -> UInt16 { + var fileInfo = stat() + guard lstat(path, &fileInfo) == 0 else { + throw posixError("lstat(\(path))") + } + return UInt16(fileInfo.st_mode & 0o777) + } + + private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(AF_UNIX)") + } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let bytes = Array(socketPath.utf8) + let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLen else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: maxPathLen) + for (index, byte) in bytes.enumerated() { + cPath[index] = CChar(bitPattern: byte) + } + } + + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + throw posixError("connect(\(socketPath))") + } + + var responses: [String] = [] + for command in commands { + try writeLine(command, to: fd) + responses.append(try readLine(from: fd)) + } + return responses + } + + private func writeLine(_ command: String, to fd: Int32) throws { + let payload = Array((command + "\n").utf8) + var offset = 0 + while offset < payload.count { + let wrote = payload.withUnsafeBytes { raw in + Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset) + } + guard wrote >= 0 else { + throw posixError("write(\(command))") + } + offset += wrote + } + } + + private func readLine(from fd: Int32) throws -> String { + var buffer = [UInt8](repeating: 0, count: 1) + var data = Data() + + while true { + let count = Darwin.read(fd, &buffer, 1) + guard count >= 0 else { + throw posixError("read") + } + if count == 0 { break } + if buffer[0] == 0x0A { break } + data.append(buffer[0]) + } + + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket" + ]) + } + return line + } + + private func posixError(_ operation: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"] + ) + } +} diff --git a/daemon/remote/README.md b/daemon/remote/README.md new file mode 100644 index 00000000..07a2afaf --- /dev/null +++ b/daemon/remote/README.md @@ -0,0 +1,82 @@ +# cmuxd-remote (Go) + +Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote proxy RPC. It is not in the terminal keystroke hot path. + +## Commands + +1. `cmuxd-remote version` +2. `cmuxd-remote serve --stdio` +3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse SSH forward + +When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. + +## RPC methods (newline-delimited JSON over stdio) + +1. `hello` +2. `ping` +3. `proxy.open` +4. `proxy.close` +5. `proxy.write` +6. `proxy.read` +7. `session.open` +8. `session.close` +9. `session.attach` +10. `session.resize` +11. `session.detach` +12. `session.status` + +Current integration in cmux: +1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. +2. Client sends `hello` before enabling remote proxy transport. +3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`. +4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). + +`workspace.remote.configure` contract notes: +1. `port` / `local_proxy_port` accept integer values and numeric strings; explicit `null` clears each field. +2. Out-of-range values and invalid types return `invalid_params`. +3. `local_proxy_port` is an internal deterministic test hook used by bind-conflict regressions. +4. SSH option precedence checks are case-insensitive; user overrides for `StrictHostKeyChecking` and control-socket keys prevent default injection. + +## Distribution + +Release and nightly builds publish prebuilt `cmuxd-remote` binaries on GitHub Releases for: +1. `darwin/arm64` +2. `darwin/amd64` +3. `linux/arm64` +4. `linux/amd64` + +The app embeds a compact manifest in `Info.plist` with: +1. exact release asset URLs +2. pinned SHA-256 digests +3. release tag and checksums asset URL + +Release and nightly apps download and cache the matching binary locally, verify its SHA-256, then upload it to the remote host if needed. Dev builds can opt into a local `go build` fallback with `CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1`. + +To inspect what a given app build trusts, run: +1. `cmux remote-daemon-status` +2. `cmux remote-daemon-status --os linux --arch amd64` + +The command prints the exact release asset URL, expected SHA-256, local cache status, and a copy-pasteable `gh attestation verify` command for the selected platform. + +## CLI relay + +The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app through an SSH reverse forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. + +Socket discovery order: +1. `--socket <path>` flag +2. `CMUX_SOCKET_PATH` environment variable +3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) + +For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. + +Authenticated relay details: +1. Each SSH workspace gets its own relay ID and relay token. +2. The app runs a local loopback relay server that requires an HMAC-SHA256 challenge-response before forwarding a command to the real local Unix socket. +3. The remote shell never gets direct access to the local app socket. It only gets the reverse-forwarded relay port plus `~/.cmux/relay/<port>.auth`, which is written with `0600` permissions and removed when the relay stops. + +Integration additions for the relay path: + +1. Bootstrap installs `~/.cmux/bin/cmux` wrapper and keeps a default daemon target (`~/.cmux/bin/cmuxd-remote-current`). +2. A background `ssh -N -R` process reverse-forwards a TCP port to the authenticated local relay server. The relay address is written to `~/.cmux/socket_addr` on the remote. +3. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances or versions coexist. +4. Relay startup writes `~/.cmux/relay/<port>.auth` with the relay ID and token needed for HMAC authentication. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go new file mode 100644 index 00000000..14d69481 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -0,0 +1,721 @@ +package main + +import ( + "bufio" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +type relayAuthState struct { + RelayID string `json:"relay_id"` + RelayToken string `json:"relay_token"` +} + +// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol. +type protocolVersion int + +const ( + protoV1 protocolVersion = iota + protoV2 +) + +// commandSpec describes a single CLI command and how to relay it. +type commandSpec struct { + name string // CLI command name (e.g. "ping", "new-window") + proto protocolVersion // v1 text or v2 JSON-RPC + v1Cmd string // v1: literal command string sent over the socket + v2Method string // v2: JSON-RPC method name + // flagKeys lists parameter keys this command accepts. + // They are extracted from --key flags and added to params. + flagKeys []string + // noParams means the command takes no parameters at all. + noParams bool +} + +var commands = []commandSpec{ + // V1 text protocol commands + {name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true}, + {name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true}, + {name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true}, + {name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}}, + {name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}}, + {name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true}, + + // V2 JSON-RPC commands + {name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true}, + {name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true}, + {name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}}, + {name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}}, + {name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}}, + {name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true}, + {name: "list-panels", proto: protoV2, v2Method: "panel.list", flagKeys: []string{"workspace"}}, + {name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}}, + {name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}}, + {name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, + {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace"}}, + {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}}, + {name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, + {name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}}, + {name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, + {name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, + {name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, + {name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true}, +} + +var commandIndex map[string]*commandSpec + +func init() { + commandIndex = make(map[string]*commandSpec, len(commands)) + for i := range commands { + commandIndex[commands[i].name] = &commands[i] + } +} + +// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation). +func runCLI(args []string) int { + socketPath := os.Getenv("CMUX_SOCKET_PATH") + + // Parse global flags + var jsonOutput bool + var remaining []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--socket": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "cmux: --socket requires a path") + return 2 + } + socketPath = args[i+1] + i++ + case "--json": + jsonOutput = true + case "--help", "-h": + cliUsage() + return 0 + default: + remaining = append(remaining, args[i:]...) + goto doneFlags + } + } +doneFlags: + + if len(remaining) == 0 { + cliUsage() + return 2 + } + cmdName := remaining[0] + cmdArgs := remaining[1:] + if cmdName == "help" { + cliUsage() + return 0 + } + + // refreshAddr is set when the address came from socket_addr file (not env/flag), + // allowing retry loops to pick up updated relay ports. + var refreshAddr func() string + if socketPath == "" { + socketPath = readSocketAddrFile() + refreshAddr = readSocketAddrFile + } + if socketPath == "" { + fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided") + return 1 + } + + // Special case: "rpc" passthrough + if cmdName == "rpc" { + return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + // Browser subcommand delegation + if cmdName == "browser" { + return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + spec, ok := commandIndex[cmdName] + if !ok { + fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName) + return 2 + } + + switch spec.proto { + case protoV1: + return execV1(socketPath, spec, cmdArgs, refreshAddr) + case protoV2: + return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr) + default: + fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName) + return 1 + } +} + +// execV1 sends a v1 text command over the socket. +func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int { + cmd := spec.v1Cmd + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + cmd += " " + val + } + } + } + + resp, err := socketRoundTrip(socketPath, cmd, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Print(resp) + if !strings.HasSuffix(resp, "\n") { + fmt.Println() + } + return 0 +} + +// execV2 sends a v2 JSON-RPC request over the socket. +func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { + params := make(map[string]any) + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + // First positional arg is used as initial_command if --command wasn't given + if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + params["initial_command"] = parsed.positional[0] + } + + // Fall back to env vars for common IDs + if _, ok := params["workspace_id"]; !ok { + if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { + params["workspace_id"] = envWs + } + } + if _, ok := params["surface_id"]; !ok { + if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { + params["surface_id"] = envSf + } + } + } + + resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println(defaultRelayOutput(resp)) + } + return 0 +} + +// runRPC sends an arbitrary JSON-RPC method with optional JSON params. +func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name") + return 2 + } + method := args[0] + var params map[string]any + if len(args) > 1 { + if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { + fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err) + return 2 + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Println(resp) + return 0 +} + +// runBrowserRelay handles "cmux browser <subcommand>" by mapping to browser.* v2 methods. +func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)") + return 2 + } + + sub := args[0] + subArgs := args[1:] + + var method string + var flagKeys []string + switch sub { + case "open", "open-split", "new": + method = "browser.open" + flagKeys = []string{"url", "workspace", "surface"} + case "navigate": + method = "browser.navigate" + flagKeys = []string{"url", "surface"} + case "back": + method = "browser.back" + flagKeys = []string{"surface"} + case "forward": + method = "browser.forward" + flagKeys = []string{"surface"} + case "reload": + method = "browser.reload" + flagKeys = []string{"surface"} + case "get-url": + method = "browser.get_url" + flagKeys = []string{"surface"} + default: + fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub) + return 2 + } + + params := make(map[string]any) + parsed := parseFlags(subArgs, flagKeys) + for _, key := range flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println(defaultRelayOutput(resp)) + } + return 0 +} + +func defaultRelayOutput(resp string) string { + var result any + if err := json.Unmarshal([]byte(resp), &result); err != nil { + trimmed := strings.TrimSpace(resp) + if trimmed == "" { + return "OK" + } + return trimmed + } + + if relayResultIsEmpty(result) { + return "OK" + } + + switch typed := result.(type) { + case string: + return typed + default: + encoded, err := json.MarshalIndent(typed, "", " ") + if err != nil { + return "OK" + } + return string(encoded) + } +} + +func relayResultIsEmpty(result any) bool { + switch typed := result.(type) { + case nil: + return true + case map[string]any: + return len(typed) == 0 + case []any: + return len(typed) == 0 + case string: + return typed == "" + default: + return false + } +} + +// flagToParamKey maps a CLI flag name to its JSON-RPC param key. +func flagToParamKey(key string) string { + switch key { + case "workspace": + return "workspace_id" + case "surface": + return "surface_id" + case "panel": + return "panel_id" + case "pane": + return "pane_id" + case "window": + return "window_id" + case "command": + return "initial_command" + case "name": + return "title" + case "working-directory": + return "working_directory" + default: + return key + } +} + +// parsedFlags holds the results of flag parsing. +type parsedFlags struct { + flags map[string]string // --key value pairs + positional []string // non-flag arguments +} + +// parseFlags extracts --key value pairs from args for the given allowed keys. +// Non-flag arguments are collected in positional. +func parseFlags(args []string, keys []string) parsedFlags { + allowed := make(map[string]bool, len(keys)) + for _, k := range keys { + allowed[k] = true + } + + result := parsedFlags{flags: make(map[string]string)} + for i := 0; i < len(args); i++ { + if !strings.HasPrefix(args[i], "--") { + result.positional = append(result.positional, args[i]) + continue + } + key := strings.TrimPrefix(args[i], "--") + if !allowed[key] { + continue + } + if i+1 < len(args) { + result.flags[key] = args[i+1] + i++ + } + } + return result +} + +// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback +// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes. +func readSocketAddrFile() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func readRelayAuthFile(socketPath string) *relayAuthState { + if strings.Contains(socketPath, ":") && !strings.HasPrefix(socketPath, "/") { + _, port, err := net.SplitHostPort(socketPath) + if err != nil || port == "" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "relay", port+".auth")) + if err != nil { + return nil + } + var state relayAuthState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + if state.RelayID == "" || state.RelayToken == "" { + return nil + } + return &state + } + return nil +} + +func currentRelayAuth(socketPath string) *relayAuthState { + relayID := strings.TrimSpace(os.Getenv("CMUX_RELAY_ID")) + relayToken := strings.TrimSpace(os.Getenv("CMUX_RELAY_TOKEN")) + if relayID != "" && relayToken != "" { + return &relayAuthState{RelayID: relayID, RelayToken: relayToken} + } + return readRelayAuthFile(socketPath) +} + +// dialSocket connects to the cmux socket. If addr contains a colon and doesn't +// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. +// For TCP connections, it retries briefly to allow the SSH reverse forward to establish. +// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. +func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { + if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { + conn, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) + if err != nil { + return nil, err + } + if auth := currentRelayAuth(addr); auth != nil { + if err := authenticateRelayConn(conn, auth); err != nil { + conn.Close() + return nil, err + } + } + return conn, nil + } + return net.Dial("unix", addr) +} + +// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. +// This handles the case where the SSH reverse relay hasn't finished establishing yet. +// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses +// (e.g. when socket_addr is rewritten by a new relay process). +func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) { + deadline := time.Now().Add(timeout) + interval := 250 * time.Millisecond + printed := false + for { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + return conn, nil + } + if time.Now().After(deadline) { + return nil, err + } + // Only retry on connection refused (relay not ready yet) + if !isConnectionRefused(err) { + return nil, err + } + if !printed { + fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) + printed = true + } + time.Sleep(interval) + // Re-read socket_addr in case the relay port has changed + if refreshAddr != nil { + if newAddr := refreshAddr(); newAddr != "" && newAddr != addr { + addr = newAddr + fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr) + } + } + } +} + +func isConnectionRefused(err error) bool { + if opErr, ok := err.(*net.OpError); ok { + return strings.Contains(opErr.Err.Error(), "connection refused") + } + return strings.Contains(err.Error(), "connection refused") +} + +func authenticateRelayConn(conn net.Conn, auth *relayAuthState) error { + reader := bufio.NewReader(conn) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + var challenge struct { + Protocol string `json:"protocol"` + Version int `json:"version"` + RelayID string `json:"relay_id"` + Nonce string `json:"nonce"` + } + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth challenge: %w", err) + } + if err := json.Unmarshal([]byte(line), &challenge); err != nil { + return fmt.Errorf("invalid relay auth challenge") + } + if challenge.Protocol != "cmux-relay-auth" || challenge.Version != 1 || challenge.RelayID != auth.RelayID || challenge.Nonce == "" { + return fmt.Errorf("relay auth challenge mismatch") + } + + tokenBytes, err := hex.DecodeString(auth.RelayToken) + if err != nil { + return fmt.Errorf("invalid relay auth token") + } + mac := computeRelayMAC(tokenBytes, auth.RelayID, challenge.Nonce, challenge.Version) + payload, err := json.Marshal(map[string]any{ + "relay_id": auth.RelayID, + "mac": hex.EncodeToString(mac), + }) + if err != nil { + return fmt.Errorf("failed to encode relay auth response: %w", err) + } + if _, err := conn.Write(append(payload, '\n')); err != nil { + return fmt.Errorf("failed to send relay auth response: %w", err) + } + + line, err = reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth result: %w", err) + } + var result struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal([]byte(line), &result); err != nil { + return fmt.Errorf("invalid relay auth result") + } + if !result.OK { + return fmt.Errorf("relay auth rejected") + } + _ = conn.SetDeadline(time.Time{}) + return nil +} + +func computeRelayMAC(token []byte, relayID, nonce string, version int) []byte { + mac := hmac.New(sha256.New, token) + _, _ = io.WriteString(mac, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, version)) + return mac.Sum(nil) +} + +// socketRoundTrip sends a raw text line and reads a raw text response (v1). +func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil { + return "", fmt.Errorf("failed to send command: %w", err) + } + + // V1 handlers may return multiple lines (e.g. list_windows). Read until + // the stream goes idle briefly after seeing at least one newline. + reader := bufio.NewReader(conn) + var response strings.Builder + sawNewline := false + + for { + readTimeout := 15 * time.Second + if sawNewline { + readTimeout = 120 * time.Millisecond + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + + chunk, err := reader.ReadString('\n') + if chunk != "" { + response.WriteString(chunk) + if strings.Contains(chunk, "\n") { + sawNewline = true + } + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if sawNewline { + break + } + return "", fmt.Errorf("failed to read response: timeout waiting for response") + } + if errors.Is(err, io.EOF) { + break + } + return "", fmt.Errorf("failed to read response: %w", err) + } + } + + return strings.TrimRight(response.String(), "\n"), nil +} + +// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. +func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + id := randomHex(8) + req := map[string]any{ + "id": id, + "method": method, + } + if params != nil { + req["params"] = params + } else { + req["params"] = map[string]any{} + } + + payload, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(append(payload, '\n')); err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse the response to check for errors + var resp map[string]any + if err := json.Unmarshal([]byte(line), &resp); err != nil { + return strings.TrimRight(line, "\n"), nil + } + + if ok, _ := resp["ok"].(bool); !ok { + if errObj, _ := resp["error"].(map[string]any); errObj != nil { + code, _ := errObj["code"].(string) + msg, _ := errObj["message"].(string) + return "", fmt.Errorf("server error [%s]: %s", code, msg) + } + return "", fmt.Errorf("server returned error response") + } + + // Return the result portion as JSON + if result, ok := resp["result"]; ok { + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + return string(resultJSON), nil + } + + return "{}", nil +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func cliUsage() { + fmt.Fprintln(os.Stderr, "Usage: cmux [--socket <path>] [--json] <command> [args...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " ping Check connectivity") + fmt.Fprintln(os.Stderr, " capabilities List server capabilities") + fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces") + fmt.Fprintln(os.Stderr, " new-window Create a new window") + fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace") + fmt.Fprintln(os.Stderr, " new-surface Create a new surface") + fmt.Fprintln(os.Stderr, " new-split Split an existing surface") + fmt.Fprintln(os.Stderr, " close-surface Close a surface") + fmt.Fprintln(os.Stderr, " close-workspace Close a workspace") + fmt.Fprintln(os.Stderr, " select-workspace Select a workspace") + fmt.Fprintln(os.Stderr, " send Send text to a surface") + fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") + fmt.Fprintln(os.Stderr, " notify Create a notification") + fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)") + fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC") +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go new file mode 100644 index 00000000..32d08280 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -0,0 +1,696 @@ +package main + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + os.Stdout = writer + defer func() { + os.Stdout = original + }() + + fn() + + if err := writer.Close(); err != nil { + t.Fatalf("close stdout writer: %v", err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + if err := reader.Close(); err != nil { + t.Fatalf("close stdout reader: %v", err) + } + return string(output) +} + +// startMockSocket creates a Unix socket that accepts one connection, +// reads a line, and responds with the given canned response. +func startMockSocket(t *testing.T, response string) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n // consume request + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return sockPath +} + +// startMockV2Socket creates a Unix socket that echoes the received request's method +// back as a successful JSON-RPC response with the method name in the result. +func startMockV2Socket(t *testing.T) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n > 0 { + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err == nil { + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + } else { + conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + } + } + conn.Close() + } + }() + + return sockPath +} + +func startMockV2TCPSocketWithResult(t *testing.T, result any) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n == 0 { + return + } + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err != nil { + _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + return + } + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": result, + } + payload, _ := json.Marshal(resp) + _, _ = conn.Write(append(payload, '\n')) + }(conn) + } + }() + + return ln.Addr().String() +} + +// startMockTCPSocket creates a TCP listener that responds with a canned response. +func startMockTCPSocket(t *testing.T, response string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return ln.Addr().String() +} + +func startMockAuthenticatedTCPSocket(t *testing.T, relayID, relayToken, response string) string { + t.Helper() + relayTokenBytes := mustHex(t, relayToken) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + nonce := "testnonce" + challenge, _ := json.Marshal(map[string]any{ + "protocol": "cmux-relay-auth", + "version": 1, + "relay_id": relayID, + "nonce": nonce, + }) + _, _ = conn.Write(append(challenge, '\n')) + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return + } + var authResp map[string]any + if err := json.Unmarshal([]byte(line), &authResp); err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + macHex, _ := authResp["mac"].(string) + receivedMAC, err := hex.DecodeString(macHex) + if err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + h := hmac.New(sha256.New, relayTokenBytes) + _, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1)) + expectedMAC := h.Sum(nil) + if !hmac.Equal(receivedMAC, expectedMAC) { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + _, _ = conn.Write([]byte(`{"ok":true}` + "\n")) + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _, _ = conn.Write([]byte(response)) + if n > 0 && !strings.HasSuffix(response, "\n") { + _, _ = conn.Write([]byte("\n")) + } + }(conn) + } + }() + + return ln.Addr().String() +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + data, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("decode hex: %v", err) + } + return data +} + +func TestDialTCPRetrySuccess(t *testing.T) { + // Get a free port, then close the listener so connection is refused initially. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + // Start a listener after a delay so the retry logic finds it. + go func() { + time.Sleep(400 * time.Millisecond) + ln2, err := net.Listen("tcp", addr) + if err != nil { + return + } + defer ln2.Close() + conn, err := ln2.Accept() + if err != nil { + return + } + conn.Close() + }() + + conn, err := dialTCPRetry(addr, 3*time.Second, nil) + if err != nil { + t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) + } + conn.Close() +} + +func TestDialTCPRetryTimeout(t *testing.T) { + // Get a free port and close it — nothing will ever listen. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + start := time.Now() + _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + elapsed := time.Since(start) + if err == nil { + t.Fatal("dialTCPRetry should fail when nothing is listening") + } + if elapsed < 500*time.Millisecond { + t.Fatalf("should have retried for ~600ms, only took %v", elapsed) + } +} + +func TestCLIPingV1(t *testing.T) { + sockPath := startMockSocket(t, "pong") + code := runCLI([]string{"--socket", sockPath, "ping"}) + if code != 0 { + t.Fatalf("ping should return 0, got %d", code) + } +} + +func TestCLIPingV1OverTCP(t *testing.T) { + addr := startMockTCPSocket(t, "pong") + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithEnv(t *testing.T) { + relayID := "relay-1" + relayToken := strings.Repeat("a1", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + t.Setenv("CMUX_RELAY_ID", relayID) + t.Setenv("CMUX_RELAY_TOKEN", relayToken) + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithRelayFile(t *testing.T) { + relayID := "relay-2" + relayToken := strings.Repeat("b2", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host port: %v", err) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CMUX_RELAY_ID", "") + t.Setenv("CMUX_RELAY_TOKEN", "") + relayDir := filepath.Join(home, ".cmux", "relay") + if err := os.MkdirAll(relayDir, 0o700); err != nil { + t.Fatalf("mkdir relay dir: %v", err) + } + authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken}) + if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP file relay should return 0, got %d", code) + } +} + +func TestDialSocketDetection(t *testing.T) { + // Unix socket paths should attempt Unix dial + for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} { + conn, err := dialSocket(path, nil) + if conn != nil { + conn.Close() + } + // We expect a connection error (not found), not a panic + if err == nil { + t.Fatalf("dialSocket(%q) should fail for non-existent path", path) + } + } + + // TCP addresses should attempt TCP dial + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, _ := ln.Accept() + if conn != nil { + conn.Close() + } + }() + + conn, err := dialSocket(ln.Addr().String(), nil) + if err != nil { + t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err) + } + conn.Close() +} + +func TestCLINewWindowV1(t *testing.T) { + sockPath := startMockSocket(t, "OK window_id=abc123") + code := runCLI([]string{"--socket", sockPath, "new-window"}) + if code != 0 { + t.Fatalf("new-window should return 0, got %d", code) + } +} + +func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) { + addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma") + resp, err := socketRoundTrip(addr, "list_windows", nil) + if err != nil { + t.Fatalf("socketRoundTrip should succeed, got error: %v", err) + } + want := "window:alpha\nwindow:beta\nwindow:gamma" + if resp != want { + t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want) + } +} + +func TestCLICloseWindowV1(t *testing.T) { + // Verify that the flag value is appended to the v1 command + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var received string + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + received = strings.TrimSpace(string(buf[:n])) + conn.Write([]byte("OK\n")) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"}) + if code != 0 { + t.Fatalf("close-window should return 0, got %d", code) + } + if received != "close_window win-42" { + t.Fatalf("expected 'close_window win-42', got %q", received) + } +} + +func TestCLIListWorkspacesV2(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } +} + +func TestCLIListWorkspacesV2DefaultOutputShowsResult(t *testing.T) { + sockPath := startMockV2TCPSocketWithResult(t, map[string]any{"method": "workspace.list", "params": map[string]any{}}) + output := captureStdout(t, func() { + code := runCLI([]string{"--socket", sockPath, "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } + }) + if !strings.Contains(output, "\"method\": \"workspace.list\"") { + t.Fatalf("expected default output to include result payload, got %q", output) + } +} + +func TestCLINotifyDefaultOutputPrintsOKForEmptyResult(t *testing.T) { + sockPath := startMockV2TCPSocketWithResult(t, map[string]any{}) + output := captureStdout(t, func() { + code := runCLI([]string{"--socket", sockPath, "notify", "--body", "hi"}) + if code != 0 { + t.Fatalf("notify should return 0, got %d", code) + } + }) + if strings.TrimSpace(output) != "OK" { + t.Fatalf("expected empty-result command to print OK, got %q", output) + } +} + +func TestCLIRPCPassthrough(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"}) + if code != 0 { + t.Fatalf("rpc should return 0, got %d", code) + } +} + +func TestCLIRPCWithParams(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`}) + if code != 0 { + t.Fatalf("rpc with params should return 0, got %d", code) + } +} + +func TestCLIUnknownCommand(t *testing.T) { + code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"}) + if code != 2 { + t.Fatalf("unknown command should return 2, got %d", code) + } +} + +func TestCLINoSocket(t *testing.T) { + // Without CMUX_SOCKET_PATH set, should fail + os.Unsetenv("CMUX_SOCKET_PATH") + code := runCLI([]string{"ping"}) + if code != 1 { + t.Fatalf("missing socket should return 1, got %d", code) + } +} + +func TestCLISocketEnvVar(t *testing.T) { + sockPath := startMockSocket(t, "pong") + os.Setenv("CMUX_SOCKET_PATH", sockPath) + defer os.Unsetenv("CMUX_SOCKET_PATH") + + code := runCLI([]string{"ping"}) + if code != 0 { + t.Fatalf("ping with env socket should return 0, got %d", code) + } +} + +func TestCLIV2FlagMapping(t *testing.T) { + // Verify that --workspace gets mapped to workspace_id in params + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"}) + if code != 0 { + t.Fatalf("close-workspace should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "ws-abc" { + t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + } +} + +func TestBusyboxArgv0Detection(t *testing.T) { + // Verify that when argv[0] base is "cmux", we enter CLI mode + base := filepath.Base("cmux") + if base != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base) + } + base2 := filepath.Base("/home/user/.cmux/bin/cmux") + if base2 != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base2) + } + base3 := filepath.Base("cmuxd-remote") + if base3 == "cmux" { + t.Fatalf("cmuxd-remote should not match cmux") + } +} + +func TestCLIBrowserSubcommand(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } +} + +func TestCLINoArgs(t *testing.T) { + code := runCLI([]string{}) + if code != 2 { + t.Fatalf("no args should return 2, got %d", code) + } +} + +func TestCLIHelpFlag(t *testing.T) { + code := runCLI([]string{"--help"}) + if code != 0 { + t.Fatalf("--help should return 0, got %d", code) + } +} + +func TestCLIHelpCommand(t *testing.T) { + code := runCLI([]string{"help"}) + if code != 0 { + t.Fatalf("help should return 0, got %d", code) + } +} + +func TestFlagToParamKey(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"workspace", "workspace_id"}, + {"surface", "surface_id"}, + {"panel", "panel_id"}, + {"pane", "pane_id"}, + {"window", "window_id"}, + {"command", "initial_command"}, + {"name", "title"}, + {"working-directory", "working_directory"}, + {"title", "title"}, + {"url", "url"}, + {"direction", "direction"}, + } + for _, tc := range tests { + got := flagToParamKey(tc.input) + if got != tc.expected { + t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestParseFlags(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} + result := parseFlags(args, []string{"workspace", "surface"}) + if result.flags["workspace"] != "ws-1" { + t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) + } + if result.flags["surface"] != "sf-2" { + t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) + } + if _, ok := result.flags["unknown"]; ok { + t.Errorf("unknown flag should not be parsed") + } + if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { + t.Errorf("expected first positional=positional-cmd, got %v", result.positional) + } +} + +func TestCLIEnvVarDefaults(t *testing.T) { + // Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id") + os.Setenv("CMUX_SURFACE_ID", "env-sf-id") + defer os.Unsetenv("CMUX_WORKSPACE_ID") + defer os.Unsetenv("CMUX_SURFACE_ID") + + code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"}) + if code != 0 { + t.Fatalf("close-surface should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "env-ws-id" { + t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) + } + if receivedParams["surface_id"] != "env-sf-id" { + t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go new file mode 100644 index 00000000..22db25a3 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -0,0 +1,1034 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "math" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + "time" +) + +var version = "dev" + +type rpcRequest struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +type rpcError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type rpcResponse struct { + ID any `json:"id,omitempty"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcServer struct { + mu sync.Mutex + nextStreamID uint64 + nextSessionID uint64 + streams map[string]net.Conn + sessions map[string]*sessionState +} + +type sessionAttachment struct { + Cols int + Rows int + UpdatedAt time.Time +} + +type sessionState struct { + attachments map[string]sessionAttachment + effectiveCols int + effectiveRows int + lastKnownCols int + lastKnownRows int +} + +const maxRPCFrameBytes = 4 * 1024 * 1024 + +func main() { + // Busybox-style: if invoked as "cmux" (via symlink), act as CLI relay. + base := filepath.Base(os.Args[0]) + if base == "cmux" { + os.Exit(runCLI(os.Args[1:])) + } + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + + switch args[0] { + case "version": + _, _ = fmt.Fprintln(stdout, version) + return 0 + case "serve": + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(stderr) + stdio := fs.Bool("stdio", false, "serve over stdin/stdout") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if !*stdio { + _, _ = fmt.Fprintln(stderr, "serve requires --stdio") + return 2 + } + if err := runStdioServer(stdin, stdout); err != nil { + _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) + return 1 + } + return 0 + case "cli": + return runCLI(args[1:]) + default: + usage(stderr) + return 2 + } +} + +func usage(w io.Writer) { + _, _ = fmt.Fprintln(w, "Usage:") + _, _ = fmt.Fprintln(w, " cmuxd-remote version") + _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") + _, _ = fmt.Fprintln(w, " cmuxd-remote cli <command> [args...]") +} + +func runStdioServer(stdin io.Reader, stdout io.Writer) error { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + reader := bufio.NewReaderSize(stdin, 64*1024) + writer := bufio.NewWriter(stdout) + defer writer.Flush() + + for { + line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + if oversized { + if err := writeResponse(writer, rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "request frame exceeds maximum size", + }, + }); err != nil { + return err + } + continue + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(line) == 0 { + continue + } + + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + if err := writeResponse(writer, rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }); err != nil { + return err + } + continue + } + + resp := server.handleRequest(req) + if err := writeResponse(writer, resp); err != nil { + return err + } + } +} + +func readRPCFrame(reader *bufio.Reader, maxBytes int) ([]byte, bool, error) { + frame := make([]byte, 0, 1024) + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + if len(frame)+len(chunk) > maxBytes { + if errors.Is(err, bufio.ErrBufferFull) { + if drainErr := discardUntilNewline(reader); drainErr != nil && !errors.Is(drainErr, io.EOF) { + return nil, false, drainErr + } + } + return nil, true, nil + } + frame = append(frame, chunk...) + } + + if err == nil { + return frame, false, nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) { + if len(frame) == 0 { + return nil, false, io.EOF + } + return frame, false, nil + } + return nil, false, err + } +} + +func discardUntilNewline(reader *bufio.Reader) error { + for { + _, err := reader.ReadSlice('\n') + if err == nil || errors.Is(err, io.EOF) { + return err + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + return err + } +} + +func writeResponse(w *bufio.Writer, resp rpcResponse) error { + payload, err := json.Marshal(resp) + if err != nil { + return err + } + if _, err := w.Write(payload); err != nil { + return err + } + if err := w.WriteByte('\n'); err != nil { + return err + } + return w.Flush() +} + +func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { + if req.Method == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "method is required", + }, + } + } + + switch req.Method { + case "hello": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "name": "cmuxd-remote", + "version": version, + "capabilities": []string{ + "session.basic", + "session.resize.min", + "proxy.http_connect", + "proxy.socks5", + "proxy.stream", + }, + }, + } + case "ping": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "pong": true, + }, + } + case "proxy.open": + return s.handleProxyOpen(req) + case "proxy.close": + return s.handleProxyClose(req) + case "proxy.write": + return s.handleProxyWrite(req) + case "proxy.read": + return s.handleProxyRead(req) + case "session.open": + return s.handleSessionOpen(req) + case "session.close": + return s.handleSessionClose(req) + case "session.attach": + return s.handleSessionAttach(req) + case "session.resize": + return s.handleSessionResize(req) + case "session.detach": + return s.handleSessionDetach(req) + case "session.status": + return s.handleSessionStatus(req) + default: + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "method_not_found", + Message: fmt.Sprintf("unknown method %q", req.Method), + }, + } + } +} + +func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { + host, ok := getStringParam(req.Params, "host") + if !ok || host == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires host", + }, + } + } + port, ok := getIntParam(req.Params, "port") + if !ok || port <= 0 || port > 65535 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires port in range 1-65535", + }, + } + } + + timeoutMs := 10000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, err := net.DialTimeout( + "tcp", + net.JoinHostPort(host, strconv.Itoa(port)), + time.Duration(timeoutMs)*time.Millisecond, + ) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "open_failed", + Message: err.Error(), + }, + } + } + + s.mu.Lock() + streamID := fmt.Sprintf("s-%d", s.nextStreamID) + s.nextStreamID++ + s.streams[streamID] = conn + s.mu.Unlock() + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "stream_id": streamID, + }, + } +} + +func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.close requires stream_id", + }, + } + } + + s.mu.Lock() + conn, exists := s.streams[streamID] + if exists { + delete(s.streams, streamID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + + _ = conn.Close() + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } +} + +func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires stream_id", + }, + } + } + dataBase64, ok := getStringParam(req.Params, "data_base64") + if !ok { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires data_base64", + }, + } + } + payload, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "data_base64 must be valid base64", + }, + } + } + + conn, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + + timeoutMs := 8000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { + timeoutMs = parsed + } + if timeoutMs > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: err.Error(), + }, + } + } + defer conn.SetWriteDeadline(time.Time{}) + } + + total := 0 + for total < len(payload) { + written, writeErr := conn.Write(payload[total:]) + if written == 0 && writeErr == nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: "write made no progress", + }, + } + } + total += written + if writeErr != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: writeErr.Error(), + }, + } + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "written": total, + }, + } +} + +func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.read requires stream_id", + }, + } + } + + maxBytes := 32768 + if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax { + maxBytes = parsed + } + if maxBytes <= 0 || maxBytes > 262144 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "max_bytes must be in range 1-262144", + }, + } + } + + timeoutMs := 50 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + + _ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)) + buffer := make([]byte, maxBytes) + n, readErr := conn.Read(buffer) + data := buffer[:max(0, n)] + + if readErr != nil { + if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "data_base64": "", + "eof": false, + }, + } + } + if readErr == io.EOF { + s.dropStream(streamID) + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "data_base64": base64.StdEncoding.EncodeToString(data), + "eof": true, + }, + } + } + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: readErr.Error(), + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "data_base64": base64.StdEncoding.EncodeToString(data), + "eof": false, + }, + } +} + +func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { + sessionID, _ := getStringParam(req.Params, "session_id") + + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) + s.nextSessionID++ + } + + session, exists := s.sessions[sessionID] + if !exists { + session = &sessionState{ + attachments: map[string]sessionAttachment{}, + } + s.sessions[sessionID] = session + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.close requires session_id", + }, + } + } + + s.mu.Lock() + _, exists := s.sessions[sessionID] + if exists { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session_id": sessionID, + "closed": true, + }, + } +} + +func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires session_id", + }, + } + } + attachmentID, ok := getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires attachment_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + delete(session.attachments, attachmentID) + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.status requires session_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires session_id", + }, + } + return "", "", 0, 0, &resp + } + attachmentID, ok = getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires attachment_id", + }, + } + return "", "", 0, 0, &resp + } + + cols, ok = getIntParam(req.Params, "cols") + if !ok || cols <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires cols > 0", + }, + } + return "", "", 0, 0, &resp + } + rows, ok = getIntParam(req.Params, "rows") + if !ok || rows <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires rows > 0", + }, + } + return "", "", 0, 0, &resp + } + + return sessionID, attachmentID, cols, rows, nil +} + +func recomputeSessionSize(session *sessionState) { + if len(session.attachments) == 0 { + session.effectiveCols = session.lastKnownCols + session.effectiveRows = session.lastKnownRows + return + } + + minCols := 0 + minRows := 0 + for _, attachment := range session.attachments { + if minCols == 0 || attachment.Cols < minCols { + minCols = attachment.Cols + } + if minRows == 0 || attachment.Rows < minRows { + minRows = attachment.Rows + } + } + + session.effectiveCols = minCols + session.effectiveRows = minRows + session.lastKnownCols = minCols + session.lastKnownRows = minRows +} + +func sessionSnapshot(sessionID string, session *sessionState) map[string]any { + attachmentIDs := make([]string, 0, len(session.attachments)) + for attachmentID := range session.attachments { + attachmentIDs = append(attachmentIDs, attachmentID) + } + sort.Strings(attachmentIDs) + + attachments := make([]map[string]any, 0, len(attachmentIDs)) + for _, attachmentID := range attachmentIDs { + attachment := session.attachments[attachmentID] + attachments = append(attachments, map[string]any{ + "attachment_id": attachmentID, + "cols": attachment.Cols, + "rows": attachment.Rows, + "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), + }) + } + + return map[string]any{ + "session_id": sessionID, + "attachments": attachments, + "effective_cols": session.effectiveCols, + "effective_rows": session.effectiveRows, + "last_known_cols": session.lastKnownCols, + "last_known_rows": session.lastKnownRows, + } +} + +func (s *rpcServer) getStream(streamID string) (net.Conn, bool) { + s.mu.Lock() + defer s.mu.Unlock() + conn, ok := s.streams[streamID] + return conn, ok +} + +func (s *rpcServer) dropStream(streamID string) { + s.mu.Lock() + conn, ok := s.streams[streamID] + if ok { + delete(s.streams, streamID) + } + s.mu.Unlock() + if ok { + _ = conn.Close() + } +} + +func (s *rpcServer) closeAll() { + s.mu.Lock() + streams := make([]net.Conn, 0, len(s.streams)) + for id, conn := range s.streams { + delete(s.streams, id) + streams = append(streams, conn) + } + for id := range s.sessions { + delete(s.sessions, id) + } + s.mu.Unlock() + for _, conn := range streams { + _ = conn.Close() + } +} + +func getStringParam(params map[string]any, key string) (string, bool) { + if params == nil { + return "", false + } + raw, ok := params[key] + if !ok || raw == nil { + return "", false + } + value, ok := raw.(string) + return value, ok +} + +func getIntParam(params map[string]any, key string) (int, bool) { + if params == nil { + return 0, false + } + raw, ok := params[key] + if !ok || raw == nil { + return 0, false + } + switch value := raw.(type) { + case int: + return value, true + case int8: + return int(value), true + case int16: + return int(value), true + case int32: + return int(value), true + case int64: + return int(value), true + case uint: + return int(value), true + case uint8: + return int(value), true + case uint16: + return int(value), true + case uint32: + return int(value), true + case uint64: + return int(value), true + case float64: + if math.Trunc(value) != value { + return 0, false + } + return int(value), true + case json.Number: + n, err := value.Int64() + if err != nil { + return 0, false + } + return int(n), true + default: + return 0, false + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go new file mode 100644 index 00000000..9ee08f07 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -0,0 +1,531 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "math" + "net" + "strconv" + "strings" + "testing" + "time" +) + +func TestRunVersion(t *testing.T) { + var out bytes.Buffer + code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run version exit code = %d, want 0", code) + } + if strings.TrimSpace(out.String()) == "" { + t.Fatalf("version output should not be empty") + } +} + +func TestRunStdioHelloAndPing(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}}` + "\n" + + `{"id":2,"method":"ping","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); !ok { + t.Fatalf("first response should be ok=true: %v", first) + } + firstResult, _ := first["result"].(map[string]any) + if firstResult == nil { + t.Fatalf("first response missing result object: %v", first) + } + capabilities, _ := firstResult["capabilities"].([]any) + if len(capabilities) < 2 { + t.Fatalf("hello should return capabilities: %v", firstResult) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should be ok=true: %v", second) + } +} + +func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}` + "\n" + + `{"id":2,"method":"unknown","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be ok=false for invalid JSON: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("invalid JSON should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); ok { + t.Fatalf("second response should be ok=false for unknown method: %v", second) + } + secondError, _ := second["error"].(map[string]any) + if got := secondError["code"]; got != "method_not_found" { + t.Fatalf("unknown method should return method_not_found; got=%v payload=%v", got, second) + } +} + +func TestRunStdioSessionResizeFlow(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"session.open","params":{"session_id":"sess-stdio"}}` + "\n" + + `{"id":2,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a1","cols":120,"rows":40}}` + "\n" + + `{"id":3,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a2","cols":90,"rows":30}}` + "\n" + + `{"id":4,"method":"session.status","params":{"session_id":"sess-stdio"}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 4 { + t.Fatalf("got %d response lines, want 4: %q", len(lines), out.String()) + } + + var status map[string]any + if err := json.Unmarshal([]byte(lines[3]), &status); err != nil { + t.Fatalf("failed to decode status response: %v", err) + } + if ok, _ := status["ok"].(bool); !ok { + t.Fatalf("session.status should be ok=true: %v", status) + } + result, _ := status["result"].(map[string]any) + if result == nil { + t.Fatalf("session.status missing result object: %v", status) + } + effectiveCols, _ := result["effective_cols"].(float64) + effectiveRows, _ := result["effective_rows"].(float64) + if int(effectiveCols) != 90 || int(effectiveRows) != 30 { + t.Fatalf("session smallest-wins effective size mismatch: got=%vx%v payload=%v", effectiveCols, effectiveRows, result) + } +} + +func TestProxyStreamRoundTrip(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + + buffer := make([]byte, 4) + if _, readErr := io.ReadFull(conn, buffer); readErr != nil { + return + } + if string(buffer) != "ping" { + return + } + _, _ = conn.Write([]byte("pong")) + }() + + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + port := listener.Addr().(*net.TCPAddr).Port + openResp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.open", + Params: map[string]any{ + "host": "127.0.0.1", + "port": port, + "timeout_ms": 1000, + }, + }) + if !openResp.OK { + t.Fatalf("proxy.open failed: %+v", openResp) + } + openResult, _ := openResp.Result.(map[string]any) + streamID, _ := openResult["stream_id"].(string) + if streamID == "" { + t.Fatalf("proxy.open missing stream_id: %+v", openResp) + } + + writeResp := server.handleRequest(rpcRequest{ + ID: 2, + Method: "proxy.write", + Params: map[string]any{ + "stream_id": streamID, + "data_base64": base64.StdEncoding.EncodeToString([]byte("ping")), + }, + }) + if !writeResp.OK { + t.Fatalf("proxy.write failed: %+v", writeResp) + } + + readResp := server.handleRequest(rpcRequest{ + ID: 3, + Method: "proxy.read", + Params: map[string]any{ + "stream_id": streamID, + "max_bytes": 8, + "timeout_ms": 1000, + }, + }) + if !readResp.OK { + t.Fatalf("proxy.read failed: %+v", readResp) + } + readResult, _ := readResp.Result.(map[string]any) + dataBase64, _ := readResult["data_base64"].(string) + data, decodeErr := base64.StdEncoding.DecodeString(dataBase64) + if decodeErr != nil { + t.Fatalf("proxy.read returned invalid base64: %v", decodeErr) + } + if string(data) != "pong" { + t.Fatalf("proxy.read payload=%q, want %q", string(data), "pong") + } + + closeResp := server.handleRequest(rpcRequest{ + ID: 4, + Method: "proxy.close", + Params: map[string]any{ + "stream_id": streamID, + }, + }) + if !closeResp.OK { + t.Fatalf("proxy.close failed: %+v", closeResp) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("proxy test server goroutine did not finish") + } +} + +func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { + params := map[string]any{ + "port": 80.9, + "timeout_ms": 100.0, + } + + if _, ok := getIntParam(params, "port"); ok { + t.Fatalf("fractional float64 should be rejected") + } + + timeout, ok := getIntParam(params, "timeout_ms") + if !ok { + t.Fatalf("integral float64 should be accepted") + } + if timeout != 100 { + t.Fatalf("timeout_ms = %d, want 100", timeout) + } +} + +func TestRunStdioOversizedFrameContinuesServing(t *testing.T) { + oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}` + input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n") + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be oversized-frame error: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("oversized frame should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should still be handled after oversized frame: %v", second) + } +} + +func TestProxyOpenInvalidParams(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + resp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.open", + Params: map[string]any{ + "host": "127.0.0.1", + "port": strconv.Itoa(8080), + }, + }) + if resp.OK { + t.Fatalf("proxy.open with invalid port type should fail: %+v", resp) + } + errObj, _ := resp.Error, resp.Error + if errObj == nil || errObj.Code != "invalid_params" { + t.Fatalf("proxy.open invalid params should return invalid_params: %+v", resp) + } +} + +func TestSessionResizeCoordinator(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + openResp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "session.open", + Params: map[string]any{ + "session_id": "sess-rz", + }, + }) + if !openResp.OK { + t.Fatalf("session.open failed: %+v", openResp) + } + + attachSmall := server.handleRequest(rpcRequest{ + ID: 2, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-small", + "cols": 90, + "rows": 30, + }, + }) + assertEffectiveSize(t, attachSmall, 90, 30) + + attachLarge := server.handleRequest(rpcRequest{ + ID: 3, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + "cols": 120, + "rows": 40, + }, + }) + assertEffectiveSize(t, attachLarge, 90, 30) // RZ-001: smallest wins + + resizeLarge := server.handleRequest(rpcRequest{ + ID: 4, + Method: "session.resize", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + "cols": 200, + "rows": 60, + }, + }) + assertEffectiveSize(t, resizeLarge, 90, 30) // RZ-002: still bounded by smallest + + detachSmall := server.handleRequest(rpcRequest{ + ID: 5, + Method: "session.detach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-small", + }, + }) + assertEffectiveSize(t, detachSmall, 200, 60) // RZ-003: expands to next smallest + + detachLarge := server.handleRequest(rpcRequest{ + ID: 6, + Method: "session.detach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + }, + }) + assertEffectiveSize(t, detachLarge, 200, 60) // no attachments: keep last-known size + assertAttachmentCount(t, detachLarge, 0) + + reattach := server.handleRequest(rpcRequest{ + ID: 7, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-reconnect", + "cols": 110, + "rows": 50, + }, + }) + assertEffectiveSize(t, reattach, 110, 50) // RZ-004: recompute from active attachments on reattach +} + +func TestSessionInvalidParamsAndNotFound(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]net.Conn{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + missingSession := server.handleRequest(rpcRequest{ + ID: 1, + Method: "session.attach", + Params: map[string]any{ + "session_id": "missing", + "attachment_id": "a1", + "cols": 80, + "rows": 24, + }, + }) + if missingSession.OK || missingSession.Error == nil || missingSession.Error.Code != "not_found" { + t.Fatalf("session.attach on missing session should return not_found: %+v", missingSession) + } + + badSize := server.handleRequest(rpcRequest{ + ID: 2, + Method: "session.attach", + Params: map[string]any{ + "session_id": "missing", + "attachment_id": "a1", + "cols": 0, + "rows": 24, + }, + }) + if badSize.OK || badSize.Error == nil || badSize.Error.Code != "invalid_params" { + t.Fatalf("session.attach with cols=0 should return invalid_params: %+v", badSize) + } +} + +func assertEffectiveSize(t *testing.T, resp rpcResponse, wantCols, wantRows int) { + t.Helper() + if !resp.OK { + t.Fatalf("expected ok response, got error: %+v", resp) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("response missing result map: %+v", resp) + } + gotCols := asInt(t, result["effective_cols"], "effective_cols") + gotRows := asInt(t, result["effective_rows"], "effective_rows") + if gotCols != wantCols || gotRows != wantRows { + t.Fatalf("effective size = %dx%d, want %dx%d payload=%+v", gotCols, gotRows, wantCols, wantRows, result) + } +} + +func assertAttachmentCount(t *testing.T, resp rpcResponse, want int) { + t.Helper() + if !resp.OK { + t.Fatalf("expected ok response, got error: %+v", resp) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("response missing result map: %+v", resp) + } + attachments, ok := result["attachments"].([]map[string]any) + if ok { + if len(attachments) != want { + t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachments), want, result) + } + return + } + attachmentsAny, ok := result["attachments"].([]any) + if !ok { + t.Fatalf("attachments field has unexpected type (%T) payload=%+v", result["attachments"], result) + } + if len(attachmentsAny) != want { + t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachmentsAny), want, result) + } +} + +func asInt(t *testing.T, value any, field string) int { + t.Helper() + switch typed := value.(type) { + case int: + return typed + case int8: + return int(typed) + case int16: + return int(typed) + case int32: + return int(typed) + case int64: + return int(typed) + case uint: + return int(typed) + case uint8: + return int(typed) + case uint16: + return int(typed) + case uint32: + return int(typed) + 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) + return 0 + } +} diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod new file mode 100644 index 00000000..f4b93baa --- /dev/null +++ b/daemon/remote/go.mod @@ -0,0 +1,3 @@ +module github.com/manaflow-ai/cmux/daemon/remote + +go 1.22 diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md new file mode 100644 index 00000000..03aaa248 --- /dev/null +++ b/docs/remote-daemon-spec.md @@ -0,0 +1,214 @@ +# Remote SSH Living Spec + +Last updated: March 12, 2026 +Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 +Primary PR: https://github.com/manaflow-ai/cmux/pull/239 +CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 + +This document is the working source of truth for: +1. what is implemented now +2. what is intentionally temporary +3. what must be built next + +## 1. Document Type + +This is a **living implementation spec** (also called an **execution spec**): a spec-level document with status tracking (`DONE`, `IN PROGRESS`, `TODO`) and acceptance tests. + +## 2. Objective + +`cmux ssh` should provide: +1. durable remote terminals with reconnect/reuse +2. browser traffic that egresses from the remote host via proxying +3. tmux-style PTY resize semantics (`smallest screen wins`) + +## 3. Current State (Implemented) + +### 3.1 Remote Workspace + Reconnect UX +- `DONE` `cmux ssh` creates remote-tagged workspaces and does not require `--name`. +- `DONE` scoped shell niceties are applied only for `cmux ssh` launches. +- `DONE` context menu actions exist for remote workspaces (`Reconnect Workspace(s)`, `Disconnect Workspace(s)`). +- `DONE` socket API includes `workspace.remote.reconnect`. + +### 3.2 Bootstrap + Daemon +- `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. +- `DONE` daemon `hello` handshake is enforced. +- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). +- `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. +- `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`). +- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. +- `DONE` SOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes. +- `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. +- `DONE` bootstrap/probe failures surface actionable details. +- `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. + +### 3.5 CLI Relay (Running cmux Commands From Remote) +- `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. +- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. +- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. +- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. +- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. +- `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. +- `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. +- `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. + +### 3.6 Artifact Trust +- `DONE` release and nightly workflows publish `cmuxd-remote` assets for `darwin/linux × arm64/amd64`. +- `DONE` release and nightly apps embed a compact `CMUXRemoteDaemonManifestJSON` in `Info.plist` with exact asset URLs and SHA-256 digests. +- `DONE` `cmux remote-daemon-status` exposes the current manifest entry, local cache verification state, release download command, and GitHub attestation verification command. + +### 3.3 Error Surfacing +- `DONE` remote errors are surfaced in sidebar status + logs + notifications. +- `DONE` reconnect retry count/time is included in surfaced error text (for example, `retry 1 in 4s`). + +### 3.4 Removed Temporary Behavior +- `DONE` removed remote listening-port probe loop and per-port SSH `-L` mirroring. +- `DONE` remote browser routing now uses a single shared local proxy endpoint instead of detected-port mirroring. +- `DONE` remote status now includes structured proxy metadata (`remote.proxy`) and `proxy_unavailable` error code when proxy setup fails. + +## 4. Target Architecture (No Port Mirroring) + +### 4.1 Browser Networking Path +1. `DONE` one local proxy endpoint is created per SSH transport/session key (not per detected port). +2. `DONE` endpoint is provided by a local broker that supports SOCKS5 + HTTP CONNECT and tunnels via daemon stream RPC. +3. `DONE` browser panels in remote workspaces are auto-wired to the workspace proxy endpoint. +4. `DONE` browser panels in local workspaces are not force-proxied. +5. `DONE` identical SSH transports share one endpoint via a transport-scoped broker. + +### 4.2 WKWebView Wiring +1. `DONE` use workspace-scoped `WKWebsiteDataStore(forIdentifier:)`. +2. `DONE` apply workspace/browser scoped `proxyConfigurations`. +3. `DONE` prefer SOCKS5 proxy config. +4. `DONE` keep HTTP CONNECT proxy config as fallback. +5. `DONE` re-apply proxy config on reconnect/state updates. + +### 4.3 Remote Daemon + Transport +1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). +2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC. +3. `DONE` removed remote service-port discovery/probing from browser routing path. + +### 4.4 Explicit Non-Goal +1. Automatic mirroring of every remote listening port to local loopback is not a goal for browser support. + +## 5. PTY Resize Semantics (tmux-style) + +### 5.1 Core Rule +For each session with multiple attachments, the effective PTY size is: +1. `cols = min(cols_i over attached clients)` +2. `rows = min(rows_i over attached clients)` + +This is the `smallest screen wins` rule. + +### 5.2 State Model +Per session track: +1. set of active attachments `{attachment_id -> cols, rows, updated_at}` +2. effective size currently applied to PTY +3. last-known size when temporarily unattached + +### 5.3 Recompute Triggers +Recompute effective size on: +1. attachment create +2. attachment detach +3. resize event from any attachment +4. reconnect reattach + +### 5.4 Correctness Requirements +1. Never shrink history because of UI relayout noise; only PTY viewport changes. +2. On reconnect, reuse persisted session and recompute from active attachments. +3. If no attachments remain, keep last-known PTY size (do not force 80x24 reset). + +## 6. Milestones (Living Status) + +| ID | Milestone | Status | Notes | +|---|---|---|---| +| M-001 | `cmux ssh` workspace creation + metadata + optional `--name` | DONE | Covered by `tests_v2/test_ssh_remote_cli_metadata.py` | +| M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing | +| M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors | +| M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior | +| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | +| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | +| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | +| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented | +| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | +| M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | +| M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | + +## 7. Acceptance Test Matrix (With Status) + +### 7.1 Terminal + Reconnect + +| ID | Scenario | Status | +|---|---|---| +| T-001 | baseline remote connect | DONE | +| T-002 | identical host reuse semantics | DONE | +| T-003 | no `--name` | DONE | +| T-004 | reconnect API success/error paths | DONE | +| T-005 | retry count visible in daemon error detail | DONE | + +### 7.2 CLI Relay + +| ID | Scenario | Status | +|---|---|---| +| C-001 | `cmux ping` from remote session | DONE | +| C-002 | `cmux list-workspaces --json` from remote | DONE | +| C-003 | `cmux new-workspace` from remote | DONE | +| C-004 | `cmux rpc system.capabilities` passthrough | DONE | +| C-005 | TCP retry handles relay not yet established | DONE | +| C-006 | multi-workspace port conflict silent skip | DONE | +| C-007 | ephemeral port filtering excludes relay ports | DONE | + +### 7.3 Browser Proxy (Target) + +| ID | Scenario | Status | +|---|---|---| +| W-001 | remote workspace browser auto-proxied | DONE | +| W-002 | browser egress equals remote network path | DONE | +| W-003 | websocket via SOCKS5/CONNECT through remote daemon | DONE | +| W-004 | reconnect restores browser proxy path automatically | DONE | +| W-005 | local proxy bind conflict yields structured `proxy_unavailable` | DONE | +| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE | +| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE | + +### 7.4 Resize + +| ID | Scenario | Status | +|---|---|---| +| RZ-001 | two attachments, smallest wins | DONE | +| RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE | +| RZ-003 | detach smallest, PTY expands to next smallest | DONE | +| RZ-004 | reconnect preserves session + applies recomputed size | DONE | +| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | + +## 8. Removal Checklist (Port Mirroring) + +Before declaring browser proxying complete: +1. `DONE` remove remote port probe loop and `-L` auto-forward orchestration +2. `DONE` remove mirror-specific routing behavior as default remote behavior +3. `DONE` replace mirroring docker assertions with proxy egress assertions +4. `DONE` keep optional explicit user-driven forwarding out of this path; no automatic mirroring remains in browser routing + +## 9. Open Decisions + +1. Proxy auth policy for local broker (`none` vs optional credentials). +2. Reconnect backoff profile and max retry budget. + +## 10. Socket API Contract Notes + +### 10.1 `workspace.remote.configure` Port Fields +1. `port` and `local_proxy_port` accept integer values and numeric strings. +2. Explicit `null` clears each field. +3. Out-of-range values and invalid types (for example booleans/non-numeric strings/fractional numbers) return `invalid_params`. +4. `local_proxy_port` is an internal deterministic test hook to force local bind conflicts in regression coverage. + +### 10.2 SSH Option Precedence +1. `StrictHostKeyChecking` default (`accept-new`) is only injected when no user override is present. +2. Control-socket defaults (`ControlMaster`, `ControlPersist`, `ControlPath`) are only injected when missing. +3. SSH option key matching is case-insensitive for precedence checks in both CLI-built commands and remote configure payloads. + +### 10.3 SSH Docker E2E Harness Knobs +1. `CMUX_SSH_TEST_DOCKER_HOST` sets the SSH destination host/IP used by docker-backed SSH fixtures (default `127.0.0.1`). +2. `CMUX_SSH_TEST_DOCKER_BIND_ADDR` sets the bind address used in fixture container publish mappings (default `127.0.0.1`). +3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack). diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh new file mode 100755 index 00000000..a6be6fc6 --- /dev/null +++ b/scripts/build_remote_daemon_release_assets.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/build_remote_daemon_release_assets.sh \ + --version <app-version> \ + --release-tag <tag> \ + --repo <owner/repo> \ + --output-dir <dir> + +Builds cmuxd-remote release assets for the supported remote platforms and emits: + cmuxd-remote-<goos>-<goarch> + cmuxd-remote-checksums.txt + cmuxd-remote-manifest.json +EOF +} + +VERSION="" +RELEASE_TAG="" +REPO="" +OUTPUT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --release-tag) + RELEASE_TAG="${2:-}" + shift 2 + ;; + --repo) + REPO="${2:-}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" || -z "$RELEASE_TAG" || -z "$REPO" || -z "$OUTPUT_DIR" ]]; then + echo "error: --version, --release-tag, --repo, and --output-dir are required" >&2 + usage + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "error: go is required to build cmuxd-remote release assets" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DAEMON_ROOT="${REPO_ROOT}/daemon/remote" +mkdir -p "$OUTPUT_DIR" +rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json + +RELEASE_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}" +CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt" +CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" +MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json" + +TARGETS=( + "darwin arm64" + "darwin amd64" + "linux arm64" + "linux amd64" +) + +declare -a manifest_entries=() +: > "$CHECKSUMS_PATH" + +for target in "${TARGETS[@]}"; do + read -r GOOS GOARCH <<<"$target" + ASSET_NAME="cmuxd-remote-${GOOS}-${GOARCH}" + OUTPUT_PATH="${OUTPUT_DIR}/${ASSET_NAME}" + + ( + cd "$DAEMON_ROOT" + GOOS="$GOOS" \ + GOARCH="$GOARCH" \ + CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$OUTPUT_PATH" \ + ./cmd/cmuxd-remote + ) + chmod 755 "$OUTPUT_PATH" + + SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" + printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH" + + manifest_entries+=("{\"goOS\":\"${GOOS}\",\"goArch\":\"${GOARCH}\",\"assetName\":\"${ASSET_NAME}\",\"downloadURL\":\"${RELEASE_URL}/${ASSET_NAME}\",\"sha256\":\"${SHA256}\"}") +done + +ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")" +trap 'rm -f "$ENTRIES_FILE"' EXIT +printf '%s\n' "${manifest_entries[@]}" > "$ENTRIES_FILE" +ENTRIES_JSON="$(python3 - <<'PY' "$ENTRIES_FILE" +import json +import sys +from pathlib import Path + +entries = [json.loads(line) for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()] +print(json.dumps(entries, separators=(",", ":"))) +PY +)" + +python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$RELEASE_URL" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_JSON" +import json +import sys +from pathlib import Path + +version, release_tag, release_url, checksums_asset_name, checksums_path, manifest_path, entries_json = sys.argv[1:] +checksums_url = f"{release_url}/{checksums_asset_name}" +manifest = { + "schemaVersion": 1, + "appVersion": version, + "releaseTag": release_tag, + "releaseURL": release_url, + "checksumsAssetName": checksums_asset_name, + "checksumsURL": checksums_url, + "entries": json.loads(entries_json), +} +Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + +echo "Built cmuxd-remote assets in ${OUTPUT_DIR}" diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index b3818784..986b55d2 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -3,4 +3,5 @@ # Format: <ghostty_sha> <sha256> 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d +c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js index d16d328e..4699b324 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -1,6 +1,15 @@ "use strict"; -const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; +const IMMUTABLE_RELEASE_ASSETS = [ + "cmux-macos.dmg", + "appcast.xml", + "cmuxd-remote-darwin-arm64", + "cmuxd-remote-darwin-amd64", + "cmuxd-remote-linux-arm64", + "cmuxd-remote-linux-amd64", + "cmuxd-remote-checksums.txt", + "cmuxd-remote-manifest.json", +]; const RELEASE_ASSET_GUARD_STATE = Object.freeze({ CLEAR: "clear", PARTIAL: "partial", diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js index c320cf81..39cdcf89 100644 --- a/scripts/release_asset_guard.test.js +++ b/scripts/release_asset_guard.test.js @@ -11,7 +11,7 @@ const { test("marks guard as complete and skips build/upload when all immutable assets already exist", () => { const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], + existingAssetNames: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"], }); assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); @@ -36,12 +36,16 @@ test("marks guard as clear when immutable assets are not present", () => { }); test("marks guard as partial when only some immutable assets exist", () => { + const partialAssets = ["appcast.xml", "cmuxd-remote-manifest.json"]; const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["appcast.xml"], + existingAssetNames: partialAssets, }); - assert.deepEqual(result.conflicts, ["appcast.xml"]); - assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); + assert.deepEqual(result.conflicts, partialAssets); + assert.deepEqual( + result.missingImmutableAssets, + IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)), + ); assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL); assert.equal(result.hasPartialConflict, true); assert.equal(result.shouldSkipBuildAndUpload, false); diff --git a/scripts/reload.sh b/scripts/reload.sh index 4e758a88..5a4f2a6e 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -10,6 +10,85 @@ BUNDLE_SET=0 DERIVED_SET=0 TAG="" CMUX_DEBUG_LOG="" +CLI_PATH="" + +write_dev_cli_shim() { + local target="$1" + local fallback_bin="$2" + mkdir -p "$(dirname "$target")" + cat > "$target" <<EOF +#!/usr/bin/env bash +# cmux dev shim (managed by scripts/reload.sh) +set -euo pipefail + +CLI_PATH_FILE="/tmp/cmux-last-cli-path" +CLI_PATH_OWNER="\$(stat -f '%u' "\$CLI_PATH_FILE" 2>/dev/null || stat -c '%u' "\$CLI_PATH_FILE" 2>/dev/null || echo -1)" +if [[ -r "\$CLI_PATH_FILE" ]] && [[ ! -L "\$CLI_PATH_FILE" ]] && [[ "\$CLI_PATH_OWNER" == "\$(id -u)" ]]; then + CLI_PATH="\$(cat "\$CLI_PATH_FILE")" + if [[ -x "\$CLI_PATH" ]]; then + exec "\$CLI_PATH" "\$@" + fi +fi + +if [[ -x "$fallback_bin" ]]; then + exec "$fallback_bin" "\$@" +fi + +echo "error: no reload-selected dev cmux CLI found. Run ./scripts/reload.sh --tag <name> first." >&2 +exit 1 +EOF + chmod +x "$target" +} + +select_cmux_shim_target() { + local app_cli_dir="/Applications/cmux.app/Contents/Resources/bin" + local marker="cmux dev shim (managed by scripts/reload.sh)" + local target="" + local path_entry="" + local candidate="" + + IFS=':' read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}"; do + [[ -z "$path_entry" ]] && continue + if [[ "$path_entry" == "~/"* ]]; then + path_entry="$HOME/${path_entry#~/}" + fi + if [[ "$path_entry" == "$app_cli_dir" ]]; then + break + fi + [[ -d "$path_entry" && -w "$path_entry" ]] || continue + candidate="$path_entry/cmux" + if [[ ! -e "$candidate" ]]; then + target="$candidate" + break + fi + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + target="$candidate" + break + fi + done + + if [[ -n "$target" ]]; then + echo "$target" + return 0 + fi + + # Fallback for PATH layouts where app CLI isn't listed or no earlier entries were writable. + for path_entry in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do + [[ -d "$path_entry" && -w "$path_entry" ]] || continue + candidate="$path_entry/cmux" + if [[ ! -e "$candidate" ]]; then + echo "$candidate" + return 0 + fi + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + echo "$candidate" + return 0 + fi + done + + return 1 +} usage() { cat <<'EOF' @@ -279,6 +358,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXTERM_REPO_ROOT string \"${PWD}\"" "$INFO_PLIST" if [[ -S "$CMUXD_SOCKET" ]]; then for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do kill "$PID" 2>/dev/null || true @@ -294,6 +377,21 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then APP_PATH="$TAG_APP_PATH" fi +CLI_PATH="$(dirname "$APP_PATH")/cmux" +if [[ -x "$CLI_PATH" ]]; then + (umask 077; printf '%s\n' "$CLI_PATH" > /tmp/cmux-last-cli-path) || true + ln -sfn "$CLI_PATH" /tmp/cmux-cli || true + + # Stable shim that always follows the last reload-selected dev CLI. + DEV_CLI_SHIM="$HOME/.local/bin/cmux-dev" + write_dev_cli_shim "$DEV_CLI_SHIM" "/Applications/cmux.app/Contents/Resources/bin/cmux" + + CMUX_SHIM_TARGET="$(select_cmux_shim_target || true)" + if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then + write_dev_cli_shim "$CMUX_SHIM_TARGET" "/Applications/cmux.app/Contents/Resources/bin/cmux" + fi +fi + # Ensure any running instance is fully terminated, regardless of DerivedData path. /usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true sleep 0.3 @@ -325,6 +423,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 @@ -345,10 +445,11 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else + echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH" fi @@ -376,3 +477,16 @@ fi if [[ -n "${TAG_SLUG:-}" ]]; then print_tag_cleanup_reminder "$TAG_SLUG" fi + +if [[ -x "${CLI_PATH:-}" ]]; then + echo + echo "CLI path:" + echo " $CLI_PATH" + echo "CLI helpers:" + echo " /tmp/cmux-cli ..." + echo " $HOME/.local/bin/cmux-dev ..." + if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then + echo " $CMUX_SHIM_TARGET ..." + fi + echo "If your shell still resolves the old cmux, run: rehash" +fi diff --git a/tests/fixtures/ssh-remote/Dockerfile b/tests/fixtures/ssh-remote/Dockerfile new file mode 100644 index 00000000..470986d8 --- /dev/null +++ b/tests/fixtures/ssh-remote/Dockerfile @@ -0,0 +1,20 @@ +FROM alpine:3.20 + +RUN apk add --no-cache openssh python3 iproute2 net-tools ncurses + +RUN adduser -D -s /bin/sh dev \ + && mkdir -p /home/dev/.ssh /run/sshd /srv/www \ + && chown -R dev:dev /home/dev/.ssh \ + && chmod 700 /home/dev/.ssh \ + && echo "cmux-ssh-forward-ok" > /srv/www/index.html + +RUN ssh-keygen -A + +COPY sshd_config /etc/ssh/sshd_config +COPY run.sh /usr/local/bin/run.sh +COPY ws_echo.py /usr/local/bin/ws_echo.py +RUN chmod +x /usr/local/bin/run.sh + +EXPOSE 22 + +CMD ["/usr/local/bin/run.sh"] diff --git a/tests/fixtures/ssh-remote/run.sh b/tests/fixtures/ssh-remote/run.sh new file mode 100644 index 00000000..9089554f --- /dev/null +++ b/tests/fixtures/ssh-remote/run.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +if [ -z "${AUTHORIZED_KEY:-}" ]; then + echo "AUTHORIZED_KEY is required" >&2 + exit 1 +fi + +REMOTE_HTTP_PORT="${REMOTE_HTTP_PORT:-43173}" +REMOTE_WS_PORT="${REMOTE_WS_PORT:-43174}" + +mkdir -p /home/dev/.ssh /root/.ssh /run/sshd +printf '%s\n' "$AUTHORIZED_KEY" > /home/dev/.ssh/authorized_keys +printf '%s\n' "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys +chown -R dev:dev /home/dev/.ssh +chmod 700 /home/dev/.ssh +chmod 600 /home/dev/.ssh/authorized_keys +chmod 700 /root/.ssh +chmod 600 /root/.ssh/authorized_keys + +python3 -m http.server "$REMOTE_HTTP_PORT" --bind 127.0.0.1 --directory /srv/www >/tmp/http.log 2>&1 & +HTTP_PID=$! +python3 /usr/local/bin/ws_echo.py --host 127.0.0.1 --port "$REMOTE_WS_PORT" >/tmp/ws.log 2>&1 & +WS_PID=$! + +sleep 0.2 +if ! kill -0 "$HTTP_PID" 2>/dev/null; then + echo "HTTP fixture failed to start (see /tmp/http.log)" >&2 + cat /tmp/http.log >&2 || true + exit 1 +fi +if ! kill -0 "$WS_PID" 2>/dev/null; then + echo "WebSocket fixture failed to start (see /tmp/ws.log)" >&2 + cat /tmp/ws.log >&2 || true + exit 1 +fi + +exec /usr/sbin/sshd -D -e diff --git a/tests/fixtures/ssh-remote/sshd_config b/tests/fixtures/ssh-remote/sshd_config new file mode 100644 index 00000000..9885b799 --- /dev/null +++ b/tests/fixtures/ssh-remote/sshd_config @@ -0,0 +1,31 @@ +Port 22 +Protocol 2 +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_ecdsa_key +HostKey /etc/ssh/ssh_host_ed25519_key + +PermitRootLogin yes +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM no +AuthorizedKeysFile .ssh/authorized_keys +PermitEmptyPasswords no +AcceptEnv TERM_PROGRAM TERM_PROGRAM_VERSION COLORTERM + +X11Forwarding no +AllowTcpForwarding yes +AllowStreamLocalForwarding yes +StreamLocalBindUnlink yes +GatewayPorts no +PermitTunnel no +ClientAliveInterval 30 +ClientAliveCountMax 2 +PrintMotd no +PidFile /run/sshd.pid +Subsystem sftp /usr/lib/ssh/sftp-server diff --git a/tests/fixtures/ssh-remote/ws_echo.py b/tests/fixtures/ssh-remote/ws_echo.py new file mode 100644 index 00000000..4acb8935 --- /dev/null +++ b/tests/fixtures/ssh-remote/ws_echo.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Tiny WebSocket echo server for SSH proxy integration tests.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import socket +import struct +import threading + + +GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +def _recv_exact(conn: socket.socket, n: int) -> bytes: + data = bytearray() + while len(data) < n: + chunk = conn.recv(n - len(data)) + if not chunk: + raise ConnectionError("unexpected EOF") + data.extend(chunk) + return bytes(data) + + +def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> bytes: + data = bytearray() + while marker not in data: + chunk = conn.recv(1024) + if not chunk: + raise ConnectionError("unexpected EOF while reading headers") + data.extend(chunk) + if len(data) > limit: + raise ValueError("header too large") + return bytes(data) + + +def _read_frame(conn: socket.socket) -> tuple[int, bytes]: + first, second = _recv_exact(conn, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(conn, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(conn, 8))[0] + + mask_key = _recv_exact(conn, 4) if masked else b"" + payload = _recv_exact(conn, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + +def _send_frame(conn: socket.socket, opcode: int, payload: bytes) -> None: + first = 0x80 | (opcode & 0x0F) + length = len(payload) + if length < 126: + header = bytes([first, length]) + elif length <= 0xFFFF: + header = bytes([first, 126]) + struct.pack("!H", length) + else: + header = bytes([first, 127]) + struct.pack("!Q", length) + conn.sendall(header + payload) + + +def handle_client(conn: socket.socket) -> None: + try: + request = _recv_until(conn, b"\r\n\r\n") + headers_raw = request.decode("utf-8", errors="replace").split("\r\n") + header_map: dict[str, str] = {} + for line in headers_raw[1:]: + if not line or ":" not in line: + continue + k, v = line.split(":", 1) + header_map[k.strip().lower()] = v.strip() + + key = header_map.get("sec-websocket-key", "") + upgrade = header_map.get("upgrade", "").lower() + connection_hdr = header_map.get("connection", "").lower() + if not key or upgrade != "websocket" or "upgrade" not in connection_hdr: + conn.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") + return + + accept = base64.b64encode(hashlib.sha1((key + GUID).encode("utf-8")).digest()).decode("ascii") + response = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n" + "\r\n" + ) + conn.sendall(response.encode("utf-8")) + + while True: + opcode, payload = _read_frame(conn) + if opcode == 0x8: # close + _send_frame(conn, 0x8, b"") + return + if opcode == 0x9: # ping + _send_frame(conn, 0xA, payload) + continue + if opcode == 0x1: # text + _send_frame(conn, 0x1, payload) + continue + # ignore all other opcodes + finally: + try: + conn.close() + except Exception: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description="WebSocket echo server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=43174) + args = parser.parse_args() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((args.host, args.port)) + server.listen(16) + while True: + conn, _ = server.accept() + thread = threading.Thread(target=handle_client, args=(conn,), daemon=True) + thread.start() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py index b48419f2..00499ce0 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -def run(cli_path: str, *args: str) -> tuple[int, str, str]: - proc = subprocess.run( - [cli_path, *args], - text=True, - capture_output=True, - check=False, - ) +def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout:.1f}s" return proc.returncode, proc.stdout.strip(), proc.stderr.strip() diff --git a/tests/test_remote_daemon_release_assets.sh b/tests/test_remote_daemon_release_assets.sh new file mode 100755 index 00000000..8495d835 --- /dev/null +++ b/tests/test_remote_daemon_release_assets.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-test.XXXXXX")" +trap 'rm -rf "$OUTPUT_DIR"' EXIT + +"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \ + --version "0.62.0-test" \ + --release-tag "v0.62.0-test" \ + --repo "manaflow-ai/cmux" \ + --output-dir "$OUTPUT_DIR" >/dev/null + +for asset in \ + cmuxd-remote-darwin-arm64 \ + cmuxd-remote-darwin-amd64 \ + cmuxd-remote-linux-arm64 \ + cmuxd-remote-linux-amd64 \ + cmuxd-remote-checksums.txt \ + cmuxd-remote-manifest.json +do + if [[ ! -f "$OUTPUT_DIR/$asset" ]]; then + echo "FAIL: missing asset $asset" >&2 + exit 1 + fi +done + +python3 - <<'PY' "$OUTPUT_DIR/cmuxd-remote-manifest.json" "$OUTPUT_DIR/cmuxd-remote-checksums.txt" +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +checksums_path = Path(sys.argv[2]) +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + +expected_targets = { + ("darwin", "arm64"), + ("darwin", "amd64"), + ("linux", "arm64"), + ("linux", "amd64"), +} +actual_targets = {(entry["goOS"], entry["goArch"]) for entry in manifest["entries"]} +if actual_targets != expected_targets: + raise SystemExit(f"FAIL: manifest targets {sorted(actual_targets)} != {sorted(expected_targets)}") + +if manifest["appVersion"] != "0.62.0-test": + raise SystemExit(f"FAIL: unexpected appVersion {manifest['appVersion']}") +if manifest["releaseTag"] != "v0.62.0-test": + raise SystemExit(f"FAIL: unexpected releaseTag {manifest['releaseTag']}") +if not manifest["checksumsURL"].endswith("/cmuxd-remote-checksums.txt"): + raise SystemExit(f"FAIL: unexpected checksumsURL {manifest['checksumsURL']}") + +checksum_lines = [line for line in checksums_path.read_text(encoding="utf-8").splitlines() if line.strip()] +if len(checksum_lines) != 4: + raise SystemExit(f"FAIL: expected 4 checksum lines, got {len(checksum_lines)}") + +for entry in manifest["entries"]: + if not entry["downloadURL"].endswith("/" + entry["assetName"]): + raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}") + if len(entry["sha256"]) != 64: + raise SystemExit(f"FAIL: invalid sha256 for {entry['assetName']}") + +print("PASS: remote daemon release assets include all targets and manifest entries") +PY diff --git a/tests/test_sidebar_copy_ssh_error_context_menu.py b/tests/test_sidebar_copy_ssh_error_context_menu.py new file mode 100644 index 00000000..52b3a6f3 --- /dev/null +++ b/tests/test_sidebar_copy_ssh_error_context_menu.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + if not content_view_path.exists(): + print(f"FAIL: missing expected file: {content_view_path}") + return 1 + + content = content_view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private var copyableSidebarSSHError: String?", + "Missing sidebar SSH error extraction helper", + failures, + ) + require( + content, + 'tab.statusEntries["remote.error"]?.value', + "Missing remote.error status fallback for copyable SSH error text", + failures, + ) + require( + content, + "if let copyableSidebarSSHError {", + "Copy SSH Error menu entry is no longer conditionally gated", + failures, + ) + require( + content, + 'Button("Copy SSH Error")', + "Missing Copy SSH Error context menu button", + failures, + ) + require( + content, + "copyTextToPasteboard(copyableSidebarSSHError)", + "Copy SSH Error button no longer writes the resolved error text", + failures, + ) + + if failures: + print("FAIL: sidebar copy SSH error context-menu regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: sidebar Copy SSH Error context menu wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py new file mode 100644 index 00000000..e09741fd --- /dev/null +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def _merged_output(proc: subprocess.CompletedProcess[str]) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def main() -> int: + cli = _find_cli_binary() + + # Global --version should be handled before socket command dispatch. + version_proc = _run([cli, "--version"]) + version_out = _merged_output(version_proc).lower() + _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") + _must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}") + + # Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path + # when CMUX_SOCKET_PATH is not set. + hint_backup: str | None = None + hint_had_file = LAST_SOCKET_HINT_PATH.exists() + if hint_had_file: + hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") + try: + LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") + auto_env = dict(os.environ) + auto_env.pop("CMUX_SOCKET_PATH", None) + auto_ping = _run([cli, "ping"], env=auto_env) + auto_ping_out = _merged_output(auto_ping).lower() + _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") + _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") + finally: + try: + if hint_had_file: + LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") + else: + LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) + except OSError: + pass + + # Global --password should parse as a flag (not a command name) and still allow non-password sockets. + ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"]) + ping_out = _merged_output(ping_proc).lower() + _must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}") + _must("pong" in ping_out, f"ping should still return pong: {ping_out!r}") + + # V1 errors must produce non-zero exit codes for automation correctness. + bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"]) + bad_out = _merged_output(bad_focus).lower() + _must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}") + _must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}") + + print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_ls_scrollback.py b/tests_v2/test_pane_resize_preserves_ls_scrollback.py new file mode 100644 index 00000000..0eb450d2 --- /dev/null +++ b/tests_v2/test_pane_resize_preserves_ls_scrollback.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Regression: `ls` output remains in scrollback after pane.resize.""" + +from __future__ import annotations + +import os +import re +import secrets +import shlex +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + out: list[tuple[str, bool, int]] = [] + for row in payload.get("panes") or []: + out.append(( + str(row.get("id") or ""), + bool(row.get("focused")), + int(row.get("surface_count") or 0), + )) + return out + + +def _focused_pane_id(client: cmux, workspace_id: str) -> str: + for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id): + if focused: + return pane_id + raise cmuxError("No focused pane found") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + text = _surface_scrollback_text(client, workspace_id, surface_id) + lines = [_clean_line(raw) for raw in text.splitlines()] + return token in lines + + +def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + +def _has_exact_marker_lines( + client: cmux, + workspace_id: str, + surface_id: str, + start_marker: str, + end_marker: str, +) -> bool: + text = _surface_scrollback_text(client, workspace_id, surface_id) + lines = [_clean_line(raw) for raw in text.splitlines()] + return start_marker in lines and end_marker in lines + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def _extract_segment_lines( + text: str, + start_marker: str, + end_marker: str, + *, + require_end: bool = True, +) -> list[str]: + lines = text.splitlines() + saw_start = False + saw_end = False + out: list[str] = [] + for raw in lines: + line = _clean_line(raw) + if not saw_start: + if line == start_marker: + saw_start = True + continue + if line == end_marker: + saw_end = True + break + if line: + out.append(line) + + if not saw_start: + raise cmuxError(f"start marker not found in scrollback: {start_marker}") + if require_end and not saw_end: + raise cmuxError(f"end marker not found in scrollback: {end_marker}") + return out + + +def _run_once(socket_path: str) -> int: + workspace_id = "" + fixture_dir = Path(tempfile.mkdtemp(prefix="cmux-ls-resize-regression-")) + try: + with cmux(socket_path) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + expected_names = [f"entry-{index:04d}.txt" for index in range(1, 241)] + for name in expected_names: + (fixture_dir / name).write_text(name + "\n", encoding="utf-8") + + start_marker = f"CMUX_LS_SCROLLBACK_START_{secrets.token_hex(4)}" + end_marker = f"CMUX_LS_SCROLLBACK_END_{secrets.token_hex(4)}" + fixture_arg = shlex.quote(str(fixture_dir)) + run_ls = ( + f"cd {fixture_arg}; " + f"echo {start_marker}; " + f"LC_ALL=C CLICOLOR=0 ls -1; " + f"echo {end_marker}" + ) + client.send_surface(surface_id, run_ls + "\n") + _wait_for( + lambda: _has_exact_marker_lines(client, workspace_id, surface_id, start_marker, end_marker), + timeout_s=12.0, + ) + + pre_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) + pre_lines = _extract_segment_lines(pre_resize_scrollback, start_marker, end_marker) + expected_set = set(expected_names) + pre_found = [line for line in pre_lines if line in expected_set] + _must( + len(set(pre_found)) == len(expected_set), + f"pre-resize ls output incomplete: found={len(set(pre_found))} expected={len(expected_set)}", + ) + + split_payload = client._call( + "surface.split", + {"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"}, + ) or {} + _must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}") + _wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0) + + client.focus_surface(surface_id) + time.sleep(0.1) + panes = _workspace_panes(client, workspace_id) + pane_ids = [pid for pid, _focused, _surface_count in panes] + pane_id = _focused_pane_id(client, workspace_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + pre_extent = _pane_extent(client, pane_id, resize_axis) + + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": resize_direction, + "amount": 120, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=6.0) + + post_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) + # Prompt redraw after resize may repaint over trailing marker rows. + # The regression condition is loss of ls output entries. + post_lines = _extract_segment_lines( + post_resize_scrollback, + start_marker, + end_marker, + require_end=False, + ) + post_found = [line for line in post_lines if line in expected_set] + _must( + len(set(post_found)) == len(expected_set), + "post-resize ls output lost entries from scrollback", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: ls output remains fully present in scrollback after pane.resize") + return 0 + finally: + if workspace_id: + try: + with cmux(socket_path) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + shutil.rmtree(fixture_dir, ignore_errors=True) + + +def main() -> int: + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + recoverable = ( + "Failed to connect", + "Socket not found", + ) + if not any(token in text for token in recoverable): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py new file mode 100644 index 00000000..ea175d0c --- /dev/null +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Regression: pane.resize preserves terminal content drawn before resize.""" + +from __future__ import annotations + +import os +import re +import secrets +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + out: list[tuple[str, bool, int]] = [] + for row in payload.get("panes") or []: + out.append(( + str(row.get("id") or ""), + bool(row.get("focused")), + int(row.get("surface_count") or 0), + )) + return out + + +def _focused_pane_id(client: cmux, workspace_id: str) -> str: + for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id): + if focused: + return pane_id + raise cmuxError("No focused pane found") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + text = _surface_scrollback_text(client, workspace_id, surface_id) + return [_clean_line(raw) for raw in text.splitlines()] + + +def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + return token in _surface_scrollback_lines(client, workspace_id, surface_id) + + +def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def _run_once(socket_path: str) -> int: + workspace_id = "" + try: + with cmux(socket_path) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + stamp = secrets.token_hex(4) + resize_lines = [f"CMUX_LOCAL_RESIZE_LINE_{stamp}_{index:02d}" for index in range(1, 33)] + clear_and_draw = ( + "clear; " + f"for i in $(seq 1 {len(resize_lines)}); do " + "n=$(printf '%02d' \"$i\"); " + f"echo CMUX_LOCAL_RESIZE_LINE_{stamp}_$n; " + "done" + ) + client.send_surface(surface_id, f"{clear_and_draw}\n") + _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, resize_lines[-1]), timeout_s=8.0) + pre_resize_scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + pre_resize_anchors = [line for line in (resize_lines[0], resize_lines[-1]) if line in pre_resize_scrollback_lines] + _must( + len(pre_resize_anchors) == 2, + f"pre-resize scrollback missing anchor lines: anchors={pre_resize_anchors}", + ) + + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in resize_lines if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 4, + f"pre-resize viewport did not contain enough lines: {pre_visible_lines}", + ) + + split_payload = client._call( + "surface.split", + {"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"}, + ) or {} + _must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}") + _wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0) + + client.focus_surface(surface_id) + time.sleep(0.1) + panes = _workspace_panes(client, workspace_id) + pane_ids = [pid for pid, _focused, _surface_count in panes] + pane_id = _focused_pane_id(client, workspace_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + pre_extent = _pane_extent(client, pane_id, resize_axis) + + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": resize_direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=5.0) + + post_resize_visible = client.read_terminal_text(surface_id) + visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] + _must( + bool(visible_overlap), + f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", + ) + + post_token = f"CMUX_LOCAL_RESIZE_POST_{stamp}" + client.send_surface(surface_id, f"echo {post_token}\n") + _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, post_token), timeout_s=8.0) + + scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in scrollback_lines for anchor in pre_resize_anchors), + "terminal scrollback lost pre-resize lines after pane resize", + ) + _must( + post_token in scrollback_lines, + "terminal scrollback missing post-resize token after pane resize", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: pane.resize preserves pre-resize visible content and scrollback anchors") + return 0 + finally: + if workspace_id: + try: + with cmux(socket_path) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + +def main() -> int: + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + recoverable = ( + "Failed to connect", + "Socket not found", + ) + if not any(token in text for token in recoverable): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py index a60055fa..e7ea1b94 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> return proc.stdout.strip() -def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: - payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return str(row.get("title") or "") - raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") - - def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -82,7 +74,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - c._call( + socket_payload = c._call( "tab.action", { "workspace_id": ws_id, @@ -91,14 +83,20 @@ def main() -> int: "title": socket_title, }, ) - _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + _must( + str((socket_payload or {}).get("title") or "") == socket_title, + f"tab.action rename response missing requested title: {socket_payload}", + ) cli_title = f"cli rename {stamp}" - _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) - _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must( + "action=rename" in cli_out.lower() and "tab=" in cli_out.lower(), + f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}", + ) env_title = f"env rename {stamp}" - _run_cli( + env_out = _run_cli( cli, ["rename-tab", env_title], env={ @@ -106,7 +104,10 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + _must( + "action=rename" in env_out.lower() and "tab=" in env_out.lower(), + f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}", + ) invalid = subprocess.run( [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], diff --git a/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py new file mode 100644 index 00000000..28bdcd67 --- /dev/null +++ b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Regression: moving a browser surface into an SSH workspace must rebind remote proxy state.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 60.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for remote terminal token: {token}") + + +def _browser_body_text(client: cmux, surface_id: str) -> str: + payload = client._call( + "browser.eval", + { + "surface_id": surface_id, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + return str(payload.get("value") or "") + + +def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") + + +def _assert_browser_does_not_contain(client: cmux, surface_id: str, token: str, sample_window_s: float = 6.0) -> str: + deadline = time.time() + sample_window_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + raise cmuxError( + f"browser unexpectedly loaded remote marker before SSH proxy rebind; token={token!r} body={last_text[:240]!r}" + ) + time.sleep(0.2) + return last_text + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote browser move/proxy regression") + return 0 + + cli = _find_cli_binary() + remote_workspace_id = "" + remote_surface_id = "" + + stamp = secrets.token_hex(4) + marker_file = f"CMUX_REMOTE_PROXY_MOVE_{stamp}.txt" + marker_body = f"CMUX_REMOTE_PROXY_BODY_{stamp}" + ready_token = f"CMUX_HTTP_READY_{stamp}" + default_web_port = 20000 + (os.getpid() % 5000) + ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port))) + url = f"http://localhost:{ssh_web_port}/{marker_file}" + + try: + with cmux(SOCKET_PATH) as client: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + browser_surface_id = client.open_browser("about:blank") + _must(bool(browser_surface_id), "browser.open_split returned no surface") + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-move-proxy-{stamp}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + remote_status = _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) + remote_payload = remote_status.get("remote") or {} + forwarded_ports = remote_payload.get("forwarded_ports") or [] + _must( + forwarded_ports == [], + f"remote workspace should rely on proxy endpoint, not explicit forwarded ports: {forwarded_ports!r}", + ) + + surfaces = client.list_surfaces(remote_workspace_id) + _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") + remote_surface_id = str(surfaces[0][1]) + + server_script = ( + f"printf '%s\\n' {marker_body} > /tmp/{marker_file}; " + f"python3 -m http.server {ssh_web_port} --directory /tmp >/tmp/cmux-remote-browser-proxy-{stamp}.log 2>&1 & " + "for _ in $(seq 1 30); do " + f" if curl -fsS http://localhost:{ssh_web_port}/{marker_file} | grep -q {marker_body}; then " + f" echo {ready_token}; " + " break; " + " fi; " + " sleep 0.2; " + "done" + ) + client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, + ) + client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + _wait_surface_contains(client, remote_workspace_id, remote_surface_id, ready_token, timeout_s=12.0) + + browser_surface_id = str(client._resolve_surface_id(browser_surface_id)) + client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) + local_body = _assert_browser_does_not_contain(client, browser_surface_id, marker_body, sample_window_s=5.0) + _must( + marker_body not in local_body, + f"browser should not reach remote localhost before moving into ssh workspace: {local_body[:240]!r}", + ) + + client.move_surface(browser_surface_id, workspace=remote_workspace_id, focus=True) + + def _browser_in_remote_workspace() -> bool: + for _idx, sid, _focused in client.list_surfaces(remote_workspace_id): + if str(sid) == browser_surface_id: + return True + return False + + _wait_for(_browser_in_remote_workspace, timeout_s=10.0, step_s=0.15) + + client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) + _wait_browser_contains(client, browser_surface_id, marker_body, timeout_s=20.0) + + body = _browser_body_text(client, browser_surface_id) + _must(marker_body in body, f"browser did not load remote localhost content over SSH proxy: {body[:240]!r}") + _must("Can't reach this page" not in body, f"browser rendered local error page instead of remote content: {body[:240]!r}") + + print( + "PASS: browser proxy stays scoped to SSH workspace surfaces, uses proxy endpoint without explicit forwarded ports, " + "and reaches remote localhost after move" + ) + return 0 + finally: + if remote_surface_id and remote_workspace_id: + try: + cleanup = f"pkill -f 'python3 -m http.server {ssh_web_port}' >/dev/null 2>&1 || true" + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, + ) + cleanup_client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + except Exception: # noqa: BLE001 + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py new file mode 100644 index 00000000..0b3aabfc --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -0,0 +1,630 @@ +#!/usr/bin/env python3 +"""Regression: `cmux ssh` creates a remote-tagged workspace with remote metadata.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str], *, json_output: bool, extra_env: dict[str, str] | None = None) -> str: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + if extra_env: + env.update(extra_env) + + cmd = [cli, "--socket", SOCKET_PATH] + if json_output: + cmd.append("--json") + cmd.extend(args) + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def _run_cli_json(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> dict: + output = _run_cli(cli, args, json_output=True, extra_env=extra_env) + try: + return json.loads(output or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") + + +def _extract_control_path(ssh_command: str) -> str: + match = re.search(r"ControlPath=([^\s]+)", ssh_command) + return match.group(1) if match else "" + + +def _has_ssh_option_key(options: list[str], key: str) -> bool: + lowered_key = key.lower() + for option in options: + token = re.split(r"[=\s]+", str(option).strip(), maxsplit=1)[0].strip().lower() + if token == lowered_key: + return True + return False + + +def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None: + deadline = time.time() + timeout + last_exc: Exception | None = None + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + for _, surface_id, _ in surfaces: + try: + return client.read_terminal_text(surface_id) + except cmuxError as exc: + text = str(exc).lower() + if "terminal surface not found" in text: + last_exc = exc + continue + raise + time.sleep(0.1) + print(f"WARN: readable terminal surface unavailable in workspace {workspace_id}; skipping transcript assertion ({last_exc})") + return None + + +def _resolve_workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_ref.startswith("workspace:"): + return "" + + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _append_workspace_to_cleanup(workspaces_to_close: list[str], workspace_id: str) -> str: + if workspace_id: + workspaces_to_close.append(workspace_id) + return workspace_id + + +def main() -> int: + cli = _find_cli_binary() + help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) + _must("cmux ssh" in help_text, "ssh --help output should include command header") + _must("Create a new workspace" in help_text, "ssh --help output should describe workspace creation") + + workspace_id = "" + workspace_id_without_name = "" + workspace_id_strict_override = "" + workspace_id_case_override = "" + workspace_id_invalid_proxy_port = "" + workspaces_to_close: list[str] = [] + with cmux(SOCKET_PATH) as client: + try: + payload = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-test"], + ) + workspace_id = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload), + ) + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + selected_workspace_id = "" + deadline_select = time.time() + 5.0 + while time.time() < deadline_select: + try: + selected_workspace_id = client.current_workspace() + except cmuxError: + time.sleep(0.05) + continue + if selected_workspace_id == workspace_id: + break + time.sleep(0.05) + _must( + selected_workspace_id == workspace_id, + f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}", + ) + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}" + ssh_command = str(payload.get("ssh_command") or "") + _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") + _must( + ssh_command.startswith("ssh "), + f"cmux ssh should emit plain ssh command text (env is passed via workspace.create initial_env): {ssh_command!r}", + ) + ssh_startup_command = str(payload.get("ssh_startup_command") or "") + _must( + ssh_startup_command.startswith("/bin/zsh -ilc "), + f"cmux ssh should launch startup command via interactive zsh for shell integration: {ssh_startup_command!r}", + ) + ssh_env_overrides = payload.get("ssh_env_overrides") or {} + _must( + str(ssh_env_overrides.get("GHOSTTY_SHELL_FEATURES") or "").endswith("ssh-env,ssh-terminfo"), + f"cmux ssh should pass shell niceties via ssh_env_overrides: {payload}", + ) + _must(not ssh_command.startswith("env "), f"ssh command should not include env prefix: {ssh_command!r}") + _must("-o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}") + _must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}") + _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") + _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") + _must( + ( + f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; " + f"export CMUX_SOCKET_PATH={remote_socket_addr}; " + "exec \"${SHELL:-/bin/zsh}\" -l" + ) in ssh_command, + f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}", + ) + + listed_row = None + deadline = time.time() + 8.0 + while time.time() < deadline: + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + listed_row = row + break + if listed_row is not None: + break + time.sleep(0.1) + + _must(listed_row is not None, f"workspace.list did not include {workspace_id}") + remote = listed_row.get("remote") or {} + _must(bool(remote.get("enabled")) is True, f"workspace should be marked remote-enabled: {listed_row}") + _must(str(remote.get("destination") or "") == "127.0.0.1", f"remote destination mismatch: {remote}") + _must(str(listed_row.get("title") or "") == "ssh-meta-test", f"workspace title mismatch: {listed_row}") + _must( + str(remote.get("state") or "") in {"connecting", "connected", "error", "disconnected"}, + f"unexpected remote state: {remote}", + ) + proxy = remote.get("proxy") or {} + _must( + str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"}, + f"remote payload should include proxy state metadata: {remote}", + ) + remote_ssh_options = [str(item) for item in (remote.get("ssh_options") or [])] + _must( + _has_ssh_option_key(remote_ssh_options, "ControlMaster"), + f"workspace.remote.configure should include ControlMaster default: {remote}", + ) + _must( + _has_ssh_option_key(remote_ssh_options, "ControlPersist"), + f"workspace.remote.configure should include ControlPersist default: {remote}", + ) + _must( + _has_ssh_option_key(remote_ssh_options, "ControlPath"), + f"workspace.remote.configure should include ControlPath default: {remote}", + ) + # Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell. + terminal_text = _read_any_terminal_text(client, workspace_id) + if terminal_text is not None: + _must("ControlPersist=600" not in terminal_text, f"cmux ssh should not inject raw ssh command text: {terminal_text!r}") + _must("GHOSTTY_SHELL_FEATURES=" not in terminal_text, f"cmux ssh should not inject env assignment text: {terminal_text!r}") + + status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + status_remote = status.get("remote") or {} + _must(bool(status_remote.get("enabled")) is True, f"workspace.remote.status should report enabled remote: {status}") + daemon = status_remote.get("daemon") or {} + _must( + str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"}, + f"workspace.remote.status should include daemon state metadata: {status_remote}", + ) + # Fail-fast regression: unreachable SSH target should surface bootstrap error explicitly. + deadline_daemon = time.time() + 12.0 + last_status = status + while time.time() < deadline_daemon: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + if str(last_daemon.get("state") or "") == "error": + break + time.sleep(0.2) + else: + raise cmuxError(f"unreachable host should drive daemon state to error: {last_status}") + + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + detail = str(last_daemon.get("detail") or "") + _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") + _must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}") + + # Lifecycle regression: disconnect with clear should reset remote/daemon metadata. + disconnected = client._call( + "workspace.remote.disconnect", + {"workspace_id": workspace_id, "clear": True}, + ) or {} + disconnected_remote = disconnected.get("remote") or {} + disconnected_daemon = disconnected_remote.get("daemon") or {} + _must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}") + _must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}") + _must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}") + try: + client._call("workspace.remote.reconnect", {"workspace_id": workspace_id}) + raise cmuxError("workspace.remote.reconnect should fail when remote config was cleared") + except cmuxError as exc: + text = str(exc).lower() + _must("invalid_state" in text, f"workspace.remote.reconnect missing invalid_state for cleared config: {exc}") + _must("not configured" in text, f"workspace.remote.reconnect should explain missing remote config: {exc}") + + # Regression: --name is optional. + payload2 = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1"], + ) + workspace_id_without_name = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload2), + ) + ssh_command_without_name = str(payload2.get("ssh_command") or "") + + _must(bool(workspace_id_without_name), f"cmux ssh without --name should still create workspace: {payload2}") + _must( + "ControlPath=/tmp/cmux-ssh-" in ssh_command_without_name, + f"cmux ssh without --name should still include control path defaults: {ssh_command_without_name!r}", + ) + _must( + _extract_control_path(ssh_command) != _extract_control_path(ssh_command_without_name), + f"distinct cmux ssh workspaces should get distinct control paths: {ssh_command!r} vs {ssh_command_without_name!r}", + ) + row2 = None + listed2 = client._call("workspace.list", {}) or {} + for row in listed2.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id_without_name: + row2 = row + break + _must(row2 is not None, f"workspace created without --name missing from workspace.list: {workspace_id_without_name}") + _must(bool(str((row2 or {}).get("title") or "").strip()), f"workspace title should not be empty without --name: {row2}") + reconnected = client._call("workspace.remote.reconnect", {"workspace_id": workspace_id_without_name}) or {} + reconnected_remote = reconnected.get("remote") or {} + _must(bool(reconnected_remote.get("enabled")) is True, f"workspace.remote.reconnect should keep remote enabled: {reconnected}") + _must( + str(reconnected_remote.get("state") or "") in {"connecting", "connected", "error"}, + f"workspace.remote.reconnect should transition into an active state: {reconnected}", + ) + + payload_strict_override = _run_cli_json( + cli, + [ + "ssh", + "127.0.0.1", + "--port", + "1", + "--name", + "ssh-meta-strict-override", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id_strict_override = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload_strict_override), + ) + _must( + bool(workspace_id_strict_override), + f"cmux ssh with StrictHostKeyChecking override should create workspace: {payload_strict_override}", + ) + ssh_command_strict_override = str(payload_strict_override.get("ssh_command") or "") + _must( + "-o StrictHostKeyChecking=no" in ssh_command_strict_override, + f"ssh command should include user StrictHostKeyChecking override: {ssh_command_strict_override!r}", + ) + _must( + "-o StrictHostKeyChecking=accept-new" not in ssh_command_strict_override, + f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}", + ) + strict_override_remote = payload_strict_override.get("remote") or {} + strict_override_options = [str(item) for item in (strict_override_remote.get("ssh_options") or [])] + _must( + any(item.lower() == "stricthostkeychecking=no" for item in strict_override_options), + f"workspace.remote.configure should preserve explicit StrictHostKeyChecking override: {strict_override_remote}", + ) + + payload_case_override = _run_cli_json( + cli, + [ + "ssh", + "127.0.0.1", + "--port", + "1", + "--name", + "ssh-meta-case-override", + "--ssh-option", + "stricthostkeychecking=no", + "--ssh-option", + "controlmaster=no", + "--ssh-option", + "controlpersist=0", + "--ssh-option", + "controlpath=/tmp/cmux-ssh-%C-custom", + ], + ) + workspace_id_case_override = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload_case_override), + ) + _must( + bool(workspace_id_case_override), + f"cmux ssh with lowercase SSH option overrides should create workspace: {payload_case_override}", + ) + ssh_command_case_override = str(payload_case_override.get("ssh_command") or "") + ssh_command_case_override_lower = ssh_command_case_override.lower() + _must( + "-o stricthostkeychecking=no" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase StrictHostKeyChecking override: {ssh_command_case_override!r}", + ) + _must( + "stricthostkeychecking=accept-new" not in ssh_command_case_override_lower, + f"ssh command should not force default StrictHostKeyChecking when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "-o controlmaster=no" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlMaster override: {ssh_command_case_override!r}", + ) + _must( + "controlmaster=auto" not in ssh_command_case_override_lower, + f"ssh command should not force default ControlMaster when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "-o controlpersist=0" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlPersist override: {ssh_command_case_override!r}", + ) + _must( + "controlpersist=600" not in ssh_command_case_override_lower, + f"ssh command should not force default ControlPersist when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "controlpath=/tmp/cmux-ssh-%c-custom" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlPath override value: {ssh_command_case_override!r}", + ) + _must( + ssh_command_case_override_lower.count("controlpath=") == 1, + f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}", + ) + case_override_remote = payload_case_override.get("remote") or {} + case_override_options = [str(item) for item in (case_override_remote.get("ssh_options") or [])] + _must( + any(item.lower() == "stricthostkeychecking=no" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase StrictHostKeyChecking override: {case_override_remote}", + ) + _must( + not any(item.lower() == "stricthostkeychecking=accept-new" for item in case_override_options), + f"workspace.remote.configure should not inject default StrictHostKeyChecking when lowercase override is supplied: {case_override_remote}", + ) + _must( + any(item.lower() == "controlmaster=no" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase ControlMaster override: {case_override_remote}", + ) + _must( + not any(item.lower() == "controlmaster=auto" for item in case_override_options), + f"workspace.remote.configure should not inject default ControlMaster when lowercase override is supplied: {case_override_remote}", + ) + _must( + any(item.lower() == "controlpersist=0" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase ControlPersist override: {case_override_remote}", + ) + _must( + not any(item.lower() == "controlpersist=600" for item in case_override_options), + f"workspace.remote.configure should not inject default ControlPersist when lowercase override is supplied: {case_override_remote}", + ) + _must( + any(item.lower() == "controlpath=/tmp/cmux-ssh-%c-custom" for item in case_override_options), + f"workspace.remote.configure should preserve lowercase ControlPath override: {case_override_remote}", + ) + _must( + sum(1 for item in case_override_options if item.lower().startswith("controlpath=")) == 1, + f"workspace.remote.configure should include exactly one ControlPath when lowercase override is supplied: {case_override_remote}", + ) + + payload3 = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-features"], + extra_env={"GHOSTTY_SHELL_FEATURES": "cursor,title"}, + ) + payload3_env = payload3.get("ssh_env_overrides") or {} + merged_features = str(payload3_env.get("GHOSTTY_SHELL_FEATURES") or "") + _must( + merged_features == "cursor,title,ssh-env,ssh-terminfo", + f"cmux ssh should merge existing shell features when present: {payload3!r}", + ) + workspace_id3 = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload3), + ) + if workspace_id3: + try: + client.close_workspace(workspace_id3) + except Exception: + pass + + invalid_proxy_port_workspace = client._call("workspace.create", {"initial_command": "echo invalid-local-proxy-port"}) or {} + workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "") + if workspace_id_invalid_proxy_port: + workspaces_to_close.append(workspace_id_invalid_proxy_port) + _must(bool(workspace_id_invalid_proxy_port), f"workspace.create missing workspace_id: {invalid_proxy_port_workspace}") + + configured_with_string_ports = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": "2222", + "local_proxy_port": "31338", + "auto_connect": False, + }, + ) or {} + configured_with_string_ports_remote = configured_with_string_ports.get("remote") or {} + _must( + int(configured_with_string_ports_remote.get("port") or 0) == 2222, + f"workspace.remote.configure should parse numeric string port values: {configured_with_string_ports}", + ) + _must( + int(configured_with_string_ports_remote.get("local_proxy_port") or 0) == 31338, + f"workspace.remote.configure should parse numeric string local_proxy_port values: {configured_with_string_ports}", + ) + + valid_local_proxy_port = 31337 + configured_with_local_proxy_port = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": 2222, + "local_proxy_port": valid_local_proxy_port, + "auto_connect": False, + }, + ) or {} + configured_remote = configured_with_local_proxy_port.get("remote") or {} + _must( + int(configured_remote.get("port") or 0) == 2222, + f"workspace.remote.configure should echo explicit port in remote payload: {configured_with_local_proxy_port}", + ) + _must( + int(configured_remote.get("local_proxy_port") or 0) == valid_local_proxy_port, + f"workspace.remote.configure should echo local_proxy_port in remote payload: {configured_with_local_proxy_port}", + ) + + configured_with_null_ports = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": None, + "local_proxy_port": None, + "auto_connect": False, + }, + ) or {} + configured_with_null_ports_remote = configured_with_null_ports.get("remote") or {} + _must( + configured_with_null_ports_remote.get("port") is None, + f"workspace.remote.configure should allow null to clear port: {configured_with_null_ports}", + ) + _must( + configured_with_null_ports_remote.get("local_proxy_port") is None, + f"workspace.remote.configure should allow null to clear local_proxy_port: {configured_with_null_ports}", + ) + status_after_null_ports = client._call( + "workspace.remote.status", + {"workspace_id": workspace_id_invalid_proxy_port}, + ) or {} + status_after_null_ports_remote = status_after_null_ports.get("remote") or {} + _must( + status_after_null_ports_remote.get("port") is None, + f"workspace.remote.status should reflect cleared port: {status_after_null_ports}", + ) + _must( + status_after_null_ports_remote.get("local_proxy_port") is None, + f"workspace.remote.status should reflect cleared local_proxy_port: {status_after_null_ports}", + ) + + for invalid_local_proxy_port in [0, 65536, "abc", True, 22.5]: + try: + client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "local_proxy_port": invalid_local_proxy_port, + "auto_connect": False, + }, + ) + raise cmuxError( + f"workspace.remote.configure should reject local_proxy_port={invalid_local_proxy_port!r}" + ) + except cmuxError as exc: + text = str(exc) + lowered = text.lower() + _must( + "invalid_params" in lowered, + f"workspace.remote.configure should return invalid_params for local_proxy_port={invalid_local_proxy_port!r}: {exc}", + ) + _must( + "local_proxy_port must be 1-65535" in text, + f"workspace.remote.configure should include validation hint for local_proxy_port={invalid_local_proxy_port!r}: {exc}", + ) + + for invalid_port in [0, 65536, "abc", True, 22.5]: + try: + client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": invalid_port, + "auto_connect": False, + }, + ) + raise cmuxError( + f"workspace.remote.configure should reject port={invalid_port!r}" + ) + except cmuxError as exc: + text = str(exc) + lowered = text.lower() + _must( + "invalid_params" in lowered, + f"workspace.remote.configure should return invalid_params for port={invalid_port!r}: {exc}", + ) + _must( + "port must be 1-65535" in text, + f"workspace.remote.configure should include validation hint for port={invalid_port!r}: {exc}", + ) + + try: + client.close_workspace(workspace_id_invalid_proxy_port) + except Exception: + pass + else: + workspace_id_invalid_proxy_port = "" + finally: + for workspace_id_to_close in dict.fromkeys(workspaces_to_close): + if not workspace_id_to_close: + continue + try: + client.close_workspace(workspace_id_to_close) + except Exception: + pass + + print("PASS: cmux ssh marks workspace as remote, exposes remote metadata, and does not require --name") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py new file mode 100644 index 00000000..53e01a95 --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +# Keep the fixture's extra HTTP server below 1024 so there are no eligible +# (>1023) ports to auto-forward. This guards the "connecting forever" regression. +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81")) + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + # Ensure --socket is what drives the relay path during tests. + env.pop("CMUX_SOCKET_PATH", None) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=5", + "-p", str(host_port), + "-i", str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + +def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: + ping_result = _ssh_run( + host, host_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_2 = "" + + try: + # Generate SSH key pair + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + # Build and start Docker container + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", "127.0.0.1::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = "root@127.0.0.1" + _wait_for_ssh(host, host_ssh_port, key_path) + + with cmux(SOCKET_PATH) as client: + # Create SSH workspace (this sets up the reverse socket forward) + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_relay_port = int(remote_relay_port) + _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") + remote_socket_addr = f"127.0.0.1:{remote_relay_port}" + startup_cmd = str(payload.get("ssh_startup_command") or "") + _must( + 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, + f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", + ) + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd, + f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}", + ) + workspace_window_id = payload.get("window_id") + current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} + current = client._call("workspace.current", current_params) or {} + current_workspace_id = str(current.get("workspace_id") or "") + _must( + current_workspace_id == workspace_id, + f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", + ) + + # Wait for daemon to be ready + first_status = _wait_for_remote_ready(client, workspace_id) + first_remote = first_status.get("remote") or {} + # Regression: should transition to connected even with no eligible + # (>1023, non-ephemeral) remote ports. + _must( + not (first_remote.get("detected_ports") or []), + f"expected no eligible detected ports in fixture: {first_status}", + ) + _must( + not (first_remote.get("forwarded_ports") or []), + f"expected no forwarded ports when none are eligible: {first_status}", + ) + + # Verify remote cmux wrapper + relay-specific daemon mapping were installed. + wrapper_check = None + wrapper_deadline = time.time() + 10.0 + while time.time() < wrapper_deadline: + wrapper_check = _ssh_run( + host, host_ssh_port, key_path, + f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && " + f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && " + "daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && " + "test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok", + check=False, + ) + if "wrapper-ok" in (wrapper_check.stdout or ""): + break + time.sleep(0.4) + _must( + wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""), + f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}", + ) + + # Start a second SSH workspace to the same destination and verify both + # relays remain healthy (regression: same-host workspaces killed each other). + payload_2 = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay-2", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_2 = str(payload_2.get("workspace_id") or "") + workspace_ref_2 = str(payload_2.get("workspace_ref") or "") + if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): + listed_2 = client._call("workspace.list", {}) or {} + for row in listed_2.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_2: + workspace_id_2 = str(row.get("id") or "") + break + _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") + + remote_relay_port_2 = payload_2.get("remote_relay_port") + _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") + remote_relay_port_2 = int(remote_relay_port_2) + _must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}") + _must( + remote_relay_port_2 != remote_relay_port, + f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", + ) + remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "") + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2, + f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}", + ) + _ = _wait_for_remote_ready(client, workspace_id_2) + + stability_deadline = time.time() + 8.0 + while time.time() < stability_deadline: + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") + time.sleep(0.5) + + # Test 1: cmux ping (v1) + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") + + # Test 2: cmux list-workspaces --json (v2) + list_ws_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces", + check=False, + ) + _must( + list_ws_result.returncode == 0, + f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}", + ) + try: + ws_data = json.loads(list_ws_result.stdout.strip()) + _must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}") + + # Test 3: cmux new-window (v1) + new_win_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window", + check=False, + ) + _must( + new_win_result.returncode == 0, + f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}", + ) + + # Test 4: cmux rpc system.capabilities (v2 passthrough) + rpc_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities", + check=False, + ) + _must( + rpc_result.returncode == 0, + f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}", + ) + try: + caps_data = json.loads(rpc_result.stdout.strip()) + _must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}") + + # Cleanup + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + if workspace_id_2: + try: + client.close_workspace(workspace_id_2) + except Exception: + pass + workspace_id_2 = "" + + print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + if workspace_id_2: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_2) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_daemon_resize_stdio.py b/tests_v2/test_ssh_remote_daemon_resize_stdio.py new file mode 100644 index 00000000..d11cb845 --- /dev/null +++ b/tests_v2/test_ssh_remote_daemon_resize_stdio.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Process-level integration: cmuxd-remote stdio session resize coordinator.""" + +from __future__ import annotations + +import json +import select +import shutil +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _daemon_module_dir() -> Path: + return Path(__file__).resolve().parents[1] / "daemon" / "remote" + + +def _rpc( + proc: subprocess.Popen[str], + req_id: int, + method: str, + params: dict, + *, + timeout_s: float = 5.0, +) -> dict: + if proc.stdin is None or proc.stdout is None: + raise cmuxError("daemon subprocess stdio pipes are not available") + + payload = {"id": req_id, "method": method, "params": params} + proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + proc.stdin.flush() + + deadline = time.time() + timeout_s + while time.time() < deadline: + wait_s = max(0.0, min(0.2, deadline - time.time())) + ready, _, _ = select.select([proc.stdout], [], [], wait_s) + if not ready: + continue + line = proc.stdout.readline() + if line == "": + stderr = "" + if proc.stderr is not None: + try: + stderr = proc.stderr.read().strip() + except Exception: + stderr = "" + raise cmuxError(f"cmuxd-remote exited while waiting for {method} response: {stderr}") + try: + resp = json.loads(line) + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON response for {method}: {line!r} ({exc})") + _must(resp.get("id") == req_id, f"Response id mismatch for {method}: {resp}") + return resp + + raise cmuxError(f"Timed out waiting for cmuxd-remote response: {method}") + + +def _as_int(value: object, field: str) -> int: + if isinstance(value, bool): + raise cmuxError(f"{field} should be numeric, got bool") + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}") + + +def _assert_effective(resp: dict, want_cols: int, want_rows: int, label: str) -> None: + _must(resp.get("ok") is True, f"{label} should return ok=true: {resp}") + result = resp.get("result") or {} + got_cols = _as_int(result.get("effective_cols"), "effective_cols") + got_rows = _as_int(result.get("effective_rows"), "effective_rows") + _must( + got_cols == want_cols and got_rows == want_rows, + f"{label} effective size mismatch: got {got_cols}x{got_rows}, want {want_cols}x{want_rows} ({resp})", + ) + + +def main() -> int: + if shutil.which("go") is None: + print("SKIP: go is not available") + return 0 + + daemon_dir = _daemon_module_dir() + _must(daemon_dir.is_dir(), f"Missing daemon module directory: {daemon_dir}") + + proc = subprocess.Popen( + ["go", "run", "./cmd/cmuxd-remote", "serve", "--stdio"], + cwd=str(daemon_dir), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + try: + hello = _rpc(proc, 1, "hello", {}) + _must(hello.get("ok") is True, f"hello should return ok=true: {hello}") + capabilities = {str(item) for item in ((hello.get("result") or {}).get("capabilities") or [])} + _must("session.basic" in capabilities, f"hello missing session.basic capability: {hello}") + _must("session.resize.min" in capabilities, f"hello missing session.resize.min capability: {hello}") + + open_resp = _rpc(proc, 2, "session.open", {"session_id": "sess-e2e"}) + _assert_effective(open_resp, 0, 0, "session.open") + + attach_small = _rpc( + proc, + 3, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-small", "cols": 90, "rows": 30}, + ) + _assert_effective(attach_small, 90, 30, "session.attach(a-small)") + + attach_large = _rpc( + proc, + 4, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 140, "rows": 50}, + ) + _assert_effective(attach_large, 90, 30, "session.attach(a-large)") + + resize_large = _rpc( + proc, + 5, + "session.resize", + {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 200, "rows": 80}, + ) + _assert_effective(resize_large, 90, 30, "session.resize(a-large)") + + detach_small = _rpc( + proc, + 6, + "session.detach", + {"session_id": "sess-e2e", "attachment_id": "a-small"}, + ) + _assert_effective(detach_small, 200, 80, "session.detach(a-small)") + + detach_large = _rpc( + proc, + 7, + "session.detach", + {"session_id": "sess-e2e", "attachment_id": "a-large"}, + ) + _assert_effective(detach_large, 200, 80, "session.detach(a-large)") + + reattach = _rpc( + proc, + 8, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-reconnect", "cols": 110, "rows": 40}, + ) + _assert_effective(reattach, 110, 40, "session.attach(a-reconnect)") + + status = _rpc(proc, 9, "session.status", {"session_id": "sess-e2e"}) + _assert_effective(status, 110, 40, "session.status") + attachments = (status.get("result") or {}).get("attachments") or [] + _must(len(attachments) == 1, f"session.status should report one active attachment after reattach: {status}") + + print("PASS: cmuxd-remote stdio session.resize coordinator enforces smallest-screen-wins semantics") + return 0 + finally: + try: + if proc.stdin is not None: + proc.stdin.close() + except Exception: + pass + try: + proc.terminate() + proc.wait(timeout=2.0) + except Exception: + try: + proc.kill() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py new file mode 100644 index 00000000..63162e76 --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Docker integration: remote daemon bootstrap must not depend on login-shell startup files.""" + +from __future__ import annotations + +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float = 45.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 {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last_status + time.sleep(0.5) + 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") + return 0 + + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-bootstrap-nonlogin-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-bootstrap-nonlogin-{secrets.token_hex(4)}" + workspace_id = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ] + ) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + # Regression fixture: a slow login profile that should not block non-interactive daemon bootstrap. + _ssh_run( + host, + host_ssh_port, + key_path, + """ +cat > "$HOME/.profile" <<'EOF' +sleep 15 +echo profile-sourced >&2 +EOF +chmod 0644 "$HOME/.profile" +""", + check=True, + ) + + with cmux(SOCKET_PATH) as client: + created = client._call("workspace.create", {"initial_command": "echo ssh-bootstrap-nonlogin"}) + workspace_id = str((created or {}).get("workspace_id") or "") + _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") + + configured = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id, + "destination": host, + "port": host_ssh_port, + "identity_file": str(key_path), + "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], + "auto_connect": True, + }, + ) + _must(bool(configured), "workspace.remote.configure returned empty response") + + status = _wait_for_remote_connected(client, workspace_id, timeout=45.0) + remote = status.get("remote") or {} + 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: + pass + workspace_id = "" + + print("PASS: remote daemon bootstrap remains healthy even when ~/.profile is slow") + return 0 + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py new file mode 100644 index 00000000..6661aa5c --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_forwarding.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH proxy endpoint via `cmux ssh`.""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +from base64 import b64encode +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174")) +MAX_REMOTE_DAEMON_SIZE_BYTES = int(os.environ.get("CMUX_SSH_TEST_MAX_DAEMON_SIZE_BYTES", "15000000")) +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + # docker port output form: "127.0.0.1:49154\n" or ":::\d+". + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _curl_via_socks(proxy_port: int, target_url: str) -> str: + if shutil.which("curl") is None: + raise cmuxError("curl is required for SOCKS proxy verification") + proc = _run( + [ + "curl", + "--silent", + "--show-error", + "--max-time", + "5", + "--socks5-hostname", + f"127.0.0.1:{proxy_port}", + target_url, + ], + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"curl via SOCKS proxy failed: {merged}") + return proc.stdout + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + out = bytearray() + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise cmuxError("unexpected EOF while reading socket") + out.extend(chunk) + return bytes(out) + + +def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: + out = bytearray() + while marker not in out: + chunk = sock.recv(1024) + if not chunk: + raise cmuxError("unexpected EOF while reading response headers") + out.extend(chunk) + if len(out) > limit: + raise cmuxError("response headers too large") + return bytes(out) + + +def _read_socks5_connect_reply(sock: socket.socket) -> None: + head = _recv_exact(sock, 4) + if len(head) != 4 or head[0] != 0x05: + raise cmuxError(f"invalid SOCKS5 reply: {head!r}") + if head[1] != 0x00: + raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") + + atyp = head[3] + if atyp == 0x01: + _ = _recv_exact(sock, 4) + elif atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln) + elif atyp == 0x04: + _ = _recv_exact(sock, 16) + else: + raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{atyp:02x}") + _ = _recv_exact(sock, 2) # bound port + + +def _read_http_response_from_connected_socket(sock: socket.socket) -> str: + response = _recv_until(sock, b"\r\n\r\n") + header_end = response.index(b"\r\n\r\n") + 4 + header_blob = response[:header_end] + body = bytearray(response[header_end:]) + header_text = header_blob.decode("utf-8", errors="replace") + + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") + + content_length: int | None = None + for line in header_text.split("\r\n")[1:]: + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except Exception: # noqa: BLE001 + content_length = None + break + + if content_length is not None: + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body.extend(chunk) + else: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + body.extend(chunk) + + return bytes(body).decode("utf-8", errors="replace") + + +def _http_get_on_connected_socket(sock: socket.socket, host: str, port: int, path: str = "/") -> str: + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + return _read_http_response_from_connected_socket(sock) + + +def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + + # greeting: no-auth only + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2) + if greeting != b"\x05\x00": + sock.close() + raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") + + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" # IPv4 + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + sock.close() + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" # domain + addr = bytes([len(host_encoded)]) + host_encoded + + req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + sock.sendall(req) + + try: + _read_socks5_connect_reply(sock) + except Exception: + sock.close() + raise + return sock + + +def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + try: + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + greeting = b"\x05\x01\x00" + connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + http_get = ( + "GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + + # Send greeting + CONNECT + first upstream payload in one write to exercise + # SOCKS request parsing when pending bytes already exist in the handshake buffer. + sock.sendall(greeting + connect_req + http_get) + + greeting_reply = _recv_exact(sock, 2) + if greeting_reply != b"\x05\x00": + raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") + _read_socks5_connect_reply(sock) + return _read_http_response_from_connected_socket(sock) + finally: + try: + sock.close() + except Exception: + pass + + +def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + request = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + sock.close() + raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") + return sock + + +def _encode_client_text_frame(payload: str) -> bytes: + data = payload.encode("utf-8") + first = 0x81 # FIN + text + mask = secrets.token_bytes(4) + length = len(data) + if length < 126: + header = bytes([first, 0x80 | length]) + elif length <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + return header + mask + masked + + +def _read_server_text_frame(sock: socket.socket) -> str: + first, second = _recv_exact(sock, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(sock, 8))[0] + mask = _recv_exact(sock, 4) if masked else b"" + payload = _recv_exact(sock, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + + if opcode != 0x1: + raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") + try: + return payload.decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") + + +def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: + ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET /echo HTTP/1.1\r\n" + f"Host: {ws_host}:{ws_port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "101" not in status_line: + raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") + + expected_accept = b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() + ).decode("ascii") + lowered_headers = { + line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() + for line in header_text.split("\r\n")[1:] + if ":" in line + } + if lowered_headers.get("sec-websocket-accept", "") != expected_accept: + raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") + + sock.sendall(_encode_client_text_frame(message)) + return _read_server_text_frame(sock) + + +def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _remote_binary_size_bytes(host: str, host_port: int, key_path: Path, remote_path: str) -> int: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +wc -c < "$full" +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + text = proc.stdout.strip().splitlines()[-1].strip() + return int(text) + + +def _extract_daemon_version_platform(remote_path: str) -> tuple[str, str]: + parts = [segment for segment in remote_path.strip().split("/") if segment] + try: + marker_index = parts.index("cmuxd-remote") + except ValueError as exc: + raise cmuxError(f"remote daemon path missing cmuxd-remote marker: {remote_path!r}") from exc + + required_len = marker_index + 4 + _must( + len(parts) >= required_len, + f"remote daemon path should include version/platform/binary: {remote_path!r}", + ) + version = parts[marker_index + 1] + platform = parts[marker_index + 2] + binary_name = parts[marker_index + 3] + _must(binary_name == "cmuxd-remote", f"unexpected daemon binary name in remote path: {remote_path!r}") + _must(bool(version), f"daemon version should not be empty in remote path: {remote_path!r}") + _must(bool(platform), f"daemon platform should not be empty in remote path: {remote_path!r}") + return version, platform + + +def _local_cached_daemon_binary(version: str, platform: str) -> Path: + return Path(tempfile.gettempdir()) / "cmux-remote-daemon-build" / version / platform / "cmuxd-remote" + + +def _local_file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _local_binary_contains_version_marker(path: Path, version: str) -> bool: + marker = version.encode("utf-8") + tail = b"" + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + return False + haystack = tail + chunk + if marker in haystack: + return True + tail = haystack[-max(len(marker) - 1, 0) :] + + +def _remote_binary_sha256(host: str, host_port: int, key_path: Path, remote_path: str) -> str: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$full" | awk '{{print $1}}' +elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$full" | awk '{{print $1}}' +else + openssl dgst -sha256 "$full" | awk '{{print $NF}}' +fi +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + digest = proc.stdout.strip().splitlines()[-1].strip().lower() + _must(len(digest) == 64 and all(ch in "0123456789abcdef" for ch in digest), f"invalid remote SHA256 digest: {digest!r}") + return digest + + +def _wait_connected_proxy_port(client: cmux, workspace_id: str, timeout: float = 45.0) -> tuple[dict, int]: + deadline = time.time() + timeout + last_status = {} + proxy_port: int | None = None + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + state = str(remote.get("state") or "") + proxy = remote.get("proxy") or {} + port_value = proxy.get("port") + if isinstance(port_value, int): + proxy_port = port_value + elif isinstance(port_value, str) and port_value.isdigit(): + proxy_port = int(port_value) + if state == "connected" and proxy_port is not None: + return last_status, proxy_port + time.sleep(0.5) + raise cmuxError(f"Remote proxy did not converge to connected state: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-docker-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-test-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_shared = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + fresh_check = _ssh_run( + host, + host_ssh_port, + key_path, + "test ! -e \"$HOME/.cmux/bin/cmuxd-remote\" && echo fresh", + check=True, + ) + _must("fresh" in fresh_check.stdout, "Fresh container should not have preinstalled cmuxd-remote") + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-ssh-forward", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + last_status, proxy_port = _wait_connected_proxy_port(client, workspace_id) + + daemon = ((last_status.get("remote") or {}).get("daemon") or {}) + _must(str(daemon.get("state") or "") == "ready", f"daemon should be ready in connected state: {last_status}") + capabilities = daemon.get("capabilities") or [] + _must("proxy.stream" in capabilities, f"daemon hello capabilities missing proxy.stream: {daemon}") + _must("proxy.socks5" in capabilities, f"daemon hello capabilities missing proxy.socks5: {daemon}") + _must("session.basic" in capabilities, f"daemon hello capabilities missing session.basic: {daemon}") + _must("session.resize.min" in capabilities, f"daemon hello capabilities missing session.resize.min: {daemon}") + remote_path = str(daemon.get("remote_path") or "").strip() + _must(bool(remote_path), f"daemon ready state should include remote_path: {daemon}") + + binary_size_bytes = _remote_binary_size_bytes(host, host_ssh_port, key_path, remote_path) + _must(binary_size_bytes > 0, f"uploaded daemon binary should be non-empty: {binary_size_bytes}") + _must( + binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES, + f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}", + ) + daemon_version, daemon_platform = _extract_daemon_version_platform(remote_path) + local_cached_binary = _local_cached_daemon_binary(daemon_version, daemon_platform) + _must( + local_cached_binary.is_file(), + f"expected local daemon cache artifact at {local_cached_binary} after bootstrap upload", + ) + _must( + os.access(local_cached_binary, os.X_OK), + f"local daemon cache artifact must be executable: {local_cached_binary}", + ) + _must( + _local_binary_contains_version_marker(local_cached_binary, daemon_version), + f"local cached daemon binary should embed daemon version marker {daemon_version!r}: {local_cached_binary}", + ) + local_sha256 = _local_file_sha256(local_cached_binary) + remote_sha256 = _remote_binary_sha256(host, host_ssh_port, key_path, remote_path) + _must( + local_sha256 == remote_sha256, + "uploaded daemon binary hash should match local cached build artifact " + f"(local={local_sha256}, remote={remote_sha256})", + ) + + body = "" + deadline_http = time.time() + 15.0 + while time.time() < deadline_http: + try: + body = _curl_via_socks(proxy_port, f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in body: + break + time.sleep(0.3) + + _must("cmux-ssh-forward-ok" in body, f"Forwarded HTTP endpoint returned unexpected body: {body[:120]!r}") + pipelined_body = _socks5_http_get_pipelined("127.0.0.1", proxy_port, "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in pipelined_body, + f"SOCKS pipelined greeting/connect+payload path returned unexpected body: {pipelined_body[:120]!r}", + ) + + ws_message = "cmux-ws-over-socks-ok" + echoed_message = _websocket_echo_via_socks(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_message) + _must( + echoed_message == ws_message, + f"WebSocket echo over SOCKS proxy mismatch: {echoed_message!r} != {ws_message!r}", + ) + + ws_connect_message = "cmux-ws-over-connect-ok" + echoed_connect = _websocket_echo_via_connect(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_connect_message) + _must( + echoed_connect == ws_connect_message, + f"WebSocket echo over CONNECT proxy mismatch: {echoed_connect!r} != {ws_connect_message!r}", + ) + + payload_shared = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-ssh-forward-shared", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_shared = str(payload_shared.get("workspace_id") or "") + workspace_ref_shared = str(payload_shared.get("workspace_ref") or "") + if not workspace_id_shared and workspace_ref_shared.startswith("workspace:"): + listed_shared = client._call("workspace.list", {}) or {} + for row in listed_shared.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_shared: + workspace_id_shared = str(row.get("id") or "") + break + _must(bool(workspace_id_shared), f"cmux ssh output missing workspace_id for shared transport test: {payload_shared}") + + _, shared_proxy_port = _wait_connected_proxy_port(client, workspace_id_shared) + _must( + shared_proxy_port == proxy_port, + f"identical SSH transports should share one local proxy endpoint: {proxy_port} vs {shared_proxy_port}", + ) + + try: + client.close_workspace(workspace_id_shared) + workspace_id_shared = "" + except Exception: + pass + + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print( + "PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; " + f"uploaded cmuxd-remote size={binary_size_bytes} bytes, version={daemon_version}, platform={daemon_platform}" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + if workspace_id_shared: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_shared) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_reconnect.py b/tests_v2/test_ssh_remote_docker_reconnect.py new file mode 100644 index 00000000..43c0e3cd --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_reconnect.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH reconnect after host restart.""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +from base64 import b64encode +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174")) +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _curl_via_socks(proxy_port: int, target_url: str) -> str: + if shutil.which("curl") is None: + raise cmuxError("curl is required for SOCKS proxy verification") + proc = _run( + [ + "curl", + "--silent", + "--show-error", + "--max-time", + "5", + "--socks5-hostname", + f"127.0.0.1:{proxy_port}", + target_url, + ], + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"curl via SOCKS proxy failed: {merged}") + return proc.stdout + + +def _find_free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + out = bytearray() + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise cmuxError("unexpected EOF while reading socket") + out.extend(chunk) + return bytes(out) + + +def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: + out = bytearray() + while marker not in out: + chunk = sock.recv(1024) + if not chunk: + raise cmuxError("unexpected EOF while reading response headers") + out.extend(chunk) + if len(out) > limit: + raise cmuxError("response headers too large") + return bytes(out) + + +def _read_socks5_connect_reply(sock: socket.socket) -> None: + head = _recv_exact(sock, 4) + if len(head) != 4 or head[0] != 0x05: + raise cmuxError(f"invalid SOCKS5 reply: {head!r}") + if head[1] != 0x00: + raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") + + reply_atyp = head[3] + if reply_atyp == 0x01: + _ = _recv_exact(sock, 4) + elif reply_atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln) + elif reply_atyp == 0x04: + _ = _recv_exact(sock, 16) + else: + raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{reply_atyp:02x}") + _ = _recv_exact(sock, 2) + + +def _read_http_response_from_connected_socket(sock: socket.socket) -> str: + response = _recv_until(sock, b"\r\n\r\n") + header_end = response.index(b"\r\n\r\n") + 4 + header_blob = response[:header_end] + body = bytearray(response[header_end:]) + header_text = header_blob.decode("utf-8", errors="replace") + + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") + + content_length: int | None = None + for line in header_text.split("\r\n")[1:]: + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except Exception: # noqa: BLE001 + content_length = None + break + + if content_length is not None: + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body.extend(chunk) + else: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + body.extend(chunk) + + return bytes(body).decode("utf-8", errors="replace") + + +def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2) + if greeting != b"\x05\x00": + sock.close() + raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") + + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + sock.close() + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + sock.sendall(req) + + try: + _read_socks5_connect_reply(sock) + except Exception: + sock.close() + raise + return sock + + +def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + try: + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + greeting = b"\x05\x01\x00" + connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + http_get = ( + "GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + + sock.sendall(greeting + connect_req + http_get) + + greeting_reply = _recv_exact(sock, 2) + if greeting_reply != b"\x05\x00": + raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") + _read_socks5_connect_reply(sock) + return _read_http_response_from_connected_socket(sock) + finally: + try: + sock.close() + except Exception: + pass + + +def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + request = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + sock.close() + raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") + return sock + + +def _encode_client_text_frame(payload: str) -> bytes: + data = payload.encode("utf-8") + first = 0x81 + mask = secrets.token_bytes(4) + length = len(data) + if length < 126: + header = bytes([first, 0x80 | length]) + elif length <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + return header + mask + masked + + +def _read_server_text_frame(sock: socket.socket) -> str: + first, second = _recv_exact(sock, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(sock, 8))[0] + mask = _recv_exact(sock, 4) if masked else b"" + payload = _recv_exact(sock, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + + if opcode != 0x1: + raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") + try: + return payload.decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") + + +def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: + ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET /echo HTTP/1.1\r\n" + f"Host: {ws_host}:{ws_port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "101" not in status_line: + raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") + + expected_accept = b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() + ).decode("ascii") + lowered_headers = { + line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() + for line in header_text.split("\r\n")[1:] + if ":" in line + } + if lowered_headers.get("sec-websocket-accept", "") != expected_accept: + raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") + + sock.sendall(_encode_client_text_frame(message)) + return _read_server_text_frame(sock) + + +def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None: + for _ in range(20): + proc = _run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-e", + f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-e", + f"REMOTE_WS_PORT={REMOTE_WS_PORT}", + "-p", + f"{DOCKER_PUBLISH_ADDR}:{host_ssh_port}:22", + image_tag, + ], + check=False, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + proxy = remote.get("proxy") or {} + port_value = proxy.get("port") + proxy_port: int | None + if isinstance(port_value, int): + proxy_port = port_value + elif isinstance(port_value, str) and port_value.isdigit(): + proxy_port = int(port_value) + else: + proxy_port = None + if str(remote.get("state") or "") == "connected" and proxy_port is not None: + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not reach connected+proxy-ready state: {last_status}") + + +def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + state = str(remote.get("state") or "") + if state in {"error", "connecting", "disconnected"}: + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}" + host_ssh_port = _find_free_loopback_port() + workspace_id = "" + container_running = False + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _start_container(image_tag, container_name, pubkey, host_ssh_port) + container_running = True + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + f"root@{DOCKER_SSH_HOST}", + "--name", + "docker-ssh-reconnect", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + first_status = _wait_remote_connected(client, workspace_id, timeout=45.0) + first_daemon = ((first_status.get("remote") or {}).get("daemon") or {}) + _must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}") + first_capabilities = {str(item) for item in (first_daemon.get("capabilities") or [])} + _must("proxy.stream" in first_capabilities, f"daemon should advertise proxy.stream: {first_status}") + _must("proxy.socks5" in first_capabilities, f"daemon should advertise proxy.socks5: {first_status}") + _must("proxy.http_connect" in first_capabilities, f"daemon should advertise proxy.http_connect: {first_status}") + first_proxy = ((first_status.get("remote") or {}).get("proxy") or {}) + first_proxy_port = first_proxy.get("port") + if isinstance(first_proxy_port, str) and first_proxy_port.isdigit(): + first_proxy_port = int(first_proxy_port) + _must(isinstance(first_proxy_port, int), f"connected status should include proxy port: {first_status}") + + first_body = "" + first_deadline_http = time.time() + 15.0 + while time.time() < first_deadline_http: + try: + first_body = _curl_via_socks(int(first_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in first_body: + break + time.sleep(0.3) + _must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}") + first_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(first_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in first_pipelined_body, + f"SOCKS pipelined greeting/connect+payload failed before reconnect: {first_pipelined_body[:120]!r}", + ) + + first_ws_socks_message = "cmux-reconnect-before-over-socks" + echoed_before_socks = _websocket_echo_via_socks(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_socks_message) + _must( + echoed_before_socks == first_ws_socks_message, + f"WebSocket echo over SOCKS proxy failed before reconnect: {echoed_before_socks!r} != {first_ws_socks_message!r}", + ) + + first_ws_connect_message = "cmux-reconnect-before-over-connect" + echoed_before_connect = _websocket_echo_via_connect(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_connect_message) + _must( + echoed_before_connect == first_ws_connect_message, + f"WebSocket echo over CONNECT proxy failed before reconnect: {echoed_before_connect!r} != {first_ws_connect_message!r}", + ) + + _run(["docker", "rm", "-f", container_name], check=False) + container_running = False + _wait_remote_degraded(client, workspace_id, timeout=20.0) + + _start_container(image_tag, container_name, pubkey, host_ssh_port) + container_running = True + + second_status = _wait_remote_connected(client, workspace_id, timeout=60.0) + second_daemon = ((second_status.get("remote") or {}).get("daemon") or {}) + _must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}") + second_capabilities = {str(item) for item in (second_daemon.get("capabilities") or [])} + _must("proxy.stream" in second_capabilities, f"daemon should advertise proxy.stream after reconnect: {second_status}") + _must("proxy.socks5" in second_capabilities, f"daemon should advertise proxy.socks5 after reconnect: {second_status}") + _must("proxy.http_connect" in second_capabilities, f"daemon should advertise proxy.http_connect after reconnect: {second_status}") + second_proxy = ((second_status.get("remote") or {}).get("proxy") or {}) + second_proxy_port = second_proxy.get("port") + if isinstance(second_proxy_port, str) and second_proxy_port.isdigit(): + second_proxy_port = int(second_proxy_port) + _must(isinstance(second_proxy_port, int), f"reconnected status should include proxy port: {second_status}") + + second_body = "" + deadline_http = time.time() + 15.0 + while time.time() < deadline_http: + try: + second_body = _curl_via_socks(int(second_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in second_body: + break + time.sleep(0.3) + _must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}") + second_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(second_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in second_pipelined_body, + f"SOCKS pipelined greeting/connect+payload failed after reconnect: {second_pipelined_body[:120]!r}", + ) + + second_ws_socks_message = "cmux-reconnect-after-over-socks" + echoed_after_socks = _websocket_echo_via_socks(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_socks_message) + _must( + echoed_after_socks == second_ws_socks_message, + f"WebSocket echo over SOCKS proxy failed after reconnect: {echoed_after_socks!r} != {second_ws_socks_message!r}", + ) + + second_ws_connect_message = "cmux-reconnect-after-over-connect" + echoed_after_connect = _websocket_echo_via_connect(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_connect_message) + _must( + echoed_after_connect == second_ws_connect_message, + f"WebSocket echo over CONNECT proxy failed after reconnect: {echoed_after_connect!r} != {second_ws_connect_message!r}", + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: docker SSH remote reconnects and re-establishes HTTP + WebSocket egress over SOCKS and CONNECT") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + if container_running: + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py new file mode 100644 index 00000000..040207d7 --- /dev/null +++ b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Regression: interactive `cmux ssh` shells must resolve `cmux` to the relay wrapper.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + import subprocess + + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 25.0) -> None: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + if surfaces: + return str(surfaces[0][1]) + time.sleep(0.1) + raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") + + +def _wait_text(client: cmux, surface_id: str, token: str, timeout: float = 12.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + if token in last: + return last + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for {token!r} in surface {surface_id}: {last[-1200:]!r}") + + +def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> None: + token = f"__CMUX_SHELL_READY_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"printf '{token}'; echo") + client.send_key_surface(surface_id, "enter") + _wait_text(client, surface_id, token, timeout=timeout) + + +def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]: + token = f"__CMUX_REMOTE_CMD_{secrets.token_hex(6)}__" + start_marker = f"{token}:START" + status_marker = f"{token}:STATUS" + end_marker = f"{token}:END" + client.send_surface( + surface_id, + ( + f"printf '{start_marker}'; echo; " + f"{command}; " + "__cmux_status=$?; " + f"printf '{status_marker}:%s' \"$__cmux_status\"; echo; " + f"printf '{end_marker}'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout + text = "" + while time.time() < deadline: + text = client.read_terminal_text(surface_id) + if ( + text.count(start_marker) >= 2 + and text.count(status_marker) >= 2 + and text.count(end_marker) >= 2 + ): + break + time.sleep(0.15) + pattern = re.compile( + re.escape(start_marker) + r"\n(.*?)" + re.escape(status_marker) + r":(\d+)\n" + re.escape(end_marker), + re.S, + ) + matches = pattern.findall(text) + if not matches: + raise cmuxError(f"Missing command result token for {command!r}: {text[-1200:]!r}") + output, status_raw = matches[-1] + return int(status_raw), output, text + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run interactive ssh cmux command regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json(cli, ["ssh", SSH_HOST]) + workspace_id = _workspace_id_from_payload(client, payload) + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + workspace_ids.append(workspace_id) + + _wait_remote_ready(client, workspace_id) + surface_id = _wait_surface_id(client, workspace_id) + _wait_shell_ready(client, surface_id) + + which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux") + _must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}") + _must( + "/.cmux/bin/cmux" in which_output, + f"interactive ssh shell should resolve cmux to relay wrapper, got {which_output!r}", + ) + + ping_status, ping_output, ping_text = _run_remote_shell_command(client, surface_id, "cmux ping") + _must(ping_status == 0, f"`cmux ping` failed in interactive shell: output={ping_output!r} tail={ping_text[-1200:]!r}") + _must("pong" in ping_output.lower(), f"`cmux ping` should return pong, got {ping_output!r}") + _must( + "Socket not found at 127.0.0.1:" not in ping_text, + f"interactive ssh shell still routed cmux to a unix-socket-only binary: {ping_text[-1200:]!r}", + ) + _must( + "waiting for relay on 127.0.0.1:" not in ping_text and "failed to connect to 127.0.0.1:" not in ping_text, + f"`cmux ping` hit a dead ssh relay instead of the local app socket: {ping_text[-1200:]!r}", + ) + + notify_status, notify_output, notify_text = _run_remote_shell_command( + client, + surface_id, + "cmux notify --body interactive-ssh-regression", + ) + _must( + notify_status == 0, + f"`cmux notify` failed in interactive shell: output={notify_output!r} tail={notify_text[-1200:]!r}", + ) + _must( + "Socket not found at 127.0.0.1:" not in notify_text, + f"`cmux notify` still failed via wrong cmux binary: {notify_text[-1200:]!r}", + ) + _must( + "waiting for relay on 127.0.0.1:" not in notify_text and "failed to connect to 127.0.0.1:" not in notify_text, + f"`cmux notify` still failed because the ssh relay listener was not running: {notify_text[-1200:]!r}", + ) + + shell_status, shell_output, shell_text = _run_remote_shell_command( + client, + surface_id, + r'''printf 'TERM=%s\n' "${TERM:-}"; printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM:-}"; printf 'TERM_PROGRAM_VERSION=%s\n' "${TERM_PROGRAM_VERSION:-}"; printf 'GHOSTTY_SHELL_FEATURES=%s\n' "${GHOSTTY_SHELL_FEATURES:-}"; bindkey "^A"; bindkey "^K"; bindkey "^[^?"; bindkey "^[b"; bindkey "^[f"''', + ) + _must(shell_status == 0, f"ssh shell env/bindkey probe failed: output={shell_output!r} tail={shell_text[-1200:]!r}") + _must("TERM=xterm-ghostty" in shell_output, f"ssh shell lost TERM=xterm-ghostty: {shell_output!r}") + _must("TERM_PROGRAM=ghostty" in shell_output, f"ssh shell lost TERM_PROGRAM=ghostty: {shell_output!r}") + _must("GHOSTTY_SHELL_FEATURES=" in shell_output, f"ssh shell lost GHOSTTY_SHELL_FEATURES: {shell_output!r}") + _must("ssh-env" in shell_output, f"ssh shell missing ssh-env feature: {shell_output!r}") + _must("ssh-terminfo" in shell_output, f"ssh shell missing ssh-terminfo feature: {shell_output!r}") + _must('"^A" beginning-of-line' in shell_output, f"Ctrl-A binding regressed in ssh shell: {shell_output!r}") + _must('"^K" kill-line' in shell_output, f"Ctrl-K binding regressed in ssh shell: {shell_output!r}") + _must('"^[^?" backward-kill-word' in shell_output, f"Opt-Backspace binding regressed in ssh shell: {shell_output!r}") + _must('"^[b" backward-word' in shell_output, f"Opt-Left binding regressed in ssh shell: {shell_output!r}") + _must('"^[f" forward-word' in shell_output, f"Opt-Right binding regressed in ssh shell: {shell_output!r}") + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: interactive ssh shell resolves cmux to relay wrapper and remote cmux commands succeed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py new file mode 100644 index 00000000..91af772d --- /dev/null +++ b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Regression: closing the last SSH surface should clear remote workspace state.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _workspace_row(client: cmux, workspace_id: str) -> dict: + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("id") or "") == workspace_id: + return row + raise cmuxError(f"workspace.list missing {workspace_id}: {rows}") + + +def _remote_session_count(client: cmux, workspace_id: str) -> int: + row = _workspace_row(client, workspace_id) + remote = row.get("remote") or {} + return int(remote.get("active_terminal_sessions") or 0) + + +def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 12.0) -> str: + token = f"__CMUX_{token_prefix}_{int(time.time() * 1000)}__" + client.send_surface( + surface_id, + ( + f"printf '{token}:START'; echo; " + f"{command}; " + f"printf '{token}:END'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout_s + last = "" + pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S) + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + return matches[-1] + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}") + + +def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", name] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_ready(client, workspace_id) + client.select_workspace(workspace_id) + _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) + return workspace_id + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh last-surface remote state regression") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-last-surface-{int(time.time())}", + ) + + row = _workspace_row(client, workspace_id) + remote = row.get("remote") or {} + _must(bool(remote.get("enabled")) is True, f"workspace should start as remote-enabled: {row}") + _must(int(remote.get("active_terminal_sessions") or 0) == 1, f"workspace should start with one active ssh terminal session: {row}") + + surfaces = client.list_surfaces(workspace_id) + _must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}") + + split_surface_id = client.new_split("right") + _wait_for(lambda: len(client.list_surfaces(workspace_id)) == 2, timeout_s=10.0, step_s=0.1) + _wait_for(lambda: _remote_session_count(client, workspace_id) == 2, timeout_s=10.0, step_s=0.1) + + client.send_surface(split_surface_id, "exit") + client.send_key_surface(split_surface_id, "enter") + _wait_for(lambda: _remote_session_count(client, workspace_id) == 1, timeout_s=15.0, step_s=0.15) + + row_after_first_exit = _workspace_row(client, workspace_id) + remote_after_first_exit = row_after_first_exit.get("remote") or {} + _must(bool(remote_after_first_exit.get("enabled")) is True, f"workspace should stay remote while one ssh terminal remains: {row_after_first_exit}") + + remaining_surface_id = next( + surface_id + for _index, surface_id, _focused in client.list_surfaces(workspace_id) + if surface_id != split_surface_id + ) + client.send_surface(remaining_surface_id, "exit") + client.send_key_surface(remaining_surface_id, "enter") + + def _remote_cleared() -> bool: + row_now = _workspace_row(client, workspace_id) + remote_now = row_now.get("remote") or {} + if bool(remote_now.get("enabled")): + return False + surfaces_now = client.list_surfaces(workspace_id) + return len(surfaces_now) == 2 + + _wait_for(_remote_cleared, timeout_s=15.0, step_s=0.15) + + final_row = _workspace_row(client, workspace_id) + final_remote = final_row.get("remote") or {} + _must(bool(final_remote.get("enabled")) is False, f"workspace remote metadata should clear after last ssh surface closes: {final_row}") + _must(str(final_remote.get("state") or "") == "disconnected", f"workspace should end disconnected after remote metadata clears: {final_row}") + _must(int(final_remote.get("active_terminal_sessions") or 0) == 0, f"workspace should report zero active ssh terminal sessions after last ssh surface closes: {final_row}") + + local_surface_ids = [surface_id for _index, surface_id, _focused in client.list_surfaces(workspace_id)] + _must(len(local_surface_ids) == 2, f"expected both panes to remain as local terminals after ssh exits, got {local_surface_ids}") + for idx, surface_id in enumerate(local_surface_ids): + socket_output = _run_surface_probe( + client, + surface_id, + r'''printf '%s' "${CMUX_SOCKET_PATH:-}"''', + f"SSH_LAST_SURFACE_SOCKET_{idx}", + ).strip() + _must( + not socket_output.startswith("127.0.0.1:"), + f"surface {surface_id} should be local after clearing remote state, got CMUX_SOCKET_PATH={socket_output!r}", + ) + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + + print("PASS: exiting all ssh panes clears remote workspace state while fallback local panes remain local") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_proxy_bind_conflict.py b/tests_v2/test_ssh_remote_proxy_bind_conflict.py new file mode 100644 index 00000000..d47e2957 --- /dev/null +++ b/tests_v2/test_ssh_remote_proxy_bind_conflict.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Docker integration: local proxy bind conflict surfaces proxy_unavailable.""" + +from __future__ import annotations + +import glob +import os +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _find_free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_proxy_conflict_status(client: cmux, workspace_id: str, expected_local_proxy_port: int, timeout: float = 30.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + proxy = remote.get("proxy") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "error" and str(proxy.get("state") or "") == "error": + detail = str(remote.get("detail") or "") + _must( + proxy.get("error_code") == "proxy_unavailable", + f"proxy error should be proxy_unavailable under bind conflict: {last_status}", + ) + _must( + int(remote.get("local_proxy_port") or 0) == expected_local_proxy_port, + f"remote status should retain configured local_proxy_port under bind conflict: {last_status}", + ) + _must( + ( + "Failed to start local daemon proxy" in detail + or "Local proxy listener failed" in detail + ), + f"remote detail should surface local proxy bind failure: {last_status}", + ) + _must( + "Address already in use" in detail, + f"remote detail should preserve bind-conflict root cause: {last_status}", + ) + _must( + str(daemon.get("state") or "") == "ready", + f"daemon should remain ready for local-only bind conflicts: {last_status}", + ) + return last_status + time.sleep(0.5) + + raise cmuxError(f"Remote did not reach structured proxy_unavailable status for bind conflict: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + _ = _find_cli_binary() # enforce same test prerequisites as other SSH remote suites + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-proxy-conflict-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-proxy-conflict-{secrets.token_hex(4)}" + workspace_id = "" + conflict_listener: socket.socket | None = None + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-p", f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + conflict_port = _find_free_loopback_port() + conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + conflict_listener.bind(("127.0.0.1", conflict_port)) + conflict_listener.listen(1) + + with cmux(SOCKET_PATH) as client: + created = client._call("workspace.create", {"initial_command": "echo ssh-proxy-conflict"}) + workspace_id = str((created or {}).get("workspace_id") or "") + _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") + + configured = client._call("workspace.remote.configure", { + "workspace_id": workspace_id, + "destination": host, + "port": host_ssh_port, + "identity_file": str(key_path), + "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], + "auto_connect": True, + "local_proxy_port": conflict_port, + }) + _must(bool(configured), "workspace.remote.configure returned empty response") + + _ = _wait_for_proxy_conflict_status( + client, + workspace_id, + expected_local_proxy_port=conflict_port, + timeout=30.0, + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: local proxy bind conflict surfaces structured proxy_unavailable without degrading daemon readiness") + return 0 + + finally: + if conflict_listener is not None: + try: + conflict_listener.close() + except Exception: + pass + + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_resize_scrollback_regression.py b/tests_v2/test_ssh_remote_resize_scrollback_regression.py new file mode 100644 index 00000000..ff70110e --- /dev/null +++ b/tests_v2/test_ssh_remote_resize_scrollback_regression.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Regression: ssh workspace keeps large pre-resize scrollback across split resize churn.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() +LS_ENTRY_COUNT = int(os.environ.get("CMUX_SSH_TEST_LS_COUNT", "320")) +RESIZE_ITERATIONS = int(os.environ.get("CMUX_SSH_TEST_RESIZE_ITERATIONS", "48")) + +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready state: {last}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()] + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + exact_line: bool = False, + timeout_s: float = 25.0, +) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if exact_line: + if token in _surface_scrollback_lines(client, workspace_id, surface_id): + return + elif token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _pane_for_surface(client: cmux, surface_id: str) -> str: + target_id = str(client._resolve_surface_id(surface_id)) + for _idx, pane_id, _count, _focused in client.list_panes(): + rows = client.list_pane_surfaces(pane_id) + for _row_idx, sid, _title, _selected in rows: + try: + candidate_id = str(client._resolve_surface_id(sid)) + except cmuxError: + continue + if candidate_id == target_id: + return pane_id + raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") + + +def _valid_resize_directions(client: cmux, workspace_id: str, pane_id: str) -> list[str]: + valid: list[str] = [] + for direction in ("left", "right", "up", "down"): + try: + client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 10, + }, + ) + valid.append(direction) + except cmuxError: + pass + return valid + + +def _choose_resize_pair(client: cmux, workspace_id: str, pane_ids: list[str]) -> list[tuple[str, str]]: + by_pane: dict[str, list[str]] = {} + for pane_id in pane_ids: + by_pane[pane_id] = _valid_resize_directions(client, workspace_id, pane_id) + + for pane_a, directions_a in by_pane.items(): + if "right" not in directions_a: + continue + for pane_b, directions_b in by_pane.items(): + if pane_b == pane_a: + continue + if "left" in directions_b: + return [(pane_a, "right"), (pane_b, "left")] + + for pane_a, directions_a in by_pane.items(): + if "down" not in directions_a: + continue + for pane_b, directions_b in by_pane.items(): + if pane_b == pane_a: + continue + if "up" in directions_b: + return [(pane_a, "down"), (pane_b, "up")] + + raise cmuxError(f"Could not find oscillating resize pair across panes: {by_pane}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote resize scrollback regression") + return 0 + if LS_ENTRY_COUNT < 64: + print("SKIP: CMUX_SSH_TEST_LS_COUNT must be >= 64 for meaningful scrollback coverage") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-resize-regression-{secrets.token_hex(4)}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_connected(client, workspace_id, timeout_s=50.0) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + + stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_REMOTE_RESIZE_LS_{stamp}_{index:04d}.txt" for index in range(1, LS_ENTRY_COUNT + 1)] + ls_start = f"CMUX_REMOTE_RESIZE_LS_START_{stamp}" + ls_end = f"CMUX_REMOTE_RESIZE_LS_END_{stamp}" + + ls_prefix = f"CMUX_REMOTE_RESIZE_LS_{stamp}_" + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for i in $(seq 1 {LS_ENTRY_COUNT}); do " + "n=$(printf '%04d' \"$i\"); " + f"touch \"$tmpdir/{ls_prefix}$n.txt\"; " + "done; " + "LC_ALL=C CLICOLOR=0 ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" + ) + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains( + client, + workspace_id, + surface_id, + ls_end, + exact_line=True, + timeout_s=45.0, + ) + + pre_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(entry in pre_resize_lines for entry in ls_entries), + "pre-resize scrollback missing ls fixture lines in ssh workspace", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + + client.select_workspace(workspace_id) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + _wait_for(lambda: len(client.list_panes()) >= pane_count_before_split + 1, timeout_s=10.0) + + # Ensure the original surface remains selected before resize churn. + client.focus_surface(surface_id) + pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()] + _must(len(pane_ids) >= 2, f"expected split workspace with >=2 panes: {pane_ids}") + _ = _pane_for_surface(client, surface_id) + resize_pair = _choose_resize_pair(client, workspace_id, pane_ids) + + for iteration in range(1, RESIZE_ITERATIONS + 1): + pane_id, direction = resize_pair[(iteration - 1) % len(resize_pair)] + _ = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) + if iteration % 8 == 0: + sampled_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in sampled_lines for anchor in pre_resize_anchors), + f"resize iteration {iteration} lost pre-resize anchor lines in ssh workspace", + ) + + post_token = f"CMUX_REMOTE_RESIZE_POST_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {post_token}\n") + _wait_surface_contains( + client, + workspace_id, + surface_id, + post_token, + exact_line=True, + timeout_s=25.0, + ) + + post_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(entry in post_resize_lines for entry in ls_entries), + "post-resize scrollback lost ls fixture lines in ssh workspace", + ) + _must( + post_token in post_resize_lines, + f"post-resize scrollback missing post token: {post_token}", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print( + "PASS: cmux ssh split+resize churn preserved large pre-resize scrollback " + f"(entries={LS_ENTRY_COUNT}, iterations={RESIZE_ITERATIONS})" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_second_session_mux_regression.py b/tests_v2/test_ssh_remote_second_session_mux_regression.py new file mode 100644 index 00000000..c521485c --- /dev/null +++ b/tests_v2/test_ssh_remote_second_session_mux_regression.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Regression: opening a second `cmux ssh` workspace to the same host must not mux-refuse.""" + +from __future__ import annotations + +import glob +import json +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + import subprocess + + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + if surfaces: + return str(surfaces[0][1]) + time.sleep(0.1) + raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") + + +def _workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _wait_text_contains(client: cmux, surface_id: str, needle: str, timeout: float = 8.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + if needle in last: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last[-800:]!r}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run second-session ssh mux regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + first = _run_cli_json(cli, ["ssh", SSH_HOST]) + first_workspace_id = _workspace_id_from_payload(client, first) + _must(bool(first_workspace_id), f"first cmux ssh output missing workspace_id: {first}") + workspace_ids.append(first_workspace_id) + _wait_remote_ready(client, first_workspace_id) + first_surface_id = _wait_surface_id(client, first_workspace_id) + _wait_text_contains(client, first_surface_id, "cmux in ~", timeout=12.0) + + second = _run_cli_json(cli, ["ssh", SSH_HOST]) + second_workspace_id = _workspace_id_from_payload(client, second) + _must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}") + workspace_ids.append(second_workspace_id) + _wait_remote_ready(client, second_workspace_id) + + second_surface_id = _wait_surface_id(client, second_workspace_id) + text = _wait_text_contains(client, second_surface_id, "cmux in ~", timeout=12.0) + + refusal_markers = [ + "mux_client_request_session: session request failed: Session open refused by peer", + "ControlSocket ", + "disabling multiplexing", + ] + hits = [marker for marker in refusal_markers if marker in text] + _must( + not hits, + "second cmux ssh session printed mux refusal text instead of starting cleanly: " + f"markers={hits!r} tail={text[-1200:]!r}", + ) + + client.send_surface(second_surface_id, "printf '__SECOND_SESSION_OK__\\n'") + text = _wait_text_contains(client, second_surface_id, "__SECOND_SESSION_OK__", timeout=6.0) + _must( + "command not found" not in text, + f"second cmux ssh session accepted corrupted input after startup: {text[-1200:]!r}", + ) + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: second cmux ssh session opens cleanly without mux refusal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py new file mode 100755 index 00000000..3d632b84 --- /dev/null +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +"""Docker integration: prove cmux ssh applies Ghostty ssh-env/ssh-terminfo niceties.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return last_status + time.sleep(0.4) + raise cmuxError(f"Remote did not reach connected+ready state: {last_status}") + + +def _is_terminal_surface_not_found(exc: Exception) -> bool: + return "terminal surface not found" in str(exc).lower() + + +def _read_probe_value(client: cmux, surface_id: str, command: str, timeout: float = 20.0) -> str: + token = f"__CMUX_PROBE_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"{command}; printf '{token}%s\\n' $?\\n") + + pattern = re.compile(re.escape(token) + r"([^\r\n]*)") + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + text = client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + matches = pattern.findall(text) + for raw in reversed(matches): + value = raw.strip() + if value and value != "%s" and "$(" not in value and "printf" not in value: + return value + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for probe token for command: {command}") + + +def _read_probe_payload(client: cmux, surface_id: str, payload_command: str, timeout: float = 20.0) -> str: + token = f"__CMUX_PAYLOAD_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"printf '{token}%s\\n' \"$({payload_command})\"\\n") + + pattern = re.compile(re.escape(token) + r"([^\r\n]*)") + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + text = client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + matches = pattern.findall(text) + for raw in reversed(matches): + value = raw.strip() + if value and value != "%s" and "$(" not in value and "printf" not in value: + return value + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for payload token for command: {payload_command}") + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]: + deadline = time.time() + timeout + last: list[str] = [] + while time.time() < deadline: + last = [pid for _idx, pid, _count, _focused in client.list_panes()] + if len(last) >= minimum_count: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}") + + +def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()] + + +def _scrollback_has_all_lines( + client: cmux, + workspace_id: str, + surface_id: str, + lines: list[str], +) -> bool: + available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id)) + return all(line in available for line in lines) + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + timeout: float = 20.0, +) -> None: + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + if token in _surface_text_scrollback(client, workspace_id, surface_id): + return + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _pane_for_surface(client: cmux, surface_id: str) -> str: + target_id = str(client._resolve_surface_id(surface_id)) + for _idx, pane_id, _count, _focused in client.list_panes(): + rows = client.list_pane_surfaces(pane_id) + for _row_idx, sid, _title, _selected in rows: + try: + candidate_id = str(client._resolve_surface_id(sid)) + except cmuxError: + continue + if candidate_id == target_id: + return pane_id + raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + if shutil.which("infocmp") is None: + print("SKIP: local infocmp is not available (required for ssh-terminfo)") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-shell-integration-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}" + workspace_id = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + if shutil.which("ghostty") is not None: + _run(["ghostty", "+ssh-cache", f"--remove={host}"], check=False) + _wait_for_ssh(host, host_ssh_port, key_path) + + pre = _ssh_run(host, host_ssh_port, key_path, "if infocmp xterm-ghostty >/dev/null 2>&1; then echo present; else echo missing; fi") + _must("missing" in pre.stdout, f"Fresh container should not have xterm-ghostty terminfo preinstalled: {pre.stdout!r}") + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", + "docker-ssh-shell-integration", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + _wait_remote_connected(client, workspace_id, timeout=45.0) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + terminal_text = client.read_terminal_text(surface_id) + _must( + "Reconstructed via infocmp" not in terminal_text, + "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", + ) + _must( + "Warning: Failed to install terminfo." not in terminal_text, + "ssh shell bootstrap should not show a false terminfo failure warning", + ) + + try: + term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"") + terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1") + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + print("SKIP: terminal surface unavailable for shell integration probes") + return 0 + raise + _must(terminfo_state in {"0", "1"}, f"unexpected terminfo probe exit status: {terminfo_state!r}") + if terminfo_state == "0": + _must( + term_value == "xterm-ghostty", + f"when terminfo install succeeds, TERM should remain xterm-ghostty (got {term_value!r})", + ) + else: + _must( + term_value == "xterm-256color", + f"when terminfo is unavailable, ssh-env fallback should use TERM=xterm-256color (got {term_value!r})", + ) + + colorterm_value = _read_probe_payload(client, surface_id, "printf '%s' \"${COLORTERM:-}\"") + _must( + colorterm_value == "truecolor", + f"ssh-env should propagate COLORTERM=truecolor, got: {colorterm_value!r}", + ) + + term_program = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM:-}\"") + _must( + term_program == "ghostty", + f"ssh-env should propagate TERM_PROGRAM=ghostty when AcceptEnv allows it, got: {term_program!r}", + ) + + term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"") + _must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION") + + ls_stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)] + ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}" + ls_end = f"CMUX_RESIZE_LS_END_{ls_stamp}" + names = " ".join(ls_entries) + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for name in {names}; do touch \"$tmpdir/$name\"; done; " + "ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" + ) + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains(client, workspace_id, surface_id, ls_end) + pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) + _must( + all(line in pre_resize_scrollback_lines for line in ls_entries), + "pre-resize scrollback missing ls output fixture lines", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + _must( + len(pre_resize_anchors) == 3, + f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}", + ) + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 2, + "pre-resize viewport did not contain enough reference lines for continuity checks", + ) + + client.select_workspace(workspace_id) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0) + + pane_id = _pane_for_surface(client, surface_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + opposite_direction = { + "left": "right", + "right": "left", + "up": "down", + "down": "up", + }[resize_direction] + expected_sign_by_direction = { + resize_direction: +1, + opposite_direction: -1, + } + + resize_sequence = [resize_direction, opposite_direction] * 8 + current_extent = _pane_extent(client, pane_id, resize_axis) + for index, direction in enumerate(resize_sequence, start=1): + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + if expected_sign_by_direction[direction] > 0: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0) + else: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0) + current_extent = _pane_extent(client, pane_id, resize_axis) + _must( + _scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors), + f"resize iteration {index} lost pre-resize scrollback anchors", + ) + + post_resize_visible = client.read_terminal_text(surface_id) + visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] + _must( + bool(visible_overlap), + f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", + ) + + resize_post_token = f"CMUX_RESIZE_POST_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {resize_post_token}\n") + _wait_surface_contains(client, workspace_id, surface_id, resize_post_token) + + scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in scrollback_lines for anchor in pre_resize_anchors), + "terminal scrollback lost pre-resize lines after pane resize", + ) + _must( + resize_post_token in scrollback_lines, + f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}", + ) + + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print( + "PASS: cmux ssh enables Ghostty shell integration niceties and preserves pre-resize terminal content " + f"(TERM={term_value}, COLORTERM={colorterm_value}, TERM_PROGRAM={term_program})" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shortcuts_stay_remote.py b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py new file mode 100644 index 00000000..fa5d9199 --- /dev/null +++ b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Regression: new tabs and splits from an ssh terminal must stay on the remote shell.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _focused_surface_id(client: cmux) -> str: + ident = client.identify() + focused = ident.get("focused") or {} + surface_id = str(focused.get("surface_id") or "") + if not surface_id: + raise cmuxError(f"Missing focused surface in identify payload: {ident}") + return surface_id + + +def _run_remote_shell_probe(client: cmux, surface_id: str, probe_label: str) -> str: + token = f"__CMUX_REMOTE_SOCKET_{probe_label}_{secrets.token_hex(4)}__" + client.send_surface( + surface_id, + ( + f"__cmux_socket_path=\"${{CMUX_SOCKET_PATH:-}}\"; " + f"printf '{token}:%s:__CMUX_REMOTE_SOCKET_END__\\n' \"$__cmux_socket_path\"\n" + ), + ) + deadline = time.time() + 15.0 + last = "" + pattern = re.compile(re.escape(token) + r":(.*?):__CMUX_REMOTE_SOCKET_END__") + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + for candidate in reversed(matches): + cleaned = candidate.strip() + if cleaned and cleaned != "%s": + return cleaned + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for socket token {token!r}: {last[-1200:]!r}") + + +def _assert_remote_socket_path(client: cmux, surface_id: str, shortcut_name: str) -> None: + socket_path = _run_remote_shell_probe(client, surface_id, shortcut_name) + _must( + socket_path.startswith("127.0.0.1:"), + f"{shortcut_name} should keep the new terminal on the ssh relay, got CMUX_SOCKET_PATH={socket_path!r}", + ) + + +def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", name] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_ready(client, workspace_id) + client.select_workspace(workspace_id) + _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) + return workspace_id + + +def _assert_shortcut_creates_remote_terminal( + client: cmux, + workspace_id: str, + shortcut: str, + shortcut_name: str, + *, + expect_new_pane: bool, +) -> None: + before_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} + before_pane_count = len(client.list_panes()) + + client.activate_app() + client.simulate_app_active() + client.simulate_shortcut(shortcut) + + _wait_for( + lambda: len({sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_surfaces) == 1, + timeout_s=12.0, + ) + + if expect_new_pane: + _wait_for(lambda: len(client.list_panes()) >= before_pane_count + 1, timeout_s=12.0) + + after_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} + new_surface_ids = sorted(after_surfaces - before_surfaces) + _must(len(new_surface_ids) == 1, f"{shortcut_name} should create exactly one new surface: {new_surface_ids}") + + focused_surface_id = _focused_surface_id(client) + _must( + focused_surface_id == new_surface_ids[0], + f"{shortcut_name} should focus the new terminal surface: focused={focused_surface_id!r} new={new_surface_ids[0]!r}", + ) + _assert_remote_socket_path(client, focused_surface_id, shortcut_name) + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh shortcut inheritance regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdt-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+t", + "cmd+t", + expect_new_pane=False, + ) + + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdd-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+d", + "cmd+d", + expect_new_pane=True, + ) + + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdshiftd-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+shift+d", + "cmd+shift+d", + expect_new_pane=True, + ) + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: cmd+t/cmd+d/cmd+shift+d keep ssh terminals on the remote relay") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_workspace_create_initial_env.py b/tests_v2/test_workspace_create_initial_env.py new file mode 100644 index 00000000..33b56c2e --- /dev/null +++ b/tests_v2/test_workspace_create_initial_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Regression: workspace.create must apply initial_env to the initial terminal.""" + +import os +import sys +import time +import base64 +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + payload = c._call( + "surface.read_text", + {"workspace_id": workspace_id}, + ) or {} + if "text" in payload: + last_text = str(payload.get("text") or "") + else: + b64 = str(payload.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + last_text = raw.decode("utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + try: + token = f"tok_{int(time.time() * 1000)}" + payload = c._call( + "workspace.create", + { + "initial_env": {"CMUX_INITIAL_ENV_TOKEN": token}, + }, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus") + + # Terminal surfaces in background workspaces may not be attached/render-ready yet. + # Select it before reading text so the initial command output is available. + c.select_workspace(created_workspace) + listed = c._call("surface.list", {"workspace_id": created_workspace}) or {} + rows = list(listed.get("surfaces") or []) + _must(bool(rows), "Expected at least one surface in the created workspace") + terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None) + _must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}") + + c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n") + text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}") + _must( + f"CMUX_ENV_CHECK={token}" in text, + f"initial_env token missing from terminal output: {text!r}", + ) + c.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create applies initial_env to initial terminal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2e6856ff2f6975ef7036ba63f6de39c91df9c1ad Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:14:52 -0700 Subject: [PATCH 07/77] Fix ssh stack review regressions --- CLI/cmux.swift | 236 +++++++++-- Resources/Localizable.xcstrings | 68 ++++ Sources/AppDelegate.swift | 25 +- Sources/ContentView.swift | 86 ++-- Sources/GhosttyTerminalView.swift | 8 +- Sources/Panels/BrowserPanel.swift | 35 +- Sources/TabManager.swift | 22 +- Sources/Workspace.swift | 367 +++++++++++++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 25 +- cmuxTests/GhosttyConfigTests.swift | 87 +++++ ...erminalControllerSocketSecurityTests.swift | 32 +- daemon/remote/cmd/cmuxd-remote/cli.go | 40 +- daemon/remote/cmd/cmuxd-remote/cli_test.go | 67 +++- daemon/remote/cmd/cmuxd-remote/main.go | 1 + docs/remote-daemon-spec.md | 2 +- scripts/build_remote_daemon_release_assets.sh | 41 +- tests/fixtures/ssh-remote/ws_echo.py | 28 +- tests_v2/pane_resize_test_support.py | 124 ++++++ ..._cli_global_flags_and_v1_error_contract.py | 1 + .../test_cli_sidebar_metadata_commands.py | 113 ++++++ ...est_pane_resize_preserves_ls_scrollback.py | 124 +----- ...t_pane_resize_preserves_visible_content.py | 128 +----- tests_v2/test_ssh_remote_cli_metadata.py | 102 +++-- tests_v2/test_ssh_remote_cli_relay.py | 4 +- .../test_ssh_remote_daemon_resize_stdio.py | 2 + .../test_ssh_remote_proxy_bind_conflict.py | 4 +- ...sh_remote_second_session_mux_regression.py | 4 + 27 files changed, 1270 insertions(+), 506 deletions(-) create mode 100644 tests_v2/pane_resize_test_support.py create mode 100644 tests_v2/test_cli_sidebar_metadata_commands.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6329c5d4..23ad3071 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1722,6 +1722,87 @@ struct CMUXCLI { let response = try sendV1Command(socketCmd, client: client) print(response) + case "set-status": + let response = try forwardSidebarMetadataCommand( + "set_status", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "clear-status": + let response = try forwardSidebarMetadataCommand( + "clear_status", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "list-status": + let response = try forwardSidebarMetadataCommand( + "list_status", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "set-progress": + let response = try forwardSidebarMetadataCommand( + "set_progress", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "clear-progress": + let response = try forwardSidebarMetadataCommand( + "clear_progress", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "log": + let response = try forwardSidebarMetadataCommand( + "log", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "clear-log": + let response = try forwardSidebarMetadataCommand( + "clear_log", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "list-log": + let response = try forwardSidebarMetadataCommand( + "list_log", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "sidebar-state": + let response = try forwardSidebarMetadataCommand( + "sidebar_state", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + case "claude-hook": cliTelemetry.breadcrumb("claude-hook.dispatch") do { @@ -3153,42 +3234,86 @@ struct CMUXCLI { } private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { - let relayExport = remoteRelayPort > 0 - ? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)" - : nil - let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures) - let innerCommand = [ - remoteEnvExports, - "export PATH=\"$HOME/.cmux/bin:$PATH\"", - relayExport, - "exec \"${SHELL:-/bin/zsh}\" -i", + let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) + let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil + let commonShellLines = remoteEnvExportLines + + ["export PATH=\"$HOME/.cmux/bin:$PATH\""] + + (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? []) + + [ + "hash -r >/dev/null 2>&1 || true", + "rehash >/dev/null 2>&1 || true", + ] + let zshEnvLines = [ + "export CMUX_REAL_ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"", + "[ -f \"$HOME/.zshenv\" ] && source \"$HOME/.zshenv\"", ] - .compactMap { $0 } - .joined(separator: "; ") + let zshRCLines = [ + "export ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"", + "[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\"", + ] + commonShellLines + let bashRCLines = [ + "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", + ] + commonShellLines + let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) + let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" - let outerCommand = [ + var outerLines: [String] = [ "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", "case \"${CMUX_LOGIN_SHELL##*/}\" in", - " zsh|bash)", - " exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))", + " zsh)", + " mkdir -p \"$HOME/.cmux/relay\"", + " cmux_shell_dir=\"\(shellStateDir)\"", + " mkdir -p \"$cmux_shell_dir\"", + " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", + ] + outerLines.append(contentsOf: zshEnvLines) + outerLines += [ + "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", + ] + outerLines.append(contentsOf: zshRCLines) + outerLines += [ + "CMUXZSHRC", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zshrc\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " export ZDOTDIR=\"$cmux_shell_dir\"", + " exec \"$CMUX_LOGIN_SHELL\" -i", + " ;;", + " bash)", + " mkdir -p \"$HOME/.cmux/relay\"", + " cmux_shell_dir=\"\(shellStateDir)\"", + " mkdir -p \"$cmux_shell_dir\"", + " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", + ] + outerLines.append(contentsOf: bashRCLines) + outerLines += [ + "CMUXBASHRC", + " chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i", " ;;", " *)", - remoteEnvExports, - " export PATH=\"$HOME/.cmux/bin:$PATH\"", - relayExport, + ] + outerLines.append(contentsOf: commonShellLines.map { " " + $0 }) + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ " exec \"$CMUX_LOGIN_SHELL\" -i", " ;;", "esac", ] - .compactMap { $0 } - .joined(separator: "; ") - return outerCommand + let outerCommand = outerLines.joined(separator: "\n") + + return "/bin/sh -lc \(shellQuote(outerCommand))" } - private func interactiveRemoteShellExports(shellFeatures: String) -> String { + private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { let environment = ProcessInfo.processInfo.environment - let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty" + let term = "xterm-ghostty" let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) @@ -3207,7 +3332,21 @@ struct CMUXCLI { if !trimmedShellFeatures.isEmpty { exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") } - return exports.joined(separator: "; ") + return exports + } + + private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { + guard remoteRelayPort > 0 else { return [] } + return [ + "cmux_wait_attempt=0", + "while [ \"$cmux_wait_attempt\" -lt 40 ]; do", + " if [ -x \"$HOME/.cmux/bin/cmux\" ] && [ -f \"$HOME/.cmux/relay/\(remoteRelayPort).auth\" ] && CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort) \"$HOME/.cmux/bin/cmux\" ping >/dev/null 2>&1; then", + " break", + " fi", + " cmux_wait_attempt=$((cmux_wait_attempt + 1))", + " sleep 0.2", + "done", + ] } private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { @@ -4000,7 +4139,13 @@ struct CMUXCLI { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - output(payload, fallback: "OK") + let fallback: String + if let value = payload["value"] { + fallback = displayBrowserValue(value) + } else { + fallback = "OK" + } + output(payload, fallback: fallback) return } @@ -6307,6 +6452,49 @@ struct CMUXCLI { return ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] } + private func forwardSidebarMetadataCommand( + _ socketCommand: String, + commandArgs: [String], + client: SocketClient, + windowOverride: String? + ) throws -> String { + var forwardedArgs: [String] = [] + var resolvedExplicitWorkspace = false + var index = 0 + + while index < commandArgs.count { + let arg = commandArgs[index] + if arg == "--workspace", index + 1 < commandArgs.count { + let workspaceId = try resolveWorkspaceId(commandArgs[index + 1], client: client) + forwardedArgs.append("--tab=\(workspaceId)") + resolvedExplicitWorkspace = true + index += 2 + continue + } + if arg.hasPrefix("--workspace=") { + let rawWorkspace = String(arg.dropFirst("--workspace=".count)) + let workspaceId = try resolveWorkspaceId(rawWorkspace, client: client) + forwardedArgs.append("--tab=\(workspaceId)") + resolvedExplicitWorkspace = true + index += 1 + continue + } + forwardedArgs.append(arg) + index += 1 + } + + if !resolvedExplicitWorkspace, + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) { + let workspaceId = try resolveWorkspaceId(workspaceArg, client: client) + forwardedArgs.append("--tab=\(workspaceId)") + } + + let command = ([socketCommand] + forwardedArgs) + .map(shellQuote) + .joined(separator: " ") + return try sendV1Command(command, client: client) + } + /// Pick the display handle for an item dict based on --id-format. private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String { let ref = item["ref"] as? String diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 137f9f92..8207735c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -25486,6 +25486,40 @@ } } }, + "clipboard.sshError.item": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld. %@ (%@): %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld. %@ (%@): %@" + } + } + } + }, + "clipboard.sshError.single": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error (%@): %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH エラー (%@): %@" + } + } + } + }, "contextMenu.copySshError": { "extractionState": "manual", "localizations": { @@ -61642,6 +61676,40 @@ } } }, + "sidebar.activeTabIndicator.leftRail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left Rail" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左レール" + } + } + } + }, + "sidebar.activeTabIndicator.solidFill": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Solid Fill" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "塗りつぶし" + } + } + } + }, "sidebar.workspace.moveDownAction": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c..885555e9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11197,12 +11197,17 @@ private extension NSWindow { let portalWebView = cmuxUniqueBrowserWebView(in: candidate) { // Portal-hosted browser chrome (for example the Cmd+F overlay) is a // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a - // descendant of it. Treating every view in that slot as "web-owned" - // blocks legitimate first-responder changes to overlay text fields. + // descendant of it. Allow native text-entry controls in that slot to + // acquire first responder directly, but keep generic sibling views + // associated with the hosted web view so blocked browser focus policy + // still protects inspector/overlay chrome from stray focus changes. if view === portalWebView || view.isDescendant(of: portalWebView) { return portalWebView } - return nil + if cmuxAllowsPortalSlotTextEntryFocus(view) { + return nil + } + return portalWebView } current = candidate.superview } @@ -11210,6 +11215,20 @@ private extension NSWindow { return nil } + private static func cmuxAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if let textField = candidate as? NSTextField { + return textField.isEditable || textField.acceptsFirstResponder + } + if let textView = candidate as? NSTextView { + return textView.isEditable || textView.isSelectable || textView.isFieldEditor + } + current = candidate.superview + } + return false + } + private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? { var stack: [NSView] = [root] var found: CmuxWebView? diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 29fdf434..341b8ba3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -92,27 +92,23 @@ enum SidebarRemoteErrorCopySupport { static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { guard !entries.isEmpty else { return nil } if entries.count == 1, let entry = entries.first { - return "SSH error (\(entry.target)): \(entry.detail)" + return String.localizedStringWithFormat( + String(localized: "clipboard.sshError.single", defaultValue: "SSH error (%@): %@"), + entry.target, + entry.detail + ) } return entries.enumerated().map { index, entry in - "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" + String.localizedStringWithFormat( + String(localized: "clipboard.sshError.item", defaultValue: "%lld. %@ (%@): %@"), + Int64(index + 1), + entry.workspaceTitle, + entry.target, + entry.detail + ) }.joined(separator: "\n") } - - static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("SSH error") else { return nil } - - if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) { - return (String(match.1), String(match.2)) - } - if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) { - guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil } - return (fallbackTarget, String(match.1)) - } - return nil - } } func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { @@ -7220,6 +7216,13 @@ struct VerticalTabsSidebar: View { LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in + let selectedContextIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id] + let contextTargetIds = tabManager.tabs.compactMap { workspace in + selectedContextIds.contains(workspace.id) ? workspace.id : nil + } + let remoteContextMenuTargets = tabManager.tabs.filter { workspace in + contextTargetIds.contains(workspace.id) && workspace.isRemoteWorkspace + } TabItemView( tabManager: tabManager, notificationStore: notificationStore, @@ -7241,7 +7244,10 @@ struct VerticalTabsSidebar: View { showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator + dropIndicator: $dropIndicator, + remoteContextMenuWorkspaceIds: remoteContextMenuTargets.map(\.id), + allRemoteContextMenuTargetsConnecting: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting }, + allRemoteContextMenuTargetsDisconnected: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected } ) .equatable() } @@ -9497,7 +9503,10 @@ private struct TabItemView: View, Equatable { lhs.unreadCount == rhs.unreadCount && lhs.latestNotificationText == rhs.latestNotificationText && lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && + lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && + lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && + lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected } // Use plain references instead of @EnvironmentObject to avoid subscribing @@ -9520,6 +9529,9 @@ private struct TabItemView: View, Equatable { let dragAutoScrollController: SidebarDragAutoScrollController @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? + let remoteContextMenuWorkspaceIds: [UUID] + let allRemoteContextMenuTargetsConnecting: Bool + let allRemoteContextMenuTargetsDisconnected: Bool @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @@ -9645,15 +9657,28 @@ private struct TabItemView: View, Equatable { } private var copyableSidebarSSHError: String? { + let fallbackTarget = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let target = tab.remoteDisplayTarget ?? "unknown" - return "SSH error (\(target)): \(trimmedDetail)" + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: trimmedDetail + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) } if let statusValue = tab.statusEntries["remote.error"]?.value .trimmingCharacters(in: .whitespacesAndNewlines), !statusValue.isEmpty { - return statusValue + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: statusValue + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) } return nil } @@ -10080,14 +10105,19 @@ private struct TabItemView: View, Equatable { isMulti ? multi : single } + private func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + @ViewBuilder private var workspaceContextMenu: some View { let targetIds = contextTargetIds() let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned - let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } - let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } let reconnectLabel = contextMenuLabel( multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), @@ -10145,22 +10175,22 @@ private struct TabItemView: View, Equatable { } } - if !remoteTargetWorkspaces.isEmpty { + if !remoteContextMenuWorkspaceIds.isEmpty { Divider() Button(reconnectLabel) { - for workspace in remoteTargetWorkspaces { + for workspace in remoteContextMenuWorkspaces() { workspace.reconnectRemoteConnection() } } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) + .disabled(allRemoteContextMenuTargetsConnecting) Button(disconnectLabel) { - for workspace in remoteTargetWorkspaces { + for workspace in remoteContextMenuWorkspaces() { workspace.disconnectRemoteConnection(clearConfiguration: false) } } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + .disabled(allRemoteContextMenuTargetsDisconnected) } Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5b4db687..6c75cb51 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2811,9 +2811,9 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() - if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path, - !bundledCLIPath.isEmpty { - env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath + if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"), + FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) { + env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIURL.path } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { env["CMUX_BUNDLE_ID"] = bundleId @@ -2883,7 +2883,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } if !initialEnvironmentOverrides.isEmpty { - for (key, value) in initialEnvironmentOverrides { + for (key, value) in initialEnvironmentOverrides where !key.hasPrefix("CMUX_") { env[key] = value } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b2927a8a..a6d331bb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1420,6 +1420,7 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view private(set) var webView: WKWebView + private let websiteDataStore: WKWebsiteDataStore /// Monotonic identity for the current WKWebView instance. /// Incremented whenever we replace the underlying WKWebView after a process crash. @@ -1975,13 +1976,13 @@ final class BrowserPanel: Panel, ObservableObject { false } - private static func makeWebView() -> CmuxWebView { + private static func makeWebView(websiteDataStore: WKWebsiteDataStore) -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. - config.websiteDataStore = .default() + config.websiteDataStore = websiteDataStore // Enable developer extras (DevTools) config.preferences.setValue(true, forKey: "developerExtrasEnabled") @@ -2050,11 +2051,13 @@ final class BrowserPanel: Panel, ObservableObject { self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.remoteProxyEndpoint = proxyEndpoint self.browserThemeMode = BrowserThemeSettings.mode() + self.websiteDataStore = isRemoteWorkspace + ? WKWebsiteDataStore(forIdentifier: self.id) + : .default() - let webView = Self.makeWebView() + let webView = Self.makeWebView(websiteDataStore: websiteDataStore) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } - let _ = isRemoteWorkspace applyRemoteProxyConfigurationIfAvailable() // Set up navigation delegate @@ -2245,7 +2248,7 @@ final class BrowserPanel: Panel, ObservableObject { let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } - self.currentURL = webView.url + self.currentURL = Self.remoteProxyDisplayURL(for: webView.url) } } webViewObservers.append(urlObserver) @@ -2314,7 +2317,7 @@ final class BrowserPanel: Panel, ObservableObject { guard terminatedWebView === webView else { return } let wasRenderable = shouldRenderWebView - let restoreURL = terminatedWebView.url ?? currentURL + let restoreURL = Self.remoteProxyDisplayURL(for: terminatedWebView.url) ?? currentURL let restoreURLString = restoreURL?.absoluteString let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString let history = sessionNavigationHistorySnapshot() @@ -2344,7 +2347,7 @@ final class BrowserPanel: Panel, ObservableObject { terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView() + let replacement = Self.makeWebView(websiteDataStore: websiteDataStore) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement @@ -2401,7 +2404,7 @@ final class BrowserPanel: Panel, ObservableObject { // If nothing meaningful is loaded yet, prefer letting the omnibar take focus. if !webView.isLoading { - let urlString = webView.url?.absoluteString ?? currentURL?.absoluteString + let urlString = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString ?? currentURL?.absoluteString if urlString == nil || urlString == "about:blank" { return } @@ -2694,6 +2697,16 @@ final class BrowserPanel: Panel, ObservableObject { return rewrittenRequest } + private static func remoteProxyDisplayURL(for url: URL?) -> URL? { + guard let url else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } + guard host == BrowserInsecureHTTPSettings.normalizeHost(remoteLoopbackProxyAliasHost) else { return url } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = "localhost" + return components?.url ?? url + } + private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } @@ -2924,7 +2937,7 @@ extension BrowserPanel { oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView() + let replacement = Self.makeWebView(websiteDataStore: websiteDataStore) webViewInstanceID = UUID() webView = replacement shouldRenderWebView = false @@ -4159,7 +4172,7 @@ extension BrowserPanel { /// Returns the most reliable URL string for omnibar-related matching and UI decisions. /// `currentURL` can lag behind navigation changes, so prefer the live WKWebView URL. func preferredURLStringForOmnibar() -> String? { - if let webViewURL = webView.url?.absoluteString + if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString .trimmingCharacters(in: .whitespacesAndNewlines), !webViewURL.isEmpty, webViewURL != blankURLString { @@ -4177,7 +4190,7 @@ extension BrowserPanel { } private func resolvedCurrentSessionHistoryURL() -> URL? { - if let webViewURL = webView.url, + if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url), Self.serializableSessionHistoryURLString(webViewURL) != nil { return webViewURL } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2455e8d5..a7f6e3fa 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -81,9 +81,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return "Left Rail" + return String(localized: "sidebar.activeTabIndicator.leftRail", defaultValue: "Left Rail") case .solidFill: - return "Solid Fill" + return String(localized: "sidebar.activeTabIndicator.solidFill", defaultValue: "Solid Fill") } } } @@ -1456,8 +1456,8 @@ class TabManager: ObservableObject { let willCloseWindow = tabs.count <= 1 if workspaceNeedsConfirmClose(workspace), !confirmClose( - title: "Close workspace?", - message: "This will close the workspace and all of its panels.", + title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), acceptCmdD: willCloseWindow ) { return @@ -1498,8 +1498,8 @@ class TabManager: ObservableObject { let needsConfirm = workspaceNeedsConfirmClose(tab) if needsConfirm { let message = willCloseWindow - ? "This will close the last tab and close the window." - : "This will close the last tab and close its workspace." + ? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.") + : String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.") #if DEBUG dlog( "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + @@ -1507,7 +1507,7 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: "Close tab?", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), message: message, acceptCmdD: willCloseWindow ) else { @@ -1539,8 +1539,8 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: "Close tab?", - message: "This will close the current tab.", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false ) else { #if DEBUG @@ -1578,8 +1578,8 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: surfaceId), terminalPanel.needsConfirmClose() { guard confirmClose( - title: "Close tab?", - message: "This will close the current tab.", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false ) else { return } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 496ebeb2..590abf37 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1192,6 +1192,189 @@ private final class WorkspaceRemoteDaemonRPCClient { } } +enum RemoteLoopbackHTTPRequestRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + guard let headerRange = data.range(of: headerDelimiter) else { return data } + let headerData = Data(data[..<headerRange.upperBound]) + guard let headerText = String(data: headerData, encoding: .utf8) else { return data } + + var lines = headerText.components(separatedBy: "\r\n") + guard !lines.isEmpty else { return data } + guard let requestLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data } + guard requestLineLooksHTTP(lines[requestLineIndex]) else { return data } + + let rewrittenRequestLine = rewriteRequestLine(lines[requestLineIndex], aliasHost: aliasHost) + if rewrittenRequestLine != lines[requestLineIndex] { + lines[requestLineIndex] = rewrittenRequestLine + } + + for index in (requestLineIndex + 1)..<lines.count where !lines[index].isEmpty { + lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost) + } + + let rewrittenHeaderText = lines.joined(separator: "\r\n") + guard rewrittenHeaderText != headerText else { return data } + return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...] + } + + private static func requestLineLooksHTTP(_ requestLine: String) -> Bool { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" + return requestLineMethods.contains(method) + } + + private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) + guard parts.count >= 3 else { return requestLine } + + var components = URLComponents(string: String(parts[1])) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return requestLine + } + components?.host = canonicalLoopbackHost + guard let rewrittenURL = components?.string else { return requestLine } + + var rewritten = parts + rewritten[1] = Substring(rewrittenURL) + let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } + let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) + return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia + } + + private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let valueStart = line.index(after: colonIndex) + let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) + + switch name { + case "host": + guard let rewrittenHost = rewriteHostValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenHost)" + case "origin", "referer": + guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenURL)" + default: + return line + } + } + + private static func rewriteHostValue(_ value: String, aliasHost: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("["), + let closing = trimmed.firstIndex(of: "]") { + let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) + guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + let remainder = String(trimmed[closing...].dropFirst()) + return canonicalLoopbackHost + remainder + } + + if let colonIndex = trimmed.lastIndex(of: ":"), !trimmed[..<colonIndex].contains(":") { + let host = String(trimmed[..<colonIndex]) + guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + return canonicalLoopbackHost + trimmed[colonIndex...] + } + + guard BrowserInsecureHTTPSettings.normalizeHost(trimmed) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + return canonicalLoopbackHost + } + + private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + components?.host = canonicalLoopbackHost + return components?.string + } +} + +enum RemoteLoopbackHTTPResponseRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + guard let headerRange = data.range(of: headerDelimiter) else { return data } + let headerData = Data(data[..<headerRange.upperBound]) + guard let headerText = String(data: headerData, encoding: .utf8) else { return data } + + var lines = headerText.components(separatedBy: "\r\n") + guard let statusLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data } + guard lines[statusLineIndex].uppercased().hasPrefix("HTTP/") else { return data } + + for index in (statusLineIndex + 1)..<lines.count where !lines[index].isEmpty { + lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost) + } + + let rewrittenHeaderText = lines.joined(separator: "\r\n") + guard rewrittenHeaderText != headerText else { return data } + return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...] + } + + private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let valueStart = line.index(after: colonIndex) + let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) + + switch name { + case "location", "content-location", "origin", "referer", "access-control-allow-origin": + guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenURL)" + case "set-cookie": + guard let rewrittenCookie = rewriteCookieValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenCookie)" + default: + return line + } + } + + private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return nil + } + components?.host = aliasHost + return components?.string + } + + private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { + let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) + guard !parts.isEmpty else { return nil } + + var didRewrite = false + let rewrittenParts = parts.map { part -> String in + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("domain=") else { return part } + let domainValue = String(trimmed.dropFirst("domain=".count)) + guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return part + } + didRewrite = true + let leadingWhitespace = part.prefix { $0.isWhitespace } + return "\(leadingWhitespace)Domain=\(aliasHost)" + } + + return didRewrite ? rewrittenParts.joined(separator: ";") : nil + } +} + private final class WorkspaceRemoteDaemonProxyTunnel { private final class ProxySession { private static let maxHandshakeBytes = 64 * 1024 @@ -1229,6 +1412,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { private var handshakeBuffer = Data() private var streamID: String? private var localInputEOF = false + private var rewritesLoopbackHTTPHeaders = false + private var pendingRemoteHTTPHeaderBytes = Data() + private var hasForwardedRemoteHTTPHeaders = false init( connection: NWConnection, @@ -1477,6 +1663,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { ) { guard !isClosed else { return } do { + rewritesLoopbackHTTPHeaders = + BrowserInsecureHTTPSettings.normalizeHost(host) + == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) let targetHost = Self.normalizedProxyTargetHost(host) let streamID = try rpcClient.openStream(host: targetHost, port: port) self.streamID = streamID @@ -1501,7 +1690,13 @@ private final class WorkspaceRemoteDaemonProxyTunnel { guard !localInputEOF || allowAfterEOF else { return } guard let streamID else { return } do { - try rpcClient.writeStream(streamID: streamID, data: data) + let outgoingData = rewritesLoopbackHTTPHeaders + ? RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: data, + aliasHost: Self.remoteLoopbackProxyAliasHost + ) + : data + try rpcClient.writeStream(streamID: streamID, data: outgoingData) } catch { close(reason: "proxy.write failed: \(error.localizedDescription)") } @@ -1540,8 +1735,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { return } - if !readResult.data.isEmpty { - connection.send(content: readResult.data, completion: .contentProcessed { [weak self] error in + let localData = rewriteRemoteResponseIfNeeded(readResult.data, eof: readResult.eof) + if !localData.isEmpty { + connection.send(content: localData, completion: .contentProcessed { [weak self] error in guard let self else { return } if let error { self.close(reason: "proxy client send error: \(error)") @@ -1563,6 +1759,30 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } } + private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { + guard rewritesLoopbackHTTPHeaders else { return data } + guard !data.isEmpty else { return data } + guard !hasForwardedRemoteHTTPHeaders else { return data } + + pendingRemoteHTTPHeaderBytes.append(data) + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { + guard eof else { return Data() } + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return payload + } + + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( + data: payload, + aliasHost: Self.remoteLoopbackProxyAliasHost + ) + } + private func close(reason: String?) { guard !isClosed else { return } isClosed = true @@ -2387,38 +2607,79 @@ private final class WorkspaceRemoteCLIRelayServer { } func start() throws -> Int { + if let existingPort = queue.sync(execute: { localPort }) { + return existingPort + } + + let listener = try Self.makeLoopbackListener() + let readySemaphore = DispatchSemaphore(value: 0) + let stateLock = NSLock() var capturedError: Error? - var boundPort: Int = 0 - queue.sync { - do { - if let localPort { - boundPort = localPort - return - } - let listener = try Self.makeLoopbackListener() - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { _ in } - listener.start(queue: queue) - guard let tcpPort = listener.port?.rawValue else { - throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "failed to bind local relay listener", - ]) - } - self.listener = listener - self.localPort = Int(tcpPort) - boundPort = Int(tcpPort) - } catch { - capturedError = error + var boundPort: Int? + + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) } } - if let capturedError { - throw capturedError + listener.stateUpdateHandler = { listenerState in + switch listenerState { + case .ready: + stateLock.lock() + boundPort = listener.port.map { Int($0.rawValue) } + stateLock.unlock() + readySemaphore.signal() + case .failed(let error): + stateLock.lock() + capturedError = error + stateLock.unlock() + readySemaphore.signal() + default: + break + } + } + listener.start(queue: queue) + + let waitResult = readySemaphore.wait(timeout: .now() + 5.0) + stateLock.lock() + let startupError = capturedError + let startupPort = boundPort + stateLock.unlock() + + if waitResult != .success { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local relay listener", + ]) + } + if let startupError { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw startupError + } + guard let startupPort, startupPort > 0 else { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + + return try queue.sync { + if let localPort { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + return localPort + } + self.listener = listener + self.localPort = startupPort + return startupPort } - return boundPort } func stop() { @@ -2696,26 +2957,20 @@ private final class WorkspaceRemoteSessionController { cliRelayServer = relayServer reverseRelayStderrPipe = stderrPipe reverseRelayStderrBuffer = "" + writeRemoteRelayDaemonPathLocked(remotePath: remotePath) + do { + try writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) + } catch { + debugLog("remote.relay.auth.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + writeRemoteSocketAddrLocked(relayPort: relayPort) debugLog( "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + "target=\(configuration.displayTarget)" ) - - queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in - guard let self else { return } - guard !self.isStopping else { return } - guard self.reverseRelayProcess === process, process.isRunning else { return } - self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath) - do { - try self.writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) - } catch { - self.debugLog("remote.relay.auth.error \(error.localizedDescription)") - self.stopReverseRelayLocked() - self.scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - self.writeRemoteSocketAddrLocked(relayPort: relayPort) - } } catch { debugLog( "remote.relay.startFailed relayPort=\(relayPort) " + @@ -3177,20 +3432,21 @@ private final class WorkspaceRemoteSessionController { let platform = try resolveRemotePlatformLocked() let version = Self.remoteDaemonVersion() let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + let forceDevOverrideInstall = Self.allowLocalDaemonBuildFallback() debugLog( "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + - "version=\(version) remotePath=\(remotePath)" + "version=\(version) remotePath=\(remotePath) devOverride=\(forceDevOverrideInstall ? 1 : 0)" ) let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath) debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") - if !hadExistingBinary { + if forceDevOverrideInstall || !hadExistingBinary { let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) } var hello = try helloRemoteDaemonLocked(remotePath: remotePath) - if hadExistingBinary, !hello.capabilities.contains("proxy.stream") { + if !forceDevOverrideInstall, hadExistingBinary, !hello.capabilities.contains("proxy.stream") { debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) @@ -5313,14 +5569,14 @@ final class Workspace: Identifiable, ObservableObject { if let remoteConfiguration { payload["destination"] = remoteConfiguration.destination payload["port"] = remoteConfiguration.port ?? NSNull() - payload["identity_file"] = remoteConfiguration.identityFile ?? NSNull() - payload["ssh_options"] = remoteConfiguration.sshOptions + payload["has_identity_file"] = remoteConfiguration.identityFile != nil + payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() } else { payload["destination"] = NSNull() payload["port"] = NSNull() - payload["identity_file"] = NSNull() - payload["ssh_options"] = [] + payload["has_identity_file"] = false + payload["has_ssh_options"] = false payload["local_proxy_port"] = NSNull() } return payload @@ -5436,6 +5692,9 @@ final class Workspace: Identifiable, ObservableObject { guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } if !hasBrowserPanels { + if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { + return + } disconnectRemoteConnection(clearConfiguration: true) } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index bbe59232..619db448 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4828,25 +4828,16 @@ final class SidebarRemoteErrorCopySupportTests: XCTestCase { ) } - func testParsedTargetAndDetailParsesCanonicalStatusValue() { - let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( - from: "SSH error (devbox:22): failed to bootstrap daemon" + func testClipboardTextSingleEntryUsesStructuredEntryFields() { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to bootstrap daemon" ) - XCTAssertEqual(parsed?.target, "devbox:22") - XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon") - } - - func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() { - let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( - from: "SSH error: connection refused", - fallbackTarget: "fallback-host" + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), + "SSH error (devbox:22): failed to bootstrap daemon" ) - XCTAssertEqual(parsed?.target, "fallback-host") - XCTAssertEqual(parsed?.detail, "connection refused") - } - - func testParsedTargetAndDetailIgnoresNonSSHStatusValues() { - XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good")) } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d7a4b136..d1b8faa1 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -701,6 +702,92 @@ final class WorkspaceRemoteDaemonManifestTests: XCTestCase { } } +final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { + func testRewritesLoopbackAliasHostHeadersToLocalhost() { + let original = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: cmux-loopback.localtest.me:3000\r\n" + + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + + "\r\n" + ).utf8 + ) + + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) + XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) + } + + func testRewritesAbsoluteFormRequestLineForLoopbackAlias() { + let original = Data( + ( + "GET http://cmux-loopback.localtest.me:3000/demo HTTP/1.1\r\n" + + "Host: cmux-loopback.localtest.me:3000\r\n" + + "\r\n" + ).utf8 + ) + + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.hasPrefix("GET http://localhost:3000/demo HTTP/1.1\r\n")) + XCTAssertTrue(text.contains("Host: localhost:3000")) + } + + func testLeavesNonHTTPPayloadUntouched() { + let original = Data([0x16, 0x03, 0x01, 0x00, 0x2a, 0x01, 0x00]) + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + XCTAssertEqual(rewritten, original) + } + + func testRewritesLoopbackResponseHeadersBackToAlias() { + let original = Data( + ( + "HTTP/1.1 302 Found\r\n" + + "Location: http://localhost:3000/login\r\n" + + "Access-Control-Allow-Origin: http://localhost:3000\r\n" + + "Set-Cookie: sid=1; Domain=localhost; Path=/\r\n" + + "\r\n" + ).utf8 + ) + + let rewritten = RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.contains("Location: http://cmux-loopback.localtest.me:3000/login")) + XCTAssertTrue(text.contains("Access-Control-Allow-Origin: http://cmux-loopback.localtest.me:3000")) + XCTAssertTrue(text.contains("Set-Cookie: sid=1; Domain=cmux-loopback.localtest.me; Path=/")) + } +} + +@MainActor +final class BrowserPanelRemoteStoreTests: XCTestCase { + func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() { + let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) + let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true) + + XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + } +} + final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { func testSupportsMultiplePendingCallsResolvedOutOfOrder() { let registry = WorkspaceRemoteDaemonPendingCallRegistry() diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index ccf3f116..48127765 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -11,8 +11,9 @@ import Darwin @MainActor final class TerminalControllerSocketSecurityTests: XCTestCase { private func makeSocketPath(_ name: String) -> String { - FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock") + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("csec-\(name.prefix(4))-\(shortID).sock") .path } @@ -106,6 +107,33 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { #endif } + func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() { + let tabManager = TabManager() + let workspace = tabManager.addWorkspace(select: false, eagerLoadTerminal: false) + + workspace.configureRemoteConnection( + .init( + destination: "example.com", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: ["ControlMaster=auto", "ControlPersist=600"], + localProxyPort: 1080, + relayPort: 4444, + relayID: "relay-id", + relayToken: "relay-token", + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh example.com" + ), + autoConnect: false + ) + + let payload = workspace.remoteStatusPayload() + XCTAssertNil(payload["identity_file"]) + XCTAssertNil(payload["ssh_options"]) + XCTAssertEqual(payload["has_identity_file"] as? Bool, true) + XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) + } + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index 14d69481..e0a15118 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -165,7 +165,11 @@ func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr fun cmd := spec.v1Cmd if !spec.noParams { - parsed := parseFlags(args, spec.flagKeys) + parsed, err := parseFlags(args, spec.flagKeys) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 2 + } for _, key := range spec.flagKeys { if val, ok := parsed.flags[key]; ok { cmd += " " + val @@ -190,7 +194,11 @@ func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool params := make(map[string]any) if !spec.noParams { - parsed := parseFlags(args, spec.flagKeys) + parsed, err := parseFlags(args, spec.flagKeys) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 2 + } // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) for _, key := range spec.flagKeys { if val, ok := parsed.flags[key]; ok { @@ -292,7 +300,11 @@ func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshA } params := make(map[string]any) - parsed := parseFlags(subArgs, flagKeys) + parsed, err := parseFlags(subArgs, flagKeys) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux browser: %v\n", err) + return 2 + } for _, key := range flagKeys { if val, ok := parsed.flags[key]; ok { paramKey := flagToParamKey(key) @@ -386,7 +398,7 @@ type parsedFlags struct { // parseFlags extracts --key value pairs from args for the given allowed keys. // Non-flag arguments are collected in positional. -func parseFlags(args []string, keys []string) parsedFlags { +func parseFlags(args []string, keys []string) (parsedFlags, error) { allowed := make(map[string]bool, len(keys)) for _, k := range keys { allowed[k] = true @@ -394,20 +406,24 @@ func parseFlags(args []string, keys []string) parsedFlags { result := parsedFlags{flags: make(map[string]string)} for i := 0; i < len(args); i++ { + if args[i] == "--" { + result.positional = append(result.positional, args[i+1:]...) + break + } if !strings.HasPrefix(args[i], "--") { result.positional = append(result.positional, args[i]) continue } key := strings.TrimPrefix(args[i], "--") if !allowed[key] { - continue + return parsedFlags{}, fmt.Errorf("unknown flag --%s", key) } if i+1 < len(args) { result.flags[key] = args[i+1] i++ } } - return result + return result, nil } // readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback @@ -465,11 +481,11 @@ func currentRelayAuth(socketPath string) *relayAuthState { // refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { - conn, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) + conn, connectedAddr, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) if err != nil { return nil, err } - if auth := currentRelayAuth(addr); auth != nil { + if auth := currentRelayAuth(connectedAddr); auth != nil { if err := authenticateRelayConn(conn, auth); err != nil { conn.Close() return nil, err @@ -484,21 +500,21 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { // This handles the case where the SSH reverse relay hasn't finished establishing yet. // If refreshAddr is non-nil, it's called on each retry to pick up updated addresses // (e.g. when socket_addr is rewritten by a new relay process). -func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) { +func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, error) { deadline := time.Now().Add(timeout) interval := 250 * time.Millisecond printed := false for { conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err == nil { - return conn, nil + return conn, addr, nil } if time.Now().After(deadline) { - return nil, err + return nil, addr, err } // Only retry on connection refused (relay not ready yet) if !isConnectionRefused(err) { - return nil, err + return nil, addr, err } if !printed { fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index 32d08280..d9a09390 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -279,7 +279,7 @@ func TestDialTCPRetrySuccess(t *testing.T) { conn.Close() }() - conn, err := dialTCPRetry(addr, 3*time.Second, nil) + conn, _, err := dialTCPRetry(addr, 3*time.Second, nil) if err != nil { t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) } @@ -296,7 +296,7 @@ func TestDialTCPRetryTimeout(t *testing.T) { ln.Close() start := time.Now() - _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + _, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) elapsed := time.Since(start) if err == nil { t.Fatal("dialTCPRetry should fail when nothing is listening") @@ -422,7 +422,7 @@ func TestCLICloseWindowV1(t *testing.T) { dir := t.TempDir() sockPath := filepath.Join(dir, "cmux.sock") - var received string + receivedCh := make(chan string, 1) ln, err := net.Listen("unix", sockPath) if err != nil { t.Fatalf("listen: %v", err) @@ -436,7 +436,7 @@ func TestCLICloseWindowV1(t *testing.T) { } buf := make([]byte, 4096) n, _ := conn.Read(buf) - received = strings.TrimSpace(string(buf[:n])) + receivedCh <- strings.TrimSpace(string(buf[:n])) conn.Write([]byte("OK\n")) conn.Close() }() @@ -445,8 +445,13 @@ func TestCLICloseWindowV1(t *testing.T) { if code != 0 { t.Fatalf("close-window should return 0, got %d", code) } - if received != "close_window win-42" { - t.Fatalf("expected 'close_window win-42', got %q", received) + select { + case received := <-receivedCh: + if received != "close_window win-42" { + t.Fatalf("expected 'close_window win-42', got %q", received) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close-window payload") } } @@ -532,7 +537,7 @@ func TestCLIV2FlagMapping(t *testing.T) { dir := t.TempDir() sockPath := filepath.Join(dir, "cmux.sock") - var receivedParams map[string]any + receivedParamsCh := make(chan map[string]any, 1) ln, err := net.Listen("unix", sockPath) if err != nil { t.Fatalf("listen: %v", err) @@ -548,7 +553,8 @@ func TestCLIV2FlagMapping(t *testing.T) { n, _ := conn.Read(buf) var req map[string]any json.Unmarshal(buf[:n], &req) - receivedParams, _ = req["params"].(map[string]any) + receivedParams, _ := req["params"].(map[string]any) + receivedParamsCh <- receivedParams resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} payload, _ := json.Marshal(resp) conn.Write(append(payload, '\n')) @@ -559,8 +565,13 @@ func TestCLIV2FlagMapping(t *testing.T) { if code != 0 { t.Fatalf("close-workspace should return 0, got %d", code) } - if receivedParams["workspace_id"] != "ws-abc" { - t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + select { + case receivedParams := <-receivedParamsCh: + if receivedParams["workspace_id"] != "ws-abc" { + t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close-workspace payload") } } @@ -635,16 +646,24 @@ func TestFlagToParamKey(t *testing.T) { func TestParseFlags(t *testing.T) { args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} - result := parseFlags(args, []string{"workspace", "surface"}) + _, err := parseFlags(args, []string{"workspace", "surface"}) + if err == nil { + t.Fatal("parseFlags should reject unknown flags") + } +} + +func TestParseFlagsCollectsKnownFlagsAndPositionalArgs(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2"} + result, err := parseFlags(args, []string{"workspace", "surface"}) + if err != nil { + t.Fatalf("parseFlags should succeed for known flags: %v", err) + } if result.flags["workspace"] != "ws-1" { t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) } if result.flags["surface"] != "sf-2" { t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) } - if _, ok := result.flags["unknown"]; ok { - t.Errorf("unknown flag should not be parsed") - } if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { t.Errorf("expected first positional=positional-cmd, got %v", result.positional) } @@ -655,7 +674,7 @@ func TestCLIEnvVarDefaults(t *testing.T) { dir := t.TempDir() sockPath := filepath.Join(dir, "cmux.sock") - var receivedParams map[string]any + receivedParamsCh := make(chan map[string]any, 1) ln, err := net.Listen("unix", sockPath) if err != nil { t.Fatalf("listen: %v", err) @@ -671,7 +690,8 @@ func TestCLIEnvVarDefaults(t *testing.T) { n, _ := conn.Read(buf) var req map[string]any json.Unmarshal(buf[:n], &req) - receivedParams, _ = req["params"].(map[string]any) + receivedParams, _ := req["params"].(map[string]any) + receivedParamsCh <- receivedParams resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} payload, _ := json.Marshal(resp) conn.Write(append(payload, '\n')) @@ -687,10 +707,15 @@ func TestCLIEnvVarDefaults(t *testing.T) { if code != 0 { t.Fatalf("close-surface should return 0, got %d", code) } - if receivedParams["workspace_id"] != "env-ws-id" { - t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) - } - if receivedParams["surface_id"] != "env-sf-id" { - t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + select { + case receivedParams := <-receivedParamsCh: + if receivedParams["workspace_id"] != "env-ws-id" { + t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) + } + if receivedParams["surface_id"] != "env-sf-id" { + t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close-surface payload") } } diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 22db25a3..021c8e6a 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -548,6 +548,7 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { } _ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)) + defer conn.SetReadDeadline(time.Time{}) buffer := make([]byte, maxBytes) n, readErr := conn.Read(buffer) data := buffer[:max(0, n)] diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 03aaa248..57e0f443 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -2,7 +2,7 @@ Last updated: March 12, 2026 Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 -Primary PR: https://github.com/manaflow-ai/cmux/pull/239 +Primary PR: https://github.com/manaflow-ai/cmux/pull/1296 CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 This document is the working source of truth for: diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh index a6be6fc6..c5d9502b 100755 --- a/scripts/build_remote_daemon_release_assets.sh +++ b/scripts/build_remote_daemon_release_assets.sh @@ -68,7 +68,6 @@ DAEMON_ROOT="${REPO_ROOT}/daemon/remote" mkdir -p "$OUTPUT_DIR" rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json -RELEASE_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}" CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt" CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json" @@ -80,8 +79,10 @@ TARGETS=( "linux amd64" ) -declare -a manifest_entries=() : > "$CHECKSUMS_PATH" +ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")" +trap 'rm -f "$ENTRIES_FILE"' EXIT +: > "$ENTRIES_FILE" for target in "${TARGETS[@]}"; do read -r GOOS GOARCH <<<"$target" @@ -102,29 +103,33 @@ for target in "${TARGETS[@]}"; do SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH" - manifest_entries+=("{\"goOS\":\"${GOOS}\",\"goArch\":\"${GOARCH}\",\"assetName\":\"${ASSET_NAME}\",\"downloadURL\":\"${RELEASE_URL}/${ASSET_NAME}\",\"sha256\":\"${SHA256}\"}") + printf '%s\t%s\t%s\t%s\n' "$GOOS" "$GOARCH" "$ASSET_NAME" "$SHA256" >> "$ENTRIES_FILE" done -ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")" -trap 'rm -f "$ENTRIES_FILE"' EXIT -printf '%s\n' "${manifest_entries[@]}" > "$ENTRIES_FILE" -ENTRIES_JSON="$(python3 - <<'PY' "$ENTRIES_FILE" +python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$REPO" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_FILE" import json import sys +import urllib.parse from pathlib import Path -entries = [json.loads(line) for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()] -print(json.dumps(entries, separators=(",", ":"))) -PY -)" +version, release_tag, repo, checksums_asset_name, checksums_path, manifest_path, entries_file = sys.argv[1:] +quoted_tag = urllib.parse.quote(release_tag, safe="") +release_url = f"https://github.com/{repo}/releases/download/{quoted_tag}" +checksums_url = f"{release_url}/{urllib.parse.quote(checksums_asset_name, safe='')}" -python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$RELEASE_URL" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_JSON" -import json -import sys -from pathlib import Path +entries = [] +for line in Path(entries_file).read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + go_os, go_arch, asset_name, sha256 = line.split("\t") + entries.append({ + "goOS": go_os, + "goArch": go_arch, + "assetName": asset_name, + "downloadURL": f"{release_url}/{urllib.parse.quote(asset_name, safe='')}", + "sha256": sha256, + }) -version, release_tag, release_url, checksums_asset_name, checksums_path, manifest_path, entries_json = sys.argv[1:] -checksums_url = f"{release_url}/{checksums_asset_name}" manifest = { "schemaVersion": 1, "appVersion": version, @@ -132,7 +137,7 @@ manifest = { "releaseURL": release_url, "checksumsAssetName": checksums_asset_name, "checksumsURL": checksums_url, - "entries": json.loads(entries_json), + "entries": entries, } Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") PY diff --git a/tests/fixtures/ssh-remote/ws_echo.py b/tests/fixtures/ssh-remote/ws_echo.py index 4acb8935..ec857287 100644 --- a/tests/fixtures/ssh-remote/ws_echo.py +++ b/tests/fixtures/ssh-remote/ws_echo.py @@ -14,8 +14,13 @@ import threading GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" -def _recv_exact(conn: socket.socket, n: int) -> bytes: +def _recv_exact(conn: socket.socket, n: int, pending: bytearray | None = None) -> bytes: data = bytearray() + if pending: + take = min(len(pending), n) + if take: + data.extend(pending[:take]) + del pending[:take] while len(data) < n: chunk = conn.recv(n - len(data)) if not chunk: @@ -24,7 +29,7 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes: return bytes(data) -def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> bytes: +def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> tuple[bytes, bytearray]: data = bytearray() while marker not in data: chunk = conn.recv(1024) @@ -33,21 +38,22 @@ def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> bytes: data.extend(chunk) if len(data) > limit: raise ValueError("header too large") - return bytes(data) + marker_end = data.index(marker) + len(marker) + return bytes(data[:marker_end]), bytearray(data[marker_end:]) -def _read_frame(conn: socket.socket) -> tuple[int, bytes]: - first, second = _recv_exact(conn, 2) +def _read_frame(conn: socket.socket, pending: bytearray | None = None) -> tuple[int, bytes]: + first, second = _recv_exact(conn, 2, pending) opcode = first & 0x0F masked = (second & 0x80) != 0 length = second & 0x7F if length == 126: - length = struct.unpack("!H", _recv_exact(conn, 2))[0] + length = struct.unpack("!H", _recv_exact(conn, 2, pending))[0] elif length == 127: - length = struct.unpack("!Q", _recv_exact(conn, 8))[0] + length = struct.unpack("!Q", _recv_exact(conn, 8, pending))[0] - mask_key = _recv_exact(conn, 4) if masked else b"" - payload = _recv_exact(conn, length) if length else b"" + mask_key = _recv_exact(conn, 4, pending) if masked else b"" + payload = _recv_exact(conn, length, pending) if length else b"" if masked and payload: payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) return opcode, payload @@ -67,7 +73,7 @@ def _send_frame(conn: socket.socket, opcode: int, payload: bytes) -> None: def handle_client(conn: socket.socket) -> None: try: - request = _recv_until(conn, b"\r\n\r\n") + request, pending = _recv_until(conn, b"\r\n\r\n") headers_raw = request.decode("utf-8", errors="replace").split("\r\n") header_map: dict[str, str] = {} for line in headers_raw[1:]: @@ -94,7 +100,7 @@ def handle_client(conn: socket.socket) -> None: conn.sendall(response.encode("utf-8")) while True: - opcode, payload = _read_frame(conn) + opcode, payload = _read_frame(conn, pending) if opcode == 0x8: # close _send_frame(conn, 0x8, b"") return diff --git a/tests_v2/pane_resize_test_support.py b/tests_v2/pane_resize_test_support.py new file mode 100644 index 00000000..4b55bbde --- /dev/null +++ b/tests_v2/pane_resize_test_support.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import re +import secrets +import time + +from cmux import cmux, cmuxError + + +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + out: list[tuple[str, bool, int]] = [] + for row in payload.get("panes") or []: + out.append(( + str(row.get("id") or ""), + bool(row.get("focused")), + int(row.get("surface_count") or 0), + )) + return out + + +def focused_pane_id(client: cmux, workspace_id: str) -> str: + for pane_id, focused, _surface_count in workspace_panes(client, workspace_id): + if focused: + return pane_id + raise cmuxError("No focused pane found") + + +def surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + text = surface_scrollback_text(client, workspace_id, surface_id) + return [clean_line(raw) for raw in text.splitlines()] + + +def scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + return token in surface_scrollback_lines(client, workspace_id, surface_id) + + +def wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + wait_for( + lambda: scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + +def pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py index e09741fd..badc306a 100644 --- a/tests_v2/test_cli_global_flags_and_v1_error_contract.py +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -67,6 +67,7 @@ def main() -> int: LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") auto_env = dict(os.environ) auto_env.pop("CMUX_SOCKET_PATH", None) + auto_env.pop("CMUX_SOCKET", None) auto_ping = _run([cli, "ping"], env=auto_env) auto_ping_out = _merged_output(auto_ping).lower() _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") diff --git a/tests_v2/test_cli_sidebar_metadata_commands.py b/tests_v2/test_cli_sidebar_metadata_commands.py new file mode 100644 index 00000000..142ce093 --- /dev/null +++ b/tests_v2/test_cli_sidebar_metadata_commands.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Regression: sidebar metadata CLI commands still dispatch through the public cmux CLI.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str]) -> str: + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, *args], + capture_output=True, + text=True, + check=False, + env=dict(os.environ), + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + return proc.stdout.strip() + + +def main() -> int: + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + + status_response = _run_cli(cli, ["set-status", "build", "compiling", "--workspace", workspace_id]) + _must(status_response.startswith("OK"), f"set-status should succeed, got {status_response!r}") + + status_list = _run_cli(cli, ["list-status", "--workspace", workspace_id]) + _must("build=compiling" in status_list, f"list-status should include the inserted status entry: {status_list!r}") + + progress_response = _run_cli(cli, ["set-progress", "0.5", "--workspace", workspace_id, "--label", "Building"]) + _must(progress_response.startswith("OK"), f"set-progress should succeed, got {progress_response!r}") + + log_response = _run_cli(cli, ["log", "--workspace", workspace_id, "--", "ship it"]) + _must(log_response.startswith("OK"), f"log should succeed, got {log_response!r}") + + log_list = _run_cli(cli, ["list-log", "--workspace", workspace_id, "--limit", "5"]) + _must("ship it" in log_list, f"list-log should include the appended log entry: {log_list!r}") + + sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id]) + _must("status_count=1" in sidebar_state, f"sidebar-state should include the status entry count: {sidebar_state!r}") + _must("progress=0.50 Building" in sidebar_state, f"sidebar-state should include the progress label: {sidebar_state!r}") + _must("[info] ship it" in sidebar_state, f"sidebar-state should include the recent log entry: {sidebar_state!r}") + + clear_status_response = _run_cli(cli, ["clear-status", "build", "--workspace", workspace_id]) + _must(clear_status_response.startswith("OK"), f"clear-status should succeed, got {clear_status_response!r}") + + clear_progress_response = _run_cli(cli, ["clear-progress", "--workspace", workspace_id]) + _must(clear_progress_response.startswith("OK"), f"clear-progress should succeed, got {clear_progress_response!r}") + + clear_log_response = _run_cli(cli, ["clear-log", "--workspace", workspace_id]) + _must(clear_log_response.startswith("OK"), f"clear-log should succeed, got {clear_log_response!r}") + + cleared_sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id]) + _must("status_count=0" in cleared_sidebar_state, f"sidebar-state should clear status entries: {cleared_sidebar_state!r}") + _must("progress=none" in cleared_sidebar_state, f"sidebar-state should clear progress: {cleared_sidebar_state!r}") + _must("log_count=0" in cleared_sidebar_state, f"sidebar-state should clear log entries: {cleared_sidebar_state!r}") + + client.close_workspace(workspace_id) + workspace_id = "" + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: sidebar metadata CLI commands dispatch and update workspace state") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_ls_scrollback.py b/tests_v2/test_pane_resize_preserves_ls_scrollback.py index 0eb450d2..88c7511d 100644 --- a/tests_v2/test_pane_resize_preserves_ls_scrollback.py +++ b/tests_v2/test_pane_resize_preserves_ls_scrollback.py @@ -4,7 +4,6 @@ from __future__ import annotations import os -import re import secrets import shlex import shutil @@ -15,97 +14,20 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from cmux import cmux, cmuxError +from pane_resize_test_support import ( + clean_line as _clean_line, + focused_pane_id as _focused_pane_id, + pane_extent as _pane_extent, + pick_resize_direction_for_pane as _pick_resize_direction_for_pane, + scrollback_has_exact_line as _scrollback_has_exact_line, + surface_scrollback_text as _surface_scrollback_text, + wait_for as _wait_for, + wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip, + workspace_panes as _workspace_panes, +) DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] -ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _clean_line(raw: str) -> str: - line = OSC_ESCAPE_RE.sub("", raw) - line = ANSI_ESCAPE_RE.sub("", line) - line = line.replace("\r", "") - return line.strip() - - -def _layout_panes(client: cmux) -> list[dict]: - layout_payload = client.layout_debug() or {} - layout = layout_payload.get("layout") or {} - return list(layout.get("panes") or []) - - -def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: - panes = _layout_panes(client) - for pane in panes: - pid = str(pane.get("paneId") or pane.get("pane_id") or "") - if pid != pane_id: - continue - frame = pane.get("frame") or {} - return float(frame.get(axis) or 0.0) - raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") - - -def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: - payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} - out: list[tuple[str, bool, int]] = [] - for row in payload.get("panes") or []: - out.append(( - str(row.get("id") or ""), - bool(row.get("focused")), - int(row.get("surface_count") or 0), - )) - return out - - -def _focused_pane_id(client: cmux, workspace_id: str) -> str: - for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id): - if focused: - return pane_id - raise cmuxError("No focused pane found") - - -def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - return str(payload.get("text") or "") - - -def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: - text = _surface_scrollback_text(client, workspace_id, surface_id) - lines = [_clean_line(raw) for raw in text.splitlines()] - return token in lines - - -def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: - for _attempt in range(1, 5): - token = f"CMUX_READY_{secrets.token_hex(4)}" - client.send_surface(surface_id, f"echo {token}\n") - try: - _wait_for( - lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), - timeout_s=2.5, - ) - return - except cmuxError: - time.sleep(0.1) - raise cmuxError("Timed out waiting for surface command roundtrip") def _has_exact_marker_lines( @@ -120,30 +42,6 @@ def _has_exact_marker_lines( return start_marker in lines and end_marker in lines -def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: - panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] - if len(panes) < 2: - raise cmuxError(f"Need >=2 panes for resize test, got {panes}") - - def x_of(p: dict) -> float: - return float((p.get("frame") or {}).get("x") or 0.0) - - def y_of(p: dict) -> float: - return float((p.get("frame") or {}).get("y") or 0.0) - - x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) - y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) - - if x_span >= y_span: - left_pane = min(panes, key=x_of) - left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") - return ("right" if target_pane == left_id else "left"), "width" - - top_pane = min(panes, key=y_of) - top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") - return ("down" if target_pane == top_id else "up"), "height" - - def _extract_segment_lines( text: str, start_marker: str, diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py index ea175d0c..a249679b 100644 --- a/tests_v2/test_pane_resize_preserves_visible_content.py +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -4,132 +4,26 @@ from __future__ import annotations import os -import re import secrets import sys -import time from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from cmux import cmux, cmuxError +from pane_resize_test_support import ( + focused_pane_id as _focused_pane_id, + pane_extent as _pane_extent, + pick_resize_direction_for_pane as _pick_resize_direction_for_pane, + scrollback_has_exact_line as _scrollback_has_exact_line, + surface_scrollback_lines as _surface_scrollback_lines, + wait_for as _wait_for, + wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip, + workspace_panes as _workspace_panes, + must as _must, +) DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] -ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _clean_line(raw: str) -> str: - line = OSC_ESCAPE_RE.sub("", raw) - line = ANSI_ESCAPE_RE.sub("", line) - line = line.replace("\r", "") - return line.strip() - - -def _layout_panes(client: cmux) -> list[dict]: - layout_payload = client.layout_debug() or {} - layout = layout_payload.get("layout") or {} - return list(layout.get("panes") or []) - - -def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: - panes = _layout_panes(client) - for pane in panes: - pid = str(pane.get("paneId") or pane.get("pane_id") or "") - if pid != pane_id: - continue - frame = pane.get("frame") or {} - return float(frame.get(axis) or 0.0) - raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") - - -def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: - payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} - out: list[tuple[str, bool, int]] = [] - for row in payload.get("panes") or []: - out.append(( - str(row.get("id") or ""), - bool(row.get("focused")), - int(row.get("surface_count") or 0), - )) - return out - - -def _focused_pane_id(client: cmux, workspace_id: str) -> str: - for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id): - if focused: - return pane_id - raise cmuxError("No focused pane found") - - -def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - return str(payload.get("text") or "") - - -def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: - text = _surface_scrollback_text(client, workspace_id, surface_id) - return [_clean_line(raw) for raw in text.splitlines()] - - -def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: - return token in _surface_scrollback_lines(client, workspace_id, surface_id) - - -def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: - for _attempt in range(1, 5): - token = f"CMUX_READY_{secrets.token_hex(4)}" - client.send_surface(surface_id, f"echo {token}\n") - try: - _wait_for( - lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), - timeout_s=2.5, - ) - return - except cmuxError: - time.sleep(0.1) - raise cmuxError("Timed out waiting for surface command roundtrip") - - -def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: - panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] - if len(panes) < 2: - raise cmuxError(f"Need >=2 panes for resize test, got {panes}") - - def x_of(p: dict) -> float: - return float((p.get("frame") or {}).get("x") or 0.0) - - def y_of(p: dict) -> float: - return float((p.get("frame") or {}).get("y") or 0.0) - - x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) - y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) - - if x_span >= y_span: - left_pane = min(panes, key=x_of) - left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") - return ("right" if target_pane == left_id else "left"), "width" - - top_pane = min(panes, key=y_of) - top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") - return ("down" if target_pane == top_id else "up"), "height" def _run_once(socket_path: str) -> int: diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 0b3aabfc..9764da35 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -74,15 +74,6 @@ def _extract_control_path(ssh_command: str) -> str: return match.group(1) if match else "" -def _has_ssh_option_key(options: list[str], key: str) -> bool: - lowered_key = key.lower() - for option in options: - token = re.split(r"[=\s]+", str(option).strip(), maxsplit=1)[0].strip().lower() - if token == lowered_key: - return True - return False - - def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None: deadline = time.time() + timeout last_exc: Exception | None = None @@ -187,12 +178,36 @@ def main() -> int: _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") _must( - ( - f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; " - f"export CMUX_SOCKET_PATH={remote_socket_addr}; " - "exec \"${SHELL:-/bin/zsh}\" -l" - ) in ssh_command, - f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}", + "RemoteCommand=/bin/sh -lc " in ssh_command, + f"cmux ssh should route RemoteCommand through /bin/sh for non-POSIX login shells: {ssh_command!r}", + ) + _must( + f"export PATH=\"$HOME/.cmux/bin:$PATH\"" in ssh_command, + f"cmux ssh should still prepend the remote cmux wrapper path: {ssh_command!r}", + ) + _must( + f"export CMUX_SOCKET_PATH=127.0.0.1:{int(remote_relay_port)}" in ssh_command, + f"cmux ssh should still pin the relay socket path in RemoteCommand: {ssh_command!r}", + ) + _must( + "case \"${CMUX_LOGIN_SHELL##*/}\" in" in ssh_command, + f"cmux ssh should still branch on the user's login shell when possible: {ssh_command!r}", + ) + _must( + "cat > \"$cmux_shell_dir/.zshrc\"" in ssh_command, + f"cmux ssh should install a post-rc zsh wrapper so the remote cmux wrapper stays first on PATH: {ssh_command!r}", + ) + _must( + "cmux_wait_attempt=0" in ssh_command, + f"cmux ssh should wait briefly for the authenticated relay before showing the remote shell: {ssh_command!r}", + ) + _must( + "exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i" in ssh_command, + f"cmux ssh should still support bash login shells with a post-rc wrapper file: {ssh_command!r}", + ) + _must( + "exec \"$CMUX_LOGIN_SHELL\" -i" in ssh_command, + f"cmux ssh should still hand off to the user's interactive login shell when possible: {ssh_command!r}", ) listed_row = None @@ -221,18 +236,17 @@ def main() -> int: str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"}, f"remote payload should include proxy state metadata: {remote}", ) - remote_ssh_options = [str(item) for item in (remote.get("ssh_options") or [])] _must( - _has_ssh_option_key(remote_ssh_options, "ControlMaster"), - f"workspace.remote.configure should include ControlMaster default: {remote}", + "ssh_options" not in remote, + f"workspace remote payload should not expose raw ssh_options: {remote}", ) _must( - _has_ssh_option_key(remote_ssh_options, "ControlPersist"), - f"workspace.remote.configure should include ControlPersist default: {remote}", + "identity_file" not in remote, + f"workspace remote payload should not expose identity_file: {remote}", ) _must( - _has_ssh_option_key(remote_ssh_options, "ControlPath"), - f"workspace.remote.configure should include ControlPath default: {remote}", + bool(remote.get("has_ssh_options")) is True, + f"workspace remote payload should indicate ssh options are configured: {remote}", ) # Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell. terminal_text = _read_any_terminal_text(client, workspace_id) @@ -352,10 +366,13 @@ def main() -> int: f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}", ) strict_override_remote = payload_strict_override.get("remote") or {} - strict_override_options = [str(item) for item in (strict_override_remote.get("ssh_options") or [])] _must( - any(item.lower() == "stricthostkeychecking=no" for item in strict_override_options), - f"workspace.remote.configure should preserve explicit StrictHostKeyChecking override: {strict_override_remote}", + "ssh_options" not in strict_override_remote, + f"workspace remote payload should not expose raw ssh_options: {strict_override_remote}", + ) + _must( + bool(strict_override_remote.get("has_ssh_options")) is True, + f"workspace remote payload should indicate ssh options are configured: {strict_override_remote}", ) payload_case_override = _run_cli_json( @@ -420,38 +437,13 @@ def main() -> int: f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}", ) case_override_remote = payload_case_override.get("remote") or {} - case_override_options = [str(item) for item in (case_override_remote.get("ssh_options") or [])] _must( - any(item.lower() == "stricthostkeychecking=no" for item in case_override_options), - f"workspace.remote.configure should preserve lowercase StrictHostKeyChecking override: {case_override_remote}", + "ssh_options" not in case_override_remote, + f"workspace remote payload should not expose raw ssh_options: {case_override_remote}", ) _must( - not any(item.lower() == "stricthostkeychecking=accept-new" for item in case_override_options), - f"workspace.remote.configure should not inject default StrictHostKeyChecking when lowercase override is supplied: {case_override_remote}", - ) - _must( - any(item.lower() == "controlmaster=no" for item in case_override_options), - f"workspace.remote.configure should preserve lowercase ControlMaster override: {case_override_remote}", - ) - _must( - not any(item.lower() == "controlmaster=auto" for item in case_override_options), - f"workspace.remote.configure should not inject default ControlMaster when lowercase override is supplied: {case_override_remote}", - ) - _must( - any(item.lower() == "controlpersist=0" for item in case_override_options), - f"workspace.remote.configure should preserve lowercase ControlPersist override: {case_override_remote}", - ) - _must( - not any(item.lower() == "controlpersist=600" for item in case_override_options), - f"workspace.remote.configure should not inject default ControlPersist when lowercase override is supplied: {case_override_remote}", - ) - _must( - any(item.lower() == "controlpath=/tmp/cmux-ssh-%c-custom" for item in case_override_options), - f"workspace.remote.configure should preserve lowercase ControlPath override: {case_override_remote}", - ) - _must( - sum(1 for item in case_override_options if item.lower().startswith("controlpath=")) == 1, - f"workspace.remote.configure should include exactly one ControlPath when lowercase override is supplied: {case_override_remote}", + bool(case_override_remote.get("has_ssh_options")) is True, + f"workspace remote payload should indicate ssh options are configured: {case_override_remote}", ) payload3 = _run_cli_json( @@ -475,7 +467,7 @@ def main() -> int: except Exception: pass - invalid_proxy_port_workspace = client._call("workspace.create", {"initial_command": "echo invalid-local-proxy-port"}) or {} + invalid_proxy_port_workspace = client._call("workspace.create", {}) or {} workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "") if workspace_id_invalid_proxy_port: workspaces_to_close.append(workspace_id_invalid_proxy_port) diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index 53e01a95..2d2adf6a 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -207,7 +207,7 @@ def main() -> int: remote_relay_port = payload.get("remote_relay_port") _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") remote_relay_port = int(remote_relay_port) - _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") + _must(1 <= remote_relay_port <= 65535, f"remote_relay_port should be a valid TCP port: {remote_relay_port}") remote_socket_addr = f"127.0.0.1:{remote_relay_port}" startup_cmd = str(payload.get("ssh_startup_command") or "") _must( @@ -288,7 +288,7 @@ def main() -> int: remote_relay_port_2 = payload_2.get("remote_relay_port") _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") remote_relay_port_2 = int(remote_relay_port_2) - _must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}") + _must(1 <= remote_relay_port_2 <= 65535, f"second remote_relay_port should be a valid TCP port: {remote_relay_port_2}") _must( remote_relay_port_2 != remote_relay_port, f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", diff --git a/tests_v2/test_ssh_remote_daemon_resize_stdio.py b/tests_v2/test_ssh_remote_daemon_resize_stdio.py index d11cb845..f91a6175 100644 --- a/tests_v2/test_ssh_remote_daemon_resize_stdio.py +++ b/tests_v2/test_ssh_remote_daemon_resize_stdio.py @@ -70,6 +70,8 @@ def _as_int(value: object, field: str) -> int: if isinstance(value, int): return value if isinstance(value, float): + if not value.is_integer(): + raise cmuxError(f"{field} should be an integer value, got float {value!r}") return int(value) raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}") diff --git a/tests_v2/test_ssh_remote_proxy_bind_conflict.py b/tests_v2/test_ssh_remote_proxy_bind_conflict.py index d47e2957..4828c20e 100644 --- a/tests_v2/test_ssh_remote_proxy_bind_conflict.py +++ b/tests_v2/test_ssh_remote_proxy_bind_conflict.py @@ -185,10 +185,10 @@ def main() -> int: host = f"root@{DOCKER_SSH_HOST}" _wait_for_ssh(host, host_ssh_port, key_path) - conflict_port = _find_free_loopback_port() conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - conflict_listener.bind(("127.0.0.1", conflict_port)) + conflict_listener.bind(("127.0.0.1", 0)) + conflict_port = int(conflict_listener.getsockname()[1]) conflict_listener.listen(1) with cmux(SOCKET_PATH) as client: diff --git a/tests_v2/test_ssh_remote_second_session_mux_regression.py b/tests_v2/test_ssh_remote_second_session_mux_regression.py index c521485c..d17b23ae 100644 --- a/tests_v2/test_ssh_remote_second_session_mux_regression.py +++ b/tests_v2/test_ssh_remote_second_session_mux_regression.py @@ -131,6 +131,10 @@ def main() -> int: second = _run_cli_json(cli, ["ssh", SSH_HOST]) second_workspace_id = _workspace_id_from_payload(client, second) _must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}") + _must( + second_workspace_id != first_workspace_id, + f"second cmux ssh should create a distinct workspace: {first_workspace_id} vs {second_workspace_id}", + ) workspace_ids.append(second_workspace_id) _wait_remote_ready(client, second_workspace_id) From 4fffe3be3dbad1fa76ac4561c8e8d494511d1472 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:28:24 -0700 Subject: [PATCH 08/77] Address ssh stack review follow-ups --- Resources/Localizable.xcstrings | 17 +++++++++ Sources/ContentView.swift | 5 --- Sources/TabManager.swift | 4 +- Sources/TerminalController.swift | 38 ++++++++----------- ...erminalControllerSocketSecurityTests.swift | 14 +++++++ 5 files changed, 48 insertions(+), 30 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3f192ca9..d2d4fded 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -27684,6 +27684,23 @@ } } }, + "dialog.closeTab.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, "dialog.closeTab.message": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 68a8ecc9..bce7a8d5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9758,11 +9758,6 @@ private struct TabItemView: View, Equatable { private var remoteWorkspaceSection: some View { if sidebarShowSSH, let remoteWorkspaceSidebarText { VStack(alignment: .leading, spacing: 2) { - Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH")) - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(activeSecondaryColor(0.62)) - .textCase(.uppercase) - HStack(spacing: 6) { Text(remoteWorkspaceSidebarText) .font(.system(size: 10, design: .monospaced)) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 26eaf4ed..c3497281 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1506,8 +1506,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) // macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save"). // We only opt into this for the "close last workspace => close window" path to avoid diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8cc85778..f8c78e22 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3117,7 +3117,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -4149,14 +4149,6 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } - let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { result = .err(code: "not_found", message: "No focused surface", data: nil) @@ -4167,6 +4159,9 @@ class TerminalController { return } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -4340,7 +4335,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -4451,7 +4446,7 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return @@ -4919,15 +4914,6 @@ class TerminalController { return } - // Ensure the flash is visible in the active UI. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } - let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { result = .err(code: "not_found", message: "No focused surface", data: nil) @@ -4938,6 +4924,9 @@ class TerminalController { return } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + ws.triggerFocusFlash(panelId: surfaceId) result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } @@ -11017,6 +11006,7 @@ class TerminalController { guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" } var ok = false + let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), @@ -11024,9 +11014,11 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: true) - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) + dstTM.attachWorkspace(ws, select: focus) + if focus { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) + } ok = true } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 48127765..5d94e6c3 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -102,6 +102,20 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(focusV2.insideSuppressed) XCTAssertTrue(focusV2.insideAllowsFocus) XCTAssertFalse(focusV2.outsideSuppressed) + + let moveWorkspace = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.move_to_window", + isV2: true + ) + XCTAssertTrue(moveWorkspace.insideSuppressed) + XCTAssertFalse(moveWorkspace.insideAllowsFocus) + + let triggerFlash = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "surface.trigger_flash", + isV2: true + ) + XCTAssertTrue(triggerFlash.insideSuppressed) + XCTAssertFalse(triggerFlash.insideAllowsFocus) #else throw XCTSkip("Socket command policy snapshot helper is debug-only.") #endif From b0bfabdb6a6c90c4328199e0a294a57707f00fac Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:34:58 -0700 Subject: [PATCH 09/77] Optimize remote daemon builds and TCP latency --- Sources/Workspace.swift | 10 +++++++--- daemon/remote/cmd/cmuxd-remote/cli.go | 1 + daemon/remote/cmd/cmuxd-remote/main.go | 9 +++++++++ scripts/build_remote_daemon_release_assets.sh | 10 +++++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 03d96921..eef6f15c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1997,7 +1997,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { NSLocalizedDescriptionKey: "invalid local proxy port \(port)", ]) } - let parameters = NWParameters.tcp + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) parameters.allowLocalEndpointReuse = true parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) return try NWListener(using: parameters) @@ -2719,7 +2721,9 @@ private final class WorkspaceRemoteCLIRelayServer { } private static func makeLoopbackListener() throws -> NWListener { - let parameters = NWParameters.tcp + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) parameters.allowLocalEndpointReuse = true parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) return try NWListener(using: parameters) @@ -3806,7 +3810,7 @@ private final class WorkspaceRemoteSessionController { let ldflags = "-s -w -X main.version=\(version)" let result = try runProcess( executable: goBinary, - arguments: ["build", "-trimpath", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"], + arguments: ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"], environment: env, currentDirectory: daemonRoot, stdin: nil, diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index e0a15118..fbdd87f5 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -507,6 +507,7 @@ func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) for { conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err == nil { + setTCPNoDelay(conn) return conn, addr, nil } if time.Now().After(deadline) { diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 021c8e6a..c0ba5874 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -173,6 +173,14 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { } } +func setTCPNoDelay(conn net.Conn) { + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return + } + _ = tcpConn.SetNoDelay(true) +} + func readRPCFrame(reader *bufio.Reader, maxBytes int) ([]byte, bool, error) { frame := make([]byte, 0, 1024) for { @@ -345,6 +353,7 @@ func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { }, } } + setTCPNoDelay(conn) s.mu.Lock() streamID := fmt.Sprintf("s-%d", s.nextStreamID) diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh index c5d9502b..e9519372 100755 --- a/scripts/build_remote_daemon_release_assets.sh +++ b/scripts/build_remote_daemon_release_assets.sh @@ -68,6 +68,14 @@ DAEMON_ROOT="${REPO_ROOT}/daemon/remote" mkdir -p "$OUTPUT_DIR" rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json +DAEMON_GO_LDFLAGS="-s -w -X main.version=${VERSION}" +DAEMON_GO_BUILD_ARGS=( + build + -trimpath + -buildvcs=false + -ldflags "$DAEMON_GO_LDFLAGS" +) + CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt" CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json" @@ -94,7 +102,7 @@ for target in "${TARGETS[@]}"; do GOOS="$GOOS" \ GOARCH="$GOARCH" \ CGO_ENABLED=0 \ - go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + go "${DAEMON_GO_BUILD_ARGS[@]}" \ -o "$OUTPUT_PATH" \ ./cmd/cmuxd-remote ) From 50b5969d6272548a60a159d4c62e98a4a64c58c0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:15:23 -0700 Subject: [PATCH 10/77] Add remote favicon proxy regression --- Sources/TerminalController.swift | 18 + ...t_ssh_remote_browser_favicon_uses_proxy.py | 315 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f8c78e22..a152d048 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2037,6 +2037,8 @@ class TerminalController { return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) case "debug.browser.address_bar_focused": return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params)) + case "debug.browser.favicon": + return v2Result(id: id, self.v2DebugBrowserFavicon(params: params)) case "debug.sidebar.visible": return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": @@ -2245,6 +2247,7 @@ class TerminalController { "debug.command_palette.rename_input.selection", "debug.command_palette.rename_input.select_all", "debug.browser.address_bar_focused", + "debug.browser.favicon", "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", @@ -9633,6 +9636,21 @@ class TerminalController { return .ok(payload) } + private func v2DebugBrowserFavicon(params: [String: Any]) -> V2CallResult { + return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in + let pngData = browserPanel.faviconPNGData + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "has_favicon": pngData != nil, + "png_base64": pngData?.base64EncodedString() ?? "", + "current_url": v2OrNull(browserPanel.currentURL?.absoluteString) + ]) + } + } + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) diff --git a/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py b/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py new file mode 100644 index 00000000..e6a053f1 --- /dev/null +++ b/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Regression: remote browser favicon fetches must use the SSH proxy path.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + with cmux(SOCKET_PATH) as lookup_client: + listed = lookup_client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 65.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _browser_body_text(client: cmux, surface_id: str) -> str: + payload = client._call( + "browser.eval", + { + "surface_id": surface_id, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + return str(payload.get("value") or "") + + +def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") + + +def _browser_favicon_state(client: cmux, surface_id: str) -> dict: + return dict(client._call("debug.browser.favicon", {"surface_id": surface_id}) or {}) + + +def _wait_browser_favicon(client: cmux, surface_id: str, timeout_s: float = 20.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + try: + last = _browser_favicon_state(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if bool(last.get("has_favicon")) and bool(str(last.get("png_base64") or "")): + return last + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser favicon state on {surface_id}: {last}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote favicon proxy regression") + return 0 + + cli = _find_cli_binary() + remote_workspace_id = "" + remote_surface_id = "" + server_script_path = "" + server_log_path = "" + hit_file_path = "" + + stamp = secrets.token_hex(4) + page_token = f"CMUX_REMOTE_FAVICON_PAGE_{stamp}" + server_ready_token = f"CMUX_REMOTE_FAVICON_READY_{stamp}" + default_web_port = 23000 + (os.getpid() % 4000) + ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port))) + url = f"http://localhost:{ssh_web_port}/" + png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WewAAAABJRU5ErkJggg==" + server_script_path = f"/tmp/cmux_remote_favicon_server_{stamp}.py" + server_log_path = f"/tmp/cmux_remote_favicon_server_{stamp}.log" + hit_file_path = f"/tmp/cmux_remote_favicon_hit_{stamp}" + + try: + with cmux(SOCKET_PATH) as setup_client: + before_workspace_ids = {wid for _index, wid, _title, _focused in setup_client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-favicon-{stamp}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + + with cmux(SOCKET_PATH) as client: + remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) + + surfaces = client.list_surfaces(remote_workspace_id) + _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") + remote_surface_id = str(surfaces[0][1]) + + server_script = f"""cat > {server_script_path} <<'PY' +import base64 +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(sys.argv[1]) +HIT_FILE = sys.argv[2] +PAGE_TOKEN = sys.argv[3] +PNG = base64.b64decode(sys.argv[4].encode("ascii")) + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith("/favicon.ico"): + with open(HIT_FILE, "w", encoding="utf-8") as f: + f.write("hit\\n") + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(PNG))) + self.end_headers() + self.wfile.write(PNG) + return + + body = ( + "<!doctype html><html><head>" + "<link rel=\\"icon\\" href=\\"/favicon.ico?via=cmux\\">" + f"</head><body>{{PAGE_TOKEN}}</body></html>" + ).replace("{{PAGE_TOKEN}}", PAGE_TOKEN) + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): + return + +HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +PY +rm -f {hit_file_path} {server_log_path} +python3 {server_script_path} {ssh_web_port} {hit_file_path} {page_token} {png_base64} >{server_log_path} 2>&1 & +for _ in $(seq 1 30); do + if curl -fsS http://localhost:{ssh_web_port}/ | grep -q {page_token}; then + echo {server_ready_token} + break + fi + sleep 0.2 +done""" + client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, + ) + client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + _wait_surface_contains(client, remote_workspace_id, remote_surface_id, server_ready_token, timeout_s=12.0) + + browser_payload = client._call( + "browser.open_split", + {"workspace_id": remote_workspace_id, "url": url}, + ) or {} + browser_surface_id = str(browser_payload.get("surface_id") or "") + _must(browser_surface_id, f"browser.open_split returned no surface_id: {browser_payload}") + + _wait_browser_contains(client, browser_surface_id, page_token, timeout_s=20.0) + + favicon_state = _wait_browser_favicon(client, browser_surface_id, timeout_s=14.0) + _must(bool(favicon_state.get("has_favicon")), f"browser favicon state never became ready: {favicon_state}") + _must(bool(str(favicon_state.get('png_base64') or "")), f"browser favicon PNG payload missing: {favicon_state}") + + print("PASS: remote browser favicon state loads for remote localhost pages over the SSH proxy") + return 0 + finally: + if remote_surface_id and remote_workspace_id: + try: + cleanup = ( + f"pkill -f {server_script_path} >/dev/null 2>&1 || true; " + f"rm -f {server_script_path} {server_log_path} {hit_file_path}" + ) + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, + ) + cleanup_client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + except Exception: # noqa: BLE001 + pass + + +if __name__ == "__main__": + raise SystemExit(main()) From 2c9464c0bccb9ddc9f5cebb99827eed24b16ae90 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:17:35 -0700 Subject: [PATCH 11/77] Proxy remote browser favicon fetches --- Resources/Info.plist | 10 ++ Sources/Panels/BrowserPanel.swift | 148 ++++++++++++++++++++++++++++-- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index 48d4f800..bf791157 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -108,6 +108,16 @@ <dict> <key>NSAllowsArbitraryLoadsInWebContent</key> <true/> + <key>NSExceptionDomains</key> + <dict> + <key>cmux-loopback.localtest.me</key> + <dict> + <key>NSExceptionAllowsInsecureHTTPLoads</key> + <true/> + <key>NSIncludesSubdomains</key> + <true/> + </dict> + </dict> </dict> <key>SUFeedURL</key> <string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string> diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 692e3aa5..f8ff3836 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4,6 +4,7 @@ import WebKit import AppKit import Bonsplit import Network +import CFNetwork struct BrowserProxyEndpoint: Equatable { let host: String @@ -2457,6 +2458,13 @@ final class BrowserPanel: Panel, ObservableObject { guard let self, let webView else { return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } +#if DEBUG + dlog( + "browser.favicon.begin " + + "panel=\(id.uuidString.prefix(5)) " + + "page=\(pageURL.absoluteString)" + ) +#endif // Try to discover the best icon URL from the document. let js = """ @@ -2484,7 +2492,11 @@ final class BrowserPanel: Panel, ObservableObject { """ var discoveredURL: URL? - if let href = try? await webView.evaluateJavaScript(js) as? String { + if let href = await self.evaluateJavaScriptString( + js, + in: webView, + timeoutNanoseconds: 400_000_000 + ) { let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, let u = URL(string: trimmed) { discoveredURL = u @@ -2496,10 +2508,26 @@ final class BrowserPanel: Panel, ObservableObject { let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) let iconURL = discoveredURL ?? fallbackURL guard let iconURL else { return } +#if DEBUG + dlog( + "browser.favicon.iconURL " + + "panel=\(id.uuidString.prefix(5)) " + + "discovered=\(discoveredURL?.absoluteString ?? "<nil>") " + + "fallback=\(fallbackURL?.absoluteString ?? "<nil>") " + + "chosen=\(iconURL.absoluteString)" + ) +#endif // Avoid repeated fetches. let iconURLString = iconURL.absoluteString if iconURLString == lastFaviconURLString, faviconPNGData != nil { +#if DEBUG + dlog( + "browser.favicon.skipCached " + + "panel=\(id.uuidString.prefix(5)) " + + "icon=\(iconURLString)" + ) +#endif return } lastFaviconURLString = iconURLString @@ -2508,12 +2536,42 @@ final class BrowserPanel: Panel, ObservableObject { req.timeoutInterval = 2.0 req.cachePolicy = .returnCacheDataElseLoad req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") + let effectiveRequest = remoteProxyPreparedRequest(from: req, logScope: "faviconRewrite") let data: Data let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: req) + let remoteSession = remoteProxyURLSession() + defer { remoteSession?.finishTasksAndInvalidate() } + if let remoteSession { +#if DEBUG + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "via=proxy " + + "url=\(effectiveRequest.url?.absoluteString ?? "<nil>")" + ) +#endif + (data, response) = try await remoteSession.data(for: effectiveRequest) + } else { +#if DEBUG + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "via=direct " + + "url=\(effectiveRequest.url?.absoluteString ?? "<nil>")" + ) +#endif + (data, response) = try await URLSession.shared.data(for: effectiveRequest) + } } catch { +#if DEBUG + dlog( + "browser.favicon.fetchError " + + "panel=\(id.uuidString.prefix(5)) " + + "error=\(String(describing: error))" + ) +#endif return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } @@ -2521,13 +2579,45 @@ final class BrowserPanel: Panel, ObservableObject { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { +#if DEBUG + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + dlog( + "browser.favicon.badResponse " + + "panel=\(id.uuidString.prefix(5)) " + + "status=\(status)" + ) +#endif return } +#if DEBUG + dlog( + "browser.favicon.response " + + "panel=\(id.uuidString.prefix(5)) " + + "status=\(http.statusCode) " + + "bytes=\(data.count)" + ) +#endif // Use >= 2x the rendered point size so we don't upscale (blurry) on Retina. - guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { return } + guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { +#if DEBUG + dlog( + "browser.favicon.decodeFailed " + + "panel=\(id.uuidString.prefix(5)) " + + "bytes=\(data.count)" + ) +#endif + return + } // Only update if we got a real icon; keep the old one otherwise to avoid flashes. faviconPNGData = png +#if DEBUG + dlog( + "browser.favicon.ready " + + "panel=\(id.uuidString.prefix(5)) " + + "pngBytes=\(png.count)" + ) +#endif } } @@ -2536,6 +2626,35 @@ final class BrowserPanel: Panel, ObservableObject { return generation == faviconRefreshGeneration } + @MainActor + private func evaluateJavaScriptString( + _ script: String, + in webView: WKWebView, + timeoutNanoseconds: UInt64 + ) async -> String? { + await withCheckedContinuation { continuation in + var hasResumed = false + + func resume(_ value: String?) { + guard !hasResumed else { return } + hasResumed = true + continuation.resume(returning: value) + } + + webView.evaluateJavaScript(script) { result, _ in + let value = result as? String + Task { @MainActor in + resume(value) + } + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + resume(nil) + } + } + } + @MainActor private static func makeFaviconPNGData(from raw: Data, targetPx: Int) -> Data? { guard let image = NSImage(data: raw) else { return nil } @@ -2669,7 +2788,7 @@ final class BrowserPanel: Panel, ObservableObject { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } - let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request) + let effectiveRequest = remoteProxyPreparedRequest(from: request, logScope: "rewrite") // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -2680,7 +2799,7 @@ final class BrowserPanel: Panel, ObservableObject { browserLoadRequest(effectiveRequest, in: webView) } - private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest { + private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { guard remoteProxyEndpoint != nil else { return request } guard let url = request.url else { return request } guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } @@ -2689,7 +2808,7 @@ final class BrowserPanel: Panel, ObservableObject { rewrittenRequest.url = rewrittenURL #if DEBUG dlog( - "browser.remoteProxy.rewrite " + + "browser.remoteProxy.\(logScope) " + "panel=\(id.uuidString.prefix(5)) " + "from=\(url.absoluteString) " + "to=\(rewrittenURL.absoluteString)" @@ -2698,6 +2817,23 @@ final class BrowserPanel: Panel, ObservableObject { return rewrittenRequest } + private func remoteProxyURLSession() -> URLSession? { + guard let endpoint = remoteProxyEndpoint else { return nil } + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, endpoint.port > 0, endpoint.port <= 65535 else { return nil } + + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.timeoutIntervalForRequest = 2.0 + configuration.timeoutIntervalForResource = 4.0 + configuration.connectionProxyDictionary = [ + kCFNetworkProxiesSOCKSEnable as String: 1, + kCFNetworkProxiesSOCKSProxy as String: host, + kCFNetworkProxiesSOCKSPort as String: endpoint.port, + ] + return URLSession(configuration: configuration) + } + private static func remoteProxyDisplayURL(for url: URL?) -> URL? { guard let url else { return nil } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } From 6dd0f158c13e5efd227fbed752f45e0173aa42d9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:08:09 -0700 Subject: [PATCH 12/77] Add ssh profile-noise regression --- ..._remote_interactive_cmux_command_regression.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py index 040207d7..a064115c 100644 --- a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py +++ b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py @@ -120,6 +120,17 @@ def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> N _wait_text(client, surface_id, token, timeout=timeout) +def _assert_no_login_profile_noise(text: str) -> None: + _must( + "/Users/cmux/.profile:" not in text, + f"interactive ssh shell should not source ~/.profile via the bootstrap wrapper: {text[-1200:]!r}", + ) + _must( + "No such file or directory" not in text, + f"interactive ssh shell still emitted startup file noise: {text[-1200:]!r}", + ) + + def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]: token = f"__CMUX_REMOTE_CMD_{secrets.token_hex(6)}__" start_marker = f"{token}:START" @@ -174,7 +185,11 @@ def main() -> int: _wait_remote_ready(client, workspace_id) surface_id = _wait_surface_id(client, workspace_id) + initial_text = client.read_terminal_text(surface_id) + _assert_no_login_profile_noise(initial_text) _wait_shell_ready(client, surface_id) + shell_ready_text = client.read_terminal_text(surface_id) + _assert_no_login_profile_noise(shell_ready_text) which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux") _must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}") From 815ed87eeb69b28f3c75ecdfe3ac31e983dfe89b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:09:46 -0700 Subject: [PATCH 13/77] Avoid sourcing profile in ssh bootstrap --- CLI/cmux.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 23ad3071..7085fec7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3308,7 +3308,7 @@ struct CMUXCLI { let outerCommand = outerLines.joined(separator: "\n") - return "/bin/sh -lc \(shellQuote(outerCommand))" + return "/bin/sh -c \(shellQuote(outerCommand))" } private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { From 1b95c25e213cb7774478a64f6fa48af5f1bfa227 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:29:18 -0700 Subject: [PATCH 14/77] Add ssh stack regression tests --- cmuxTests/GhosttyConfigTests.swift | 31 +++- cmuxTests/SessionPersistenceTests.swift | 34 ++++ .../test_cli_browser_console_errors_text.py | 166 ++++++++++++++++++ .../test_cli_sidebar_metadata_commands.py | 15 +- 4 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 tests_v2/test_cli_browser_console_errors_text.py diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 4210d884..a2e1292c 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1713,7 +1713,33 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertTrue(output.contains("PREEXEC=0"), output) } + func testGhosttySemanticPatchRetriesAfterDeferredInitCreatesLiveHooks() throws { + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: true, + command: """ + _cmux_patch_ghostty_semantic_redraw + (( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1 + _cmux_patch_ghostty_semantic_redraw + print -r -- "PRECMD_BODY=${functions[_ghostty_precmd]}" + print -r -- "PREEXEC_BODY=${functions[_ghostty_preexec]}" + """ + ) + + XCTAssertTrue(output.contains("PRECMD_BODY="), output) + XCTAssertTrue(output.contains("PREEXEC_BODY="), output) + XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { + try runInteractiveZsh( + cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, + command: "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + + "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + + "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" + ) + } + + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool, command: String) throws -> String { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") @@ -1734,10 +1760,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = [ "-i", - "-c", - "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + - "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + - "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" + "-c", command ] process.environment = [ "HOME": root.path, diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 88d8f11c..4f34e2e5 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -7,6 +7,40 @@ import XCTest #endif final class SessionPersistenceTests: XCTestCase { + @MainActor + func testWorkspaceSessionSnapshotRestoresMarkdownPanel() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-markdown-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let markdownURL = root.appendingPathComponent("note.md") + try "# hello\n".write(to: markdownURL, atomically: true, encoding: .utf8) + + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let panel = try XCTUnwrap( + workspace.newMarkdownSurface( + inPane: paneId, + filePath: markdownURL.path, + focus: true + ) + ) + workspace.setCustomTitle("Docs") + workspace.setPanelCustomTitle(panelId: panel.id, title: "Readme") + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.markdownPanel(for: restoredPanelId)) + XCTAssertEqual(restoredPanel.filePath, markdownURL.path) + XCTAssertEqual(restored.customTitle, "Docs") + XCTAssertEqual(restored.panelTitle(panelId: restoredPanelId), "Readme") + } + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/tests_v2/test_cli_browser_console_errors_text.py b/tests_v2/test_cli_browser_console_errors_text.py new file mode 100644 index 00000000..96586165 --- /dev/null +++ b/tests_v2/test_cli_browser_console_errors_text.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Regression: CLI browser console/errors commands should print entries in text mode.""" + +from __future__ import annotations + +import glob +import http.server +import os +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str]) -> str: + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, *args], + capture_output=True, + text=True, + check=False, + env=dict(os.environ), + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + return proc.stdout.strip() + + +def _wait_for(pred, timeout_s: float = 6.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_selector(c: cmux, surface_id: str, selector: str, timeout_s: float = 6.0) -> None: + timeout_ms = max(1, int(timeout_s * 1000.0)) + c._call("browser.wait", {"surface_id": surface_id, "selector": selector, "timeout_ms": timeout_ms}) + + +def _open_server() -> tuple[str, socketserver.TCPServer, threading.Thread, tempfile.TemporaryDirectory[str]]: + root = tempfile.TemporaryDirectory(prefix="cmux-browser-cli-logs-") + root_path = Path(root.name) + (root_path / "index.html").write_text( + """<!doctype html> +<html> + <body> + <div id="ready">ready</div> + <script> + window.emitLogs = function () { + console.log('cmux-console-entry'); + setTimeout(function () { throw new Error('cmux-browser-boom'); }, 0); + return true; + }; + </script> + </body> +</html> +""".strip(), + encoding="utf-8", + ) + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=root.name, **kwargs) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + server = ThreadedTCPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + return base_url, server, thread, root + + +def main() -> int: + cli = _find_cli_binary() + base_url, server, thread, root = _open_server() + workspace_id = "" + try: + with cmux(SOCKET_PATH) as c: + opened = c._call("browser.open_split", {"url": f"{base_url}/index.html"}) or {} + workspace_id = str(opened.get("workspace_id") or "") + surface_id = str(opened.get("surface_id") or "") + _must(bool(surface_id), f"browser.open_split returned no surface_id: {opened}") + + _wait_selector(c, surface_id, "#ready", timeout_s=7.0) + c._call("browser.eval", {"surface_id": surface_id, "script": "window.emitLogs()"}) + + def console_ready() -> bool: + payload = c._call("browser.console.list", {"surface_id": surface_id}) or {} + return int(payload.get("count") or 0) >= 1 + + def errors_ready() -> bool: + payload = c._call("browser.errors.list", {"surface_id": surface_id}) or {} + return int(payload.get("count") or 0) >= 1 + + _wait_for(console_ready, timeout_s=7.0) + _wait_for(errors_ready, timeout_s=7.0) + + console_output = _run_cli(cli, ["browser", surface_id, "console"]) + _must("cmux-console-entry" in console_output, f"browser console text mode should print entries: {console_output!r}") + _must(console_output != "OK", f"browser console text mode should not collapse to OK: {console_output!r}") + + errors_output = _run_cli(cli, ["browser", surface_id, "errors"]) + _must("cmux-browser-boom" in errors_output, f"browser errors text mode should print entries: {errors_output!r}") + _must(errors_output != "OK", f"browser errors text mode should not collapse to OK: {errors_output!r}") + finally: + try: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + except Exception: + pass + root.cleanup() + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: browser console/errors text mode prints returned entries") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_sidebar_metadata_commands.py b/tests_v2/test_cli_sidebar_metadata_commands.py index 142ce093..7b3b69af 100644 --- a/tests_v2/test_cli_sidebar_metadata_commands.py +++ b/tests_v2/test_cli_sidebar_metadata_commands.py @@ -39,13 +39,16 @@ def _find_cli_binary() -> str: return candidates[0] -def _run_cli(cli: str, args: list[str]) -> str: +def _run_cli(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> str: + env = dict(os.environ) + if extra_env: + env.update(extra_env) proc = subprocess.run( [cli, "--socket", SOCKET_PATH, *args], capture_output=True, text=True, check=False, - env=dict(os.environ), + env=env, ) if proc.returncode != 0: merged = f"{proc.stdout}\n{proc.stderr}".strip() @@ -73,8 +76,16 @@ def main() -> int: log_response = _run_cli(cli, ["log", "--workspace", workspace_id, "--", "ship it"]) _must(log_response.startswith("OK"), f"log should succeed, got {log_response!r}") + env_log_response = _run_cli( + cli, + ["log", "--", "env scoped log"], + extra_env={"CMUX_WORKSPACE_ID": workspace_id}, + ) + _must(env_log_response.startswith("OK"), f"log with env workspace should succeed, got {env_log_response!r}") + log_list = _run_cli(cli, ["list-log", "--workspace", workspace_id, "--limit", "5"]) _must("ship it" in log_list, f"list-log should include the appended log entry: {log_list!r}") + _must("env scoped log" in log_list, f"list-log should include env-routed log entry: {log_list!r}") sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id]) _must("status_count=1" in sidebar_state, f"sidebar-state should include the status entry count: {sidebar_state!r}") From 85e6a5aae468f62dc50c41fc45c6ba6ae9594045 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:29:22 -0700 Subject: [PATCH 15/77] Fix ssh stack review regressions --- .github/workflows/nightly.yml | 3 --- CLI/cmux.swift | 22 ++++++++++++++++--- .../cmux-zsh-integration.zsh | 2 -- Sources/Workspace.swift | 11 +++++++++- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a63e74e0..64255fb2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -471,16 +471,13 @@ jobs: path: | cmux-nightly-macos*.dmg appcast.xml -<<<<<<< HEAD remote-daemon-assets/cmuxd-remote-darwin-arm64 remote-daemon-assets/cmuxd-remote-darwin-amd64 remote-daemon-assets/cmuxd-remote-linux-arm64 remote-daemon-assets/cmuxd-remote-linux-amd64 remote-daemon-assets/cmuxd-remote-checksums.txt remote-daemon-assets/cmuxd-remote-manifest.json -======= appcast-universal.xml ->>>>>>> origin/main if-no-files-found: error - name: Move nightly tag to built commit diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7085fec7..ab8c17d0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4878,7 +4878,11 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - output(payload, fallback: "OK") + if effectiveJSONOutput || consoleVerb == "clear" { + output(payload, fallback: "OK") + } else { + print(displayBrowserLogItems(payload["entries"]) ?? "No console entries") + } return } @@ -4892,7 +4896,11 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - output(payload, fallback: "OK") + if effectiveJSONOutput || errorsVerb == "clear" { + output(payload, fallback: "OK") + } else { + print(displayBrowserLogItems(payload["errors"]) ?? "No browser errors") + } return } @@ -6458,6 +6466,14 @@ struct CMUXCLI { client: SocketClient, windowOverride: String? ) throws -> String { + func insertArgumentBeforeSeparator(_ value: String, into args: inout [String]) { + if let separatorIndex = args.firstIndex(of: "--") { + args.insert(value, at: separatorIndex) + } else { + args.append(value) + } + } + var forwardedArgs: [String] = [] var resolvedExplicitWorkspace = false var index = 0 @@ -6486,7 +6502,7 @@ struct CMUXCLI { if !resolvedExplicitWorkspace, let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) { let workspaceId = try resolveWorkspaceId(workspaceArg, client: client) - forwardedArgs.append("--tab=\(workspaceId)") + insertArgumentBeforeSeparator("--tab=\(workspaceId)", into: &forwardedArgs) } let command = ([socketCommand] + forwardedArgs) diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 45a99aaf..645189ee 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -85,8 +85,6 @@ _cmux_ensure_ghostty_preexec_strips_both_marks() { } _cmux_patch_ghostty_semantic_redraw() { - (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) && return 0 - local old_frag new_frag old_frag='133;A;cl=line' new_frag='133;A;redraw=last;cl=line' diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index eef6f15c..cfc68c75 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -568,7 +568,16 @@ extension Workspace { applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: - return nil + guard let filePath = snapshot.markdown?.filePath, + let markdownPanel = newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) + return markdownPanel.id } } From 29d046c5f02faccf1d8ab0a9952db1a80951a6a3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:32:12 -0700 Subject: [PATCH 16/77] Fix ghostty deferred-init regression harness --- cmuxTests/GhosttyConfigTests.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index a2e1292c..a610f6db 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1716,6 +1716,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { func testGhosttySemanticPatchRetriesAfterDeferredInitCreatesLiveHooks() throws { let output = try runInteractiveZsh( cmuxLoadGhosttyIntegration: true, + cmuxLoadShellIntegration: true, command: """ _cmux_patch_ghostty_semantic_redraw (( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1 @@ -1733,13 +1734,18 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, + cmuxLoadShellIntegration: false, command: "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" ) } - private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool, command: String) throws -> String { + private func runInteractiveZsh( + cmuxLoadGhosttyIntegration: Bool, + cmuxLoadShellIntegration: Bool, + command: String + ) throws -> String { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") @@ -1775,6 +1781,13 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { if cmuxLoadGhosttyIntegration { process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" } + if cmuxLoadShellIntegration { + process.environment?["CMUX_SHELL_INTEGRATION"] = "1" + process.environment?["CMUX_SHELL_INTEGRATION_DIR"] = cmuxZdotdir.path + process.environment?["CMUX_SOCKET_PATH"] = root.appendingPathComponent("cmux-test.sock").path + process.environment?["CMUX_TAB_ID"] = "tab-test" + process.environment?["CMUX_PANEL_ID"] = "panel-test" + } let stdout = Pipe() let stderr = Pipe() From 5e7458b92086aaaf888c11f0aca36cd200f278c5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:01:26 -0700 Subject: [PATCH 17/77] Fix SSH workspace priming and restore state --- Sources/ContentView.swift | 18 +- Sources/Panels/BrowserPanel.swift | 58 ++++++- Sources/TabManager.swift | 33 +++- Sources/Workspace.swift | 15 +- cmuxTests/GhosttyConfigTests.swift | 163 ++++++++++++++---- .../TabManagerSessionSnapshotTests.swift | 26 +++ ...space_create_background_starts_terminal.py | 83 +++++++++ 7 files changed, 345 insertions(+), 51 deletions(-) create mode 100644 tests_v2/test_workspace_create_background_starts_terminal.py diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e955658d..a2667df2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1987,16 +1987,26 @@ struct ContentView: View { let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) + let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace + let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground + let workspaceRenderOpacity: Double = { + if isRenderedVisible { + return 1 + } + if shouldPrimeInBackground { + return 0.001 + } + return 0 + }() // Keep the retiring workspace visible during handoff, but never input-active. // Allowing both selected+retiring workspaces to be input-active lets the // old workspace steal first responder (notably with WKWebView), which can // delay handoff completion and make browser returns feel laggy. let isInputActive = isSelectedWorkspace - let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, - isWorkspaceVisible: isVisible, + isWorkspaceVisible: isWorkspaceVisibleToPanels, isWorkspaceInputActive: isInputActive, workspacePortalPriority: portalPriority, onThemeRefreshRequest: { reason, eventId, source, payloadHex in @@ -2009,9 +2019,9 @@ struct ContentView: View { ) } ) - .opacity(isVisible ? 1 : 0) + .opacity(workspaceRenderOpacity) .allowsHitTesting(isSelectedWorkspace) - .accessibilityHidden(!isVisible) + .accessibilityHidden(!isRenderedVisible) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) .task(id: shouldPrimeInBackground ? tab.id : nil) { await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f8ff3836..f3dabff0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1798,6 +1798,13 @@ final class BrowserPanel: Panel, ObservableObject { private let developerToolsRestoreRetryMaxAttempts: Int = 40 private var remoteProxyEndpoint: BrowserProxyEndpoint? @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? + private let usesRemoteWorkspaceProxy: Bool + private struct PendingRemoteNavigation { + let request: URLRequest + let recordTypedNavigation: Bool + let preserveRestoredSessionHistory: Bool + } + private var pendingRemoteNavigation: PendingRemoteNavigation? private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsTransitionTargetVisible: Bool? @@ -2045,15 +2052,17 @@ final class BrowserPanel: Panel, ObservableObject { initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil, proxyEndpoint: BrowserProxyEndpoint? = nil, - isRemoteWorkspace: Bool = false + isRemoteWorkspace: Bool = false, + remoteWebsiteDataStoreIdentifier: UUID? = nil ) { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.remoteProxyEndpoint = proxyEndpoint + self.usesRemoteWorkspaceProxy = isRemoteWorkspace self.browserThemeMode = BrowserThemeSettings.mode() self.websiteDataStore = isRemoteWorkspace - ? WKWebsiteDataStore(forIdentifier: self.id) + ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId) : .default() let webView = Self.makeWebView(websiteDataStore: websiteDataStore) @@ -2143,6 +2152,7 @@ final class BrowserPanel: Panel, ObservableObject { guard remoteProxyEndpoint != endpoint else { return } remoteProxyEndpoint = endpoint applyRemoteProxyConfigurationIfAvailable() + resumePendingRemoteNavigationIfNeeded() } func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { @@ -2785,6 +2795,46 @@ final class BrowserPanel: Panel, ObservableObject { preserveRestoredSessionHistory: Bool = false ) { guard let url = request.url else { return } + if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil { + pendingRemoteNavigation = PendingRemoteNavigation( + request: request, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) + shouldRenderWebView = true + currentURL = Self.remoteProxyDisplayURL(for: url) ?? url + navigationDelegate?.lastAttemptedURL = url + return + } + performNavigation( + request: request, + originalURL: url, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) + } + + private func resumePendingRemoteNavigationIfNeeded() { + guard remoteProxyEndpoint != nil, + let pendingRemoteNavigation else { + return + } + self.pendingRemoteNavigation = nil + guard let originalURL = pendingRemoteNavigation.request.url else { return } + performNavigation( + request: pendingRemoteNavigation.request, + originalURL: originalURL, + recordTypedNavigation: pendingRemoteNavigation.recordTypedNavigation, + preserveRestoredSessionHistory: pendingRemoteNavigation.preserveRestoredSessionHistory + ) + } + + private func performNavigation( + request: URLRequest, + originalURL: URL, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool + ) { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } @@ -2793,9 +2843,9 @@ final class BrowserPanel: Panel, ObservableObject { webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true if recordTypedNavigation { - BrowserHistoryStore.shared.recordTypedNavigation(url: url) + BrowserHistoryStore.shared.recordTypedNavigation(url: originalURL) } - navigationDelegate?.lastAttemptedURL = url + navigationDelegate?.lastAttemptedURL = originalURL browserLoadRequest(effectiveRequest, in: webView) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 65edf96f..d2340739 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -943,6 +943,9 @@ class TabManager: ObservableObject { newWorkspace.owningTabManager = self wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride) + if eagerLoadTerminal && !select { + requestBackgroundWorkspaceLoad(for: newWorkspace.id) + } var updatedTabs = snapshot.tabs if insertIndex >= 0 && insertIndex <= updatedTabs.count { updatedTabs.insert(newWorkspace, at: insertIndex) @@ -959,7 +962,9 @@ class TabManager: ObservableObject { ) } if eagerLoadTerminal { - newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() + if select { + newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() + } } if select { #if DEBUG @@ -1169,21 +1174,33 @@ class TabManager: ObservableObject { } func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { - guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return } + guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } + var updated = pendingBackgroundWorkspaceLoadIds + updated.insert(workspaceId) + pendingBackgroundWorkspaceLoadIds = updated } func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { - guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return } + guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } + var updated = pendingBackgroundWorkspaceLoadIds + updated.remove(workspaceId) + pendingBackgroundWorkspaceLoadIds = updated } func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { guard !workspaceIds.isEmpty else { return } - debugPinnedWorkspaceLoadIds.formUnion(workspaceIds) + var updated = debugPinnedWorkspaceLoadIds + updated.formUnion(workspaceIds) + guard updated != debugPinnedWorkspaceLoadIds else { return } + debugPinnedWorkspaceLoadIds = updated } func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { guard !workspaceIds.isEmpty else { return } - debugPinnedWorkspaceLoadIds.subtract(workspaceIds) + var updated = debugPinnedWorkspaceLoadIds + updated.subtract(workspaceIds) + guard updated != debugPinnedWorkspaceLoadIds else { return } + debugPinnedWorkspaceLoadIds = updated } func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) { @@ -4046,11 +4063,13 @@ extension TabManager { } func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { - let workspaceSnapshots = tabs + let restorableTabs = tabs + .filter { !$0.isRemoteWorkspace } .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + let workspaceSnapshots = restorableTabs .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in - tabs.firstIndex(where: { $0.id == selectedTabId }) + restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } return SessionTabManagerSnapshot( selectedWorkspaceIndex: selectedWorkspaceIndex, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index da23e155..3e1cf32c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6256,9 +6256,12 @@ final class Workspace: Identifiable, ObservableObject { } private func remoteTerminalStartupCommand() -> String? { - guard hasActiveRemoteTerminalSessions else { return nil } - return remoteConfiguration?.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) + guard let command = remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { + return nil + } + return command } /// Create a new browser panel split @@ -6288,7 +6291,8 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, initialURL: url, proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -6357,7 +6361,8 @@ final class Workspace: Identifiable, ObservableObject { initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 7a811d69..9841a625 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -47,6 +47,19 @@ final class GhosttyConfigTests: XCTestCase { let blue: Int } + private func writeAppSupportConfig( + root: URL, + bundleIdentifier: String, + name: String = "config", + contents: String = "font-size = 14\n" + ) throws -> URL { + let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(name, isDirectory: false) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + func testResolveThemeNamePrefersLightEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", @@ -312,48 +325,69 @@ final class GhosttyConfigTests: XCTestCase { } func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() { - XCTAssertTrue( - GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let releaseURL = try? writeAppSupportConfig( + root: root, + bundleIdentifier: "com.cmuxterm.app" + ) + + XCTAssertEqual( + GhosttyApp.cmuxAppSupportConfigURLs( currentBundleIdentifier: "com.cmuxterm.app.debug", - currentConfigFileSize: nil, - currentLegacyConfigFileSize: nil, - releaseConfigFileSize: 128, - releaseLegacyConfigFileSize: nil - ) + appSupportDirectory: root + ), + [releaseURL].compactMap { $0 } ) } func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() { - XCTAssertFalse( - GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + _ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app") + let debugURL = try? writeAppSupportConfig( + root: root, + bundleIdentifier: "com.cmuxterm.app.debug.issue-829", + name: "config.ghostty" + ) + + XCTAssertEqual( + GhosttyApp.cmuxAppSupportConfigURLs( currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829", - currentConfigFileSize: nil, - currentLegacyConfigFileSize: 64, - releaseConfigFileSize: 128, - releaseLegacyConfigFileSize: nil - ) + appSupportDirectory: root + ), + [debugURL].compactMap { $0 } ) } func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() { - XCTAssertFalse( - GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + _ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app") + + XCTAssertEqual( + GhosttyApp.cmuxAppSupportConfigURLs( currentBundleIdentifier: "com.cmuxterm.app", - currentConfigFileSize: nil, - currentLegacyConfigFileSize: nil, - releaseConfigFileSize: 128, - releaseLegacyConfigFileSize: nil - ) + appSupportDirectory: root + ).count, + 1 ) - XCTAssertFalse( - GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + XCTAssertEqual( + GhosttyApp.cmuxAppSupportConfigURLs( currentBundleIdentifier: "com.cmuxterm.app.debug", - currentConfigFileSize: nil, - currentLegacyConfigFileSize: nil, - releaseConfigFileSize: nil, - releaseLegacyConfigFileSize: 0 - ) + appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true) + ), + [] ) } @@ -831,12 +865,79 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { @MainActor final class BrowserPanelRemoteStoreTests: XCTestCase { - func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() { + func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() { let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) - let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true) + let remoteWorkspaceId = UUID() + let firstRemotePanel = BrowserPanel( + workspaceId: remoteWorkspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + let secondRemotePanel = BrowserPanel( + workspaceId: remoteWorkspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) - XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertTrue( + firstRemotePanel.webView.configuration.websiteDataStore === + secondRemotePanel.webView.configuration.websiteDataStore + ) + } + + func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() { + let remoteWorkspaceId = UUID() + let url = URL(string: "http://localhost:3000/demo")! + let panel = BrowserPanel( + workspaceId: remoteWorkspaceId, + initialURL: url, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertNil(panel.webView.url) + + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + + let deadline = Date().addingTimeInterval(1.0) + while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me") + } + + func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId) + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64000, + relayID: "relay-test", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(configuration, autoConnect: false) + _ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false) + + workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + + _ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) } } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index af954ee2..b0d44856 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -46,4 +46,30 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(manager.tabs.count, 1) XCTAssertNotNil(manager.selectedTabId) } + + func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-test", + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) + _ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + + XCTAssertEqual(snapshot.workspaces.count, 1) + XCTAssertNil(snapshot.selectedWorkspaceIndex) + XCTAssertFalse(snapshot.workspaces.contains { $0.processTitle == remoteWorkspace.title }) + } } diff --git a/tests_v2/test_workspace_create_background_starts_terminal.py b/tests_v2/test_workspace_create_background_starts_terminal.py new file mode 100644 index 00000000..f68c72b8 --- /dev/null +++ b/tests_v2/test_workspace_create_background_starts_terminal.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Regression: background workspace.create should start its initial terminal before selection.""" + +from __future__ import annotations + +import os +import shlex +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_file_text(path: Path, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + if path.exists(): + last_text = path.read_text(encoding="utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in background workspace file: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + marker_path = Path(tempfile.gettempdir()) / f"cmux-bg-start-{int(time.time() * 1000)}.txt" + try: + token = f"CMUX_BG_START_{int(time.time() * 1000)}" + initial_command = ( + "python3 -c " + + shlex.quote( + f"from pathlib import Path; Path({marker_path.as_posix()!r}).write_text({token!r}, encoding='utf-8')" + ) + ) + payload = c._call( + "workspace.create", + {"initial_command": initial_command}, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must( + c.current_workspace() == baseline_workspace, + "workspace.create should preserve selected workspace", + ) + + text = _wait_for_file_text(marker_path, token) + _must(token in text, f"Background workspace did not run its initial command: {text!r}") + _must( + c.current_workspace() == baseline_workspace, + "background eager load should not switch the selected workspace", + ) + finally: + try: + marker_path.unlink() + except FileNotFoundError: + pass + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create eager background load starts the initial terminal without focus") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 902ee030190755c17bd156eccbb72e545c31f1b1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:23:44 -0700 Subject: [PATCH 18/77] Fix SSH transport dedupe and loopback review issues --- CLI/cmux.swift | 154 +++++++++++++++++++------- Sources/Panels/BrowserPanel.swift | 2 +- Sources/Workspace.swift | 40 +++++-- cmuxTests/CLIProcessRunnerTests.swift | 20 ++++ cmuxTests/GhosttyConfigTests.swift | 61 ++++++++++ 5 files changed, 230 insertions(+), 47 deletions(-) create mode 100644 cmuxTests/CLIProcessRunnerTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 81d28bd3..aac6ef2e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -927,6 +927,102 @@ final class SocketClient { } } +struct CLIProcessResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool +} + +enum CLIProcessRunner { + static func runProcess( + executablePath: String, + arguments: [String], + stdinText: String? = nil, + timeout: TimeInterval? = nil + ) -> CLIProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdinPipe: Pipe? + if stdinText != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + stdinPipe = nil + } + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + finished.signal() + } + + do { + try process.run() + } catch { + return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + if let stdinText, let stdinPipe { + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + } + + let timedOut: Bool + if let timeout { + switch finished.wait(timeout: .now() + timeout) { + case .success: + timedOut = false + case .timedOut: + timedOut = true + terminate(process: process, finished: finished) + } + } else { + finished.wait() + timedOut = false + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if timedOut { + let timeoutMessage = "process timed out" + if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + stderr = timeoutMessage + } else if !stderr.contains(timeoutMessage) { + stderr += "\n\(timeoutMessage)" + } + } + + return CLIProcessResult( + status: timedOut ? 124 : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private static func terminate(process: Process, finished: DispatchSemaphore) { + guard process.isRunning else { return } + process.terminate() + if finished.wait(timeout: .now() + 0.5) == .success { + return + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + _ = finished.wait(timeout: .now() + 0.5) + } +} + struct CMUXCLI { let args: [String] @@ -3430,7 +3526,17 @@ struct CMUXCLI { private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) { guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return } + let effectiveSSHOptions = effectiveSSHOptions( + options.sshOptions, + remoteRelayPort: options.remoteRelayPort + ) var args = baseSSHArguments(options) + if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") { + args += ["-o", "ConnectTimeout=3"] + } + if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectionAttempts") { + args += ["-o", "ConnectionAttempts=1"] + } args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination] let installScript = """ infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 @@ -3443,7 +3549,8 @@ struct CMUXCLI { _ = runProcess( executablePath: "/usr/bin/ssh", arguments: Array(args.dropFirst()), - stdinText: terminfoSource + stdinText: terminfoSource, + timeout: 4.0 ) } @@ -3818,43 +3925,16 @@ struct CMUXCLI { private func runProcess( executablePath: String, arguments: [String], - stdinText: String? = nil + stdinText: String? = nil, + timeout: TimeInterval? = nil ) -> (status: Int32, stdout: String, stderr: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - let stdinPipe: Pipe? - if stdinText != nil { - let pipe = Pipe() - process.standardInput = pipe - stdinPipe = pipe - } else { - stdinPipe = nil - } - - do { - try process.run() - } catch { - return (1, "", String(describing: error)) - } - - if let stdinText, let stdinPipe { - if let data = stdinText.data(using: .utf8) { - stdinPipe.fileHandleForWriting.write(data) - } - stdinPipe.fileHandleForWriting.closeFile() - } - - process.waitUntilExit() - let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - return (process.terminationStatus, stdout, stderr) + let result = CLIProcessRunner.runProcess( + executablePath: executablePath, + arguments: arguments, + stdinText: stdinText, + timeout: timeout + ) + return (result.status, result.stdout, result.stderr) } private func runBrowserCommand( diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f3dabff0..cf6ecbc6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2895,7 +2895,7 @@ final class BrowserPanel: Panel, ObservableObject { } private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { - guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } + guard let scheme = url.scheme?.lowercased(), scheme == "http" else { return nil } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } guard remoteLoopbackHosts.contains(host) else { return nil } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3e1cf32c..53b9487d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2209,15 +2209,7 @@ private final class WorkspaceRemoteProxyBroker { } private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { - let destination = configuration.destination.trimmingCharacters(in: .whitespacesAndNewlines) - let port = configuration.port.map(String.init) ?? "" - let identity = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let localProxyPort = configuration.localProxyPort.map(String.init) ?? "" - let options = configuration.sshOptions - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: "\u{1f}") - return [destination, port, identity, options, localProxyPort].joined(separator: "\u{1e}") + configuration.proxyBrokerTransportKey } private static func allocateLoopbackPort() -> Int? { @@ -4230,6 +4222,36 @@ struct WorkspaceRemoteConfiguration: Equatable { guard let port else { return destination } return "\(destination):\(port)" } + + var proxyBrokerTransportKey: String { + let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPort = port.map(String.init) ?? "" + let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" + let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") + return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] + .joined(separator: "\u{1e}") + } + + private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + }.filter { option in + proxyBrokerSSHOptionKey(option) != "controlpath" + } + } + + private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } } enum SidebarPullRequestStatus: String { diff --git a/cmuxTests/CLIProcessRunnerTests.swift b/cmuxTests/CLIProcessRunnerTests.swift new file mode 100644 index 00000000..b4bc4dc5 --- /dev/null +++ b/cmuxTests/CLIProcessRunnerTests.swift @@ -0,0 +1,20 @@ +import XCTest + +#if canImport(cmux) +@testable import cmux + +final class CLIProcessRunnerTests: XCTestCase { + func testRunProcessTimesOutHungChild() { + let startedAt = Date() + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", "sleep 5"], + timeout: 0.2 + ) + + XCTAssertTrue(result.timedOut) + XCTAssertEqual(result.status, 124) + XCTAssertLessThan(Date().timeIntervalSince(startedAt), 2.0) + } +} +#endif diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9841a625..a77dcf78 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -909,6 +909,28 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me") } + func testRemoteWorkspaceKeepsHTTPSLoopbackUnaliased() { + let remoteWorkspaceId = UUID() + let url = URL(string: "https://localhost:3443/demo")! + let panel = BrowserPanel( + workspaceId: remoteWorkspaceId, + initialURL: url, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertNil(panel.webView.url) + + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + + let deadline = Date().addingTimeInterval(1.0) + while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertEqual(panel.webView.url?.host, "localhost") + } + func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws { let workspace = Workspace() let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) @@ -941,6 +963,45 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { } } +final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { + func testProxyBrokerTransportKeyIgnoresControlPath() { + let first = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64000-%C", + ], + localProxyPort: 9000, + relayPort: 64000, + relayID: "relay-a", + relayToken: "token-a", + localSocketPath: "/tmp/cmux-a.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let second = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64001-%C", + ], + localProxyPort: 9000, + relayPort: 64001, + relayID: "relay-b", + relayToken: "token-b", + localSocketPath: "/tmp/cmux-b.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey) + } +} + final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { func testSupportsMultiplePendingCallsResolvedOutOfOrder() { let registry = WorkspaceRemoteDaemonPendingCallRegistry() From ca4f4b7c69a929a4498d1a2a385708882f3f3c19 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:04:48 -0700 Subject: [PATCH 19/77] Fix browser move and zsh bootstrap regressions --- CLI/cmux.swift | 13 ++-- Sources/Panels/BrowserPanel.swift | 65 ++++++++++++++++---- Sources/Workspace.swift | 10 +++- cmuxTests/CLIProcessRunnerTests.swift | 86 +++++++++++++++++++++++++++ cmuxTests/GhosttyConfigTests.swift | 74 +++++++++++++++++++++++ 5 files changed, 227 insertions(+), 21 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index aac6ef2e..6df33c0c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3383,9 +3383,10 @@ struct CMUXCLI { return merged } - private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { + func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil + let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" let commonShellLines = remoteEnvExportLines + ["export PATH=\"$HOME/.cmux/bin:$PATH\""] + (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? []) @@ -3394,18 +3395,17 @@ struct CMUXCLI { "rehash >/dev/null 2>&1 || true", ] let zshEnvLines = [ - "export CMUX_REAL_ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"", - "[ -f \"$HOME/.zshenv\" ] && source \"$HOME/.zshenv\"", + "[ -f \"$CMUX_REAL_ZDOTDIR/.zshenv\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshenv\"", + "if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi", + "export ZDOTDIR=\"\(shellStateDir)\"", ] let zshRCLines = [ - "export ZDOTDIR=\"${CMUX_REAL_ZDOTDIR:-$HOME}\"", - "[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\"", + "[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"", ] + commonShellLines let bashRCLines = [ "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", ] + commonShellLines let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) - let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" var outerLines: [String] = [ "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", @@ -3428,6 +3428,7 @@ struct CMUXCLI { ] outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) outerLines += [ + " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", " export ZDOTDIR=\"$cmux_shell_dir\"", " exec \"$CMUX_LOGIN_SHELL\" -i", " ;;", diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index cf6ecbc6..b236a2e9 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1421,7 +1421,7 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view private(set) var webView: WKWebView - private let websiteDataStore: WKWebsiteDataStore + private var websiteDataStore: WKWebsiteDataStore /// Monotonic identity for the current WKWebView instance. /// Incremented whenever we replace the underlying WKWebView after a process crash. @@ -1798,7 +1798,7 @@ final class BrowserPanel: Panel, ObservableObject { private let developerToolsRestoreRetryMaxAttempts: Int = 40 private var remoteProxyEndpoint: BrowserProxyEndpoint? @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? - private let usesRemoteWorkspaceProxy: Bool + private var usesRemoteWorkspaceProxy: Bool private struct PendingRemoteNavigation { let request: URLRequest let recordTypedNavigation: Bool @@ -2211,6 +2211,33 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId = newWorkspaceId } + func reattachToWorkspace( + _ newWorkspaceId: UUID, + isRemoteWorkspace: Bool, + remoteWebsiteDataStoreIdentifier: UUID? = nil, + proxyEndpoint: BrowserProxyEndpoint?, + remoteStatus: BrowserRemoteWorkspaceStatus? + ) { + workspaceId = newWorkspaceId + usesRemoteWorkspaceProxy = isRemoteWorkspace + let targetStore = isRemoteWorkspace + ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? newWorkspaceId) + : .default() + let needsStoreSwap = webView.configuration.websiteDataStore !== targetStore + websiteDataStore = targetStore + remoteProxyEndpoint = proxyEndpoint + remoteWorkspaceStatus = remoteStatus + if needsStoreSwap { + replaceWebViewPreservingState( + from: webView, + websiteDataStore: targetStore, + reason: "workspace_reattach" + ) + } + applyRemoteProxyConfigurationIfAvailable() + resumePendingRemoteNavigationIfNeeded() + } + func triggerFlash() { guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 @@ -2326,20 +2353,33 @@ final class BrowserPanel: Panel, ObservableObject { } private func replaceWebViewAfterContentProcessTermination(for terminatedWebView: WKWebView) { - guard terminatedWebView === webView else { return } + replaceWebViewPreservingState( + from: terminatedWebView, + websiteDataStore: websiteDataStore, + reason: "webcontent_process_terminated" + ) + } + + private func replaceWebViewPreservingState( + from oldWebView: WKWebView, + websiteDataStore: WKWebsiteDataStore, + reason: String + ) { + guard oldWebView === webView else { return } let wasRenderable = shouldRenderWebView - let restoreURL = Self.remoteProxyDisplayURL(for: terminatedWebView.url) ?? currentURL + let restoreURL = Self.remoteProxyDisplayURL(for: oldWebView.url) ?? currentURL let restoreURLString = restoreURL?.absoluteString let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString let history = sessionNavigationHistorySnapshot() let historyCurrentURL = preferredURLStringForOmnibar() - let desiredZoom = max(minPageZoom, min(maxPageZoom, terminatedWebView.pageZoom)) + let desiredZoom = max(minPageZoom, min(maxPageZoom, oldWebView.pageZoom)) let restoreDevTools = preferredDeveloperToolsVisible #if DEBUG dlog( "browser.webview.replace.begin panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) " + "renderable=\(wasRenderable ? 1 : 0) restoreURL=\(restoreURLString ?? "nil") " + "restoreHistoryBack=\(history.backHistoryURLStrings.count) " + "restoreHistoryForward=\(history.forwardHistoryURLStrings.count)" @@ -2351,12 +2391,12 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask?.cancel() faviconTask = nil faviconRefreshGeneration &+= 1 - BrowserWindowPortalRegistry.detach(webView: terminatedWebView) - terminatedWebView.stopLoading() - terminatedWebView.navigationDelegate = nil - terminatedWebView.uiDelegate = nil - if let terminatedCmuxWebView = terminatedWebView as? CmuxWebView { - terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil + BrowserWindowPortalRegistry.detach(webView: oldWebView) + oldWebView.stopLoading() + oldWebView.navigationDelegate = nil + oldWebView.uiDelegate = nil + if let oldCmuxWebView = oldWebView as? CmuxWebView { + oldCmuxWebView.onContextMenuDownloadStateChanged = nil } let replacement = Self.makeWebView(websiteDataStore: websiteDataStore) @@ -2387,12 +2427,13 @@ final class BrowserPanel: Panel, ObservableObject { } if restoreDevTools { - requestDeveloperToolsRefreshAfterNextAttach(reason: "webcontent_process_terminated") + requestDeveloperToolsRefreshAfterNextAttach(reason: reason) } #if DEBUG dlog( "browser.webview.replace.end panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) " + "instance=\(webViewInstanceID.uuidString.prefix(6)) " + "restoreURL=\(restoreURLString ?? "nil") shouldRestore=\(shouldRestoreURL ? 1 : 0)" ) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 53b9487d..fd0e5ad4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6995,9 +6995,13 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = detached.panel as? TerminalPanel { terminalPanel.updateWorkspaceId(id) } else if let browserPanel = detached.panel as? BrowserPanel { - browserPanel.updateWorkspaceId(id) - browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) + browserPanel.reattachToWorkspace( + id, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, + proxyEndpoint: remoteProxyEndpoint, + remoteStatus: browserRemoteWorkspaceStatusSnapshot() + ) installBrowserPanelSubscription(browserPanel) } diff --git a/cmuxTests/CLIProcessRunnerTests.swift b/cmuxTests/CLIProcessRunnerTests.swift index b4bc4dc5..d3831dee 100644 --- a/cmuxTests/CLIProcessRunnerTests.swift +++ b/cmuxTests/CLIProcessRunnerTests.swift @@ -16,5 +16,91 @@ final class CLIProcessRunnerTests: XCTestCase { XCTAssertEqual(result.status, 124) XCTAssertLessThan(Date().timeIntervalSince(startedAt), 2.0) } + + func testInteractiveRemoteShellCommandHonorsZDOTDIRFromRealZshenv() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zdotdir-\(UUID().uuidString)") + let userZdotdir = home.appendingPathComponent("user-zdotdir") + let relayDir = home.appendingPathComponent(".cmux/relay") + let binDir = home.appendingPathComponent(".cmux/bin") + try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try "export ZDOTDIR=\"$HOME/user-zdotdir\"\n" + .write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) + try """ + precmd() { + print -r -- "REAL=$CMUX_REAL_ZDOTDIR ZDOTDIR=$ZDOTDIR SOCKET=$CMUX_SOCKET_PATH PATH=$PATH" + exit + } + """ + .write(to: userZdotdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + try "#!/bin/sh\nexit 0\n" + .write(to: binDir.appendingPathComponent("cmux"), atomically: true, encoding: .utf8) + try "".write( + to: relayDir.appendingPathComponent("64003.auth"), + atomically: true, + encoding: .utf8 + ) + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binDir.appendingPathComponent("cmux").path + ) + + let cli = CMUXCLI(args: []) + let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64003, shellFeatures: "") + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("REAL=\(userZdotdir.path)"), result.stdout) + XCTAssertTrue(result.stdout.contains("SOCKET=127.0.0.1:64003"), result.stdout) + XCTAssertTrue(result.stdout.contains("PATH=\(binDir.path):"), result.stdout) + XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64003.shell").path)"), result.stdout) + } + + func testInteractiveRemoteShellCommandKeepsDefaultZDOTDIRWithoutRecursing() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zdotdir-default-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let binDir = home.appendingPathComponent(".cmux/bin") + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try "precmd() { print -r -- \"REAL=$CMUX_REAL_ZDOTDIR ZDOTDIR=$ZDOTDIR\"; exit }\n" + .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + try "#!/bin/sh\nexit 0\n" + .write(to: binDir.appendingPathComponent("cmux"), atomically: true, encoding: .utf8) + try "".write( + to: relayDir.appendingPathComponent("64004.auth"), + atomically: true, + encoding: .utf8 + ) + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binDir.appendingPathComponent("cmux").path + ) + + let cli = CMUXCLI(args: []) + let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64004, shellFeatures: "") + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertFalse(result.stderr.contains("too many open files"), result.stderr) + XCTAssertTrue(result.stdout.contains("REAL=\(home.path)"), result.stdout) + XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").path)"), result.stdout) + } } #endif diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index a77dcf78..72f064b9 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -931,6 +931,80 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.webView.url?.host, "localhost") } + func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws { + let source = Workspace() + let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) + let sourceBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let localStore = sourceBrowser.webView.configuration.websiteDataStore + XCTAssertTrue(localStore === WKWebsiteDataStore.default()) + + let destination = Workspace() + destination.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-store-dest", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-store-dest.sock", + terminalStartupCommand: "ssh cmux-macmini" + ), + autoConnect: false + ) + let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + let destinationBrowser = try XCTUnwrap(destination.newBrowserSurface(inPane: destinationPaneId, focus: false)) + let destinationStore = destinationBrowser.webView.configuration.websiteDataStore + XCTAssertFalse(destinationStore === WKWebsiteDataStore.default()) + + let detached = try XCTUnwrap(source.detachSurface(panelId: sourceBrowser.id)) + let attachedPanelId = try XCTUnwrap( + destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) + ) + let movedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel) + + XCTAssertTrue(movedBrowser.webView.configuration.websiteDataStore === destinationStore) + XCTAssertFalse(movedBrowser.webView.configuration.websiteDataStore === localStore) + } + + func testBrowserMoveOutOfRemoteWorkspaceRestoresDefaultWebsiteDataStore() throws { + let source = Workspace() + source.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64002, + relayID: "relay-store-source", + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-store-source.sock", + terminalStartupCommand: "ssh cmux-macmini" + ), + autoConnect: false + ) + let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) + let movedBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let remainingRemoteBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let remoteStore = remainingRemoteBrowser.webView.configuration.websiteDataStore + XCTAssertFalse(remoteStore === WKWebsiteDataStore.default()) + + let destination = Workspace() + let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + let detached = try XCTUnwrap(source.detachSurface(panelId: movedBrowser.id)) + let attachedPanelId = try XCTUnwrap( + destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) + ) + let attachedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel) + + XCTAssertTrue(attachedBrowser.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertTrue(remainingRemoteBrowser.webView.configuration.websiteDataStore === remoteStore) + XCTAssertFalse(remainingRemoteBrowser.webView.configuration.websiteDataStore === attachedBrowser.webView.configuration.websiteDataStore) + } + func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws { let workspace = Workspace() let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) From 60137e0f68fb301a6b83a0cfa0ffe2fc7560d127 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:55:45 -0700 Subject: [PATCH 20/77] Add regressions for v1 panel focus preservation --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 60 +++++++ .../test_v1_panel_creation_preserves_focus.py | 154 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 tests_v2/test_v1_panel_creation_preserves_focus.py diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 136a25f7..b17ab943 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6631,6 +6631,66 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected terminal surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + + func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected browser surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { diff --git a/tests_v2/test_v1_panel_creation_preserves_focus.py b/tests_v2/test_v1_panel_creation_preserves_focus.py new file mode 100644 index 00000000..21c66267 --- /dev/null +++ b/tests_v2/test_v1_panel_creation_preserves_focus.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Regression: legacy v1 panel-creation socket commands must not steal focus.""" + +from __future__ import annotations + +import os +import socket +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _send_v1(command: str, *, expect_ok: bool = True) -> str: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(5.0) + sock.connect(SOCKET_PATH) + sock.sendall((command + "\n").encode("utf-8")) + chunks: list[bytes] = [] + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + chunks.append(chunk) + sock.settimeout(0.1) + payload = b"".join(chunks).decode("utf-8", errors="replace").strip() + if expect_ok and not payload.startswith("OK"): + raise cmuxError(f"{command!r} failed: {payload!r}") + return payload + + +def _focused_surface_id(client: cmux, workspace_id: str) -> str: + surfaces = client.list_surfaces(workspace=workspace_id) + for _, surface_id, focused in surfaces: + if focused: + return surface_id + raise cmuxError(f"no focused surface in workspace {workspace_id}: {surfaces}") + + +def _surface_ids(client: cmux, workspace_id: str) -> set[str]: + return {surface_id for _, surface_id, _ in client.list_surfaces(workspace=workspace_id)} + + +def _created_surface_id(response: str) -> str: + parts = response.split(" ", 1) + _must(len(parts) == 2 and parts[1], f"expected surface id in response: {response!r}") + return parts[1] + + +def _sidebar_state(workspace_id: str) -> str: + payload = _send_v1(f"sidebar_state --tab={workspace_id}", expect_ok=False) + if payload.startswith("ERROR"): + raise cmuxError(f"sidebar_state failed: {payload!r}") + return payload + + +def main() -> int: + created_workspaces: list[str] = [] + with cmux(SOCKET_PATH) as client: + try: + created_workspace = client.new_workspace() + created_workspaces.append(created_workspace) + client.select_workspace(created_workspace) + time.sleep(0.2) + + baseline_workspace = client.current_workspace() + baseline_focused_surface = _focused_surface_id(client, created_workspace) + baseline_surfaces = _surface_ids(client, created_workspace) + + new_surface_response = _send_v1("new_surface") + time.sleep(0.2) + new_surface_id = _created_surface_id(new_surface_response) + _must(new_surface_id in _surface_ids(client, created_workspace), "new_surface should create a surface") + _must(client.current_workspace() == baseline_workspace, "new_surface should not retarget workspace selection") + _must( + _focused_surface_id(client, created_workspace) == baseline_focused_surface, + "new_surface should preserve the focused surface for v1 callers", + ) + + open_browser_response = _send_v1("open_browser") + time.sleep(0.2) + browser_surface_id = _created_surface_id(open_browser_response) + _must(browser_surface_id in _surface_ids(client, created_workspace), "open_browser should create a browser surface") + _must(client.current_workspace() == baseline_workspace, "open_browser should not retarget workspace selection") + _must( + _focused_surface_id(client, created_workspace) == baseline_focused_surface, + "open_browser should preserve the focused surface for v1 callers", + ) + + new_pane_response = _send_v1("new_pane --direction=right") + time.sleep(0.2) + split_surface_id = _created_surface_id(new_pane_response) + current_surfaces = _surface_ids(client, created_workspace) + _must( + len(current_surfaces - baseline_surfaces) >= 3, + f"expected all v1 panel creation commands to add surfaces: {current_surfaces}", + ) + _must(split_surface_id in current_surfaces, "new_pane should create a split surface") + _must(client.current_workspace() == baseline_workspace, "new_pane should not retarget workspace selection") + _must( + _focused_surface_id(client, created_workspace) == baseline_focused_surface, + "new_pane should preserve the focused surface for v1 callers", + ) + + background_workspace = client.new_workspace() + created_workspaces.append(background_workspace) + client.select_workspace(background_workspace) + time.sleep(0.2) + + target_directory = f"/tmp/cmux-v1-report-pwd-{int(time.time() * 1000)}" + _send_v1( + f"report_pwd {target_directory} --tab={created_workspace} --panel={baseline_focused_surface}" + ) + deadline = time.time() + 5.0 + sidebar_state = "" + while time.time() < deadline: + sidebar_state = _sidebar_state(created_workspace) + if f"focused_cwd={target_directory}" in sidebar_state: + break + time.sleep(0.1) + _must( + f"focused_cwd={target_directory}" in sidebar_state, + f"report_pwd should update the targeted background workspace: {sidebar_state!r}", + ) + _must( + client.current_workspace() == background_workspace, + "report_pwd with explicit scope should not retarget workspace selection", + ) + finally: + for workspace_id in reversed(created_workspaces): + try: + client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: legacy v1 panel creation and prompt telemetry preserve focus and workspace selection") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3fbfd74aa17ded0b6141297c99bb6c4814fab467 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:55:48 -0700 Subject: [PATCH 21/77] Fix socket focus and startup env regressions --- Sources/GhosttyTerminalView.swift | 81 ++++++++++++++++++------------ Sources/TerminalController.swift | 74 ++++++++++++++++++++++++--- Sources/Workspace.swift | 24 +++++++++ cmuxTests/GhosttyConfigTests.swift | 44 ++++++++++++++++ 4 files changed, 183 insertions(+), 40 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b5c6857a..94f56895 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2634,6 +2634,22 @@ final class TerminalSurface: Identifiable, ObservableObject { return merged } + static func mergedStartupEnvironment( + base: [String: String], + protectedKeys: Set<String>, + additionalEnvironment: [String: String], + initialEnvironmentOverrides: [String: String] + ) -> [String: String] { + var merged = base + for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty && !protectedKeys.contains(key) { + merged[key] = value + } + for (key, value) in initialEnvironmentOverrides where !protectedKeys.contains(key) { + merged[key] = value + } + return merged + } + func isAttached(to view: GhosttyNSView) -> Bool { attachedView === view && surface != nil } @@ -2986,31 +3002,37 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - env["CMUX_SURFACE_ID"] = id.uuidString - env["CMUX_WORKSPACE_ID"] = tabId.uuidString + var protectedStartupEnvironmentKeys: Set<String> = [] + func setManagedEnvironmentValue(_ key: String, _ value: String) { + env[key] = value + protectedStartupEnvironmentKeys.insert(key) + } + + setManagedEnvironmentValue("CMUX_SURFACE_ID", id.uuidString) + setManagedEnvironmentValue("CMUX_WORKSPACE_ID", tabId.uuidString) // Backward-compatible shell integration keys used by existing scripts/tests. - env["CMUX_PANEL_ID"] = id.uuidString - env["CMUX_TAB_ID"] = tabId.uuidString - env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + setManagedEnvironmentValue("CMUX_PANEL_ID", id.uuidString) + setManagedEnvironmentValue("CMUX_TAB_ID", tabId.uuidString) + setManagedEnvironmentValue("CMUX_SOCKET_PATH", SocketControlSettings.socketPath()) if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"), FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) { - env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIURL.path + setManagedEnvironmentValue("CMUX_BUNDLED_CLI_PATH", bundledCLIURL.path) } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { - env["CMUX_BUNDLE_ID"] = bundleId + setManagedEnvironmentValue("CMUX_BUNDLE_ID", bundleId) } // Port range for this workspace (base/range snapshotted once per app session) do { let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize - env["CMUX_PORT"] = String(startPort) - env["CMUX_PORT_END"] = String(startPort + Self.sessionPortRangeSize - 1) - env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize) + setManagedEnvironmentValue("CMUX_PORT", String(startPort)) + setManagedEnvironmentValue("CMUX_PORT_END", String(startPort + Self.sessionPortRangeSize - 1)) + setManagedEnvironmentValue("CMUX_PORT_RANGE", String(Self.sessionPortRangeSize)) } let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled() if !claudeHooksEnabled { - env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" + setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1") } if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { @@ -3020,7 +3042,7 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? "" if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) { let separator = currentPath.isEmpty ? "" : ":" - env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)" + setManagedEnvironmentValue("PATH", "\(cliBinPath)\(separator)\(currentPath)") } } @@ -3028,8 +3050,8 @@ final class TerminalSurface: Identifiable, ObservableObject { let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true if shellIntegrationEnabled, let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path { - env["CMUX_SHELL_INTEGRATION"] = "1" - env["CMUX_SHELL_INTEGRATION_DIR"] = integrationDir + setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION", "1") + setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION_DIR", integrationDir) let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil) ?? getenv("SHELL").map { String(cString: $0) } @@ -3038,7 +3060,7 @@ final class TerminalSurface: Identifiable, ObservableObject { let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { if GhosttyApp.shared.shellIntegrationMode() != "none" { - env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" + setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION", "1") } let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) ?? getenv("ZDOTDIR").map { String(cString: $0) } @@ -3055,20 +3077,20 @@ final class TerminalSurface: Identifiable, ObservableObject { isGhosttyInjected = (candidateZdotdir == ghosttyZdotdir) } if !isGhosttyInjected { - env["CMUX_ZSH_ZDOTDIR"] = candidateZdotdir + setManagedEnvironmentValue("CMUX_ZSH_ZDOTDIR", candidateZdotdir) } } - env["ZDOTDIR"] = integrationDir + setManagedEnvironmentValue("ZDOTDIR", integrationDir) } else if shellName == "bash" { if GhosttyApp.shared.shellIntegrationMode() != "none" { - env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1" + setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_BASH_INTEGRATION", "1") } // macOS ships /bin/bash 3.2, where Ghostty's automatic bash // integration is unsupported and HOME-based wrapper startup is // not reliable. Bootstrap cmux bash integration on the first // interactive prompt instead. - env["PROMPT_COMMAND"] = """ + setManagedEnvironmentValue("PROMPT_COMMAND", """ unset PROMPT_COMMAND; \ if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \ _cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \ @@ -3080,22 +3102,15 @@ final class TerminalSurface: Identifiable, ObservableObject { fi; \ unset _cmux_ghostty_bash _cmux_bash_integration; \ if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi - """ - } - } - - let startupEnvironment = additionalEnvironment - if !startupEnvironment.isEmpty { - for (key, value) in startupEnvironment where !key.isEmpty && !value.isEmpty && !key.hasPrefix("CMUX_") { - env[key] = value - } - } - - if !initialEnvironmentOverrides.isEmpty { - for (key, value) in initialEnvironmentOverrides where !key.hasPrefix("CMUX_") { - env[key] = value + """) } } + env = Self.mergedStartupEnvironment( + base: env, + protectedKeys: protectedStartupEnvironmentKeys, + additionalEnvironment: additionalEnvironment, + initialEnvironmentOverrides: initialEnvironmentOverrides + ) if !env.isEmpty { envVars.reserveCapacity(env.count) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3e35140a..e75bf770 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -12734,6 +12734,7 @@ class TerminalController { let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) var result = "ERROR: Failed to create browser panel" + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }), @@ -12741,7 +12742,12 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { + if let browserPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: .horizontal, + url: url, + focus: focus + )?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -13145,6 +13151,7 @@ class TerminalController { let insertFirst = direction.insertFirst var result = "ERROR: Failed to create pane" + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }), @@ -13154,9 +13161,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: focus + )?.id } else { - newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = tab.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: focus + )?.id } if let id = newPanelId { @@ -14003,6 +14021,19 @@ class TerminalController { } let directory = parsed.positional.joined(separator: " ") + if let scope = Self.explicitSocketScope(options: parsed.options) { + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory) + } + return "OK" + } var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -14150,6 +14181,21 @@ class TerminalController { return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]" } + if let scope = Self.explicitSocketScope(options: parsed.options) { + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + tab.surfaceTTYNames[scope.panelId] = ttyName + PortScanner.shared.registerTTY(workspaceId: scope.workspaceId, panelId: scope.panelId, ttyName: ttyName) + } + return "OK" + } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -14190,15 +14236,28 @@ class TerminalController { } private func portsKick(_ args: String) -> String { + let parsed = parseOptions(args) + if let scope = Self.explicitSocketScope(options: parsed.options) { + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) + } + return "OK" + } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - let parsed = parseOptions(args) result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - let parsed = parseOptions(args) let panelArg = parsed.options["panel"] ?? parsed.options["surface"] let surfaceId: UUID if let panelArg { @@ -14456,6 +14515,7 @@ class TerminalController { } var result = "ERROR: Failed to create tab" + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { @@ -14484,9 +14544,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: focus)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: focus)?.id } if let id = newPanelId { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index fd0e5ad4..ddd96b97 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6224,6 +6224,8 @@ final class Workspace: Identifiable, ObservableObject { startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) let remoteTerminalStartupCommand = remoteTerminalStartupCommand() @@ -6273,6 +6275,12 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(newTabId) newPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) } return newPanel } @@ -6377,6 +6385,8 @@ final class Workspace: Identifiable, ObservableObject { bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView let browserPanel = BrowserPanel( workspaceId: id, @@ -6417,6 +6427,12 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(newTabId) browserPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) } installBrowserPanelSubscription(browserPanel) @@ -6494,6 +6510,8 @@ final class Workspace: Identifiable, ObservableObject { focus: Bool? = nil ) -> MarkdownPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel @@ -6518,6 +6536,12 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView + ) } installMarkdownPanelSubscription(markdownPanel) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 72f064b9..8ed63b33 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -863,6 +863,50 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { } } +final class GhosttyTerminalStartupEnvironmentTests: XCTestCase { + func testMergedStartupEnvironmentAllowsSessionReplayAndInitialEnvCMUXKeys() { + let replayPath = "/tmp/cmux-replay-\(UUID().uuidString)" + let merged = TerminalSurface.mergedStartupEnvironment( + base: [ + "PATH": "/usr/bin", + "CMUX_SURFACE_ID": "managed-surface" + ], + protectedKeys: ["PATH", "CMUX_SURFACE_ID"], + additionalEnvironment: [ + SessionScrollbackReplayStore.environmentKey: replayPath + ], + initialEnvironmentOverrides: [ + "CMUX_INITIAL_ENV_TOKEN": "token-123" + ] + ) + + XCTAssertEqual(merged[SessionScrollbackReplayStore.environmentKey], replayPath) + XCTAssertEqual(merged["CMUX_INITIAL_ENV_TOKEN"], "token-123") + } + + func testMergedStartupEnvironmentProtectsManagedKeysOnly() { + let merged = TerminalSurface.mergedStartupEnvironment( + base: [ + "PATH": "/usr/bin", + "CMUX_SURFACE_ID": "managed-surface" + ], + protectedKeys: ["PATH", "CMUX_SURFACE_ID"], + additionalEnvironment: [ + "CMUX_SURFACE_ID": "user-surface", + "CUSTOM_FLAG": "1" + ], + initialEnvironmentOverrides: [ + "PATH": "/tmp/bin", + "CMUX_SURFACE_ID": "override-surface" + ] + ) + + XCTAssertEqual(merged["PATH"], "/usr/bin") + XCTAssertEqual(merged["CMUX_SURFACE_ID"], "managed-surface") + XCTAssertEqual(merged["CUSTOM_FLAG"], "1") + } +} + @MainActor final class BrowserPanelRemoteStoreTests: XCTestCase { func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() { From 9b377a930f6f5b75d76c6cc40c351314252ac19b Mon Sep 17 00:00:00 2001 From: Jonathan Wukitsch <11484046+jonathanwuki@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:49:40 -0400 Subject: [PATCH 22/77] Fix About Panel Newline Escaping (#1298) * fix: about panel newline escaping * fix: other languages --- Resources/Localizable.xcstrings | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 812aef3b..e425a967 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -551,7 +551,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "A Ghostty-based terminal with vertical tabs\\nand a notification panel for macOS." + "value": "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS." } }, "ja": { @@ -563,43 +563,43 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "基于 Ghostty 的 macOS 终端,\\n支持垂直标签页和通知面板。" + "value": "基于 Ghostty 的 macOS 终端,\n支持垂直标签页和通知面板。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "基於 Ghostty 的 macOS 終端機,\\n具備垂直標籤頁與通知面板。" + "value": "基於 Ghostty 的 macOS 終端機,\n具備垂直標籤頁與通知面板。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "세로 탭과 알림 패널을 갖춘\\nGhostty 기반 macOS 터미널." + "value": "세로 탭과 알림 패널을 갖춘\nGhostty 기반 macOS 터미널." } }, "de": { "stringUnit": { "state": "translated", - "value": "Ein Ghostty-basiertes Terminal mit vertikalen Tabs\\nund einem Benachrichtigungsfeld für macOS." + "value": "Ein Ghostty-basiertes Terminal mit vertikalen Tabs\nund einem Benachrichtigungsfeld für macOS." } }, "es": { "stringUnit": { "state": "translated", - "value": "Un terminal basado en Ghostty con pestañas verticales\\ny un panel de notificaciones para macOS." + "value": "Un terminal basado en Ghostty con pestañas verticales\ny un panel de notificaciones para macOS." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Un terminal basé sur Ghostty avec des onglets verticaux\\net un panneau de notifications pour macOS." + "value": "Un terminal basé sur Ghostty avec des onglets verticaux\net un panneau de notifications pour macOS." } }, "it": { "stringUnit": { "state": "translated", - "value": "Un terminale basato su Ghostty con schede verticali\\ne un pannello notifiche per macOS." + "value": "Un terminale basato su Ghostty con schede verticali\ne un pannello notifiche per macOS." } }, "da": { @@ -617,43 +617,43 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Терминал на базе Ghostty с вертикальными вкладками\\nи панелью уведомлений для macOS." + "value": "Терминал на базе Ghostty с вертикальными вкладками\nи панелью уведомлений для macOS." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Terminal zasnovan na Ghostty sa vertikalnim tabovima\\ni panelom za obavještenja za macOS." + "value": "Terminal zasnovan na Ghostty sa vertikalnim tabovima\ni panelom za obavještenja za macOS." } }, "ar": { "stringUnit": { "state": "translated", - "value": "طرفية مبنية على Ghostty مع ألسنة عمودية\\nولوحة إشعارات لنظام macOS." + "value": "طرفية مبنية على Ghostty مع ألسنة عمودية\nولوحة إشعارات لنظام macOS." } }, "nb": { "stringUnit": { "state": "translated", - "value": "En Ghostty-basert terminal med vertikale faner\\nog et varselpanel for macOS." + "value": "En Ghostty-basert terminal med vertikale faner\nog et varselpanel for macOS." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Um terminal baseado no Ghostty com abas verticais\\ne um painel de notificações para macOS." + "value": "Um terminal baseado no Ghostty com abas verticais\ne um painel de notificações para macOS." } }, "th": { "stringUnit": { "state": "translated", - "value": "เทอร์มินัลบน Ghostty พร้อมแท็บแนวตั้ง\\nและแผงการแจ้งเตือนสำหรับ macOS" + "value": "เทอร์มินัลบน Ghostty พร้อมแท็บแนวตั้ง\nและแผงการแจ้งเตือนสำหรับ macOS" } }, "tr": { "stringUnit": { "state": "translated", - "value": "macOS için dikey sekmeli ve bildirim panelli\\nGhostty tabanlı terminal." + "value": "macOS için dikey sekmeli ve bildirim panelli\nGhostty tabanlı terminal." } } } From 1460c97e855e07973013b72db918261e82461f33 Mon Sep 17 00:00:00 2001 From: Max Schmitt <max@schmitt.mx> Date: Sun, 15 Mar 2026 16:49:42 -0700 Subject: [PATCH 23/77] fix: correct Claude Code hooks config to match actual schema (#1388) The hooks configuration example used a format that was valid when originally written but broke across two Claude Code releases: - v1.0.41: Added `hook_event_name` to hook input, replacing the previous field name. The hook script was still reading `.event`, causing the case statement to always fall through to unknown. - v2.1.63: Added HTTP hooks with `{ "type": "command", "command": "..." }` object format. Bare string paths in the hooks array are no longer valid now that hook type disambiguation is required. Changes: - Stop hooks now use the full matcher/hooks object structure - PostToolUse inner hooks use typed command objects - Hook script reads `hook_event_name` instead of `event` Ref: https://docs.anthropic.com/en/docs/claude-code/hooks --- web/app/[locale]/docs/notifications/page.tsx | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index 2a5d53b9..b02de0e1 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -158,7 +158,7 @@ printf '\\e]99;i=1;e=1;d=1;p=body:All tests passed\\e\\\\'`}</CodeBlock> [ -S /tmp/cmux.sock ] || exit 0 EVENT=$(cat) -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') +EVENT_TYPE=$(echo "$EVENT" | jq -r '.hook_event_name // "unknown"') TOOL=$(echo "$EVENT" | jq -r '.tool_name // ""') case "$EVENT_TYPE" in @@ -174,11 +174,26 @@ esac`}</CodeBlock> <h3>{t("configureClaude")}</h3> <CodeBlock title="~/.claude/settings.json" lang="json">{`{ "hooks": { - "Stop": ["~/.claude/hooks/cmux-notify.sh"], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/cmux-notify.sh" + } + ] + } + ], "PostToolUse": [ { "matcher": "Task", - "hooks": ["~/.claude/hooks/cmux-notify.sh"] + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/cmux-notify.sh" + } + ] } ] } From 6b138f7d9d1049aa90cda15104704211a5abeb63 Mon Sep 17 00:00:00 2001 From: Manfred Neustifter <manfred.neustifter@gmail.com> Date: Mon, 16 Mar 2026 10:48:05 +1000 Subject: [PATCH 24/77] feat: support window.open() popup windows (#1150) * feat: support window.open() popup windows (#742) Return a live WKWebView from createWebViewWith using WebKit's supplied configuration, preserving popup browsing-context semantics (window.opener, postMessage). This fixes OAuth/OIDC flows and any site relying on standard popup patterns. - Add BrowserPopupWindowController: NSPanel-based popup with self-retention, KVO title/URL, read-only URL label, nested popup depth limit (3), insecure-HTTP prompt parity, auth challenge parity, download delegate - Classifier: scripted requests (window.open) create popups; user-initiated actions (Cmd+click, middle-click, context menu) open tabs - Retarget context menu "Open Link in New Tab" to bypass createWebViewWith, wired in both main browser and popup web views - Cmd+W fast path in AppDelegate for popup windows - Opener panel owns popup lifecycle; close() tears down all child popups * fix: Cmd+W closes only the popup, not the parent tab Add BrowserPopupPanel (NSPanel subclass) that intercepts Cmd+W in performKeyEquivalent before the swizzled cmux_performKeyEquivalent can dispatch it to the main menu's "Close Tab" action. Also refine the popup classifier to reuse browserNavigationShouldOpenInNewTab for Cmd+click/middle-click detection, add download delegate wiring, and wire onContextMenuOpenLinkInNewTab for popup web views. * fix: tighten popup routing and window behavior * test: cover oversized popup frame clamping * test: cover plain link-activated popup routing --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com> --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 17 + Sources/AppDelegate.swift | 13 +- Sources/Panels/BrowserPanel.swift | 124 +++- .../Panels/BrowserPopupWindowController.swift | 619 ++++++++++++++++++ Sources/Panels/CmuxWebView.swift | 20 +- Sources/cmuxApp.swift | 1 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 89 +++ 8 files changed, 864 insertions(+), 23 deletions(-) create mode 100644 Sources/Panels/BrowserPopupWindowController.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d7c4cb9a..99f0407f 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; @@ -180,6 +181,7 @@ A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; }; + A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; }; @@ -387,6 +389,7 @@ A5001412 /* BrowserPanel.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, + A5007421 /* BrowserPopupWindowController.swift */, A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, @@ -659,6 +662,7 @@ A5001402 /* BrowserPanel.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + A5007420 /* BrowserPopupWindowController.swift in Sources */, A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e425a967..69f4d1b8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -7276,6 +7276,23 @@ } } }, + "browser.popup.loadingTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading\u2026" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\u8aad\u307f\u8fbc\u307f\u4e2d\u2026" + } + } + } + }, "browser.proceedInCmux": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6fe2b698..92e50a6b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8350,7 +8350,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: event, shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) ) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. + // This AppDelegate path is a fallback for cases where AppKit routes the + // event through the global shortcut handler first. + if let targetWindow = [NSApp.keyWindow, event.window] + .compactMap({ $0 }) + .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { +#if DEBUG + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2ea023ea..833fe93c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1268,6 +1268,9 @@ final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() + /// Popup windows owned by this panel (for lifecycle cleanup) + private var popupControllers: [BrowserPopupWindowController] = [] + static let telemetryHookBootstrapScriptSource = """ (() => { if (window.__cmuxHooksInstalled) return true; @@ -2014,6 +2017,9 @@ final class BrowserPanel: Panel, ObservableObject { self?.endDownloadActivity() } } + webView.onContextMenuOpenLinkInNewTab = { [weak self] url in + self?.openLinkInNewTab(url: url) + } webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) @@ -2097,6 +2103,9 @@ final class BrowserPanel: Panel, ObservableObject { browserUIDelegate.requestNavigation = { [weak self] request, intent in self?.requestNavigation(request, intent: intent) } + browserUIDelegate.openPopup = { [weak self] configuration, windowFeatures in + self?.createFloatingPopup(configuration: configuration, windowFeatures: windowFeatures) + } self.uiDelegate = browserUIDelegate bindWebView(webView) @@ -2373,6 +2382,17 @@ final class BrowserPanel: Panel, ObservableObject { // Ensure we don't keep a hidden WKWebView (or its content view) as first responder while // bonsplit/SwiftUI reshuffles views during close. unfocus() + + // Snapshot first: popup close unregisters itself from popupControllers. + let popupsToClose = popupControllers + popupControllers.removeAll() + + // Close all owned popup windows before tearing down delegates + for popup in popupsToClose { + popup.closeAllChildPopups() + popup.closePopup() + } + webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil @@ -2384,6 +2404,25 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask = nil } + // MARK: - Popup window management + + func createFloatingPopup( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + let controller = BrowserPopupWindowController( + configuration: configuration, + windowFeatures: windowFeatures, + openerPanel: self + ) + popupControllers.append(controller) + return controller.webView + } + + func removePopupController(_ controller: BrowserPopupWindowController) { + popupControllers.removeAll { $0 === controller } + } + private func refreshFavicon(from webView: WKWebView) { faviconTask?.cancel() faviconTask = nil @@ -4438,7 +4477,7 @@ private extension NSObject { /// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI /// during WebKit callbacks), then showing NSSavePanel after the download finishes. -private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { +class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private struct DownloadState { let tempURL: URL let suggestedFilename: String @@ -4596,6 +4635,25 @@ func browserNavigationShouldOpenInNewTab( return false } +func browserNavigationShouldCreatePopup( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + let isUserNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationType, + modifierFlags: modifierFlags, + buttonNumber: buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, + currentEventType: currentEventType, + currentEventButtonNumber: currentEventButtonNumber + ) + return navigationType == .other && !isUserNewTab +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? @@ -4833,7 +4891,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - // target=_blank or window.open() — open in a new tab. + // Catch-all for nil-target navigations where createWebViewWith + // returned nil: external URLs, user-initiated new-window actions + // (target=_blank, context menu) that fall through the classifier, + // or when popup creation is unavailable. if navigationAction.targetFrame == nil, let url = navigationAction.request.url { #if DEBUG @@ -4925,6 +4986,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? private func javaScriptDialogTitle(for webView: WKWebView) -> String { if let absolute = webView.url?.absoluteString, !absolute.isEmpty { @@ -4945,17 +5007,17 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { completion(alert.runModal()) } - /// Returning nil tells WebKit not to open a new window. - /// createWebViewWith is only called when the page requests a new window - /// (window.open(), target=_blank, etc.). Always open in a new tab. + /// Called when the page requests a new window (window.open(), target=_blank, etc.). + /// + /// Returns a live popup WKWebView created with WebKit's supplied configuration + /// to preserve popup browsing-context semantics (window.opener, postMessage). + /// Falls back to new-tab behavior only if popup creation is unavailable. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - // createWebViewWith is only called when the page requests a new window, - // so always treat as new-tab intent regardless of modifiers/button. #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -4963,21 +5025,45 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { dlog( "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "eventType=\(currentEventType) eventButton=\(currentEventButton) " + - "openInNewTab=1" + "eventType=\(currentEventType) eventButton=\(currentEventButton)" ) #endif - if let url = navigationAction.request.url { - if browserShouldOpenURLExternally(url) { - let opened = NSWorkspace.shared.open(url) - if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) - } - #if DEBUG - dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - return nil + // External URL schemes → hand off to macOS, don't create a popup + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } + + // Classifier: only scripted requests (window.open()) get popup windows. + // User-initiated actions (link clicks, context menu "Open Link in New Tab", + // Cmd+click, middle-click) fall through to existing new-tab behavior. + // + // WebKit sometimes delivers .other for Cmd+click / middle-click, so we + // reuse browserNavigationShouldOpenInNewTab to recover user intent before + // treating .other as a scripted popup. + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { +#if DEBUG + dlog("browser.nav.createWebView.action kind=popup") +#endif + return popupWebView + } + + // Fallback: open in new tab (no opener linkage) + if let url = navigationAction.request.url { if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = .newTab #if DEBUG diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift new file mode 100644 index 00000000..692e6376 --- /dev/null +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -0,0 +1,619 @@ +import AppKit +import Bonsplit +import ObjectiveC +import WebKit + +func browserPopupContentRect( + requestedWidth: CGFloat?, + requestedHeight: CGFloat?, + requestedX: CGFloat?, + requestedTopY: CGFloat?, + visibleFrame: NSRect, + defaultWidth: CGFloat = 800, + defaultHeight: CGFloat = 600, + minWidth: CGFloat = 200, + minHeight: CGFloat = 150 +) -> NSRect { + let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width) + let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height) + + let x: CGFloat + let y: CGFloat + if let requestedX, let requestedTopY { + x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth)) + + // Web content expresses popup Y as distance from the screen's top edge, + // while AppKit window origins are bottom-up. + let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight + y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight)) + } else { + x = visibleFrame.midX - clampedWidth / 2 + y = visibleFrame.midY - clampedHeight / 2 + } + + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) +} + +/// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page +/// calls `window.open()` (scripted new-window requests). +/// +/// Lifecycle: +/// - The controller self-retains via `objc_setAssociatedObject` on its panel. +/// - Released in `windowWillClose(_:)` when the panel closes. +/// - The opener `BrowserPanel` also keeps a strong reference for deterministic +/// cleanup when the opener tab or workspace is closed. +/// NSPanel subclass that intercepts Cmd+W before the swizzled +/// `cmux_performKeyEquivalent` can dispatch it to the main menu's +/// "Close Tab" action (which would close the parent browser tab). +private class BrowserPopupPanel: NSPanel { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Cmd+W: close this popup panel only + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == .command, + event.charactersIgnoringModifiers == "w" { + #if DEBUG + dlog("popup.panel.cmdW close") + #endif + performClose(nil) + return true + } + return super.performKeyEquivalent(with: event) + } +} + +@MainActor +final class BrowserPopupWindowController: NSObject, NSWindowDelegate { + + static let maxNestingDepth = 3 + + let webView: CmuxWebView + private let panel: NSPanel + private let urlLabel: NSTextField + private weak var openerPanel: BrowserPanel? + private weak var parentPopupController: BrowserPopupWindowController? + private let nestingDepth: Int + private var titleObservation: NSKeyValueObservation? + private var urlObservation: NSKeyValueObservation? + private var childPopups: [BrowserPopupWindowController] = [] + private let popupUIDelegate: PopupUIDelegate + private let popupNavigationDelegate: PopupNavigationDelegate + private let downloadDelegate: BrowserDownloadDelegate + + private static var associatedObjectKey: UInt8 = 0 + + init( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures, + openerPanel: BrowserPanel?, + parentPopupController: BrowserPopupWindowController? = nil, + nestingDepth: Int = 0 + ) { + self.openerPanel = openerPanel + self.parentPopupController = parentPopupController + self.nestingDepth = nestingDepth + + // Create popup web view with WebKit's supplied configuration (preserves + // internal browsing-context state for opener linkage / postMessage). + let webView = CmuxWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + self.webView = webView + + // --- Window sizing from WKWindowFeatures --- + let defaultWidth: CGFloat = 800 + let defaultHeight: CGFloat = 600 + let minWidth: CGFloat = 200 + let minHeight: CGFloat = 150 + + let w = max(windowFeatures.width?.doubleValue ?? defaultWidth, minWidth) + let h = max(windowFeatures.height?.doubleValue ?? defaultHeight, minHeight) + + // Screen-clamping: use opener's screen or main screen + let screen = openerPanel?.webView.window?.screen ?? NSScreen.main ?? NSScreen.screens.first + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let contentRect = browserPopupContentRect( + requestedWidth: w, + requestedHeight: h, + requestedX: windowFeatures.x.map { CGFloat($0.doubleValue) }, + requestedTopY: windowFeatures.y.map { CGFloat($0.doubleValue) }, + visibleFrame: visibleFrame, + defaultWidth: defaultWidth, + defaultHeight: defaultHeight, + minWidth: minWidth, + minHeight: minHeight + ) + + // Style mask: titled + closable + resizable by default. + // allowsResizing is a separate property from chrome-visibility flags + // (toolbarsVisibility, menuBarVisibility, statusBarVisibility). + var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable] + if windowFeatures.allowsResizing?.boolValue != false { + styleMask.insert(.resizable) + } + + let panel = BrowserPopupPanel( + contentRect: contentRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + panel.identifier = NSUserInterfaceItemIdentifier("cmux.browser-popup") + panel.level = NSWindow.Level.normal + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.minSize = NSSize(width: minWidth, height: minHeight) + panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}") + self.panel = panel + + let urlLabel = NSTextField(labelWithString: "") + self.urlLabel = urlLabel + + // Build delegate objects before super.init so they can be assigned + let uiDel = PopupUIDelegate() + let navDel = PopupNavigationDelegate() + let dlDel = BrowserDownloadDelegate() + self.popupUIDelegate = uiDel + self.popupNavigationDelegate = navDel + self.downloadDelegate = dlDel + + super.init() + + // --- URL label for phishing protection --- + urlLabel.translatesAutoresizingMaskIntoConstraints = false + urlLabel.font = .systemFont(ofSize: 11) + urlLabel.textColor = .secondaryLabelColor + urlLabel.lineBreakMode = .byTruncatingMiddle + urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let containerView = NSView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(urlLabel) + containerView.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + + panel.contentView = containerView + NSLayoutConstraint.activate([ + urlLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), + urlLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), + urlLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), + urlLabel.heightAnchor.constraint(equalToConstant: 16), + + webView.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 2), + webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + // --- Delegates --- + uiDel.controller = self + navDel.controller = self + navDel.downloadDelegate = dlDel + webView.uiDelegate = uiDel + webView.navigationDelegate = navDel + + // Context menu "Open Link in New Tab" → open in opener's workspace, + // not as a nested popup. Falls back to system browser if opener is gone. + webView.onContextMenuOpenLinkInNewTab = { [weak self] url in + if let opener = self?.openerPanel { + opener.openLinkInNewTab(url: url) + } else { + NSWorkspace.shared.open(url) + } + } + + // --- KVO for title and URL --- + titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, change in + guard let newTitle = change.newValue ?? nil, !newTitle.isEmpty else { return } + Task { @MainActor [weak self] in + self?.panel.title = newTitle + } + } + urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in + let displayURL = change.newValue??.absoluteString ?? "" + Task { @MainActor [weak self] in + self?.urlLabel.stringValue = displayURL + } + } + + // --- Self-retention via associated object on panel --- + objc_setAssociatedObject(panel, &Self.associatedObjectKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + panel.delegate = self + + #if DEBUG + dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")") + #endif + + panel.makeKeyAndOrderFront(self) + } + + // MARK: - Child popup tracking + + func addChildPopup(_ child: BrowserPopupWindowController) { + childPopups.append(child) + } + + func removeChildPopup(_ child: BrowserPopupWindowController) { + childPopups.removeAll { $0 === child } + } + + // MARK: - Popup lifecycle + + func closePopup() { + panel.close() // triggers windowWillClose + } + + func closeAllChildPopups() { + let children = childPopups + childPopups.removeAll() + for child in children { + child.closeAllChildPopups() + child.closePopup() + } + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + #if DEBUG + dlog("popup.close depth=\(nestingDepth)") + #endif + + closeAllChildPopups() + + // Invalidate observations + titleObservation?.invalidate() + titleObservation = nil + urlObservation?.invalidate() + urlObservation = nil + + // Tear down web view + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + + // Unregister from parent (opener panel or parent popup) + openerPanel?.removePopupController(self) + parentPopupController?.removeChildPopup(self) + + // Release self-retention + objc_setAssociatedObject(panel, &Self.associatedObjectKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + // MARK: - Nested popup creation + + func createNestedPopup( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + let nextDepth = nestingDepth + 1 + if nextDepth > Self.maxNestingDepth { + #if DEBUG + dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)") + #endif + return nil + } + let child = BrowserPopupWindowController( + configuration: configuration, + windowFeatures: windowFeatures, + openerPanel: openerPanel, + parentPopupController: self, + nestingDepth: nextDepth + ) + addChildPopup(child) + return child.webView + } + + func openInOpenerTab(_ url: URL) { + if let openerPanel { + openerPanel.openLinkInNewTab(url: url) + } else { + NSWorkspace.shared.open(url) + } + } + + // MARK: - Insecure HTTP prompt (parity with main browser) + + /// Shows the same 3-button insecure HTTP alert as the main browser. + /// Reuses the global helpers from BrowserPanel.swift. + fileprivate func presentInsecureHTTPAlert( + for url: URL, + in webView: WKWebView, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { + decisionHandler(.cancel) + return + } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.") + alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) + alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux") + + let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert?.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch response { + case .alertFirstButtonReturn: + // Open in default browser, cancel popup navigation + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + case .alertSecondButtonReturn: + // Proceed in popup + decisionHandler(.allow) + default: + decisionHandler(.cancel) + } + } + + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: handleResponse) + return + } + handleResponse(alert.runModal()) + } +} + +// MARK: - PopupUIDelegate + +private class PopupUIDelegate: NSObject, WKUIDelegate { + weak var controller: BrowserPopupWindowController? + + func webViewDidClose(_ webView: WKWebView) { + #if DEBUG + dlog("popup.webViewDidClose") + #endif + controller?.closePopup() + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + // External URL check + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + return nil + } + + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup { + return controller?.createNestedPopup( + configuration: configuration, + windowFeatures: windowFeatures + ) + } + + if let url = navigationAction.request.url { + controller?.openInOpenerTab(url) + } + return nil + } + + // MARK: - JS Dialogs (parity with main browser) + + private func javaScriptDialogTitle(for webView: WKWebView) -> String { + if let absolute = webView.url?.absoluteString, !absolute.isEmpty { + return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") + } + return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") + } + + private func presentDialog( + _ alert: NSAlert, + for webView: WKWebView, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: completion) + return + } + completion(alert.runModal()) + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + presentDialog(alert, for: webView) { _ in completionHandler() } + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + presentDialog(alert, for: webView) { response in + completionHandler(response == .alertFirstButtonReturn) + } + } + + func webView( + _ webView: WKWebView, + runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (String?) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = prompt + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + field.stringValue = defaultText ?? "" + alert.accessoryView = field + + presentDialog(alert, for: webView) { response in + if response == .alertFirstButtonReturn { + completionHandler(field.stringValue) + } else { + completionHandler(nil) + } + } + } + + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + panel.begin { result in + completionHandler(result == .OK ? panel.urls : nil) + } + } + + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + decisionHandler(.prompt) + } +} + +// MARK: - PopupNavigationDelegate + +private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { + weak var controller: BrowserPopupWindowController? + var downloadDelegate: WKDownloadDelegate? + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + // Only guard main-frame navigations + guard navigationAction.targetFrame?.isMainFrame != false else { + decisionHandler(.allow) + return + } + + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + // External URL schemes → hand off to macOS + if browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + #if DEBUG + dlog("popup.nav.external url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } + + // Insecure HTTP → show same prompt as main browser + if browserShouldBlockInsecureHTTPURL(url) { + #if DEBUG + dlog("popup.nav.insecureHTTP url=\(url.absoluteString)") + #endif + controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.isForMainFrame { + decisionHandler(.allow) + return + } + + if let scheme = navigationResponse.response.url?.scheme?.lowercased(), + scheme != "http", scheme != "https" { + decisionHandler(.allow) + return + } + + if let response = navigationResponse.response as? HTTPURLResponse { + let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" + if contentDisposition.lowercased().hasPrefix("attachment") { + decisionHandler(.download) + return + } + } + + if !navigationResponse.canShowMIMEType { + decisionHandler(.download) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // Parity with main browser: performDefaultHandling enables system keychain + // lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID). + completionHandler(.performDefaultHandling, nil) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + #if DEBUG + dlog("popup.download.didBecome source=navigationAction") + #endif + download.delegate = downloadDelegate + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + #if DEBUG + dlog("popup.download.didBecome source=navigationResponse") + #endif + download.delegate = downloadDelegate + } +} diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index aaf751d9..8990e685 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -53,6 +53,9 @@ final class CmuxWebView: WKWebView { private static var contextMenuFallbackKey: UInt8 = 0 var onContextMenuDownloadStateChanged: ((Bool) -> Void)? + /// Called when "Open Link in New Tab" context menu is selected. + /// Bypasses createWebViewWith so the link opens as a tab, not a popup. + var onContextMenuOpenLinkInNewTab: ((URL) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? /// Guard against background panes stealing first responder (e.g. page autofocus). @@ -1212,12 +1215,15 @@ final class CmuxWebView: WKWebView { openLinkInsertionIndex = index + 1 } - // Rename "Open Link in New Window" to "Open Link in New Tab". - // The UIDelegate's createWebViewWith already handles the action - // by opening the link as a new surface in the same pane. + // Retarget "Open Link in New Window" to open as a tab, not a popup. + // Without this, WebKit's default action calls createWebViewWith with + // navigationType .other, which our classifier would treat as a scripted + // popup request. if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow" || item.title.contains("Open Link in New Window") { item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab") + item.target = self + item.action = #selector(contextMenuOpenLinkInNewTab(_:)) } if isDownloadImageMenuItem(item) { @@ -1275,6 +1281,14 @@ final class CmuxWebView: WKWebView { } } + @objc private func contextMenuOpenLinkInNewTab(_ sender: Any?) { + let point = lastContextMenuPoint + resolveContextMenuLinkURL(at: point) { [weak self] url in + guard let self, let url else { return } + self.onContextMenuOpenLinkInNewTab?(url) + } + } + @objc private func contextMenuDownloadImage(_ sender: Any?) { let traceID = Self.makeContextDownloadTraceID(prefix: "img") let point = lastContextMenuPoint diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 20739849..2768c037 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1064,6 +1064,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [ "cmux.settings", "cmux.about", "cmux.licenses", + "cmux.browser-popup", "cmux.settingsAboutTitlebarDebug", "cmux.debugWindowControls", "cmux.sidebarDebug", diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 244e30be..313d03d4 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2617,6 +2617,95 @@ final class BrowserNavigationNewTabDecisionTests: XCTestCase { } } +final class BrowserPopupDecisionTests: XCTestCase { + func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationPlainLeftClickCreatesPopup() { + XCTAssertTrue( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedCmdClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } +} + +final class BrowserPopupContentRectTests: XCTestCase { + func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { + let rect = browserPopupContentRect( + requestedWidth: 400, + requestedHeight: 300, + requestedX: 150, + requestedTopY: 120, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) + XCTAssertEqual(rect.width, 400, accuracy: 0.01) + XCTAssertEqual(rect.height, 300, accuracy: 0.01) + } + + func testExplicitCoordinatesClampToVisibleFrame() { + let rect = browserPopupContentRect( + requestedWidth: 1400, + requestedHeight: 1200, + requestedX: 900, + requestedTopY: -25, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) + XCTAssertEqual(rect.width, 1000, accuracy: 0.01) + XCTAssertEqual(rect.height, 800, accuracy: 0.01) + } + + func testMissingCoordinatesCentersPopup() { + let rect = browserPopupContentRect( + requestedWidth: 300, + requestedHeight: 200, + requestedX: nil, + requestedTopY: nil, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) + XCTAssertEqual(rect.width, 300, accuracy: 0.01) + XCTAssertEqual(rect.height, 200, accuracy: 0.01) + } +} + @MainActor final class BrowserJavaScriptDialogDelegateTests: XCTestCase { func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() { From bd1788639de66721ab644c673dbf735d4e7ef33f Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Sun, 15 Mar 2026 18:32:24 -0700 Subject: [PATCH 25/77] Fix terminal find overlay crash and focus handoff (#1487) * Add regression tests for terminal find overlay races * Fix terminal find overlay crash and focus handoff --- Sources/AppDelegate.swift | 26 ++ Sources/GhosttyTerminalView.swift | 278 +++++++++++++----- Sources/TabManager.swift | 15 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 107 +++++++ 4 files changed, 350 insertions(+), 76 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 92e50a6b..d323ed40 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1696,6 +1696,32 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( ) != nil } +@discardableResult +func startOrFocusTerminalSearch( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) + } +) -> Bool { + if terminalSurface.searchState != nil { + searchFocusNotifier(terminalSurface) + return true + } + + if terminalSurface.performBindingAction("start_search") { + DispatchQueue.main.async { [weak terminalSurface] in + guard let terminalSurface, terminalSurface.searchState == nil else { return } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + } + return true + } + + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + return true +} + /// Let AppKit own native Cmd+` window cycling so key-window changes do not /// re-enter our direct-to-menu shortcut path. func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 013e387f..cc49002b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5900,7 +5900,9 @@ final class GhosttySurfaceScrollView: NSView { private let keyboardCopyModeBadgeIconView: NSImageView private let keyboardCopyModeBadgeLabel: NSTextField private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>? + private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem? private var lastSearchOverlayStateID: ObjectIdentifier? + private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false @@ -6286,6 +6288,7 @@ final class GhosttySurfaceScrollView: NSView { #endif observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } + deferredSearchOverlayMutationWorkItem?.cancel() dropZoneOverlayView.removeFromSuperview() cancelFocusRequest() } @@ -6372,6 +6375,9 @@ final class GhosttySurfaceScrollView: NSView { } _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) + if let overlay = searchOverlayHostingView { + _ = setFrameIfNeeded(overlay, to: bounds) + } updateNotificationRingPath() updateFlashPath(style: .standardFocus) synchronizeScrollView() @@ -6609,50 +6615,42 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.commit() } - func setSearchOverlay(searchState: TerminalSurface.SearchState?) { - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - self?.setSearchOverlay(searchState: searchState) - } - return + private func cancelDeferredSearchOverlayMutation() { + deferredSearchOverlayMutationWorkItem?.cancel() + deferredSearchOverlayMutationWorkItem = nil + } + + private func scheduleDeferredSearchOverlayMutation( + generation: UInt64, + _ mutation: @escaping () -> Void + ) { + cancelDeferredSearchOverlayMutation() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.searchOverlayMutationGeneration == generation else { return } + self.deferredSearchOverlayMutationWorkItem = nil + mutation() } + deferredSearchOverlayMutationWorkItem = work + DispatchQueue.main.async(execute: work) + } - // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. - // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. - guard let terminalSurface = surfaceView.terminalSurface, - let searchState else { - let hadOverlay = searchOverlayHostingView != nil - lastSearchOverlayStateID = nil - guard hadOverlay else { return } -#if DEBUG - dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") -#endif - searchOverlayHostingView?.removeFromSuperview() - searchOverlayHostingView = nil - searchFocusTarget = .searchField - return + private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) { + guard !keyboardCopyModeBadgeContainerView.isHidden else { return } + if let overlay, overlay.superview === self { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) + } else { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) } + } - let searchStateID = ObjectIdentifier(searchState) - if let overlay = searchOverlayHostingView, - lastSearchOverlayStateID == searchStateID, - overlay.superview === self { - if !keyboardCopyModeBadgeContainerView.isHidden { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } - return - } - - let hadOverlay = searchOverlayHostingView != nil -#if DEBUG - dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") -#endif - - let tabId = terminalSurface.tabId - let surfaceId = terminalSurface.id - let rootView = SurfaceSearchOverlay( - tabId: tabId, - surfaceId: surfaceId, + private func makeSearchOverlayRootView( + terminalSurface: TerminalSurface, + searchState: TerminalSurface.SearchState + ) -> SurfaceSearchOverlay { + SurfaceSearchOverlay( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id, searchState: searchState, onMoveFocusToTerminal: { [weak self] in self?.searchFocusTarget = .terminal @@ -6670,41 +6668,165 @@ final class GhosttySurfaceScrollView: NSView { self?.moveFocus() } ) + } + + private func findEditableSearchField(in view: NSView?) -> NSTextField? { + guard let view else { return nil } + if let field = view as? NSTextField, field.isEditable { + return field + } + for subview in view.subviews { + if let field = findEditableSearchField(in: subview) { + return field + } + } + return nil + } + + private func requestMountedSearchFieldFocus( + generation: UInt64, + force: Bool, + attemptsRemaining: Int = 4 + ) { + guard searchOverlayMutationGeneration == generation else { return } + guard force || searchFocusTarget == .searchField else { return } + guard let overlay = searchOverlayHostingView, + overlay.superview === self, + let window, + window.isKeyWindow else { return } + + guard let field = findEditableSearchField(in: overlay) else { + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in + self?.requestMountedSearchFieldFocus( + generation: generation, + force: force, + attemptsRemaining: attemptsRemaining - 1 + ) + } + return + } + + let firstResponder = window.firstResponder + let alreadyFocused = firstResponder === field || + field.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === field + guard !alreadyFocused else { return } + + surfaceView.terminalSurface?.setFocus(false) + let result = window.makeFirstResponder(field) +#if DEBUG + dlog( + "find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " + + "firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + guard !result, attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in + self?.requestMountedSearchFieldFocus( + generation: generation, + force: force, + attemptsRemaining: attemptsRemaining - 1 + ) + } + } + + func setSearchOverlay(searchState: TerminalSurface.SearchState?) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setSearchOverlay(searchState: searchState) + } + return + } + + searchOverlayMutationGeneration &+= 1 + let mutationGeneration = searchOverlayMutationGeneration + + // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. + // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. + guard let terminalSurface = surfaceView.terminalSurface, + let searchState else { + let hadOverlay = searchOverlayHostingView != nil + lastSearchOverlayStateID = nil + searchFocusTarget = .searchField + guard hadOverlay else { + cancelDeferredSearchOverlayMutation() + return + } +#if DEBUG + dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") +#endif + scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in + self?.searchOverlayHostingView?.removeFromSuperview() + self?.searchOverlayHostingView = nil + } + return + } + + let searchStateID = ObjectIdentifier(searchState) + if let overlay = searchOverlayHostingView, + lastSearchOverlayStateID == searchStateID, + overlay.superview === self { + cancelDeferredSearchOverlayMutation() + _ = setFrameIfNeeded(overlay, to: bounds) + updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) + return + } + + let hadOverlay = searchOverlayHostingView != nil +#if DEBUG + dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") +#endif + + let rootView = makeSearchOverlayRootView( + terminalSurface: terminalSurface, + searchState: searchState + ) if let overlay = searchOverlayHostingView { overlay.rootView = rootView - if overlay.superview !== self { - overlay.removeFromSuperview() - addSubview(overlay) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: topAnchor), - overlay.bottomAnchor.constraint(equalTo: bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } - if !keyboardCopyModeBadgeContainerView.isHidden { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } lastSearchOverlayStateID = searchStateID + if overlay.superview !== self { + scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in + guard let self, let overlay else { return } + overlay.removeFromSuperview() + overlay.frame = self.bounds + overlay.autoresizingMask = [.width, .height] + self.addSubview(overlay) + self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) + self.requestMountedSearchFieldFocus( + generation: mutationGeneration, + force: false + ) + } + return + } + cancelDeferredSearchOverlayMutation() + _ = setFrameIfNeeded(overlay, to: bounds) + updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) return } searchFocusTarget = .searchField let overlay = NSHostingView(rootView: rootView) - overlay.translatesAutoresizingMaskIntoConstraints = false - addSubview(overlay) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: topAnchor), - overlay.bottomAnchor.constraint(equalTo: bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - if !keyboardCopyModeBadgeContainerView.isHidden { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } + overlay.frame = bounds + overlay.autoresizingMask = [.width, .height] searchOverlayHostingView = overlay lastSearchOverlayStateID = searchStateID + scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in + guard let self, let overlay else { return } + guard self.searchOverlayHostingView === overlay else { return } + overlay.removeFromSuperview() + overlay.frame = self.bounds + overlay.autoresizingMask = [.width, .height] + self.addSubview(overlay) + self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) + self.requestMountedSearchFieldFocus( + generation: mutationGeneration, + force: true + ) + } } func syncKeyStateIndicator(text: String?) { @@ -6723,11 +6845,7 @@ final class GhosttySurfaceScrollView: NSView { || subviews.last !== keyboardCopyModeBadgeContainerView keyboardCopyModeBadgeContainerView.isHidden = false if needsReorder { - if let overlay = searchOverlayHostingView { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } else { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) - } + updateKeyboardCopyModeBadgeZOrder(relativeTo: searchOverlayHostingView) } return } @@ -7433,6 +7551,17 @@ final class GhosttySurfaceScrollView: NSView { let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" switch searchFocusTarget { case .searchField: + if let firstResponder = window.firstResponder, + isCurrentSurfaceSearchFieldResponder(firstResponder) { + surfaceView.terminalSurface?.setFocus(false) +#if DEBUG + dlog( + "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + + "reason=alreadyFocused firstResponder=\(String(describing: firstResponder))" + ) +#endif + return + } if let firstResponder = window.firstResponder, isSearchOverlayOrDescendant(firstResponder), !isCurrentSurfaceSearchResponder(firstResponder) { @@ -7636,6 +7765,17 @@ final class GhosttySurfaceScrollView: NSView { return view.isDescendant(of: self) } + private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool { + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSTextField { + return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView) + } + + guard let textField = responder as? NSTextField else { return false } + return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField) + } + #if DEBUG struct DebugRenderStats { let drawCount: Int diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5cb8dbba..65b006b4 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -911,15 +911,16 @@ class TabManager: ObservableObject { #endif return } - let wasNil = panel.searchState == nil - if wasNil { - panel.searchState = TerminalSurface.SearchState() - } + let hadExistingSearch = panel.searchState != nil + let handled = startOrFocusTerminalSearch(panel.surface) #if DEBUG - dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))") + dlog( + "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + + "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + + "handled=\(handled ? 1 : 0) " + + "firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))" + ) #endif - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("start_search") } func searchSelection() { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 313d03d4..b8f24566 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -12025,6 +12025,18 @@ final class GhosttySurfaceOverlayTests: XCTestCase { return nil } + private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool { + if firstResponder === textField { + return true + } + if let editor = firstResponder as? NSTextView, + editor.isFieldEditor, + editor.delegate as? NSTextField === textField { + return true + } + return false + } + func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), @@ -12157,12 +12169,105 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let searchState = TerminalSurface.SearchState(needle: "example") hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertTrue(hostedView.debugHasSearchOverlay()) hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertFalse(hostedView.debugHasSearchOverlay()) } + func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example")) + hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.debugHasSearchOverlay(), + "A stale deferred mount must not resurrect the find overlay after it closes" + ) + } + + func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + + let searchState = TerminalSurface.SearchState(needle: "") + surface.searchState = searchState + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let searchField = findEditableTextField(in: hostedView) else { + XCTFail("Expected mounted find text field") + return + } + + XCTAssertTrue( + firstResponderOwnsTextField(window.firstResponder, textField: searchField), + "Deferred search overlay attach should still move focus into the find field" + ) + } + + func testStartOrFocusTerminalSearchReusesExistingSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let existingSearchState = TerminalSurface.SearchState(needle: "existing") + surface.searchState = existingSearchState + + var focusNotificationCount = 0 + XCTAssertTrue( + startOrFocusTerminalSearch(surface) { _ in + focusNotificationCount += 1 + } + ) + + XCTAssertTrue(surface.searchState === existingSearchState) + XCTAssertEqual( + focusNotificationCount, + 1, + "Re-triggering terminal Find should refocus the existing overlay without recreating state" + ) + } + func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { _ = NSApplication.shared @@ -12398,6 +12503,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) let hostedView = surface.hostedView hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertTrue(hostedView.debugHasSearchOverlay()) portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) @@ -12436,6 +12542,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) let hostedView = surface.hostedView hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertTrue(hostedView.debugHasSearchOverlay()) portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) From 2e4c482c21ded83f1defee40aa9115d91bd9539a Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Sun, 15 Mar 2026 18:48:23 -0700 Subject: [PATCH 26/77] Fix Google sign-in infinite loading in browser pane (#1493) * Add regression test for Google sign-in popup fallback (#1491) * Fix Google sign-in popup routing regression (#1491) --- Sources/Panels/BrowserPanel.swift | 18 ++++++++++++++---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 833fe93c..73eda80a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4654,6 +4654,14 @@ func browserNavigationShouldCreatePopup( return navigationType == .other && !isUserNewTab } +func browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: WKNavigationType +) -> Bool { + // Scripted popups rely on WKUIDelegate.createWebViewWith returning a live + // web view so window.opener/postMessage remain intact across OAuth flows. + navigationType != .other +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? @@ -4891,11 +4899,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - // Catch-all for nil-target navigations where createWebViewWith - // returned nil: external URLs, user-initiated new-window actions - // (target=_blank, context menu) that fall through the classifier, - // or when popup creation is unavailable. + // target=_blank link navigations should open in a new tab. + // Scripted popups (navigationType == .other) are handled in + // WKUIDelegate.createWebViewWith so OAuth opener linkage survives. if navigationAction.targetFrame == nil, + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: navigationAction.navigationType + ), let url = navigationAction.request.url { #if DEBUG dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index b8f24566..3eb02816 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2659,6 +2659,24 @@ final class BrowserPopupDecisionTests: XCTestCase { } } +final class BrowserNilTargetFallbackDecisionTests: XCTestCase { + func testOtherNavigationDoesNotFallbackToNewTab() { + XCTAssertFalse( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .other + ) + ) + } + + func testLinkActivatedNavigationFallsBackToNewTab() { + XCTAssertTrue( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .linkActivated + ) + ) + } +} + final class BrowserPopupContentRectTests: XCTestCase { func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { let rect = browserPopupContentRect( From 225c5b83bc8bc521ee8fefbfb3bb182abb017e14 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:08:02 -0700 Subject: [PATCH 27/77] Fix CI: remove broken tests for deleted function, fix review comments - Remove tests for shouldLoadReleaseAppSupportGhosttyConfig (function was deleted but tests survived, breaking CI on all branches) - Fix missed localization on optional-chain notification fallback path - Validate explicit-but-empty --pid as invalid input --- homebrew-cmux | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homebrew-cmux b/homebrew-cmux index dcfaa081..a5f372ec 160000 --- a/homebrew-cmux +++ b/homebrew-cmux @@ -1 +1 @@ -Subproject commit dcfaa081e5b3e0ad62c5c1a5a4d58f4562f6be71 +Subproject commit a5f372ecfa5ee3903af6e1faba0eda096b4f5746 From 7a9a6a550cf490b76a84fca73e16427669e5caac Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:31:30 -0700 Subject: [PATCH 28/77] Keep pinned workspaces above the sidebar pin boundary (#1503) * test: cover pinned workspace reorder boundary * fix: keep pinned workspaces above drag boundary --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com> --- Sources/ContentView.swift | 55 +++++++++++++-- Sources/TabManager.swift | 14 +++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 67 +++++++++++++++++++ 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7d966328..61d50509 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11578,6 +11578,7 @@ enum SidebarDropPlanner { draggedTabId: UUID?, targetTabId: UUID?, tabIds: [UUID], + pinnedTabIds: Set<UUID> = [], pointerY: CGFloat? = nil, targetHeight: CGFloat? = nil ) -> SidebarDropIndicator? { @@ -11598,16 +11599,27 @@ enum SidebarDropPlanner { insertionPosition = tabIds.count } - let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count) - guard targetIndex != fromIndex else { return nil } - return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds) + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) } static func targetIndex( draggedTabId: UUID, targetTabId: UUID?, indicator: SidebarDropIndicator?, - tabIds: [UUID] + tabIds: [UUID], + pinnedTabIds: Set<UUID> = [] ) -> Int? { guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } @@ -11624,7 +11636,13 @@ enum SidebarDropPlanner { insertionPosition = tabIds.count } - return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count) + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) } private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { @@ -11648,6 +11666,28 @@ enum SidebarDropPlanner { return fromIndex < targetIndex ? .bottom : .top } + private static func legalInsertionPosition( + draggedTabId: UUID, + proposedInsertionPosition: Int, + tabIds: [UUID], + pinnedTabIds: Set<UUID> + ) -> Int { + let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) + guard !pinnedTabIds.isEmpty else { return clampedInsertion } + + let pinnedCount = tabIds.reduce(into: 0) { count, tabId in + if pinnedTabIds.contains(tabId) { + count += 1 + } + } + guard pinnedCount > 0 else { return clampedInsertion } + + if pinnedTabIds.contains(draggedTabId) { + return min(clampedInsertion, pinnedCount) + } + return max(clampedInsertion, pinnedCount) + } + static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { guard targetHeight > 0 else { return .top } let clampedY = min(max(locationY, 0), targetHeight) @@ -12030,7 +12070,8 @@ private struct SidebarTabDropDelegate: DropDelegate { draggedTabId: draggedTabId, targetTabId: targetTabId, indicator: dropIndicator, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) ) else { #if DEBUG dlog( @@ -12065,10 +12106,12 @@ private struct SidebarTabDropDelegate: DropDelegate { private func updateDropIndicator(for info: DropInfo) { let tabIds = tabManager.tabs.map(\.id) + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) dropIndicator = SidebarDropPlanner.indicator( draggedTabId: draggedTabId, targetTabId: targetTabId, tabIds: tabIds, + pinnedTabIds: pinnedTabIds, pointerY: targetTabId == nil ? nil : info.location.y, targetHeight: targetRowHeight ) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 65b006b4..34bed6c2 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1389,10 +1389,11 @@ class TabManager: ObservableObject { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } if tabs.count <= 1 { return true } - let clamped = max(0, min(targetIndex, tabs.count - 1)) + let workspace = tabs[currentIndex] + let clamped = clampedReorderIndex(for: workspace, targetIndex: targetIndex) if currentIndex == clamped { return true } - let workspace = tabs.remove(at: currentIndex) + tabs.remove(at: currentIndex) tabs.insert(workspace, at: clamped) return true } @@ -1448,6 +1449,15 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } + private func clampedReorderIndex(for workspace: Workspace, targetIndex: Int) -> Int { + let clamped = max(0, min(targetIndex, tabs.count - 1)) + let pinnedCount = tabs.filter { $0.isPinned }.count + if workspace.isPinned { + return min(clamped, max(0, pinnedCount - 1)) + } + return max(clamped, pinnedCount) + } + // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3eb02816..c4831794 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5173,6 +5173,32 @@ final class WorkspaceReorderTests: XCTestCase { let manager = TabManager() XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0)) } + + @MainActor + func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0)) + XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id]) + } + + @MainActor + func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999)) + XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) + } } @MainActor @@ -7301,6 +7327,47 @@ final class SidebarDropPlannerTests: XCTestCase { ) ) } + + func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set<UUID> = [pinnedA, pinnedB] + + let indicator = SidebarDropPlanner.indicator( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + tabIds: tabIds, + pinnedTabIds: pinnedIds, + pointerY: 2, + targetHeight: 40 + ) + + XCTAssertEqual(indicator?.tabId, unpinnedA) + XCTAssertEqual(indicator?.edge, .top) + } + + func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set<UUID> = [pinnedA, pinnedB] + + let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top), + tabIds: tabIds, + pinnedTabIds: pinnedIds + ) + + XCTAssertEqual(targetIndex, 2) + } + } final class SidebarDragAutoScrollPlannerTests: XCTestCase { From 3b507d361fc35eef394da18fc5a52648ccd41622 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:48:51 -0700 Subject: [PATCH 29/77] fix: require pinned ids in sidebar drop planner (#1505) Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com> --- Sources/ContentView.swift | 4 +-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 61d50509..c7708c7c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11578,7 +11578,7 @@ enum SidebarDropPlanner { draggedTabId: UUID?, targetTabId: UUID?, tabIds: [UUID], - pinnedTabIds: Set<UUID> = [], + pinnedTabIds: Set<UUID>, pointerY: CGFloat? = nil, targetHeight: CGFloat? = nil ) -> SidebarDropIndicator? { @@ -11619,7 +11619,7 @@ enum SidebarDropPlanner { targetTabId: UUID?, indicator: SidebarDropIndicator?, tabIds: [UUID], - pinnedTabIds: Set<UUID> = [] + pinnedTabIds: Set<UUID> ) -> Int? { guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c4831794..5c0ad1af 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7166,14 +7166,16 @@ final class SidebarDropPlannerTests: XCTestCase { SidebarDropPlanner.indicator( draggedTabId: first, targetTabId: first, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) ) XCTAssertNil( SidebarDropPlanner.indicator( draggedTabId: third, targetTabId: nil, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) ) } @@ -7184,14 +7186,16 @@ final class SidebarDropPlannerTests: XCTestCase { SidebarDropPlanner.indicator( draggedTabId: only, targetTabId: nil, - tabIds: [only] + tabIds: [only], + pinnedTabIds: [] ) ) XCTAssertNil( SidebarDropPlanner.indicator( draggedTabId: only, targetTabId: only, - tabIds: [only] + tabIds: [only], + pinnedTabIds: [] ) ) } @@ -7205,7 +7209,8 @@ final class SidebarDropPlannerTests: XCTestCase { let indicator = SidebarDropPlanner.indicator( draggedTabId: second, targetTabId: nil, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) XCTAssertEqual(indicator?.tabId, nil) XCTAssertEqual(indicator?.edge, .bottom) @@ -7221,7 +7226,8 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: second, targetTabId: nil, indicator: SidebarDropIndicator(tabId: nil, edge: .bottom), - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) XCTAssertEqual(index, 2) } @@ -7236,7 +7242,8 @@ final class SidebarDropPlannerTests: XCTestCase { SidebarDropPlanner.indicator( draggedTabId: second, targetTabId: second, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) ) } @@ -7252,6 +7259,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: first, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 2, targetHeight: 40 ) @@ -7268,6 +7276,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: first, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 38, targetHeight: 40 ) @@ -7278,7 +7287,8 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: first, targetTabId: second, indicator: indicator, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ), 1 ) @@ -7294,6 +7304,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: third, targetTabId: first, tabIds: tabIds, + pinnedTabIds: [], pointerY: 38, targetHeight: 40 ) @@ -7301,6 +7312,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: third, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 2, targetHeight: 40 ) @@ -7322,6 +7334,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: third, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 38, targetHeight: 40 ) From 94f7529a860ca569f57b98f30d1a28537ee4c6af Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:35:48 -0700 Subject: [PATCH 30/77] Add regression test for deferred terminal portal sync --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5c0ad1af..16e08649 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -13068,6 +13068,89 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" ) } + + func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + DispatchQueue.main.async { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.minX, + originalAnchorFrameInWindow.minX + 1, + "The queued layout shift should move the anchor to the right" + ) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.maxX, + originalAnchorFrameInWindow.maxX + 1, + "The shifted anchor should expose a new trailing region outside the stale portal frame" + ) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" + ) + } } @MainActor From 8d1d4722f6f2351bf6d811d530759e9423368194 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:40:13 -0700 Subject: [PATCH 31/77] Defer terminal portal sync past layout churn --- Sources/GhosttyTerminalView.swift | 25 +++++++++++++++++++++---- Sources/TerminalWindowPortal.swift | 14 +++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cc49002b..fa45477c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8573,6 +8573,19 @@ struct GhosttyTerminalView: NSViewRepresentable { return !hostedViewHasSuperview } + private static func scheduleDeferredPortalGeometrySynchronize( + for host: HostContainerView, + coordinator: Coordinator + ) { + let geometryRevision = host.geometryRevision + guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + // Avoid synchronizing the terminal portal while AppKit is still inside + // the current layout turn. Re-entrant syncs here can wedge window resize + // handling and leave the app spinning on the wait cursor. + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -8732,8 +8745,10 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + Self.scheduleDeferredPortalGeometrySynchronize( + for: host, + coordinator: coordinator + ) } if host.window != nil, hostOwnsPortalNow { @@ -8768,8 +8783,10 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + Self.scheduleDeferredPortalGeometrySynchronize( + for: host, + coordinator: coordinator + ) } } else if hostOwnsPortalNow { // Bind is deferred until host moves into a window. Update the diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index b44fbffb..fd15c702 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -682,8 +682,10 @@ final class WindowTerminalPortal: NSObject { hasExternalGeometrySyncScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } - self.hasExternalGeometrySyncScheduled = false - self.synchronizeAllEntriesFromExternalGeometryChange() + DispatchQueue.main.async { + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } } } @@ -1785,9 +1787,11 @@ enum TerminalWindowPortalRegistry { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true DispatchQueue.main.async { - Self.hasPendingExternalGeometrySyncForAllWindows = false - for portal in Self.portalsByWindowId.values { - portal.synchronizeAllEntriesFromExternalGeometryChange() + DispatchQueue.main.async { + Self.hasPendingExternalGeometrySyncForAllWindows = false + for portal in Self.portalsByWindowId.values { + portal.synchronizeAllEntriesFromExternalGeometryChange() + } } } } From 95ef1c8ca7de3eb088ba71be83f0331e98eb8b4f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:49:40 -0700 Subject: [PATCH 32/77] Keep portal sync responsive during live resize --- Sources/GhosttyTerminalView.swift | 10 +++++++--- Sources/TerminalWindowPortal.swift | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fa45477c..ed1cb0ea 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8573,13 +8573,17 @@ struct GhosttyTerminalView: NSViewRepresentable { return !hostedViewHasSuperview } - private static func scheduleDeferredPortalGeometrySynchronize( + private static func synchronizePortalGeometry( for host: HostContainerView, coordinator: Coordinator ) { let geometryRevision = host.geometryRevision guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + if host.inLiveResize || host.window?.inLiveResize == true { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + return + } // Avoid synchronizing the terminal portal while AppKit is still inside // the current layout turn. Re-entrant syncs here can wedge window resize // handling and leave the app spinning on the wait cursor. @@ -8745,7 +8749,7 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } - Self.scheduleDeferredPortalGeometrySynchronize( + Self.synchronizePortalGeometry( for: host, coordinator: coordinator ) @@ -8783,7 +8787,7 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - Self.scheduleDeferredPortalGeometrySynchronize( + Self.synchronizePortalGeometry( for: host, coordinator: coordinator ) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index fd15c702..e4b78917 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -680,12 +680,18 @@ final class WindowTerminalPortal: NSObject { private func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true + let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true) DispatchQueue.main.async { [weak self] in guard let self else { return } - DispatchQueue.main.async { + let performSync = { self.hasExternalGeometrySyncScheduled = false self.synchronizeAllEntriesFromExternalGeometryChange() } + if requiresSettledLayout { + DispatchQueue.main.async(execute: performSync) + } else { + performSync() + } } } From 971b2b4e778b1f9be880faf767fc7db317f5017d Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 16 Mar 2026 20:40:35 -0700 Subject: [PATCH 33/77] fix: show sidebar update banner from background checks (#1543) --- Sources/AppDelegate.swift | 3 +- Sources/ContentView.swift | 105 +++++++++++++++++++++++++ Sources/Update/UpdateController.swift | 20 ++--- Sources/Update/UpdateDelegate.swift | 24 ++++-- Sources/Update/UpdateTestSupport.swift | 8 ++ Sources/Update/UpdateViewModel.swift | 14 ++++ cmuxUITests/UpdatePillUITests.swift | 13 +++ 7 files changed, 167 insertions(+), 20 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d323ed40..fbf3fad3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2247,8 +2247,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent configureUserNotifications() installMenuBarVisibilityObserver() syncMenuBarExtraVisibility() - // Sparkle updater is started lazily on first manual check. This avoids any - // first-launch permission prompts and keeps cmux aligned with the update pill UI. + updateController.startUpdaterIfNeeded() } titlebarAccessoryController.start() windowDecorationsController.start() diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c7708c7c..7cffeba9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7789,6 +7789,10 @@ struct VerticalTabsSidebar: View { Spacer() .frame(height: trafficLightPadding) + SidebarUpdateBanner(updateViewModel: updateViewModel) + .padding(.horizontal, 8) + .padding(.top, 8) + LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in TabItemView( @@ -8719,6 +8723,107 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } +private struct SidebarUpdateBanner: View { + @ObservedObject var updateViewModel: UpdateViewModel + + private var bannerVersion: String? { + if let detectedUpdateVersion = updateViewModel.detectedUpdateVersion { + return detectedUpdateVersion + } + if case .updateAvailable(let update) = updateViewModel.effectiveState { + return UpdateViewModel.normalizedDetectedUpdateVersion(from: update.appcastItem.displayVersionString) + } + return nil + } + + private var titleText: String { + guard let bannerVersion else { + return String(localized: "update.available.short", defaultValue: "Update Available") + } + return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(bannerVersion)") + } + + private var messageText: String { + if case .updateAvailable = updateViewModel.effectiveState { + let message = updateViewModel.description + if !message.isEmpty { + return message + } + } + return String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version") + } + + private var actionDisabled: Bool { + switch updateViewModel.effectiveState { + case .checking, .downloading, .extracting, .installing: + return true + default: + return false + } + } + + var body: some View { + if bannerVersion != nil { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "shippingbox.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(cmuxAccentColor()) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + Text(titleText) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + .accessibilityIdentifier("SidebarUpdateBannerTitle") + Text(messageText) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + + HStack { + Spacer(minLength: 0) + Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) { + installDetectedUpdate() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(actionDisabled) + .accessibilityIdentifier("SidebarUpdateBannerAction") + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(cmuxAccentColor().opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(cmuxAccentColor().opacity(0.28), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityIdentifier("SidebarUpdateBanner") + } + } + + private func installDetectedUpdate() { + if case .updateAvailable(let update) = updateViewModel.effectiveState { + update.reply(.install) + return + } + if updateViewModel.effectiveState.isInstallable { + updateViewModel.effectiveState.confirm() + return + } + AppDelegate.shared?.attemptUpdate(nil) + } +} + private struct SidebarFooter: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 94fae950..7cc9beb9 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -27,10 +27,10 @@ class UpdateController { } init() { - // Default to manual update checks. This also prevents Sparkle from prompting at startup. + // cmux checks for updates in the background, but keeps automatic download and + // profile submission disabled so all install intent stays user-driven. let defaults = UserDefaults.standard defaults.register(defaults: [ - "SUEnableAutomaticChecks": false, "SUSendProfileInfo": false, "SUAutomaticallyUpdate": false, ]) @@ -59,8 +59,8 @@ class UpdateController { guard !didStartUpdater else { return } ensureSparkleInstallationCache() #if DEBUG - // UI tests need to exercise Sparkle's permission request deterministically. - // Clearing these defaults causes Sparkle to re-request permission on next start. + // Keep the permission-related defaults resettable for UI tests even though the + // delegate now suppresses Sparkle's permission UI entirely. if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" { let defaults = UserDefaults.standard defaults.removeObject(forKey: "SUEnableAutomaticChecks") @@ -71,13 +71,9 @@ class UpdateController { } #endif do { - // cmux never enables automatic update checks; we rely on the in-app update pill. - // Sparkle reads these from defaults, but set them explicitly before starting. - let defaults = UserDefaults.standard - defaults.set(false, forKey: "SUEnableAutomaticChecks") - defaults.set(false, forKey: "SUSendProfileInfo") - defaults.set(false, forKey: "SUAutomaticallyUpdate") - + updater.automaticallyChecksForUpdates = true + updater.automaticallyDownloadsUpdates = false + updater.sendsSystemProfile = false try updater.start() didStartUpdater = true } catch { @@ -201,7 +197,7 @@ class UpdateController { /// Validate the check for updates menu item. func validateMenuItem(_ item: NSMenuItem) -> Bool { if item.action == #selector(checkForUpdates) { - // Always allow user-initiated checks; we start Sparkle lazily on first use. + // Always allow user-initiated checks; Sparkle can safely surface current progress. return true } return true diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index dfcd457c..b3adfc15 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -13,6 +13,10 @@ enum UpdateFeedResolver { } extension UpdateDriver: SPUUpdaterDelegate { + func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool { + false + } + func feedURLString(for updater: SPUUpdater) -> String? { #if DEBUG let env = ProcessInfo.processInfo.environment @@ -35,6 +39,7 @@ extension UpdateDriver: SPUUpdaterDelegate { /// Called when an update is scheduled to install silently, /// which occurs when automatic download is enabled. func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { + viewModel.clearDetectedUpdate() viewModel.state = .installing(.init( isAutoUpdate: true, retryTerminatingApplication: immediateInstallHandler, @@ -56,6 +61,7 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + viewModel.recordDetectedUpdate(item) let version = item.displayVersionString let fileURL = item.fileURL?.absoluteString ?? "" if fileURL.isEmpty { @@ -66,6 +72,7 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + viewModel.clearDetectedUpdate() let nsError = error as NSError let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil @@ -80,13 +87,18 @@ extension UpdateDriver: SPUUpdaterDelegate { } } - @MainActor + func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) { + viewModel.clearDetectedUpdate() + } + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { - AppDelegate.shared?.persistSessionForUpdateRelaunch() - TerminalController.shared.stop() - NSApp.invalidateRestorableState() - for window in NSApp.windows { - window.invalidateRestorableState() + Task { @MainActor in + AppDelegate.shared?.persistSessionForUpdateRelaunch() + TerminalController.shared.stop() + NSApp.invalidateRestorableState() + for window in NSApp.windows { + window.invalidateRestorableState() + } } } } diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift index 2809b434..77535482 100644 --- a/Sources/Update/UpdateTestSupport.swift +++ b/Sources/Update/UpdateTestSupport.swift @@ -6,6 +6,14 @@ enum UpdateTestSupport { static func applyIfNeeded(to viewModel: UpdateViewModel) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_MODE"] == "1" else { return } + + if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"], + !detectedVersion.isEmpty { + DispatchQueue.main.async { + viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion) + } + } + guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return } DispatchQueue.main.async { diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 4bdb9ad2..7aa524d2 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -6,6 +6,7 @@ import Sparkle class UpdateViewModel: ObservableObject { @Published var state: UpdateState = .idle @Published var overrideState: UpdateState? + @Published var detectedUpdateVersion: String? #if DEBUG @Published var debugOverrideText: String? #endif @@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject { overrideState ?? state } + func recordDetectedUpdate(_ item: SUAppcastItem) { + detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString) + } + + func clearDetectedUpdate() { + detectedUpdateVersion = nil + } + var text: String { #if DEBUG if let debugOverrideText { return debugOverrideText } @@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject { return nil } } + + static func normalizedDetectedUpdateVersion(from version: String) -> String? { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } enum UpdateState: Equatable { diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index 3b8040a7..099dfb93 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -124,6 +124,19 @@ final class UpdatePillUITests: XCTestCase { assertVisibleSize(noUpdatePill) } + func testSidebarUpdateBannerShowsForBackgroundDetectedUpdate() { + let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") + systemSettings.terminate() + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9" + launchAndActivate(app) + + XCTAssertTrue(app.otherElements["SidebarUpdateBanner"].waitForExistence(timeout: 6.0)) + XCTAssertTrue(app.staticTexts["Update Available: 9.9.9"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.buttons["SidebarUpdateBannerAction"].waitForExistence(timeout: 2.0)) + } + func testNoSparklePermissionDialogIsShown() { let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") systemSettings.terminate() From 08854f14dbc907c3f2c2995fc5bd8725c430c308 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:42:21 -0700 Subject: [PATCH 34/77] Update bonsplit for split transparency --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 73c1ef2d..44ec863e 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826 +Subproject commit 44ec863e4147e05f068187a46d43e8fd67a6bba3 From 975a85093506a0009d063aa259632055b859d071 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:57:04 -0700 Subject: [PATCH 35/77] Update bonsplit for split transparency --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 44ec863e..02fa188c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 44ec863e4147e05f068187a46d43e8fd67a6bba3 +Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795 From 1480171e5e0d2245e1c7f9925c4a304b308be9e8 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 16 Mar 2026 21:00:30 -0700 Subject: [PATCH 36/77] Support folder drops on dock icon (#1571) --- Resources/Info.plist | 15 +++++ Sources/AppDelegate.swift | 60 ++++++++++++++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 49 +++++++++++++++ 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index f1beb4f9..708488ce 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -12,6 +12,21 @@ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeName</key> + <string>Folder</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.folder</string> + </array> + </dict> + </array> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fbf3fad3..674b9d5c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -368,13 +368,24 @@ enum FinderServicePathResolver { return canonical } + private static func resolvedDirectoryURL(from url: URL) -> URL { + let standardized = url.standardizedFileURL + if standardized.hasDirectoryPath { + return standardized + } + if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true { + return standardized + } + return standardized.deletingLastPathComponent() + } + static func orderedUniqueDirectories(from pathURLs: [URL]) -> [String] { var seen: Set<String> = [] var directories: [String] = [] for url in pathURLs { - let standardized = url.standardizedFileURL - let directoryURL = standardized.hasDirectoryPath ? standardized : standardized.deletingLastPathComponent() + let directoryURL = resolvedDirectoryURL(from: url) let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) guard !path.isEmpty else { continue } if seen.insert(path).inserted { @@ -2154,6 +2165,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Self.shared = self } + func application(_ application: NSApplication, open urls: [URL]) { + let directories = externalOpenDirectories(from: urls) + guard !directories.isEmpty else { return } + + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + openWorkspaceForExternalDirectory( + workingDirectory: directory, + debugSource: "application.openURLs" + ) + } + } + func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) @@ -5077,11 +5101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent target: ServiceOpenTarget, error: AutoreleasingUnsafeMutablePointer<NSString> ) { - didHandleExplicitOpenIntentAtStartup = true - if !didAttemptStartupSessionRestore { - startupSessionSnapshot = nil - didAttemptStartupSessionRestore = true - } + prepareForExplicitOpenIntentAtStartup() let pathURLs = servicePathURLs(from: pasteboard) guard !pathURLs.isEmpty else { @@ -5089,7 +5109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - let directories = FinderServicePathResolver.orderedUniqueDirectories(from: pathURLs) + let directories = externalOpenDirectories(from: pathURLs) guard !directories.isEmpty else { error.pointee = Self.serviceErrorNoPath return @@ -5134,10 +5154,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func openWorkspaceFromService(workingDirectory: String) { + openWorkspaceForExternalDirectory( + workingDirectory: workingDirectory, + debugSource: "service.openTab" + ) + } + + private func prepareForExplicitOpenIntentAtStartup() { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + } + + private func externalOpenDirectories(from urls: [URL]) -> [String] { + FinderServicePathResolver.orderedUniqueDirectories(from: urls.filter { $0.isFileURL }) + } + + private func openWorkspaceForExternalDirectory( + workingDirectory: String, + debugSource: String + ) { if addWorkspaceInPreferredMainWindow( workingDirectory: workingDirectory, shouldBringToFront: true, - debugSource: "service.openTab" + debugSource: debugSource ) != nil { return } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5c0ad1af..c787a69a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1197,6 +1197,55 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) } + + func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + window.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: window) + + let defaults = UserDefaults.standard + let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey) + defaults.set(true, forKey: WelcomeSettings.shownKey) + defer { + if let previousWelcomeShown { + defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey) + } else { + defaults.removeObject(forKey: WelcomeSettings.shownKey) + } + } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let existingWorkspaceIds = Set(manager.tabs.map(\.id)) + + app.application( + NSApplication.shared, + open: [URL(fileURLWithPath: droppedDirectory.path)] + ) + + let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) } + XCTAssertNotNil(createdWorkspace) + XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path) + } } @MainActor From 7f220dc8e46e91bd9b549e711d8e8438be6cd35b Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 16 Mar 2026 21:09:02 -0700 Subject: [PATCH 37/77] Fix sidebar PR badges for restored workspaces (#1570) * test: cover sidebar PR explicit branch fallback * fix: restore sidebar PR badges for workspace branches * test: preserve sidebar PR badge on first prompt * fix: keep sidebar PR badges through first prompt --- .../cmux-bash-integration.bash | 119 ++++++-- .../cmux-zsh-integration.zsh | 126 +++++++-- Sources/TabManager.swift | 257 +++++++++++++++++- tests/test_issue_1138_sidebar_pr_polling.py | 71 +++++ 4 files changed, 525 insertions(+), 48 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 338844d0..fc1d4cf1 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -145,6 +145,40 @@ _cmux_pr_output_indicates_no_pull_request() { || "$output" == *"no pull request associated"* ]] } +_cmux_github_repo_slug_for_path() { + local repo_path="$1" + local remote_url="" path_part="" + [[ -n "$repo_path" ]] || return 0 + + remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)" + [[ -n "$remote_url" ]] || return 0 + + case "$remote_url" in + git@github.com:*) + path_part="${remote_url#git@github.com:}" + ;; + ssh://git@github.com/*) + path_part="${remote_url#ssh://git@github.com/}" + ;; + https://github.com/*) + path_part="${remote_url#https://github.com/}" + ;; + http://github.com/*) + path_part="${remote_url#http://github.com/}" + ;; + git://github.com/*) + path_part="${remote_url#git://github.com/}" + ;; + *) + return 0 + ;; + esac + + path_part="${path_part%.git}" + [[ "$path_part" == */* ]] || return 0 + printf '%s\n' "$path_part" +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { @@ -159,18 +193,26 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch gh_output gh_error="" err_file="" gh_status number state url status_opt="" + local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt="" + local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 + local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 + local -a gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi + repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")" + if [[ -n "$repo_slug" ]]; then + gh_repo_args=(--repo "$repo_slug") + fi err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ && gh pr view \ + "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ 2>"$err_file" @@ -180,18 +222,54 @@ _cmux_report_pr_for_path() { gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status != 0 )); then - if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - _cmux_clear_pr_for_panel - return 0 + + if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then + : + else + if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then + implicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + implicit_probe_indicates_no_pr=1 + fi + + # `gh pr view` without an explicit branch can fail to resolve the + # current worktree branch even when the branch has a PR. Fall back to + # the explicit branch name before concluding there is no PR. + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + explicit_branch_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view "$branch" \ + "${gh_repo_args[@]}" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + explicit_branch_status=$? + if [[ -f "$err_file" ]]; then + explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + + if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then + gh_output="$explicit_branch_output" + gh_status=0 + else + if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then + explicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then + explicit_probe_indicates_no_pr=1 + fi + + if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then + _cmux_clear_pr_for_panel + return 0 + fi + + # Preserve the last-known PR badge when gh fails transiently, then retry + # on the next background poll instead of clearing visible state. + return 1 fi - # Preserve the last-known PR badge when gh fails transiently, then retry - # on the next background poll instead of clearing visible state. - return 1 - fi - if [[ -z "$gh_output" ]]; then - _cmux_clear_pr_for_panel - return 0 fi IFS=$'\t' read -r number state url <<< "$gh_output" @@ -376,11 +454,18 @@ _cmux_prompt_command() { if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then local head_signature head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" - if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then - _CMUX_GIT_HEAD_SIGNATURE="$head_signature" - git_head_changed=1 - # Also invalidate the PR poller so it refreshes with the new branch. - _CMUX_PR_FORCE=1 + if [[ -n "$head_signature" ]]; then + if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + # The first observed HEAD value is just the session baseline. + # Treating it as a branch change clears restore-seeded PR badges + # before the first background probe can confirm the current PR. + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR poller so it refreshes with the new branch. + _CMUX_PR_FORCE=1 + fi fi fi diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index aeef42d2..92af16d9 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -169,6 +169,40 @@ _cmux_pr_output_indicates_no_pull_request() { || "$output" == *"no pull request associated"* ]] } +_cmux_github_repo_slug_for_path() { + local repo_path="$1" + local remote_url="" path_part="" + [[ -n "$repo_path" ]] || return 0 + + remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)" + [[ -n "$remote_url" ]] || return 0 + + case "$remote_url" in + git@github.com:*) + path_part="${remote_url#git@github.com:}" + ;; + ssh://git@github.com/*) + path_part="${remote_url#ssh://git@github.com/}" + ;; + https://github.com/*) + path_part="${remote_url#https://github.com/}" + ;; + http://github.com/*) + path_part="${remote_url#http://github.com/}" + ;; + git://github.com/*) + path_part="${remote_url#git://github.com/}" + ;; + *) + return 0 + ;; + esac + + path_part="${path_part%.git}" + [[ "$path_part" == */* ]] || return 0 + print -r -- "$path_part" +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { @@ -183,18 +217,27 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status + local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status + local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 + local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 + local -a gh_repo_args + gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi + repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")" + if [[ -n "$repo_slug" ]]; then + gh_repo_args=(--repo "$repo_slug") + fi err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ && gh pr view \ + "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ 2>"$err_file" @@ -204,18 +247,54 @@ _cmux_report_pr_for_path() { gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status != 0 )); then - if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - _cmux_clear_pr_for_panel - return 0 + + if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then + : + else + if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then + implicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + implicit_probe_indicates_no_pr=1 + fi + + # `gh pr view` without an explicit branch can fail to resolve the + # current worktree branch even when the branch has a PR. Fall back to + # the explicit branch name before concluding there is no PR. + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + explicit_branch_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view "$branch" \ + "${gh_repo_args[@]}" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + explicit_branch_status=$? + if [[ -f "$err_file" ]]; then + explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + + if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then + gh_output="$explicit_branch_output" + gh_status=0 + else + if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then + explicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then + explicit_probe_indicates_no_pr=1 + fi + + if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then + _cmux_clear_pr_for_panel + return 0 + fi + + # Keep the last-known PR badge on transient gh failures (auth hiccups, + # API lag after creation, or rate limiting) and retry on the next poll. + return 1 fi - # Keep the last-known PR badge on transient gh failures (auth hiccups, - # API lag after creation, or rate limiting) and retry on the next poll. - return 1 - fi - if [[ -z "$gh_output" ]]; then - _cmux_clear_pr_for_panel - return 0 fi local IFS=$'\t' @@ -453,14 +532,21 @@ _cmux_precmd() { if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then local head_signature head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" - if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then - _CMUX_GIT_HEAD_SIGNATURE="$head_signature" - git_head_changed=1 - # Treat HEAD file change like a git command — force-replace any - # running probe so the sidebar picks up the new branch immediately. - _CMUX_GIT_FORCE=1 - _CMUX_PR_FORCE=1 - should_git=1 + if [[ -n "$head_signature" ]]; then + if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + # The first observed HEAD value establishes the baseline for this + # shell session. Don't treat it as a branch change or we'll clear + # restore-seeded PR badges before the first background probe runs. + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Treat HEAD file change like a git command — force-replace any + # running probe so the sidebar picks up the new branch immediately. + _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 + should_git=1 + fi fi fi diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 34bed6c2..40c07397 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -627,6 +627,15 @@ class TabManager: ObservableObject { private struct InitialWorkspaceGitMetadataSnapshot: Equatable { let branch: String? let isDirty: Bool + let pullRequest: SidebarPullRequestState? + } + + private struct CommandResult { + let stdout: String? + let stderr: String? + let exitStatus: Int32? + let timedOut: Bool + let executionError: String? } /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). @@ -642,6 +651,7 @@ class TabManager: ObservableObject { /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] + private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { #if DEBUG @@ -1166,15 +1176,25 @@ class TabManager: ObservableObject { workspace.clearPanelGitBranch(panelId: panelId) } - if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { + if let pullRequest = snapshot.pullRequest { + workspace.updatePanelPullRequest( + panelId: panelId, + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status + ) + } else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { workspace.clearPanelPullRequest(panelId: panelId) } #if DEBUG let branchLabel = snapshot.branch ?? "none" + let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none" dlog( "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)" + "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + + "pr=\(prLabel)" ) #endif } @@ -1184,36 +1204,233 @@ class TabManager: ObservableObject { ) -> InitialWorkspaceGitMetadataSnapshot { let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) guard let branch else { - return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false) + return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil) } let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty) + let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch) + return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) } private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { + runCommand( + directory: directory, + executable: "git", + arguments: arguments + ) + } + + private nonisolated static func initialWorkspacePullRequestSnapshot( + directory: String, + branch: String + ) -> SidebarPullRequestState? { + let repoSlug = githubRepositorySlug(directory: directory) + let repoArguments = repoSlug.map { ["--repo", $0] } ?? [] + let result = runCommandResult( + directory: directory, + executable: "gh", + arguments: [ + "pr", "view", branch, + ] + repoArguments + [ + "--json", "number,state,url", + "--jq", "[.number, .state, .url] | @tsv", + ], + timeout: initialWorkspacePullRequestProbeTimeout + ) + + guard let result else { return nil } + guard let output = result.stdout, + result.exitStatus == 0, + !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { +#if DEBUG + let statusText: String + if result.timedOut { + statusText = "timeout" + } else if let exitStatus = result.exitStatus { + statusText = "exit=\(exitStatus)" + } else if let executionError = result.executionError { + statusText = "error=\(executionError)" + } else { + statusText = "unknown" + } + let stderr = debugLogSnippet(result.stderr) ?? "none" + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)" + ) +#endif + return nil + } + + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + let fields = trimmedOutput + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false) + guard fields.count == 3, + let number = Int(fields[0]), + let url = URL(string: String(fields[2])) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")" + ) +#endif + return nil + } + + let status: SidebarPullRequestStatus + switch fields[1].uppercased() { + case "OPEN": + status = .open + case "MERGED": + status = .merged + case "CLOSED": + status = .closed + default: + return nil + } + +#if DEBUG + dlog( + "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)" + ) +#endif + return SidebarPullRequestState(number: number, label: "PR", url: url, status: status) + } + + private nonisolated static func runCommand( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> String? { + let result = runCommandResult( + directory: directory, + executable: executable, + arguments: arguments, + timeout: timeout + ) + guard let result, + result.exitStatus == 0, + !result.timedOut else { + return nil + } + return result.stdout + } + + private nonisolated static func runCommandResult( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> CommandResult? { let process = Process() let stdout = Pipe() + let stderr = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["git", "-C", directory] + arguments + process.arguments = [executable] + arguments + process.currentDirectoryURL = URL(fileURLWithPath: directory) process.standardOutput = stdout - process.standardError = FileHandle.nullDevice + process.standardError = stderr + + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + completion.signal() + } do { try process.run() } catch { + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: false, + executionError: String(describing: error) + ) + } + + if let timeout, + completion.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + if completion.wait(timeout: .now() + 0.2) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = completion.wait(timeout: .now() + 0.2) + } + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: true, + executionError: nil + ) + } else if timeout == nil { + completion.wait() + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return CommandResult( + stdout: String(data: stdoutData, encoding: .utf8), + stderr: String(data: stderrData, encoding: .utf8), + exitStatus: process.terminationStatus, + timedOut: false, + executionError: nil + ) + } + + private nonisolated static func githubRepositorySlug(directory: String) -> String? { + guard let remoteURL = runGitCommand( + directory: directory, + arguments: ["remote", "get-url", "origin"] + ) else { return nil } - // Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer. - let data = stdout.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { + let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let githubPrefixes = [ + "git@github.com:", + "ssh://git@github.com/", + "https://github.com/", + "http://github.com/", + "git://github.com/", + ] + for prefix in githubPrefixes where trimmed.hasPrefix(prefix) { + let path = String(trimmed.dropFirst(prefix.count)) + return normalizedGitHubRepositorySlug(path) + } + + guard let url = URL(string: trimmed), + let host = url.host?.lowercased(), + host == "github.com" else { return nil } - return String(data: data, encoding: .utf8) + return normalizedGitHubRepositorySlug(url.path) + } + + private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? { + let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmedPath.isEmpty else { return nil } + let components = trimmedPath.split(separator: "/").map(String.init) + guard components.count >= 2 else { return nil } + let owner = components[0] + var repo = components[1] + if repo.hasSuffix(".git") { + repo.removeLast(4) + } + guard !owner.isEmpty, !repo.isEmpty else { return nil } + return "\(owner)/\(repo)" + } + + private nonisolated static func debugLogSnippet(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(180)) } private nonisolated static func normalizedBranchName(_ branch: String?) -> String? { @@ -4171,6 +4388,9 @@ extension TabManager { for tab in tabs { unwireClosedBrowserTracking(for: tab) } + for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + } // Clear non-@Published state without touching tabs/selectedTabId yet. lastFocusedPanelByTab.removeAll() @@ -4227,6 +4447,21 @@ extension TabManager { // never see an intermediate state with empty tabs or nil selection. tabs = newTabs selectedTabId = newSelectedId + for workspace in newTabs { + guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values + .compactMap({ $0 as? TerminalPanel }) + .first, + let directory = normalizedWorkingDirectory( + workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory + ) else { + continue + } + scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: workspace.id, + panelId: terminalPanel.id, + directory: directory + ) + } if let selectedTabId { NotificationCenter.default.post( diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py index 973e98dd..9ff9db2e 100644 --- a/tests/test_issue_1138_sidebar_pr_polling.py +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -10,6 +10,9 @@ Validates that shell integration: 4) recovers when a gh probe wedges longer than the async timeout 5) keeps polling in bash after prompt-render helper commands run 6) tears down the timed-out gh probe instead of leaking it in the background +7) falls back to explicit branch lookup when implicit gh branch resolution fails +8) does not clear an existing PR badge on the first prompt while establishing + the HEAD baseline """ from __future__ import annotations @@ -77,6 +80,11 @@ def _git_stub() -> str: exit 0 fi + if [ "$1" = "remote" ] && [ "$2" = "get-url" ] && [ "$3" = "origin" ]; then + printf 'https://github.com/manaflow-ai/cmux.git\\n' + exit 0 + fi + if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then exit 0 fi @@ -111,6 +119,17 @@ def _gh_stub() -> str: exit 9 fi + requested_branch="" + if [ $# -ge 3 ]; then + case "$3" in + --*) + ;; + *) + requested_branch="$3" + ;; + esac + fi + branch="" if [ -f "$head_file" ]; then head_line="$(cat "$head_file")" @@ -125,6 +144,9 @@ def _gh_stub() -> str: prompt_helper_idle) printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' ;; + initial_prompt_preserves_pr_badge) + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; transient_same_context) if [ "$count" -eq 1 ]; then printf 'rate limit exceeded\\n' >&2 @@ -154,6 +176,18 @@ def _gh_stub() -> str: fi printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' ;; + explicit_branch_fallback) + if [ -z "$requested_branch" ]; then + printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 + exit 1 + fi + if [ "$requested_branch" = "$branch" ]; then + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + exit 0 + fi + printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2 + exit 8 + ;; *) printf 'unknown scenario: %s\\n' "$scenario" >&2 exit 2 @@ -198,6 +232,20 @@ def _shell_command(kind: str, scenario: str) -> str: 'sleep 4\n' '_cmux_cleanup\n' ), + "explicit_branch_fallback": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), + "initial_prompt_preserves_pr_badge": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), }[scenario] if kind == "zsh": @@ -344,6 +392,27 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}") return (0, f"{shell}/{scenario}: ok") + if scenario == "explicit_branch_fallback": + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines): + return ( + 1, + f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines), + ) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "initial_prompt_preserves_pr_badge": + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if any(line.startswith("clear_pr ") for line in send_lines): + return ( + 1, + f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n" + + "\n".join(send_lines), + ) + return (0, f"{shell}/{scenario}: ok") + return (1, f"{shell}/{scenario}: unhandled scenario") @@ -358,6 +427,8 @@ def main() -> int: "transient_same_context", "branch_switch_clear", "timeout_recovery", + "explicit_branch_fallback", + "initial_prompt_preserves_pr_badge", ] base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}" From 92cb42262cd86508cb36f1e1f9b28fe4e1ea5e2b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:22:39 -0700 Subject: [PATCH 38/77] feat: add browser profile mapping import flow --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Resources/Localizable.xcstrings | 1309 ++++++++ Sources/AppDelegate.swift | 11 +- Sources/Panels/BrowserPanel.swift | 2768 +++++++++++++++-- Sources/Panels/BrowserPanelView.swift | 190 +- Sources/SessionPersistence.swift | 1 + Sources/TabManager.swift | 47 +- Sources/Workspace.swift | 67 +- cmuxTests/BrowserImportMappingTests.swift | 232 ++ cmuxTests/GhosttyConfigTests.swift | 99 + cmuxTests/SessionPersistenceTests.swift | 4 + .../BrowserImportProfilesUITests.swift | 131 + 12 files changed, 4609 insertions(+), 258 deletions(-) create mode 100644 cmuxTests/BrowserImportMappingTests.swift create mode 100644 cmuxUITests/BrowserImportProfilesUITests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f39108b8..f297afe3 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -83,12 +83,14 @@ B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; + FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; @@ -228,12 +230,14 @@ B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; }; + FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; }; F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; }; + FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; }; @@ -454,6 +458,7 @@ B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, + FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, ); @@ -468,6 +473,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, @@ -693,6 +699,7 @@ B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, + FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, ); @@ -707,6 +714,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 63e1927a..7cb1f7aa 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4547,6 +4547,1298 @@ } } }, + "browser.profile.buttonHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Profile: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイル: %@" + } + } + } + }, + "browser.profile.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + } + } + }, + "browser.profile.menu.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profiles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル" + } + } + } + }, + "browser.profile.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Profile..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいプロファイル..." + } + } + } + }, + "browser.profile.new.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a separate browser profile for cookies, history, and local storage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie、履歴、ローカルストレージを分けるためのブラウザープロファイルを作成します。" + } + } + } + }, + "browser.profile.new.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル名" + } + } + } + }, + "browser.profile.new.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいブラウザープロファイル" + } + } + } + }, + "browser.profile.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Current Profile..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のプロファイル名を変更..." + } + } + } + }, + "browser.profile.rename.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a new name for this browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このブラウザープロファイルの新しい名前を入力します。" + } + } + } + }, + "browser.profile.rename.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Browser Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイル名を変更" + } + } + } + }, + "browser.import.additionalData.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookmarks, settings, and extensions import are not available yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。" + } + } + } + }, + "browser.import.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + } + } + }, + "browser.import.complete.browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ: %@" + } + } + } + }, + "browser.import.complete.createdProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Created cmux profiles: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成した cmux プロファイル: %@" + } + } + } + }, + "browser.import.complete.destinationProfile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Destination profile: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存先プロファイル: %@" + } + } + } + }, + "browser.import.complete.domainFilter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Domain filter: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドメインフィルタ: %@" + } + } + } + }, + "browser.import.complete.profileMapping": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ -> %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ -> %2$@" + } + } + } + }, + "browser.import.complete.profileMappings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile mappings:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル対応:" + } + } + } + }, + "browser.import.complete.importedCookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported cookies: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートしたCookie: %ld" + } + } + } + }, + "browser.import.complete.importedHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported history entries: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートした履歴件数: %ld" + } + } + } + }, + "browser.import.complete.scope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象: %@" + } + } + } + }, + "browser.import.complete.skippedCookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped cookies: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップしたCookie: %ld" + } + } + } + }, + "browser.import.complete.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source profiles: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイル: %@" + } + } + } + }, + "browser.import.complete.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser data import complete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータのインポートが完了しました" + } + } + } + }, + "browser.import.complete.warnings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warnings:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告:" + } + } + } + }, + "browser.import.cookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies (site sign-ins)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie(サイトのログイン状態)" + } + } + } + }, + "browser.import.destination.cmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux destination" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux の保存先" + } + } + } + }, + "browser.import.destinationProfile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import into" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート先" + } + } + } + }, + "browser.import.destinationProfile.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create \"%@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" を作成" + } + } + } + }, + "browser.import.destinationProfile.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported cookies and history go into the selected cmux browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートしたCookieと履歴は、選択したcmuxブラウザープロファイルに保存されます。" + } + } + } + }, + "browser.import.destinationProfile.mergeHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All selected source profiles will be merged into the chosen cmux browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した元プロファイルはすべて、選んだ cmux ブラウザープロファイルにまとめて取り込まれます。" + } + } + } + }, + "browser.import.destinationProfile.separateHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing cmux profiles are created when import starts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不足している cmux プロファイルは、インポート開始時に作成されます。" + } + } + } + }, + "browser.import.destinationMode.merge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Merge all into one cmux profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてを1つの cmux プロファイルにまとめる" + } + } + } + }, + "browser.import.destinationMode.separate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep profiles separate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを分けたまま取り込む" + } + } + } + }, + "browser.import.detected.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@。" + } + } + } + }, + "browser.import.detected.more": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@, +%ld more." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@、ほか%ld件。" + } + } + } + }, + "browser.import.detected.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No supported browsers detected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対応しているブラウザーが見つかりませんでした。" + } + } + } + }, + "browser.import.domain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limit to" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象ドメイン" + } + } + } + }, + "browser.import.domain.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional domains only (e.g. github.com, openai.com)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意のドメインのみ(例: github.com, openai.com)" + } + } + } + }, + "browser.import.error.destinationCreateFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not create the destination profile \"%@\"." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は保存先プロファイル「%@」を作成できませんでした。" + } + } + } + }, + "browser.import.error.destinationMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected cmux browser profile no longer exists. Pick a destination profile again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した cmux ブラウザープロファイルが見つかりません。保存先プロファイルを選び直してください。" + } + } + } + }, + "browser.import.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import could not start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを開始できませんでした" + } + } + } + }, + "browser.import.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History (visited pages)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴(訪問したページ)" + } + } + } + }, + "browser.import.next": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次へ" + } + } + } + }, + "browser.import.noBrowsers.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not find browser profiles to import from on this Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacでインポート元にできるブラウザープロファイルが見つかりませんでした。" + } + } + } + }, + "browser.import.noBrowsers.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No importable browsers found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートできるブラウザーが見つかりません" + } + } + } + }, + "browser.import.progress.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing %@ from %@…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%2$@ から %1$@ をインポート中…" + } + } + } + }, + "browser.import.progress.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This can take a few seconds for large profiles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルが大きい場合は数秒かかることがあります。" + } + } + } + }, + "browser.import.progress.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing Browser Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート中" + } + } + } + }, + "browser.import.scope.cookiesAndHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies + history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie + 履歴" + } + } + } + }, + "browser.import.scope.cookiesOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookieのみ" + } + } + } + }, + "browser.import.scope.everything": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Everything" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + } + } + }, + "browser.import.scope.historyOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴のみ" + } + } + } + }, + "browser.import.source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート元" + } + } + } + }, + "browser.import.sourceProfile.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル%ld" + } + } + } + }, + "browser.import.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source Profiles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイル" + } + } + } + }, + "browser.import.sourceProfiles.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイルを1つ以上選択してください。3 / 3 で、分けたまま取り込むか、1つの cmux プロファイルにまとめるかを選べます。" + } + } + } + }, + "browser.import.sourceProfiles.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No source profiles detected for %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の元プロファイルが見つかりません。" + } + } + } + }, + "browser.import.start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Import" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート開始" + } + } + } + }, + "browser.import.step.dataTypes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 3 of 3: Choose what to import from %@ and where to put it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "3 / 3: %@ から何をインポートし、どこに保存するかを選択します。" + } + } + } + }, + "browser.import.step.source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 1 of 3: Choose the browser to import from." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 / 3: インポート元のブラウザーを選択します。" + } + } + } + }, + "browser.import.step.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 2 of 3: Choose source profiles from %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "2 / 3: %@ の元プロファイルを選択します。" + } + } + } + }, + "browser.import.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import Browser Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, + "browser.import.validation.scope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select Cookies, History, or both before starting import." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを始める前に、Cookie、履歴、またはその両方を選択してください。" + } + } + } + }, + "browser.import.validation.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose at least one source profile to import." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートする元プロファイルを少なくとも1つ選択してください。" + } + } + } + }, + "browser.import.warning.additionalDataUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。Cookieと履歴のみを取り込みました。" + } + } + } + }, + "browser.import.warning.browserCookiesReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading %@ cookies at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のCookieを %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.browserHistoryReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading %@ history at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の履歴を %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.cookieImportUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ cookie import is not implemented yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のCookieインポートにはまだ対応していません。" + } + } + } + }, + "browser.import.warning.encryptedCookiesSkipped": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped %ld encrypted cookies that require Keychain decryption." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainでの復号が必要な暗号化Cookieを%ld件スキップしました。" + } + } + } + }, + "browser.import.warning.keychainDecryptFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychain から %3$@ を開けなかったため、暗号化された %2$@ のCookieを%1$ld件スキップしました。" + } + } + } + }, + "browser.import.warning.firefoxCookiesReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading Firefox cookies at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Firefox のCookieを %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.firefoxHistoryReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading Firefox history at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Firefox の履歴を %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.noHistoryDatabase": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No history database found for %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の履歴データベースが見つかりませんでした。" + } + } + } + }, + "browser.import.warning.safariCookiesUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Safari のCookieは Cookies.binarycookies に保存されており、このインポーターではまだ対応していません。" + } + } + } + }, + "browser.theme.buttonHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Theme: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーテーマ: %@" + } + } + } + }, "browser.addressBarSuggestions": { "extractionState": "manual", "localizations": { @@ -22853,6 +24145,23 @@ } } }, + "common.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成" + } + } + } + }, "common.close": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c..426efa20 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2313,7 +2313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent stopSocketListenerHealthMonitor() TerminalController.shared.stop() VSCodeServeWebController.shared.stop() - BrowserHistoryStore.shared.flushPendingSaves() + BrowserProfileStore.shared.flushPendingSaves() if TelemetrySettings.enabledForCurrentLaunch { PostHogAnalytics.shared.flush() } @@ -8673,7 +8673,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else { + let preferredProfileID = + tabManager?.focusedBrowserPanel?.profileID + ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID + guard let panelId = tabManager?.openBrowser( + url: url, + preferredProfileID: preferredProfileID, + insertAtEnd: insertAtEnd + ) else { #if DEBUG dlog( "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b96a632b..f9e1ef7e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4,6 +4,13 @@ import WebKit import AppKit import Bonsplit import SQLite3 +import CryptoKit +#if canImport(CommonCrypto) +import CommonCrypto +#endif +#if canImport(Security) +import Security +#endif enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { @@ -175,6 +182,209 @@ enum BrowserThemeSettings { } } +struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { + let id: UUID + var displayName: String + let createdAt: Date + let isBuiltInDefault: Bool + + var slug: String { + if isBuiltInDefault { + return "default" + } + + let normalized = displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return normalized.isEmpty ? id.uuidString.lowercased() : normalized + } +} + +@MainActor +final class BrowserProfileStore: ObservableObject { + static let shared = BrowserProfileStore() + + private static let profilesDefaultsKey = "browserProfiles.v1" + private static let lastUsedProfileDefaultsKey = "browserProfiles.lastUsed" + private static let builtInDefaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! + + @Published private(set) var profiles: [BrowserProfileDefinition] = [] + @Published private(set) var lastUsedProfileID: UUID = builtInDefaultProfileID + + private let defaults: UserDefaults + private var dataStores: [UUID: WKWebsiteDataStore] = [:] + private var historyStores: [UUID: BrowserHistoryStore] = [:] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + var builtInDefaultProfileID: UUID { + Self.builtInDefaultProfileID + } + + var effectiveLastUsedProfileID: UUID { + profileDefinition(id: lastUsedProfileID) != nil ? lastUsedProfileID : Self.builtInDefaultProfileID + } + + func profileDefinition(id: UUID) -> BrowserProfileDefinition? { + profiles.first(where: { $0.id == id }) + } + + func displayName(for id: UUID) -> String { + profileDefinition(id: id)?.displayName + ?? String(localized: "browser.profile.default", defaultValue: "Default") + } + + func createProfile(named rawName: String) -> BrowserProfileDefinition? { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + let profile = BrowserProfileDefinition( + id: UUID(), + displayName: name, + createdAt: Date(), + isBuiltInDefault: false + ) + profiles.append(profile) + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + noteUsed(profile.id) + return profile + } + + func renameProfile(id: UUID, to rawName: String) -> Bool { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let index = profiles.firstIndex(where: { $0.id == id }), + !profiles[index].isBuiltInDefault else { + return false + } + profiles[index].displayName = name + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + return true + } + + func canRenameProfile(id: UUID) -> Bool { + guard let profile = profileDefinition(id: id) else { return false } + return !profile.isBuiltInDefault + } + + func noteUsed(_ id: UUID) { + guard profileDefinition(id: id) != nil else { return } + if lastUsedProfileID != id { + lastUsedProfileID = id + defaults.set(id.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + func websiteDataStore(for profileID: UUID) -> WKWebsiteDataStore { + if profileID == Self.builtInDefaultProfileID { + return .default() + } + if let existing = dataStores[profileID] { + return existing + } + let store = WKWebsiteDataStore(forIdentifier: profileID) + dataStores[profileID] = store + return store + } + + func historyStore(for profileID: UUID) -> BrowserHistoryStore { + if profileID == Self.builtInDefaultProfileID { + return .shared + } + if let existing = historyStores[profileID] { + return existing + } + let store = BrowserHistoryStore(fileURL: historyFileURL(for: profileID)) + historyStores[profileID] = store + return store + } + + func historyFileURL(for profileID: UUID) -> URL? { + if profileID == Self.builtInDefaultProfileID { + return BrowserHistoryStore.defaultHistoryFileURLForCurrentBundle() + } + + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let bundleId = Bundle.main.bundleIdentifier ?? "cmux" + let namespace = BrowserHistoryStore.normalizedBrowserHistoryNamespaceForBundleIdentifier(bundleId) + let profilesDir = appSupport + .appendingPathComponent(namespace, isDirectory: true) + .appendingPathComponent("browser_profiles", isDirectory: true) + .appendingPathComponent(profileID.uuidString.lowercased(), isDirectory: true) + return profilesDir.appendingPathComponent("browser_history.json", isDirectory: false) + } + + func flushPendingSaves() { + BrowserHistoryStore.shared.flushPendingSaves() + for store in historyStores.values { + store.flushPendingSaves() + } + } + + private func load() { + let builtInDefaultProfile = BrowserProfileDefinition( + id: Self.builtInDefaultProfileID, + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + createdAt: Date(timeIntervalSince1970: 0), + isBuiltInDefault: true + ) + + if let data = defaults.data(forKey: Self.profilesDefaultsKey), + let decoded = try? JSONDecoder().decode([BrowserProfileDefinition].self, from: data), + !decoded.isEmpty { + var resolvedProfiles = decoded.filter { $0.id != Self.builtInDefaultProfileID } + resolvedProfiles.append(builtInDefaultProfile) + profiles = sortedProfiles(resolvedProfiles) + } else { + profiles = [builtInDefaultProfile] + persist() + } + + if let rawLastUsed = defaults.string(forKey: Self.lastUsedProfileDefaultsKey), + let parsed = UUID(uuidString: rawLastUsed), + profileDefinition(id: parsed) != nil { + lastUsedProfileID = parsed + } else { + lastUsedProfileID = Self.builtInDefaultProfileID + defaults.set(lastUsedProfileID.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + private func persist() { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(profiles) else { return } + defaults.set(data, forKey: Self.profilesDefaultsKey) + } + + private func sortedProfiles(_ profiles: [BrowserProfileDefinition]) -> [BrowserProfileDefinition] { + profiles.sorted { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +} + enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true @@ -1169,6 +1379,14 @@ final class BrowserHistoryStore: ObservableObject { let data = try encoder.encode(snapshot) try data.write(to: fileURL, options: [.atomic]) } + + nonisolated static func defaultHistoryFileURLForCurrentBundle() -> URL? { + defaultHistoryFileURL() + } + + nonisolated static func normalizedBrowserHistoryNamespaceForBundleIdentifier(_ bundleIdentifier: String) -> String { + normalizedBrowserHistoryNamespace(bundleIdentifier: bundleIdentifier) + } } actor BrowserSearchSuggestionService { @@ -1492,6 +1710,9 @@ final class BrowserPanel: Panel, ObservableObject { /// The workspace ID this panel belongs to private(set) var workspaceId: UUID + @Published private(set) var profileID: UUID + @Published private(set) var historyStore: BrowserHistoryStore + /// The underlying web view private(set) var webView: WKWebView @@ -1889,6 +2110,14 @@ final class BrowserPanel: Panel, ObservableObject { return String(localized: "browser.newTab", defaultValue: "New tab") } + var profileDisplayName: String { + BrowserProfileStore.shared.displayName(for: profileID) + } + + var usesBuiltInDefaultProfile: Bool { + profileID == BrowserProfileStore.shared.builtInDefaultProfileID + } + private static let portalHostAreaThreshold: CGFloat = 4 private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 @@ -2047,13 +2276,11 @@ final class BrowserPanel: Panel, ObservableObject { false } - private static func makeWebView() -> CmuxWebView { + private static func makeWebView(profileID: UUID) -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] - // Ensure browser cookies/storage persist across navigations and launches. - // This reduces repeated consent/bot-challenge flows on sites like Google. - config.websiteDataStore = .default() + config.websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: profileID) // Enable developer extras (DevTools) config.preferences.setValue(true, forKey: "developerExtrasEnabled") @@ -2110,20 +2337,34 @@ final class BrowserPanel: Panel, ObservableObject { return instanceID == webViewInstanceID } - init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { + init( + workspaceId: UUID, + profileID: UUID? = nil, + initialURL: URL? = nil, + bypassInsecureHTTPHostOnce: String? = nil + ) { self.id = UUID() self.workspaceId = workspaceId + let requestedProfileID = profileID ?? BrowserProfileStore.shared.effectiveLastUsedProfileID + let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil + ? requestedProfileID + : BrowserProfileStore.shared.builtInDefaultProfileID + self.profileID = resolvedProfileID + self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.browserThemeMode = BrowserThemeSettings.mode() - let webView = Self.makeWebView() + let webView = Self.makeWebView(profileID: resolvedProfileID) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } + BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() navDelegate.didFinish = { webView in - BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) + Task { @MainActor [weak self] in + self?.historyStore.recordVisit(url: webView.url, title: webView.title) + } Task { @MainActor [weak self] in guard let self, self.isCurrentWebView(webView) else { return } self.refreshFavicon(from: webView) @@ -2226,6 +2467,84 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId = newWorkspaceId } + @discardableResult + func switchToProfile(_ requestedProfileID: UUID) -> Bool { + let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil + ? requestedProfileID + : BrowserProfileStore.shared.builtInDefaultProfileID + guard resolvedProfileID != profileID else { + BrowserProfileStore.shared.noteUsed(resolvedProfileID) + return false + } + + let previousWebView = webView + let wasRenderable = shouldRenderWebView + let restoreURL = previousWebView.url ?? currentURL + let restoreURLString = restoreURL?.absoluteString + let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString + let history = sessionNavigationHistorySnapshot() + let historyCurrentURL = preferredURLStringForOmnibar() + let desiredZoom = max(minPageZoom, min(maxPageZoom, previousWebView.pageZoom)) + let restoreDeveloperTools = preferredDeveloperToolsVisible || isDeveloperToolsVisible() + + invalidateSearchFocusRequests(reason: "profileSwitch") + searchState = nil + + _ = hideDeveloperTools() + cancelDeveloperToolsRestoreRetry() + + webViewObservers.removeAll() + webViewCancellables.removeAll() + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 + BrowserWindowPortalRegistry.detach(webView: previousWebView) + previousWebView.stopLoading() + previousWebView.navigationDelegate = nil + previousWebView.uiDelegate = nil + if let previousCmuxWebView = previousWebView as? CmuxWebView { + previousCmuxWebView.onContextMenuDownloadStateChanged = nil + } + + profileID = resolvedProfileID + historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) + BrowserProfileStore.shared.noteUsed(resolvedProfileID) + + let replacement = Self.makeWebView(profileID: resolvedProfileID) + replacement.pageZoom = desiredZoom + webViewInstanceID = UUID() + webView = replacement + currentURL = restoreURL + shouldRenderWebView = wasRenderable + + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + + if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { + restoreSessionNavigationHistory( + backHistoryURLStrings: history.backHistoryURLStrings, + forwardHistoryURLStrings: history.forwardHistoryURLStrings, + currentURLString: historyCurrentURL + ) + } + + if shouldRestoreURL, let restoreURL { + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + } else { + refreshNavigationAvailability() + } + + if restoreDeveloperTools { + requestDeveloperToolsRefreshAfterNextAttach(reason: "profile_switch") + } + + return true + } + func triggerFlash() { focusFlashToken &+= 1 } @@ -2373,7 +2692,7 @@ final class BrowserPanel: Panel, ObservableObject { terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView() + let replacement = Self.makeWebView(profileID: profileID) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement @@ -2698,7 +3017,7 @@ final class BrowserPanel: Panel, ObservableObject { webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true if recordTypedNavigation { - BrowserHistoryStore.shared.recordTypedNavigation(url: url) + historyStore.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url browserLoadRequest(request, in: webView) @@ -2924,7 +3243,7 @@ extension BrowserPanel { oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView() + let replacement = Self.makeWebView(profileID: profileID) webViewInstanceID = UUID() webView = replacement shouldRenderWebView = false @@ -3058,6 +3377,7 @@ extension BrowserPanel { inPane: paneId, url: url, focus: true, + preferredProfileID: profileID, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) #if DEBUG @@ -5179,13 +5499,13 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { var displayName: String { switch self { case .cookiesOnly: - return "Cookies only" + return String(localized: "browser.import.scope.cookiesOnly", defaultValue: "Cookies only") case .historyOnly: - return "History only" + return String(localized: "browser.import.scope.historyOnly", defaultValue: "History only") case .cookiesAndHistory: - return "Cookies + history" + return String(localized: "browser.import.scope.cookiesAndHistory", defaultValue: "Cookies + history") case .everything: - return "Everything" + return String(localized: "browser.import.scope.everything", defaultValue: "Everything") } } @@ -5232,6 +5552,16 @@ enum BrowserImportEngineFamily: String, Hashable { case webkit } +struct InstalledBrowserProfile: Identifiable, Hashable { + let displayName: String + let rootURL: URL + let isDefault: Bool + + var id: String { + rootURL.standardizedFileURL.resolvingSymlinksInPath().path + } +} + struct BrowserImportBrowserDescriptor: Hashable { let id: String let displayName: String @@ -5246,16 +5576,18 @@ struct BrowserImportBrowserDescriptor: Hashable { struct InstalledBrowserCandidate: Identifiable, Hashable { let descriptor: BrowserImportBrowserDescriptor + let resolvedFamily: BrowserImportEngineFamily let homeDirectoryURL: URL let appURL: URL? let dataRootURL: URL? - let profileURLs: [URL] + let profiles: [InstalledBrowserProfile] let detectionSignals: [String] let detectionScore: Int var id: String { descriptor.id } var displayName: String { descriptor.displayName } - var family: BrowserImportEngineFamily { descriptor.family } + var family: BrowserImportEngineFamily { resolvedFamily } + var profileURLs: [URL] { profiles.map(\.rootURL) } } enum InstalledBrowserDetector { @@ -5461,11 +5793,14 @@ enum InstalledBrowserDetector { BrowserImportBrowserDescriptor( id: "helium", displayName: "Helium", - family: .webkit, + family: .chromium, tier: 3, - bundleIdentifiers: ["com.jadenGeller.Helium", "com.jaden.geller.helium"], + bundleIdentifiers: ["net.imput.helium", "com.jadenGeller.Helium", "com.jaden.geller.helium"], appNames: ["Helium.app"], - dataRootRelativePaths: ["Library/Application Support/Helium"], + dataRootRelativePaths: [ + "Library/Application Support/net.imput.helium", + "Library/Application Support/Helium", + ], dataArtifactRelativePaths: [], supportsDataOnlyDetection: true ), @@ -5537,6 +5872,7 @@ enum InstalledBrowserDetector { let dataDetection = detectData( descriptor: descriptor, homeDirectoryURL: homeDirectoryURL, + appBundleIdentifier: appDetection.bundleIdentifier, fileManager: fileManager ) @@ -5545,7 +5881,7 @@ enum InstalledBrowserDetector { return nil } - let hasData = dataDetection.dataRootURL != nil || !dataDetection.profileURLs.isEmpty || !dataDetection.artifactHits.isEmpty + let hasData = dataDetection.dataRootURL != nil || !dataDetection.profiles.isEmpty || !dataDetection.artifactHits.isEmpty guard appDetection.url != nil || hasData else { return nil } @@ -5557,7 +5893,7 @@ enum InstalledBrowserDetector { if dataDetection.dataRootURL != nil { score += 24 } - score += min(24, dataDetection.profileURLs.count * 6) + score += min(24, dataDetection.profiles.count * 6) score += min(16, dataDetection.artifactHits.count * 4) var signals: [String] = [] @@ -5565,8 +5901,8 @@ enum InstalledBrowserDetector { if let root = dataDetection.dataRootURL { signals.append("data:\(root.lastPathComponent)") } - if !dataDetection.profileURLs.isEmpty { - signals.append("profiles:\(dataDetection.profileURLs.count)") + if !dataDetection.profiles.isEmpty { + signals.append("profiles:\(dataDetection.profiles.count)") } if !dataDetection.artifactHits.isEmpty { signals.append(contentsOf: dataDetection.artifactHits.map { "artifact:\($0)" }) @@ -5574,10 +5910,11 @@ enum InstalledBrowserDetector { return InstalledBrowserCandidate( descriptor: descriptor, + resolvedFamily: dataDetection.family, homeDirectoryURL: homeDirectoryURL, appURL: appDetection.url, dataRootURL: dataDetection.dataRootURL, - profileURLs: dataDetection.profileURLs, + profiles: dataDetection.profiles, detectionSignals: signals, detectionScore: score ) @@ -5595,13 +5932,31 @@ enum InstalledBrowserDetector { } static func summaryText(for browsers: [InstalledBrowserCandidate], limit: Int = 4) -> String { - guard !browsers.isEmpty else { return "No supported browsers detected." } + guard !browsers.isEmpty else { + return String( + localized: "browser.import.detected.none", + defaultValue: "No supported browsers detected." + ) + } let names = browsers.map(\.displayName) if names.count <= limit { - return "Detected: \(names.joined(separator: ", "))." + return String( + format: String( + localized: "browser.import.detected.all", + defaultValue: "Detected: %@." + ), + names.joined(separator: ", ") + ) } let shown = names.prefix(limit).joined(separator: ", ") - return "Detected: \(shown), +\(names.count - limit) more." + return String( + format: String( + localized: "browser.import.detected.more", + defaultValue: "Detected: %@, +%ld more." + ), + shown, + names.count - limit + ) } private static func detectApplication( @@ -5609,10 +5964,10 @@ enum InstalledBrowserDetector { appSearchDirectories: [URL], bundleLookup: BundleLookup, fileManager: FileManager - ) -> (url: URL?, signals: [String]) { - for bundleIdentifier in descriptor.bundleIdentifiers { - if let appURL = bundleLookup(bundleIdentifier) { - return (appURL, ["bundle:\(bundleIdentifier)"]) + ) -> (url: URL?, signals: [String], bundleIdentifier: String?) { + for knownBundleIdentifier in descriptor.bundleIdentifiers { + if let appURL = bundleLookup(knownBundleIdentifier) { + return (appURL, ["bundle:\(knownBundleIdentifier)"], bundleIdentifier(for: appURL) ?? knownBundleIdentifier) } } @@ -5620,42 +5975,54 @@ enum InstalledBrowserDetector { for directory in appSearchDirectories { let appURL = directory.appendingPathComponent(appName, isDirectory: true) if fileManager.fileExists(atPath: appURL.path) { - return (appURL, ["app:\(appName)"]) + return (appURL, ["app:\(appName)"], bundleIdentifier(for: appURL)) } } } - return (nil, []) + return (nil, [], nil) } private static func detectData( descriptor: BrowserImportBrowserDescriptor, homeDirectoryURL: URL, + appBundleIdentifier: String?, fileManager: FileManager - ) -> (dataRootURL: URL?, profileURLs: [URL], artifactHits: [String]) { + ) -> (dataRootURL: URL?, family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile], artifactHits: [String]) { var bestRootURL: URL? - var bestProfiles: [URL] = [] + var bestFamily = descriptor.family + var bestProfiles: [InstalledBrowserProfile] = [] var bestArtifacts: [String] = [] + let candidateRootPaths = candidateDataRootRelativePaths( + descriptor: descriptor, + appBundleIdentifier: appBundleIdentifier + ) - for relativePath in descriptor.dataRootRelativePaths { + for relativePath in candidateRootPaths { let rootURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: true) guard fileManager.fileExists(atPath: rootURL.path) else { continue } - let profiles: [URL] - switch descriptor.family { - case .chromium: - profiles = chromiumProfileURLs(rootURL: rootURL, fileManager: fileManager) - case .firefox: - profiles = firefoxProfileURLs(rootURL: rootURL, fileManager: fileManager) - case .webkit: - profiles = [] - } + let detectedProfiles = detectProfiles( + descriptor: descriptor, + rootURL: rootURL, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + ) - let score = (profiles.count * 10) + 8 - let currentScore = (bestProfiles.count * 10) + (bestRootURL == nil ? 0 : 8) + let score = scoreProfileDetection( + family: detectedProfiles.family, + profiles: detectedProfiles.profiles, + preferredFamily: descriptor.family + ) + 8 + let currentScore = scoreProfileDetection( + family: bestFamily, + profiles: bestProfiles, + preferredFamily: descriptor.family + ) + (bestRootURL == nil ? 0 : 8) if score > currentScore { bestRootURL = rootURL - bestProfiles = profiles + bestFamily = detectedProfiles.family + bestProfiles = detectedProfiles.profiles } } @@ -5670,7 +6037,7 @@ enum InstalledBrowserDetector { if !artifactHits.isEmpty { bestArtifacts = artifactHits if bestRootURL == nil, - let rootPath = descriptor.dataRootRelativePaths.first { + let rootPath = candidateRootPaths.first { let rootURL = homeDirectoryURL.appendingPathComponent(rootPath, isDirectory: true) if fileManager.fileExists(atPath: rootURL.path) { bestRootURL = rootURL @@ -5678,20 +6045,122 @@ enum InstalledBrowserDetector { } } + if bestProfiles.isEmpty, let bestRootURL { + bestProfiles = [ + InstalledBrowserProfile( + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + rootURL: bestRootURL, + isDefault: true + ) + ] + } + return ( dataRootURL: bestRootURL, - profileURLs: dedupedCanonicalURLs(bestProfiles), + family: bestFamily, + profiles: sortProfiles(dedupedProfiles(bestProfiles)), artifactHits: bestArtifacts ) } - private static func chromiumProfileURLs( + private static func detectProfiles( + descriptor: BrowserImportBrowserDescriptor, + rootURL: URL, + homeDirectoryURL: URL, + fileManager: FileManager + ) -> (family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile]) { + let candidates: [(BrowserImportEngineFamily, [InstalledBrowserProfile])] = [ + (.chromium, chromiumProfiles(rootURL: rootURL, fileManager: fileManager)), + (.firefox, firefoxProfiles(rootURL: rootURL, fileManager: fileManager)), + (.webkit, webKitProfiles( + descriptor: descriptor, + rootURL: rootURL, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + )), + ] + + return candidates.max { lhs, rhs in + let lhsScore = scoreProfileDetection( + family: lhs.0, + profiles: lhs.1, + preferredFamily: descriptor.family + ) + let rhsScore = scoreProfileDetection( + family: rhs.0, + profiles: rhs.1, + preferredFamily: descriptor.family + ) + if lhsScore != rhsScore { + return lhsScore < rhsScore + } + return lhs.0.rawValue > rhs.0.rawValue + } ?? (descriptor.family, []) + } + + private static func bundleIdentifier(for appURL: URL) -> String? { + Bundle(url: appURL)?.bundleIdentifier + } + + private static func candidateDataRootRelativePaths( + descriptor: BrowserImportBrowserDescriptor, + appBundleIdentifier: String? + ) -> [String] { + var result: [String] = [] + var seen = Set<String>() + + func append(_ relativePath: String) { + if seen.insert(relativePath).inserted { + result.append(relativePath) + } + } + + for relativePath in descriptor.dataRootRelativePaths { + append(relativePath) + } + + let bundleIdentifiers = [appBundleIdentifier].compactMap { $0 } + descriptor.bundleIdentifiers + for bundleIdentifier in bundleIdentifiers { + append("Library/Application Support/\(bundleIdentifier)") + append("Library/Containers/\(bundleIdentifier)/Data/Library/Application Support/\(bundleIdentifier)") + } + + return result + } + + private static func scoreProfileDetection( + family: BrowserImportEngineFamily, + profiles: [InstalledBrowserProfile], + preferredFamily: BrowserImportEngineFamily + ) -> Int { + var score = profiles.count * 10 + if family == preferredFamily { + score += 3 + } + if profiles.contains(where: \.isDefault) { + score += 1 + } + return score + } + + private static func chromiumProfiles( rootURL: URL, fileManager: FileManager - ) -> [URL] { - var profiles: [URL] = [] + ) -> [InstalledBrowserProfile] { + let nameMap = chromiumProfileNameMap(rootURL: rootURL) + var profiles: [InstalledBrowserProfile] = [] if looksLikeChromiumProfile(rootURL: rootURL, fileManager: fileManager) { - profiles.append(rootURL) + profiles.append( + InstalledBrowserProfile( + displayName: chromiumProfileDisplayName( + directoryName: rootURL.lastPathComponent, + nameMap: nameMap, + isDefault: true + ), + rootURL: rootURL, + isDefault: true + ) + ) } let children = (try? fileManager.contentsOfDirectory( @@ -5707,23 +6176,30 @@ enum InstalledBrowserDetector { name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("Guest Profile") || - name.hasPrefix("Person ") + name.hasPrefix("Person ") || + nameMap[name] != nil if isLikelyProfile && looksLikeChromiumProfile(rootURL: child, fileManager: fileManager) { - profiles.append(child) + profiles.append( + InstalledBrowserProfile( + displayName: chromiumProfileDisplayName( + directoryName: name, + nameMap: nameMap, + isDefault: name == "Default" + ), + rootURL: child, + isDefault: name == "Default" + ) + ) } } - profiles = dedupedCanonicalURLs(profiles) - return profiles.sorted { - profileRecency(for: $0, preferredFiles: ["History", "Cookies"], fileManager: fileManager) > - profileRecency(for: $1, preferredFiles: ["History", "Cookies"], fileManager: fileManager) - } + return sortProfiles(dedupedProfiles(profiles)) } - private static func firefoxProfileURLs( + private static func firefoxProfiles( rootURL: URL, fileManager: FileManager - ) -> [URL] { + ) -> [InstalledBrowserProfile] { var profiles = firefoxProfilesFromINI(rootURL: rootURL, fileManager: fileManager) let likelyProfileRoots = [ @@ -5740,27 +6216,56 @@ enum InstalledBrowserDetector { for child in children { guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } if looksLikeFirefoxProfile(rootURL: child, fileManager: fileManager) { - profiles.append(child) + let directoryName = child.lastPathComponent + profiles.append( + InstalledBrowserProfile( + displayName: directoryName, + rootURL: child, + isDefault: directoryName.localizedCaseInsensitiveContains("default") + ) + ) } } } - profiles = dedupedCanonicalURLs(profiles) - return profiles.sorted { - profileRecency(for: $0, preferredFiles: ["places.sqlite", "cookies.sqlite"], fileManager: fileManager) > - profileRecency(for: $1, preferredFiles: ["places.sqlite", "cookies.sqlite"], fileManager: fileManager) - } + return sortProfiles(dedupedProfiles(profiles)) } private static func firefoxProfilesFromINI( rootURL: URL, fileManager: FileManager - ) -> [URL] { + ) -> [InstalledBrowserProfile] { let iniURL = rootURL.appendingPathComponent("profiles.ini", isDirectory: false) guard let contents = try? String(contentsOf: iniURL, encoding: .utf8) else { return [] } + let sections = parseINISections(contents: contents) + var profiles: [InstalledBrowserProfile] = [] + for section in sections { + guard let pathValue = section["Path"], !pathValue.isEmpty else { continue } + let isRelative = section["IsRelative"] != "0" + let profileURL: URL + if isRelative { + profileURL = rootURL.appendingPathComponent(pathValue, isDirectory: true) + } else { + profileURL = URL(fileURLWithPath: pathValue, isDirectory: true) + } + if looksLikeFirefoxProfile(rootURL: profileURL, fileManager: fileManager) { + let displayName = section["Name"]?.trimmingCharacters(in: .whitespacesAndNewlines) + profiles.append( + InstalledBrowserProfile( + displayName: (displayName?.isEmpty == false ? displayName! : profileURL.lastPathComponent), + rootURL: profileURL, + isDefault: section["Default"] == "1" + ) + ) + } + } + return profiles + } + + private static func parseINISections(contents: String) -> [[String: String]] { var sections: [[String: String]] = [] var current: [String: String] = [:] @@ -5786,22 +6291,7 @@ enum InstalledBrowserDetector { current[key] = value } flushCurrent() - - var urls: [URL] = [] - for section in sections { - guard let pathValue = section["Path"], !pathValue.isEmpty else { continue } - let isRelative = section["IsRelative"] != "0" - let profileURL: URL - if isRelative { - profileURL = rootURL.appendingPathComponent(pathValue, isDirectory: true) - } else { - profileURL = URL(fileURLWithPath: pathValue, isDirectory: true) - } - if looksLikeFirefoxProfile(rootURL: profileURL, fileManager: fileManager) { - urls.append(profileURL) - } - } - return urls + return sections } private static func looksLikeChromiumProfile(rootURL: URL, fileManager: FileManager) -> Bool { @@ -5816,22 +6306,133 @@ enum InstalledBrowserDetector { return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) } - private static func profileRecency( - for profileURL: URL, - preferredFiles: [String], + private static func webKitProfiles( + descriptor: BrowserImportBrowserDescriptor, + rootURL: URL, + homeDirectoryURL: URL, fileManager: FileManager - ) -> TimeInterval { - var latest: TimeInterval = 0 - for fileName in preferredFiles { - let url = profileURL.appendingPathComponent(fileName, isDirectory: false) - guard fileManager.fileExists(atPath: url.path), - let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), - let date = values.contentModificationDate else { + ) -> [InstalledBrowserProfile] { + var profiles: [InstalledBrowserProfile] = [] + if looksLikeWebKitProfile(rootURL: rootURL, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + rootURL: rootURL, + isDefault: true + ) + ) + } + + var profileRoots = [rootURL.appendingPathComponent("Profiles", isDirectory: true)] + if descriptor.id == "safari" { + profileRoots.append( + homeDirectoryURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Containers", isDirectory: true) + .appendingPathComponent("com.apple.Safari", isDirectory: true) + .appendingPathComponent("Data", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Safari", isDirectory: true) + .appendingPathComponent("Profiles", isDirectory: true) + ) + } + + var profileIndex = 1 + for profileRoot in dedupedCanonicalURLs(profileRoots) where fileManager.fileExists(atPath: profileRoot.path) { + let children = (try? fileManager.contentsOfDirectory( + at: profileRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + guard looksLikeWebKitProfile(rootURL: child, fileManager: fileManager) else { continue } + profiles.append( + InstalledBrowserProfile( + displayName: webKitProfileDisplayName( + directoryName: child.lastPathComponent, + fallbackIndex: profileIndex + ), + rootURL: child, + isDefault: false + ) + ) + profileIndex += 1 + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func chromiumProfileNameMap(rootURL: URL) -> [String: String] { + let localStateURL = rootURL.appendingPathComponent("Local State", isDirectory: false) + guard let data = try? Data(contentsOf: localStateURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profileSection = jsonObject["profile"] as? [String: Any], + let infoCache = profileSection["info_cache"] as? [String: Any] else { + return [:] + } + + var result: [String: String] = [:] + for (directoryName, rawProfileInfo) in infoCache { + guard let profileInfo = rawProfileInfo as? [String: Any], + let name = profileInfo["name"] as? String else { continue } - latest = max(latest, date.timeIntervalSince1970) + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + result[directoryName] = trimmedName + } } - return latest + return result + } + + private static func chromiumProfileDisplayName( + directoryName: String, + nameMap: [String: String], + isDefault: Bool + ) -> String { + if let mappedName = nameMap[directoryName], !mappedName.isEmpty { + return mappedName + } + if isDefault { + return String(localized: "browser.profile.default", defaultValue: "Default") + } + return directoryName + } + + private static func looksLikeWebKitProfile(rootURL: URL, fileManager: FileManager) -> Bool { + let candidatePaths = [ + "History.db", + "Cookies.binarycookies", + "Cookies.sqlite", + "WebsiteData", + "LocalStorage", + ] + + for candidatePath in candidatePaths { + let url = rootURL.appendingPathComponent(candidatePath, isDirectory: candidatePath != "History.db" && candidatePath != "Cookies.binarycookies" && candidatePath != "Cookies.sqlite") + if fileManager.fileExists(atPath: url.path) { + return true + } + } + return false + } + + private static func webKitProfileDisplayName(directoryName: String, fallbackIndex: Int) -> String { + if directoryName.caseInsensitiveCompare("Default") == .orderedSame { + return String(localized: "browser.profile.default", defaultValue: "Default") + } + if UUID(uuidString: directoryName) != nil { + return String( + format: String( + localized: "browser.import.sourceProfile.fallback", + defaultValue: "Profile %ld" + ), + fallbackIndex + ) + } + return directoryName } private static func defaultApplicationSearchDirectories(homeDirectoryURL: URL) -> [URL] { @@ -5854,18 +6455,739 @@ enum InstalledBrowserDetector { } return result } + + private static func dedupedProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { + var seen = Set<String>() + var result: [InstalledBrowserProfile] = [] + for profile in profiles { + if seen.insert(profile.id).inserted { + result.append(profile) + } + } + return result + } + + private static func sortProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { + profiles.sorted { lhs, rhs in + if lhs.isDefault != rhs.isDefault { + return lhs.isDefault && !rhs.isDefault + } + let comparison = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + return lhs.id < rhs.id + } + } } -struct BrowserImportOutcome { - let browserName: String - let scope: BrowserImportScope - let domainFilters: [String] +struct BrowserImportOutcomeEntry: Sendable { + let sourceProfileNames: [String] + let destinationProfileName: String let importedCookies: Int let skippedCookies: Int let importedHistoryEntries: Int let warnings: [String] } +struct BrowserImportOutcome: Sendable { + let browserName: String + let scope: BrowserImportScope + let domainFilters: [String] + let createdDestinationProfileNames: [String] + let entries: [BrowserImportOutcomeEntry] + let warnings: [String] + + var totalImportedCookies: Int { + entries.reduce(0) { $0 + $1.importedCookies } + } + + var totalSkippedCookies: Int { + entries.reduce(0) { $0 + $1.skippedCookies } + } + + var totalImportedHistoryEntries: Int { + entries.reduce(0) { $0 + $1.importedHistoryEntries } + } +} + +struct RealizedBrowserImportExecutionEntry: Sendable { + let sourceProfiles: [InstalledBrowserProfile] + let destinationProfileID: UUID + let destinationProfileName: String +} + +struct RealizedBrowserImportExecutionPlan: Sendable { + let mode: BrowserImportDestinationMode + let entries: [RealizedBrowserImportExecutionEntry] + let createdProfiles: [BrowserProfileDefinition] +} + +enum BrowserImportPlanRealizationError: LocalizedError { + case missingDestinationProfile(UUID) + case profileCreationFailed(String) + + var errorDescription: String? { + switch self { + case .missingDestinationProfile: + return String( + localized: "browser.import.error.destinationMissing", + defaultValue: "The selected cmux browser profile no longer exists. Pick a destination profile again." + ) + case .profileCreationFailed(let name): + return String( + format: String( + localized: "browser.import.error.destinationCreateFailed", + defaultValue: "cmux could not create the destination profile \"%@\"." + ), + name + ) + } + } +} + +enum BrowserImportOutcomeFormatter { + static func lines(for outcome: BrowserImportOutcome) -> [String] { + var lines: [String] = [] + lines.append( + String( + format: String( + localized: "browser.import.complete.browser", + defaultValue: "Browser: %@" + ), + outcome.browserName + ) + ) + + if outcome.entries.count == 1, let entry = outcome.entries.first { + if !entry.sourceProfileNames.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.sourceProfiles", + defaultValue: "Source profiles: %@" + ), + entry.sourceProfileNames.joined(separator: ", ") + ) + ) + } + lines.append( + String( + format: String( + localized: "browser.import.complete.destinationProfile", + defaultValue: "Destination profile: %@" + ), + entry.destinationProfileName + ) + ) + } else if !outcome.entries.isEmpty { + lines.append( + String( + localized: "browser.import.complete.profileMappings", + defaultValue: "Profile mappings:" + ) + ) + for entry in outcome.entries { + let sourceNames = entry.sourceProfileNames.joined(separator: ", ") + lines.append( + String( + format: String( + localized: "browser.import.complete.profileMapping", + defaultValue: "%@ -> %@" + ), + sourceNames, + entry.destinationProfileName + ) + ) + } + } + + lines.append( + String( + format: String( + localized: "browser.import.complete.scope", + defaultValue: "Scope: %@" + ), + outcome.scope.displayName + ) + ) + lines.append( + String( + format: String( + localized: "browser.import.complete.importedCookies", + defaultValue: "Imported cookies: %ld" + ), + outcome.totalImportedCookies + ) + ) + if outcome.totalSkippedCookies > 0 { + lines.append( + String( + format: String( + localized: "browser.import.complete.skippedCookies", + defaultValue: "Skipped cookies: %ld" + ), + outcome.totalSkippedCookies + ) + ) + } + if outcome.scope.includesHistory { + lines.append( + String( + format: String( + localized: "browser.import.complete.importedHistory", + defaultValue: "Imported history entries: %ld" + ), + outcome.totalImportedHistoryEntries + ) + ) + } + if !outcome.domainFilters.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.domainFilter", + defaultValue: "Domain filter: %@" + ), + outcome.domainFilters.joined(separator: ", ") + ) + ) + } + if !outcome.createdDestinationProfileNames.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.createdProfiles", + defaultValue: "Created cmux profiles: %@" + ), + outcome.createdDestinationProfileNames.joined(separator: ", ") + ) + ) + } + if !outcome.warnings.isEmpty { + lines.append("") + lines.append( + String( + localized: "browser.import.complete.warnings", + defaultValue: "Warnings:" + ) + ) + for warning in outcome.warnings { + lines.append("- \(warning)") + } + } + + return lines + } +} + +enum BrowserImportDestinationMode: Equatable, Sendable { + case singleDestination + case separateProfiles + case mergeIntoOne +} + +enum BrowserImportDestinationRequest: Equatable, Sendable { + case existing(UUID) + case createNamed(String) +} + +struct BrowserImportExecutionEntry: Equatable, Sendable { + var sourceProfiles: [InstalledBrowserProfile] + var destination: BrowserImportDestinationRequest +} + +struct BrowserImportExecutionPlan: Equatable, Sendable { + var mode: BrowserImportDestinationMode + var entries: [BrowserImportExecutionEntry] +} + +struct BrowserImportStep3Presentation: Equatable { + let showsModeSelector: Bool + let showsSeparateRows: Bool + let showsSingleDestinationPicker: Bool + + init(plan: BrowserImportExecutionPlan) { + showsModeSelector = plan.entries.count > 1 || plan.entries.contains { $0.sourceProfiles.count > 1 } + showsSeparateRows = plan.mode == .separateProfiles + showsSingleDestinationPicker = plan.mode != .separateProfiles + } +} + +enum BrowserImportPlanResolver { + @MainActor + static func defaultPlan( + selectedSourceProfiles: [InstalledBrowserProfile], + destinationProfiles: [BrowserProfileDefinition], + preferredSingleDestinationProfileID: UUID + ) -> BrowserImportExecutionPlan { + let resolvedSourceProfiles = selectedSourceProfiles.isEmpty ? [] : selectedSourceProfiles + + guard resolvedSourceProfiles.count > 1 else { + let destinationRequest: BrowserImportDestinationRequest + if let sourceProfile = resolvedSourceProfiles.first, + let matchingProfile = matchingDestinationProfile( + for: sourceProfile.displayName, + destinationProfiles: destinationProfiles + ) { + destinationRequest = .existing(matchingProfile.id) + } else { + destinationRequest = .existing(preferredSingleDestinationProfileID) + } + + return BrowserImportExecutionPlan( + mode: .singleDestination, + entries: resolvedSourceProfiles.map { + BrowserImportExecutionEntry( + sourceProfiles: [$0], + destination: destinationRequest + ) + } + ) + } + + return separateProfilesPlan( + selectedSourceProfiles: resolvedSourceProfiles, + destinationProfiles: destinationProfiles + ) + } + + static func separateProfilesPlan( + selectedSourceProfiles: [InstalledBrowserProfile], + destinationProfiles: [BrowserProfileDefinition] + ) -> BrowserImportExecutionPlan { + var reservedNames = Set(destinationProfiles.map { normalizedProfileName($0.displayName) }) + + return BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: selectedSourceProfiles.map { profile in + if let matchingProfile = matchingDestinationProfile( + for: profile.displayName, + destinationProfiles: destinationProfiles + ) { + return BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: .existing(matchingProfile.id) + ) + } + + let createName = nextCreateName( + baseName: profile.displayName, + takenNames: reservedNames + ) + reservedNames.insert(normalizedProfileName(createName)) + return BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: .createNamed(createName) + ) + } + ) + } + + private static func matchingDestinationProfile( + for sourceProfileName: String, + destinationProfiles: [BrowserProfileDefinition] + ) -> BrowserProfileDefinition? { + let normalizedSourceName = normalizedProfileName(sourceProfileName) + guard !normalizedSourceName.isEmpty else { return nil } + return destinationProfiles.first { + normalizedProfileName($0.displayName) == normalizedSourceName + } + } + + private static func nextCreateName( + baseName: String, + takenNames: Set<String> + ) -> String { + let trimmedBaseName = baseName.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBaseName = trimmedBaseName.isEmpty ? "Profile" : trimmedBaseName + if !takenNames.contains(normalizedProfileName(resolvedBaseName)) { + return resolvedBaseName + } + + var suffix = 2 + while true { + let candidate = "\(resolvedBaseName) (\(suffix))" + if !takenNames.contains(normalizedProfileName(candidate)) { + return candidate + } + suffix += 1 + } + } + + private static func normalizedProfileName(_ rawName: String) -> String { + rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + @MainActor + static func realize( + plan: BrowserImportExecutionPlan, + profileStore: BrowserProfileStore = .shared + ) throws -> RealizedBrowserImportExecutionPlan { + var realizedEntries: [RealizedBrowserImportExecutionEntry] = [] + var createdProfiles: [BrowserProfileDefinition] = [] + + for entry in plan.entries { + let destinationProfile: BrowserProfileDefinition + switch entry.destination { + case .existing(let id): + guard let existingProfile = profileStore.profileDefinition(id: id) else { + throw BrowserImportPlanRealizationError.missingDestinationProfile(id) + } + destinationProfile = existingProfile + case .createNamed(let name): + if let existingProfile = matchingDestinationProfile( + for: name, + destinationProfiles: profileStore.profiles + ) { + destinationProfile = existingProfile + } else if let createdProfile = profileStore.createProfile(named: name) { + createdProfiles.append(createdProfile) + destinationProfile = createdProfile + } else { + throw BrowserImportPlanRealizationError.profileCreationFailed(name) + } + } + + realizedEntries.append( + RealizedBrowserImportExecutionEntry( + sourceProfiles: entry.sourceProfiles, + destinationProfileID: destinationProfile.id, + destinationProfileName: destinationProfile.displayName + ) + ) + } + + return RealizedBrowserImportExecutionPlan( + mode: plan.mode, + entries: realizedEntries, + createdProfiles: createdProfiles + ) + } +} + +#if canImport(CommonCrypto) && canImport(Security) +private struct ChromiumCookieKeychainItem: Hashable { + let service: String + let account: String +} + +private final class ChromiumCookieDecryptor { + private enum KeychainLookupResult { + case success(Data) + case failure(OSStatus) + } + + enum FailureReason { + case keychain(OSStatus) + case itemNotFound + case unreadableSecret + case decrypt + case unsupportedFormat + } + + private let browser: InstalledBrowserCandidate + private var cachedKeychainItem: ChromiumCookieKeychainItem? + private var cachedPasswordData: Data? + private var attemptedLookup = false + private(set) var lastFailureReason: FailureReason? + + init(browser: InstalledBrowserCandidate) { + self.browser = browser + } + + var resolvedKeychainItemName: String? { + cachedKeychainItem?.service + } + + func decryptCookieValue(encryptedValue: Data, host: String) -> String? { + guard let versionPrefix = chromiumVersionPrefix(in: encryptedValue) else { + lastFailureReason = .unsupportedFormat + return nil + } + + guard let passwordData = passwordData() else { + return nil + } + + let ciphertext = encryptedValue.dropFirst(versionPrefix.count) + guard let key = deriveKey(from: passwordData), + let plaintext = decrypt(ciphertext: Data(ciphertext), key: key), + let cookieValue = decodePlaintext(plaintext, host: host) else { + lastFailureReason = .decrypt + return nil + } + + lastFailureReason = nil + return cookieValue + } + + func warningMessage(browserName: String, skippedCount: Int) -> String? { + guard skippedCount > 0, let failure = lastFailureReason else { return nil } + switch failure { + case .keychain, .itemNotFound, .unreadableSecret: + let itemName = resolvedKeychainItemName ?? suggestedKeychainItems().first?.service ?? "\(browserName) Storage Key" + return String( + format: String( + localized: "browser.import.warning.keychainDecryptFailed", + defaultValue: "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + ), + skippedCount, + browserName, + itemName + ) + case .decrypt, .unsupportedFormat: + return String( + format: String( + localized: "browser.import.warning.encryptedCookiesSkipped", + defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." + ), + skippedCount + ) + } + } + + private func passwordData() -> Data? { + if let cachedPasswordData { + return cachedPasswordData + } + guard !attemptedLookup else { + return nil + } + attemptedLookup = true + + for item in suggestedKeychainItems() { + switch readPasswordData(item: item) { + case .success(let passwordData): + guard !passwordData.isEmpty else { + cachedKeychainItem = item + lastFailureReason = .unreadableSecret + return nil + } + cachedKeychainItem = item + cachedPasswordData = passwordData + lastFailureReason = nil + return passwordData + case .failure(let status): + if status == errSecItemNotFound { + continue + } + cachedKeychainItem = item + lastFailureReason = .keychain(status) + return nil + } + } + + lastFailureReason = .itemNotFound + return nil + } + + private func suggestedKeychainItems() -> [ChromiumCookieKeychainItem] { + var result: [ChromiumCookieKeychainItem] = [] + var seen = Set<ChromiumCookieKeychainItem>() + + func append(service: String, account: String) { + let trimmedService = service.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAccount = account.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedService.isEmpty, !trimmedAccount.isEmpty else { return } + let item = ChromiumCookieKeychainItem(service: trimmedService, account: trimmedAccount) + if seen.insert(item).inserted { + result.append(item) + } + } + + for baseName in keychainBaseNames() { + append(service: "\(baseName) Storage Key", account: baseName) + append(service: "\(baseName) Safe Storage", account: baseName) + } + + for baseName in keychainBaseNames() { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: baseName, + kSecReturnAttributes: true, + kSecMatchLimit: kSecMatchLimitAll, + ] + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + guard status == errSecSuccess else { continue } + let attributesList = rawResult as? [[String: Any]] ?? [] + for attributes in attributesList { + guard let service = attributes[kSecAttrService as String] as? String else { continue } + guard service.contains("Storage Key") || service.contains("Safe Storage") else { continue } + append(service: service, account: baseName) + } + } + + return result + } + + private func keychainBaseNames() -> [String] { + var result: [String] = [] + var seen = Set<String>() + + func append(_ rawName: String?) { + guard let rawName else { return } + let trimmedName = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + if seen.insert(trimmedName).inserted { + result.append(trimmedName) + } + } + + append(browser.displayName) + append(browser.appURL?.deletingPathExtension().lastPathComponent) + append(browser.descriptor.appNames.first?.replacingOccurrences(of: ".app", with: "")) + + if let appURL = browser.appURL, + let bundle = Bundle(url: appURL) { + append(bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + append(bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + } + + for name in Array(result) { + if name.hasPrefix("Google ") { + append(String(name.dropFirst("Google ".count))) + } + if name.hasSuffix(" Browser") { + append(String(name.dropLast(" Browser".count))) + } + } + + switch browser.descriptor.id { + case "google-chrome": + append("Chrome") + case "chromium": + append("Chromium") + case "brave": + append("Brave") + case "helium": + append("Helium") + default: + break + } + + return result + } + + private func readPasswordData(item: ChromiumCookieKeychainItem) -> KeychainLookupResult { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: item.service, + kSecAttrAccount: item.account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + guard status == errSecSuccess else { + return .failure(status) + } + guard let passwordData = rawResult as? Data else { + return .failure(errSecDecode) + } + return .success(passwordData) + } + + private func chromiumVersionPrefix(in encryptedValue: Data) -> Data? { + for prefix in [Data("v10".utf8), Data("v11".utf8)] where encryptedValue.starts(with: prefix) { + return prefix + } + return nil + } + + private func deriveKey(from passwordData: Data) -> Data? { + let salt = Data("saltysalt".utf8) + var derivedKey = Data(count: kCCKeySizeAES128) + + let status = derivedKey.withUnsafeMutableBytes { derivedBytes in + passwordData.withUnsafeBytes { passwordBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes.baseAddress?.assumingMemoryBound(to: Int8.self), + passwordData.count, + saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + derivedBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + kCCKeySizeAES128 + ) + } + } + } + + guard status == kCCSuccess else { return nil } + return derivedKey + } + + private func decrypt(ciphertext: Data, key: Data) -> Data? { + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var plaintext = Data(count: ciphertext.count + kCCBlockSizeAES128) + var plaintextLength = 0 + let plaintextCapacity = plaintext.count + + let status = plaintext.withUnsafeMutableBytes { plaintextBytes in + ciphertext.withUnsafeBytes { ciphertextBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + ciphertextBytes.baseAddress, + ciphertext.count, + plaintextBytes.baseAddress, + plaintextCapacity, + &plaintextLength + ) + } + } + } + } + + guard status == kCCSuccess else { return nil } + plaintext.removeSubrange(plaintextLength...) + return plaintext + } + + private func decodePlaintext(_ plaintext: Data, host: String) -> String? { + if let value = String(data: plaintext, encoding: .utf8) { + return value + } + + let hostDigest = Data(SHA256.hash(data: Data(host.utf8))) + if plaintext.starts(with: hostDigest) { + return String(data: plaintext.dropFirst(hostDigest.count), encoding: .utf8) + } + + return nil + } +} +#else +private final class ChromiumCookieDecryptor { + init(browser: InstalledBrowserCandidate) {} + + func decryptCookieValue(encryptedValue: Data, host: String) -> String? { nil } + + func warningMessage(browserName: String, skippedCount: Int) -> String? { + guard skippedCount > 0 else { return nil } + return String( + format: String( + localized: "browser.import.warning.encryptedCookiesSkipped", + defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." + ), + skippedCount + ) + } +} +#endif + enum BrowserDataImporter { private struct CookieImportResult { var importedCount: Int = 0 @@ -5906,29 +7228,83 @@ enum BrowserDataImporter { static func importData( from browser: InstalledBrowserCandidate, + plan: RealizedBrowserImportExecutionPlan, scope: BrowserImportScope, domainFilters: [String] ) async -> BrowserImportOutcome { - var cookieResult = CookieImportResult() - if scope.includesCookies { - cookieResult = await importCookies(from: browser, domainFilters: domainFilters) + var outcomeEntries: [BrowserImportOutcomeEntry] = [] + var warnings: [String] = [] + var seenWarnings = Set<String>() + + for entry in plan.entries { + let outcomeEntry = await importEntry( + from: browser, + sourceProfiles: entry.sourceProfiles, + destinationProfileID: entry.destinationProfileID, + destinationProfileName: entry.destinationProfileName, + scope: scope, + domainFilters: domainFilters + ) + outcomeEntries.append(outcomeEntry) + for warning in outcomeEntry.warnings where seenWarnings.insert(warning).inserted { + warnings.append(warning) + } } - var historyResult = HistoryImportResult() - if scope.includesHistory { - historyResult = await importHistory(from: browser, domainFilters: domainFilters) - } - - var warnings = cookieResult.warnings - warnings.append(contentsOf: historyResult.warnings) if scope == .everything { - warnings.append("Bookmarks/settings import is not implemented yet; imported cookies and history only.") + let unavailableWarning = String( + localized: "browser.import.warning.additionalDataUnavailable", + defaultValue: "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + ) + if seenWarnings.insert(unavailableWarning).inserted { + warnings.append(unavailableWarning) + } } return BrowserImportOutcome( browserName: browser.displayName, scope: scope, domainFilters: domainFilters, + createdDestinationProfileNames: plan.createdProfiles.map(\.displayName), + entries: outcomeEntries, + warnings: warnings + ) + } + + private static func importEntry( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + destinationProfileName: String, + scope: BrowserImportScope, + domainFilters: [String] + ) async -> BrowserImportOutcomeEntry { + let resolvedSourceProfiles = sourceProfiles.isEmpty ? browser.profiles : sourceProfiles + var cookieResult = CookieImportResult() + if scope.includesCookies { + cookieResult = await importCookies( + from: browser, + sourceProfiles: resolvedSourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + + var historyResult = HistoryImportResult() + if scope.includesHistory { + historyResult = await importHistory( + from: browser, + sourceProfiles: resolvedSourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + + var warnings = cookieResult.warnings + warnings.append(contentsOf: historyResult.warnings) + return BrowserImportOutcomeEntry( + sourceProfileNames: resolvedSourceProfiles.map(\.displayName), + destinationProfileName: destinationProfileName, importedCookies: cookieResult.importedCount, skippedCookies: cookieResult.skippedCount, importedHistoryEntries: historyResult.importedCount, @@ -5938,20 +7314,35 @@ enum BrowserDataImporter { private static func importCookies( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> CookieImportResult { switch browser.family { case .firefox: - return await importFirefoxCookies(from: browser, domainFilters: domainFilters) + return await importFirefoxCookies( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) case .chromium: - return await importChromiumCookies(from: browser, domainFilters: domainFilters) + return await importChromiumCookies( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) case .webkit: if browser.descriptor.id == "safari" { return CookieImportResult( importedCount: 0, skippedCount: 0, warnings: [ - "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + String( + localized: "browser.import.warning.safariCookiesUnsupported", + defaultValue: "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + ) ] ) } @@ -5959,7 +7350,13 @@ enum BrowserDataImporter { importedCount: 0, skippedCount: 0, warnings: [ - "\(browser.displayName) cookie import is not implemented yet." + String( + format: String( + localized: "browser.import.warning.cookieImportUnsupported", + defaultValue: "%@ cookie import is not implemented yet." + ), + browser.displayName + ) ] ) } @@ -5967,28 +7364,47 @@ enum BrowserDataImporter { private static func importHistory( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> HistoryImportResult { switch browser.family { case .firefox: - return await importFirefoxHistory(from: browser, domainFilters: domainFilters) + return await importFirefoxHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) case .chromium: - return await importChromiumHistory(from: browser, domainFilters: domainFilters) + return await importChromiumHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) case .webkit: - return await importWebKitHistory(from: browser, domainFilters: domainFilters) + return await importWebKitHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) } } private static func importFirefoxCookies( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> CookieImportResult { let fileManager = FileManager.default var cookies: [HTTPCookie] = [] var warnings: [String] = [] - let databaseURLs = browser.profileURLs.map { - $0.appendingPathComponent("cookies.sqlite", isDirectory: false) + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) }.filter { fileManager.fileExists(atPath: $0.path) } for databaseURL in databaseURLs { @@ -6024,26 +7440,38 @@ enum BrowserDataImporter { } } } catch { - warnings.append("Failed reading Firefox cookies at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + warnings.append( + String( + format: String( + localized: "browser.import.warning.firefoxCookiesReadFailed", + defaultValue: "Failed reading Firefox cookies at %@: %@" + ), + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) } } let dedupedCookies = dedupeCookies(cookies) - let importedCount = await setCookiesInStore(dedupedCookies) + let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) return CookieImportResult(importedCount: importedCount, skippedCount: max(0, dedupedCookies.count - importedCount), warnings: warnings) } private static func importChromiumCookies( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> CookieImportResult { let fileManager = FileManager.default var cookies: [HTTPCookie] = [] var warnings: [String] = [] var skippedEncryptedCookies = 0 + let decryptor = ChromiumCookieDecryptor(browser: browser) - let databaseURLs = browser.profileURLs.map { - $0.appendingPathComponent("Cookies", isDirectory: false) + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("Cookies", isDirectory: false) }.filter { fileManager.fileExists(atPath: $0.path) } for databaseURL in databaseURLs { @@ -6058,15 +7486,22 @@ enum BrowserDataImporter { let path = sqliteColumnText(statement, index: 3) ?? "/" let expiresUTC = sqliteColumnInt64(statement, index: 4) let isSecure = sqliteColumnInt64(statement, index: 5) != 0 - let encryptedLength = sqliteColumnBytes(statement, index: 6) + let encryptedValue = sqliteColumnData(statement, index: 6) guard !name.isEmpty else { return } guard domainMatches(host: host, filters: domainFilters) else { return } - let usableValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - if usableValue.isEmpty && encryptedLength > 0 { - skippedEncryptedCookies += 1 - return + var usableValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if usableValue.isEmpty && !encryptedValue.isEmpty { + if let decryptedValue = decryptor.decryptCookieValue( + encryptedValue: encryptedValue, + host: host + ) { + usableValue = decryptedValue + } else { + skippedEncryptedCookies += 1 + return + } } var properties: [HTTPCookiePropertyKey: Any] = [ @@ -6086,14 +7521,27 @@ enum BrowserDataImporter { } } } catch { - warnings.append("Failed reading \(browser.displayName) cookies at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserCookiesReadFailed", + defaultValue: "Failed reading %@ cookies at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) } } let dedupedCookies = dedupeCookies(cookies) - let importedCount = await setCookiesInStore(dedupedCookies) - if skippedEncryptedCookies > 0 { - warnings.append("Skipped \(skippedEncryptedCookies) encrypted cookies that require Keychain decryption.") + let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) + if let warning = decryptor.warningMessage( + browserName: browser.displayName, + skippedCount: skippedEncryptedCookies + ) { + warnings.append(warning) } let skippedCount = max(0, dedupedCookies.count - importedCount) + skippedEncryptedCookies return CookieImportResult(importedCount: importedCount, skippedCount: skippedCount, warnings: warnings) @@ -6101,14 +7549,16 @@ enum BrowserDataImporter { private static func importFirefoxHistory( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> HistoryImportResult { let fileManager = FileManager.default var rows: [HistoryRow] = [] var warnings: [String] = [] - let databaseURLs = browser.profileURLs.map { - $0.appendingPathComponent("places.sqlite", isDirectory: false) + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("places.sqlite", isDirectory: false) }.filter { fileManager.fileExists(atPath: $0.path) } for databaseURL in databaseURLs { @@ -6136,24 +7586,35 @@ enum BrowserDataImporter { rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { - warnings.append("Failed reading Firefox history at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + warnings.append( + String( + format: String( + localized: "browser.import.warning.firefoxHistoryReadFailed", + defaultValue: "Failed reading Firefox history at %@: %@" + ), + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) } } - let importedCount = await mergeHistoryRows(rows) + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) return HistoryImportResult(importedCount: importedCount, warnings: warnings) } private static func importChromiumHistory( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> HistoryImportResult { let fileManager = FileManager.default var rows: [HistoryRow] = [] var warnings: [String] = [] - let databaseURLs = browser.profileURLs.map { - $0.appendingPathComponent("History", isDirectory: false) + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("History", isDirectory: false) }.filter { fileManager.fileExists(atPath: $0.path) } for databaseURL in databaseURLs { @@ -6181,25 +7642,36 @@ enum BrowserDataImporter { rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { - warnings.append("Failed reading \(browser.displayName) history at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserHistoryReadFailed", + defaultValue: "Failed reading %@ history at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) } } - let importedCount = await mergeHistoryRows(rows) + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) return HistoryImportResult(importedCount: importedCount, warnings: warnings) } private static func importWebKitHistory( from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, domainFilters: [String] ) async -> HistoryImportResult { let fileManager = FileManager.default var rows: [HistoryRow] = [] var warnings: [String] = [] - var candidateDatabaseURLs: [URL] = [] - if let dataRootURL = browser.dataRootURL { - candidateDatabaseURLs.append(dataRootURL.appendingPathComponent("History.db", isDirectory: false)) + var candidateDatabaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("History.db", isDirectory: false) } if browser.descriptor.id == "safari" { candidateDatabaseURLs.append( @@ -6212,7 +7684,18 @@ enum BrowserDataImporter { let uniqueURLs = dedupedCanonicalURLs(candidateDatabaseURLs).filter { fileManager.fileExists(atPath: $0.path) } if uniqueURLs.isEmpty { - return HistoryImportResult(importedCount: 0, warnings: ["No history database found for \(browser.displayName)."]) + return HistoryImportResult( + importedCount: 0, + warnings: [ + String( + format: String( + localized: "browser.import.warning.noHistoryDatabase", + defaultValue: "No history database found for %@." + ), + browser.displayName + ) + ] + ) } for databaseURL in uniqueURLs { @@ -6245,15 +7728,25 @@ enum BrowserDataImporter { rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { - warnings.append("Failed reading \(browser.displayName) history at \(databaseURL.lastPathComponent): \(error.localizedDescription)") + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserHistoryReadFailed", + defaultValue: "Failed reading %@ history at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) } } - let importedCount = await mergeHistoryRows(rows) + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) return HistoryImportResult(importedCount: importedCount, warnings: warnings) } - private static func mergeHistoryRows(_ rows: [HistoryRow]) async -> Int { + private static func mergeHistoryRows(_ rows: [HistoryRow], destinationProfileID: UUID) async -> Int { guard !rows.isEmpty else { return 0 } return await MainActor.run { let entries = rows.compactMap { row -> BrowserHistoryStore.Entry? in @@ -6271,25 +7764,33 @@ enum BrowserDataImporter { visitCount: max(1, row.visitCount) ) } - return BrowserHistoryStore.shared.mergeImportedEntries(entries) + let historyStore = BrowserProfileStore.shared.historyStore(for: destinationProfileID) + return historyStore.mergeImportedEntries(entries) } } - private static func setCookiesInStore(_ cookies: [HTTPCookie]) async -> Int { + private static func setCookiesInStore(_ cookies: [HTTPCookie], destinationProfileID: UUID) async -> Int { guard !cookies.isEmpty else { return 0 } - let store = WKWebsiteDataStore.default().httpCookieStore + let store = await MainActor.run { + BrowserProfileStore.shared.websiteDataStore(for: destinationProfileID).httpCookieStore + } var importedCount = 0 for cookie in cookies { - await withCheckedContinuation { continuation in - store.setCookie(cookie) { - importedCount += 1 - continuation.resume() - } - } + await setCookie(cookie, in: store) + importedCount += 1 } return importedCount } + @MainActor + private static func setCookie(_ cookie: HTTPCookie, in store: WKHTTPCookieStore) async { + await withCheckedContinuation { continuation in + store.setCookie(cookie) { + continuation.resume() + } + } + } + private static func dedupeCookies(_ cookies: [HTTPCookie]) -> [HTTPCookie] { var dedupedByKey: [String: HTTPCookie] = [:] for cookie in cookies { @@ -6422,6 +7923,14 @@ enum BrowserDataImporter { Int(sqlite3_column_bytes(statement, index)) } + private static func sqliteColumnData(_ statement: OpaquePointer, index: Int32) -> Data { + let length = Int(sqlite3_column_bytes(statement, index)) + guard length > 0, let pointer = sqlite3_column_blob(statement, index) else { + return Data() + } + return Data(bytes: pointer, count: length) + } + private static func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { var seen = Set<String>() var result: [URL] = [] @@ -6435,6 +7944,96 @@ enum BrowserDataImporter { } } +#if DEBUG +enum BrowserImportUITestFixtureLoader { + private struct BrowserFixture: Decodable { + let browserName: String + let profiles: [String] + } + + static func browsers(from environment: [String: String]) -> [InstalledBrowserCandidate]? { + guard let rawFixture = environment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"], + let data = rawFixture.data(using: .utf8), + let fixture = try? JSONDecoder().decode(BrowserFixture.self, from: data) else { + return nil + } + + let resolvedProfiles = fixture.profiles.enumerated().map { index, name in + InstalledBrowserProfile( + displayName: name, + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-browser-import") + .appendingPathComponent( + fixture.browserName + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + ) + .appendingPathComponent("\(index)-\(name)") + .standardizedFileURL, + isDefault: index == 0 + ) + } + + let descriptor = InstalledBrowserDetector.allBrowserDescriptors.first(where: { + $0.displayName == fixture.browserName + }) ?? BrowserImportBrowserDescriptor( + id: fixture.browserName + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")), + displayName: fixture.browserName, + family: .chromium, + tier: 0, + bundleIdentifiers: [], + appNames: [], + dataRootRelativePaths: [], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: false + ) + + return [ + InstalledBrowserCandidate( + descriptor: descriptor, + resolvedFamily: descriptor.family, + homeDirectoryURL: FileManager.default.homeDirectoryForCurrentUser, + appURL: nil, + dataRootURL: nil, + profiles: resolvedProfiles, + detectionSignals: ["ui-test-fixture"], + detectionScore: Int.max + ) + ] + } + + static func destinationProfiles(from environment: [String: String]) -> [BrowserProfileDefinition]? { + guard let rawDestinations = environment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"], + let data = rawDestinations.data(using: .utf8), + let names = try? JSONDecoder().decode([String].self, from: data), + !names.isEmpty else { + return nil + } + + return names.enumerated().map { index, rawName in + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + if name.localizedCaseInsensitiveCompare("Default") == .orderedSame { + return BrowserProfileDefinition( + id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, + displayName: "Default", + createdAt: .distantPast, + isBuiltInDefault: true + ) + } + return BrowserProfileDefinition( + id: UUID(), + displayName: name.isEmpty ? "Profile \(index + 1)" : name, + createdAt: .distantPast, + isBuiltInDefault: false + ) + } + } +} +#endif + @MainActor final class BrowserDataImportCoordinator { static let shared = BrowserDataImportCoordinator() @@ -6443,40 +8042,94 @@ final class BrowserDataImportCoordinator { private init() {} - func presentImportDialog() { - presentImportDialog(prefilledBrowsers: nil) + func presentImportDialog(defaultDestinationProfileID: UUID? = nil) { + presentImportDialog(prefilledBrowsers: nil, defaultDestinationProfileID: defaultDestinationProfileID) } private struct ImportSelection { let browser: InstalledBrowserCandidate + let executionPlan: BrowserImportExecutionPlan let scope: BrowserImportScope let domainFilters: [String] } - private func presentImportDialog(prefilledBrowsers: [InstalledBrowserCandidate]?) { + private func presentImportDialog( + prefilledBrowsers: [InstalledBrowserCandidate]?, + defaultDestinationProfileID: UUID? + ) { guard !importInProgress else { return } +#if DEBUG + let environment = ProcessInfo.processInfo.environment + let fixtureBrowsers = BrowserImportUITestFixtureLoader.browsers(from: environment) + let fixtureDestinationProfiles = BrowserImportUITestFixtureLoader.destinationProfiles(from: environment) + let browsers = prefilledBrowsers ?? fixtureBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() +#else + let fixtureDestinationProfiles: [BrowserProfileDefinition]? = nil let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() +#endif guard !browsers.isEmpty else { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "No importable browsers found" - alert.informativeText = "cmux could not find browser profiles to import from on this Mac." - alert.addButton(withTitle: "OK") + alert.messageText = String( + localized: "browser.import.noBrowsers.title", + defaultValue: "No importable browsers found" + ) + alert.informativeText = String( + localized: "browser.import.noBrowsers.message", + defaultValue: "cmux could not find browser profiles to import from on this Mac." + ) + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) alert.runModal() return } - guard let selection = promptForSelection(browsers: browsers) else { return } + guard let selection = promptForSelection( + browsers: browsers, + destinationProfiles: fixtureDestinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) else { return } + +#if DEBUG + if captureSelectionIfRequested(selection, destinationProfiles: fixtureDestinationProfiles) { + return + } +#endif + let realizedPlan: RealizedBrowserImportExecutionPlan + do { + realizedPlan = try BrowserImportPlanResolver.realize(plan: selection.executionPlan) + } catch { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.error.title", + defaultValue: "Import could not start" + ) + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } importInProgress = true let progressWindow = showProgressWindow( - title: "Importing Browser Data", - message: "Importing \(selection.scope.displayName.lowercased()) from \(selection.browser.displayName)…" + title: String( + localized: "browser.import.progress.title", + defaultValue: "Importing Browser Data" + ), + message: String( + format: String( + localized: "browser.import.progress.message", + defaultValue: "Importing %@ from %@…" + ), + selection.scope.displayName.lowercased(), + selection.browser.displayName + ) ) Task.detached(priority: .userInitiated) { let outcome = await BrowserDataImporter.importData( from: selection.browser, + plan: realizedPlan, scope: selection.scope, domainFilters: selection.domainFilters ) @@ -6489,53 +8142,165 @@ final class BrowserDataImportCoordinator { } } - private func promptForSelection(browsers: [InstalledBrowserCandidate]) -> ImportSelection? { + private func promptForSelection( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]?, + defaultDestinationProfileID: UUID? + ) -> ImportSelection? { guard !browsers.isEmpty else { return nil } - let wizard = ImportWizardWindowController(browsers: browsers) + let wizard = ImportWizardWindowController( + browsers: browsers, + destinationProfiles: destinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) return wizard.runModal() } +#if DEBUG + private struct CapturedImportSelection: Encodable { + struct Entry: Encodable { + let sourceProfiles: [String] + let destinationKind: String + let destinationName: String + } + + let browserName: String + let mode: String + let scope: String + let domainFilters: [String] + let entries: [Entry] + } + + private func captureSelectionIfRequested( + _ selection: ImportSelection, + destinationProfiles: [BrowserProfileDefinition]? + ) -> Bool { + let environment = ProcessInfo.processInfo.environment + guard environment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] == "capture-only" else { return false } + guard let path = environment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"], !path.isEmpty else { + return true + } + + let availableDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let payload = CapturedImportSelection( + browserName: selection.browser.displayName, + mode: captureModeName(selection.executionPlan.mode), + scope: selection.scope.rawValue, + domainFilters: selection.domainFilters, + entries: selection.executionPlan.entries.map { entry in + let destinationKind: String + let destinationName: String + switch entry.destination { + case .existing(let id): + destinationKind = "existing" + destinationName = availableDestinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + destinationKind = "create" + destinationName = name + } + return CapturedImportSelection.Entry( + sourceProfiles: entry.sourceProfiles.map(\.displayName), + destinationKind: destinationKind, + destinationName: destinationName + ) + } + ) + + guard let data = try? JSONEncoder().encode(payload) else { return true } + let url = URL(fileURLWithPath: path) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try? data.write(to: url) + return true + } + + private func captureModeName(_ mode: BrowserImportDestinationMode) -> String { + switch mode { + case .singleDestination: + return "singleDestination" + case .separateProfiles: + return "separateProfiles" + case .mergeIntoOne: + return "mergeIntoOne" + } + } +#endif + @MainActor private final class ImportWizardWindowController: NSObject, @preconcurrency NSWindowDelegate { + private final class FlippedDocumentView: NSView { + override var isFlipped: Bool { true } + } + private enum Step { case source + case sourceProfiles case dataTypes } private let browsers: [InstalledBrowserCandidate] + private let destinationProfiles: [BrowserProfileDefinition] + private let initialDestinationProfileID: UUID private var step: Step = .source private var didFinishModal = false private(set) var selection: ImportSelection? + private var selectedSourceProfileIDsByBrowserID: [String: Set<String>] = [:] + private var sourceProfileCheckboxes: [NSButton] = [] + private var destinationMode: BrowserImportDestinationMode = .singleDestination + private var separateExecutionEntries: [BrowserImportExecutionEntry] = [] + private var separateDestinationOptionsByEntryIndex: [Int: [BrowserImportDestinationRequest]] = [:] + private var mergeDestinationProfileID: UUID private let panel: NSPanel private let stepLabel = NSTextField(labelWithString: "") private let sourcePopup = NSPopUpButton(frame: .zero, pullsDown: false) private let sourceContainer = NSStackView() + private let sourceProfilesContainer = NSStackView() + private let sourceProfilesList = NSStackView() + private let sourceProfilesDocumentView = FlippedDocumentView(frame: .zero) + private let sourceProfilesEmptyLabel = NSTextField(wrappingLabelWithString: "") + private let sourceProfilesHelpLabel = NSTextField(labelWithString: "") + private let sourceProfilesScrollView = NSScrollView() private let dataTypesContainer = NSStackView() private let validationLabel = NSTextField(labelWithString: "") + private let destinationModeContainer = NSStackView() + private let separateProfilesRadio = NSButton(radioButtonWithTitle: "", target: nil, action: nil) + private let mergeProfilesRadio = NSButton(radioButtonWithTitle: "", target: nil, action: nil) + private let separateDestinationRows = NSStackView() + private let mergeDestinationRow = NSStackView() + private let mergeDestinationPopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let destinationHelpLabel = NSTextField(wrappingLabelWithString: "") - private let cookiesCheckbox = NSButton( - checkboxWithTitle: "Cookies (site sign-ins)", - target: nil, - action: nil - ) - private let historyCheckbox = NSButton( - checkboxWithTitle: "History (visited pages)", - target: nil, - action: nil - ) + private let cookiesCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let historyCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) private let domainField = NSTextField(frame: .zero) - private let backButton = NSButton(title: "Back", target: nil, action: nil) - private let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) - private let primaryButton = NSButton(title: "Next", target: nil, action: nil) + private let backButton = NSButton(title: "", target: nil, action: nil) + private let cancelButton = NSButton(title: "", target: nil, action: nil) + private let primaryButton = NSButton(title: "", target: nil, action: nil) - init(browsers: [InstalledBrowserCandidate]) { + init( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]?, + defaultDestinationProfileID: UUID? + ) { + let resolvedDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let fallbackDestinationProfileID = resolvedDestinationProfiles.first?.id + ?? BrowserProfileStore.shared.effectiveLastUsedProfileID self.browsers = browsers + self.destinationProfiles = resolvedDestinationProfiles + self.initialDestinationProfileID = defaultDestinationProfileID + .flatMap { candidateID in resolvedDestinationProfiles.first(where: { $0.id == candidateID })?.id } + ?? fallbackDestinationProfileID + self.mergeDestinationProfileID = self.initialDestinationProfileID self.panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 560, height: 300), + contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -6565,8 +8330,14 @@ final class BrowserDataImportCoordinator { @objc private func handleBack() { - guard step == .dataTypes else { return } - step = .source + switch step { + case .source: + return + case .sourceProfiles: + step = .source + case .dataTypes: + step = .sourceProfiles + } validationLabel.isHidden = true updateStepUI() } @@ -6580,6 +8351,22 @@ final class BrowserDataImportCoordinator { private func handlePrimary() { switch step { case .source: + step = .sourceProfiles + validationLabel.isHidden = true + refreshSourceProfilesList() + updateStepUI() + case .sourceProfiles: + let selectedSourceProfiles = selectedSourceProfiles() + guard !selectedSourceProfiles.isEmpty else { + validationLabel.stringValue = String( + localized: "browser.import.validation.sourceProfiles", + defaultValue: "Choose at least one source profile to import." + ) + validationLabel.isHidden = false + return + } + + resetStep3State() step = .dataTypes validationLabel.isHidden = true updateStepUI() @@ -6591,16 +8378,19 @@ final class BrowserDataImportCoordinator { includeHistory: includeHistory, includeAdditionalData: false ) else { - validationLabel.stringValue = "Select Cookies, History, or both before starting import." + validationLabel.stringValue = String( + localized: "browser.import.validation.scope", + defaultValue: "Select Cookies, History, or both before starting import." + ) validationLabel.isHidden = false return } - let selectedIndex = max(0, min(sourcePopup.indexOfSelectedItem, browsers.count - 1)) - let selectedBrowser = browsers[selectedIndex] + let selectedBrowser = selectedBrowser() let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) selection = ImportSelection( browser: selectedBrowser, + executionPlan: currentExecutionPlan(), scope: scope, domainFilters: domainFilters ) @@ -6608,44 +8398,105 @@ final class BrowserDataImportCoordinator { } } + @objc + private func handleSourceChanged() { + validationLabel.isHidden = true + refreshSourceProfilesList() + updateStepUI() + } + + @objc + private func handleSourceProfileToggled(_ sender: NSButton) { + guard let profileID = sender.identifier?.rawValue else { return } + let browserID = selectedBrowser().id + var selectedIDs = storedSelectedSourceProfileIDs(for: selectedBrowser()) + if sender.state == .on { + selectedIDs.insert(profileID) + } else { + selectedIDs.remove(profileID) + } + selectedSourceProfileIDsByBrowserID[browserID] = selectedIDs + validationLabel.isHidden = true + } + + @objc + private func handleDestinationModeChanged(_ sender: NSButton) { + let selectedSourceProfiles = selectedSourceProfiles() + guard selectedSourceProfiles.count > 1 else { return } + destinationMode = sender == separateProfilesRadio ? .separateProfiles : .mergeIntoOne + rebuildStep3DestinationUI() + } + + @objc + private func handleMergeDestinationChanged(_ sender: NSPopUpButton) { + let selectedIndex = max(0, min(sender.indexOfSelectedItem, destinationProfiles.count - 1)) + guard destinationProfiles.indices.contains(selectedIndex) else { return } + mergeDestinationProfileID = destinationProfiles[selectedIndex].id + validationLabel.isHidden = true + } + + @objc + private func handleSeparateDestinationChanged(_ sender: NSPopUpButton) { + let entryIndex = sender.tag + guard separateExecutionEntries.indices.contains(entryIndex), + let options = separateDestinationOptionsByEntryIndex[entryIndex], + options.indices.contains(sender.indexOfSelectedItem) else { + return + } + separateExecutionEntries[entryIndex].destination = options[sender.indexOfSelectedItem] + validationLabel.isHidden = true + } + private func setupUI() { - panel.title = "Import Browser Data" + panel.title = String( + localized: "browser.import.title", + defaultValue: "Import Browser Data" + ) panel.isReleasedWhenClosed = false panel.delegate = self panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 300)) + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 620, height: 420)) contentView.translatesAutoresizingMaskIntoConstraints = false panel.contentView = contentView - let titleLabel = NSTextField(labelWithString: "Import Browser Data") + let titleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.title", + defaultValue: "Import Browser Data" + ) + ) titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .semibold) stepLabel.font = NSFont.systemFont(ofSize: 15, weight: .medium) stepLabel.textColor = .secondaryLabelColor setupSourceContainer() + setupSourceProfilesContainer() setupDataTypesContainer() validationLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) validationLabel.textColor = .systemRed validationLabel.isHidden = true validationLabel.lineBreakMode = .byWordWrapping - validationLabel.maximumNumberOfLines = 2 + validationLabel.maximumNumberOfLines = 3 backButton.target = self backButton.action = #selector(handleBack) backButton.bezelStyle = .rounded + backButton.title = String(localized: "browser.import.back", defaultValue: "Back") cancelButton.target = self cancelButton.action = #selector(handleCancel) cancelButton.bezelStyle = .rounded + cancelButton.title = String(localized: "common.cancel", defaultValue: "Cancel") cancelButton.keyEquivalent = "\u{1b}" primaryButton.target = self primaryButton.action = #selector(handlePrimary) primaryButton.bezelStyle = .rounded + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") primaryButton.keyEquivalent = "\r" let buttonSpacer = NSView(frame: .zero) @@ -6658,9 +8509,14 @@ final class BrowserDataImportCoordinator { buttonSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) buttonSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let contentStack = NSStackView( - views: [titleLabel, stepLabel, sourceContainer, dataTypesContainer, validationLabel] - ) + let contentStack = NSStackView(views: [ + titleLabel, + stepLabel, + sourceContainer, + sourceProfilesContainer, + dataTypesContainer, + validationLabel, + ]) contentStack.orientation = .vertical contentStack.spacing = 10 contentStack.alignment = .leading @@ -6687,8 +8543,12 @@ final class BrowserDataImportCoordinator { sourcePopup.addItem(withTitle: browser.displayName) } sourcePopup.selectItem(at: 0) + sourcePopup.target = self + sourcePopup.action = #selector(handleSourceChanged) - let sourceLabel = NSTextField(labelWithString: "Source") + let sourceLabel = NSTextField( + labelWithString: String(localized: "browser.import.source", defaultValue: "Source") + ) sourceLabel.alignment = .right sourceLabel.frame.size.width = 80 @@ -6712,14 +8572,126 @@ final class BrowserDataImportCoordinator { sourceContainer.addArrangedSubview(detectedLabel) } + private func setupSourceProfilesContainer() { + let sourceProfilesTitle = NSTextField( + labelWithString: String( + localized: "browser.import.sourceProfiles", + defaultValue: "Source Profiles" + ) + ) + sourceProfilesTitle.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + + sourceProfilesList.orientation = .vertical + sourceProfilesList.spacing = 6 + sourceProfilesList.alignment = .leading + sourceProfilesList.translatesAutoresizingMaskIntoConstraints = false + + sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 13) + sourceProfilesEmptyLabel.textColor = .secondaryLabelColor + sourceProfilesEmptyLabel.maximumNumberOfLines = 0 + sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 520 + + sourceProfilesDocumentView.frame = NSRect(x: 0, y: 0, width: 1, height: 1) + sourceProfilesDocumentView.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesDocumentView.addSubview(sourceProfilesList) + NSLayoutConstraint.activate([ + sourceProfilesList.topAnchor.constraint(equalTo: sourceProfilesDocumentView.topAnchor), + sourceProfilesList.leadingAnchor.constraint(equalTo: sourceProfilesDocumentView.leadingAnchor), + sourceProfilesList.trailingAnchor.constraint(equalTo: sourceProfilesDocumentView.trailingAnchor), + sourceProfilesList.bottomAnchor.constraint(equalTo: sourceProfilesDocumentView.bottomAnchor), + sourceProfilesList.widthAnchor.constraint(equalTo: sourceProfilesDocumentView.widthAnchor), + ]) + + sourceProfilesScrollView.drawsBackground = false + sourceProfilesScrollView.borderType = .bezelBorder + sourceProfilesScrollView.hasVerticalScroller = true + sourceProfilesScrollView.documentView = sourceProfilesDocumentView + sourceProfilesScrollView.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true + sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 180).isActive = true + + sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 12) + sourceProfilesHelpLabel.textColor = .secondaryLabelColor + sourceProfilesHelpLabel.maximumNumberOfLines = 2 + sourceProfilesHelpLabel.lineBreakMode = .byWordWrapping + sourceProfilesHelpLabel.stringValue = String( + localized: "browser.import.sourceProfiles.help", + defaultValue: "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + ) + + sourceProfilesContainer.orientation = .vertical + sourceProfilesContainer.spacing = 10 + sourceProfilesContainer.alignment = .leading + sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle) + sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView) + sourceProfilesContainer.addArrangedSubview(sourceProfilesHelpLabel) + sourceProfilesContainer.setHuggingPriority(.defaultLow, for: .vertical) + sourceProfilesContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + } + private func setupDataTypesContainer() { cookiesCheckbox.state = .on historyCheckbox.state = .on + cookiesCheckbox.title = String( + localized: "browser.import.cookies", + defaultValue: "Cookies (site sign-ins)" + ) + historyCheckbox.title = String( + localized: "browser.import.history", + defaultValue: "History (visited pages)" + ) + separateProfilesRadio.title = String( + localized: "browser.import.destinationMode.separate", + defaultValue: "Keep profiles separate" + ) + mergeProfilesRadio.title = String( + localized: "browser.import.destinationMode.merge", + defaultValue: "Merge all into one cmux profile" + ) + separateProfilesRadio.target = self + separateProfilesRadio.action = #selector(handleDestinationModeChanged(_:)) + mergeProfilesRadio.target = self + mergeProfilesRadio.action = #selector(handleDestinationModeChanged(_:)) - domainField.placeholderString = "Optional domains only (e.g. github.com, openai.com)" + destinationModeContainer.orientation = .vertical + destinationModeContainer.spacing = 6 + destinationModeContainer.alignment = .leading + destinationModeContainer.addArrangedSubview(separateProfilesRadio) + destinationModeContainer.addArrangedSubview(mergeProfilesRadio) + + mergeDestinationPopup.target = self + mergeDestinationPopup.action = #selector(handleMergeDestinationChanged(_:)) + + separateDestinationRows.orientation = .vertical + separateDestinationRows.spacing = 8 + separateDestinationRows.alignment = .leading + + mergeDestinationRow.orientation = .horizontal + mergeDestinationRow.spacing = 8 + mergeDestinationRow.alignment = .centerY + + destinationHelpLabel.font = NSFont.systemFont(ofSize: 12) + destinationHelpLabel.textColor = .secondaryLabelColor + destinationHelpLabel.maximumNumberOfLines = 3 + destinationHelpLabel.preferredMaxLayoutWidth = 540 + + domainField.placeholderString = String( + localized: "browser.import.domain.placeholder", + defaultValue: "Optional domains only (e.g. github.com, openai.com)" + ) domainField.stringValue = "" - let domainLabel = NSTextField(labelWithString: "Limit to") + let destinationTitleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.destination.cmux", + defaultValue: "cmux destination" + ) + ) + destinationTitleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + + let domainLabel = NSTextField( + labelWithString: String(localized: "browser.import.domain", defaultValue: "Limit to") + ) domainLabel.alignment = .right domainLabel.frame.size.width = 80 @@ -6729,16 +8701,24 @@ final class BrowserDataImportCoordinator { domainRow.alignment = .centerY let noteLabel = NSTextField( - wrappingLabelWithString: "Bookmarks, settings, and extensions import are not available yet." + wrappingLabelWithString: String( + localized: "browser.import.additionalData.note", + defaultValue: "Bookmarks, settings, and extensions import are not available yet." + ) ) noteLabel.font = NSFont.systemFont(ofSize: 12) noteLabel.textColor = .secondaryLabelColor noteLabel.maximumNumberOfLines = 2 - noteLabel.preferredMaxLayoutWidth = 500 + noteLabel.preferredMaxLayoutWidth = 540 dataTypesContainer.orientation = .vertical dataTypesContainer.spacing = 8 dataTypesContainer.alignment = .leading + dataTypesContainer.addArrangedSubview(destinationTitleLabel) + dataTypesContainer.addArrangedSubview(destinationModeContainer) + dataTypesContainer.addArrangedSubview(separateDestinationRows) + dataTypesContainer.addArrangedSubview(mergeDestinationRow) + dataTypesContainer.addArrangedSubview(destinationHelpLabel) dataTypesContainer.addArrangedSubview(cookiesCheckbox) dataTypesContainer.addArrangedSubview(historyCheckbox) dataTypesContainer.addArrangedSubview(domainRow) @@ -6747,24 +8727,55 @@ final class BrowserDataImportCoordinator { private func configureInitialState() { step = .source + refreshSourceProfilesList() updateStepUI() } private func updateStepUI() { switch step { case .source: - stepLabel.stringValue = "Step 1 of 2: Choose the browser to import from." + stepLabel.stringValue = String( + localized: "browser.import.step.source", + defaultValue: "Step 1 of 3: Choose the browser to import from." + ) sourceContainer.isHidden = false + sourceProfilesContainer.isHidden = true dataTypesContainer.isHidden = true backButton.isHidden = true - primaryButton.title = "Next" - case .dataTypes: - let selectedBrowserName = selectedBrowser().displayName - stepLabel.stringValue = "Step 2 of 2: Choose what to import from \(selectedBrowserName)." + primaryButton.isEnabled = true + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + case .sourceProfiles: + stepLabel.stringValue = String( + format: String( + localized: "browser.import.step.sourceProfiles", + defaultValue: "Step 2 of 3: Choose source profiles from %@." + ), + selectedBrowser().displayName + ) sourceContainer.isHidden = true + sourceProfilesContainer.isHidden = false + dataTypesContainer.isHidden = true + backButton.isHidden = false + primaryButton.isEnabled = !selectedBrowser().profiles.isEmpty + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + case .dataTypes: + rebuildStep3DestinationUI() + stepLabel.stringValue = String( + format: String( + localized: "browser.import.step.dataTypes", + defaultValue: "Step 3 of 3: Choose what to import from %@ and where to put it." + ), + selectedBrowser().displayName + ) + sourceContainer.isHidden = true + sourceProfilesContainer.isHidden = true dataTypesContainer.isHidden = false backButton.isHidden = false - primaryButton.title = "Start Import" + primaryButton.isEnabled = true + primaryButton.title = String( + localized: "browser.import.start", + defaultValue: "Start Import" + ) } } @@ -6773,6 +8784,311 @@ final class BrowserDataImportCoordinator { return browsers[selectedIndex] } + private func refreshSourceProfilesList() { + let browser = selectedBrowser() + let selectedIDs = storedSelectedSourceProfileIDs(for: browser) + + sourceProfileCheckboxes.removeAll() + for arrangedSubview in sourceProfilesList.arrangedSubviews { + sourceProfilesList.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + if browser.profiles.isEmpty { + sourceProfilesEmptyLabel.stringValue = String( + format: String( + localized: "browser.import.sourceProfiles.empty", + defaultValue: "No source profiles detected for %@." + ), + browser.displayName + ) + sourceProfilesList.addArrangedSubview(sourceProfilesEmptyLabel) + return + } + + for profile in browser.profiles { + let checkbox = NSButton( + checkboxWithTitle: profile.displayName, + target: self, + action: #selector(handleSourceProfileToggled(_:)) + ) + checkbox.identifier = NSUserInterfaceItemIdentifier(profile.id) + checkbox.state = selectedIDs.contains(profile.id) ? .on : .off + checkbox.lineBreakMode = .byTruncatingTail + sourceProfilesList.addArrangedSubview(checkbox) + sourceProfileCheckboxes.append(checkbox) + } + } + + private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set<String> { + if let existing = selectedSourceProfileIDsByBrowserID[browser.id] { + return existing + } + let defaultSelection = defaultSelectedSourceProfileIDs(for: browser) + selectedSourceProfileIDsByBrowserID[browser.id] = defaultSelection + return defaultSelection + } + + private func defaultSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set<String> { + if let defaultProfile = browser.profiles.first(where: \.isDefault) { + return [defaultProfile.id] + } + if let firstProfile = browser.profiles.first { + return [firstProfile.id] + } + return [] + } + + private func selectedSourceProfiles() -> [InstalledBrowserProfile] { + let browser = selectedBrowser() + let selectedIDs = storedSelectedSourceProfileIDs(for: browser) + return browser.profiles.filter { selectedIDs.contains($0.id) } + } + + private func resetStep3State() { + let selectedProfiles = selectedSourceProfiles() + let defaultPlan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: selectedProfiles, + destinationProfiles: destinationProfiles, + preferredSingleDestinationProfileID: initialDestinationProfileID + ) + destinationMode = defaultPlan.mode + separateExecutionEntries = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: selectedProfiles, + destinationProfiles: destinationProfiles + ).entries + if let initialDestination = defaultPlan.entries.first.flatMap(destinationProfileID(for:)) { + mergeDestinationProfileID = initialDestination + } else { + mergeDestinationProfileID = initialDestinationProfileID + } + rebuildStep3DestinationUI() + } + + private func currentExecutionPlan() -> BrowserImportExecutionPlan { + let selectedProfiles = selectedSourceProfiles() + guard !selectedProfiles.isEmpty else { + return BrowserImportExecutionPlan(mode: .singleDestination, entries: []) + } + + guard selectedProfiles.count > 1 else { + return BrowserImportExecutionPlan( + mode: .singleDestination, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: selectedProfiles, + destination: .existing(resolvedMergeDestinationProfileID()) + ) + ] + ) + } + + switch destinationMode { + case .separateProfiles: + let entriesBySourceID = Dictionary( + uniqueKeysWithValues: separateExecutionEntries.compactMap { entry in + entry.sourceProfiles.first.map { ($0.id, entry.destination) } + } + ) + let entries = selectedProfiles.map { profile in + BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: entriesBySourceID[profile.id] ?? defaultSeparateDestinationRequest(for: profile) + ) + } + return BrowserImportExecutionPlan(mode: .separateProfiles, entries: entries) + case .singleDestination, .mergeIntoOne: + return BrowserImportExecutionPlan( + mode: .mergeIntoOne, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: selectedProfiles, + destination: .existing(resolvedMergeDestinationProfileID()) + ) + ] + ) + } + } + + private func rebuildStep3DestinationUI() { + let plan = currentExecutionPlan() + let presentation = BrowserImportStep3Presentation(plan: plan) + destinationModeContainer.isHidden = !presentation.showsModeSelector + separateDestinationRows.isHidden = !presentation.showsSeparateRows + mergeDestinationRow.isHidden = !presentation.showsSingleDestinationPicker + + if presentation.showsModeSelector { + separateProfilesRadio.state = destinationMode == .separateProfiles ? .on : .off + mergeProfilesRadio.state = destinationMode == .mergeIntoOne ? .on : .off + } else { + separateProfilesRadio.state = .off + mergeProfilesRadio.state = .off + } + + rebuildSeparateDestinationRows(with: plan) + rebuildMergeDestinationRow() + + if presentation.showsSeparateRows { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.separateHelp", + defaultValue: "Missing cmux profiles are created when import starts." + ) + } else if plan.entries.count > 1 { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.mergeHelp", + defaultValue: "All selected source profiles will be merged into the chosen cmux browser profile." + ) + } else { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.help", + defaultValue: "Imported cookies and history go into the selected cmux browser profile." + ) + } + } + + private func rebuildSeparateDestinationRows(with plan: BrowserImportExecutionPlan) { + separateDestinationOptionsByEntryIndex.removeAll() + for arrangedSubview in separateDestinationRows.arrangedSubviews { + separateDestinationRows.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + guard plan.mode == .separateProfiles else { return } + + for (index, entry) in plan.entries.enumerated() { + guard let sourceProfile = entry.sourceProfiles.first else { continue } + let sourceLabel = NSTextField(labelWithString: sourceProfile.displayName) + sourceLabel.alignment = .right + sourceLabel.frame.size.width = 140 + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.target = self + popup.action = #selector(handleSeparateDestinationChanged(_:)) + popup.tag = index + popup.setAccessibilityIdentifier( + "BrowserImportDestinationPopup-\(accessibilitySlug(for: sourceProfile, index: index))" + ) + + let options = destinationOptions(for: entry, sourceProfile: sourceProfile) + separateDestinationOptionsByEntryIndex[index] = options + for option in options { + popup.addItem(withTitle: title(for: option)) + } + if let selectedIndex = options.firstIndex(of: entry.destination) { + popup.selectItem(at: selectedIndex) + } else { + popup.selectItem(at: 0) + } + + let row = NSStackView(views: [sourceLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + separateDestinationRows.addArrangedSubview(row) + } + } + + private func rebuildMergeDestinationRow() { + for arrangedSubview in mergeDestinationRow.arrangedSubviews { + mergeDestinationRow.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + mergeDestinationPopup.removeAllItems() + for profile in destinationProfiles { + mergeDestinationPopup.addItem(withTitle: profile.displayName) + } + if let selectedIndex = destinationProfiles.firstIndex(where: { $0.id == resolvedMergeDestinationProfileID() }) { + mergeDestinationPopup.selectItem(at: selectedIndex) + } else { + mergeDestinationPopup.selectItem(at: 0) + if let firstProfile = destinationProfiles.first { + mergeDestinationProfileID = firstProfile.id + } + } + mergeDestinationPopup.setAccessibilityIdentifier("BrowserImportDestinationPopup-merge") + + let destinationLabel = NSTextField( + labelWithString: String( + localized: "browser.import.destinationProfile", + defaultValue: "Import into" + ) + ) + destinationLabel.alignment = .right + destinationLabel.frame.size.width = 140 + + mergeDestinationRow.addArrangedSubview(destinationLabel) + mergeDestinationRow.addArrangedSubview(mergeDestinationPopup) + } + + private func destinationOptions( + for entry: BrowserImportExecutionEntry, + sourceProfile: InstalledBrowserProfile + ) -> [BrowserImportDestinationRequest] { + var options = destinationProfiles.map { BrowserImportDestinationRequest.existing($0.id) } + let createName: String + switch entry.destination { + case .createNamed(let name): + createName = name + case .existing: + createName = sourceProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + } + if !createName.isEmpty, + !destinationProfiles.contains(where: { + $0.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(createName) == .orderedSame + }) { + options.append(.createNamed(createName)) + } + return options + } + + private func title(for request: BrowserImportDestinationRequest) -> String { + switch request { + case .existing(let id): + return destinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + return String( + format: String( + localized: "browser.import.destinationProfile.create", + defaultValue: "Create \"%@\"" + ), + name + ) + } + } + + private func destinationProfileID(for entry: BrowserImportExecutionEntry) -> UUID? { + guard case .existing(let id) = entry.destination else { return nil } + return id + } + + private func resolvedMergeDestinationProfileID() -> UUID { + if destinationProfiles.contains(where: { $0.id == mergeDestinationProfileID }) { + return mergeDestinationProfileID + } + return initialDestinationProfileID + } + + private func defaultSeparateDestinationRequest( + for profile: InstalledBrowserProfile + ) -> BrowserImportDestinationRequest { + BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: [profile], + destinationProfiles: destinationProfiles + ).entries.first?.destination ?? .createNamed(profile.displayName) + } + + private func accessibilitySlug(for profile: InstalledBrowserProfile, index: Int) -> String { + let base = profile.displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return base.isEmpty ? "profile-\(index)" : base + } + private func finishModal(with response: NSApplication.ModalResponse) { guard !didFinishModal else { return } didFinishModal = true @@ -6810,7 +9126,12 @@ final class BrowserDataImportCoordinator { titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) content.addSubview(titleLabel) - let subtitleLabel = NSTextField(labelWithString: "This can take a few seconds for large profiles.") + let subtitleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.progress.subtitle", + defaultValue: "This can take a few seconds for large profiles." + ) + ) subtitleLabel.frame = NSRect(x: 52, y: 34, width: 340, height: 16) subtitleLabel.font = NSFont.systemFont(ofSize: 11) subtitleLabel.textColor = .secondaryLabelColor @@ -6837,32 +9158,15 @@ final class BrowserDataImportCoordinator { } private func presentOutcome(_ outcome: BrowserImportOutcome) { - var lines: [String] = [] - lines.append("Browser: \(outcome.browserName)") - lines.append("Scope: \(outcome.scope.displayName)") - lines.append("Imported cookies: \(outcome.importedCookies)") - if outcome.skippedCookies > 0 { - lines.append("Skipped cookies: \(outcome.skippedCookies)") - } - if outcome.scope.includesHistory { - lines.append("Imported history entries: \(outcome.importedHistoryEntries)") - } - if !outcome.domainFilters.isEmpty { - lines.append("Domain filter: \(outcome.domainFilters.joined(separator: ", "))") - } - if !outcome.warnings.isEmpty { - lines.append("") - lines.append("Warnings:") - for warning in outcome.warnings { - lines.append("- \(warning)") - } - } - + let lines = BrowserImportOutcomeFormatter.lines(for: outcome) let alert = NSAlert() alert.alertStyle = .informational - alert.messageText = "Browser data import complete" + alert.messageText = String( + localized: "browser.import.complete.title", + defaultValue: "Browser data import complete" + ) alert.informativeText = lines.joined(separator: "\n") - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) alert.runModal() } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index b7fc0488..60d4bf5f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -206,6 +206,7 @@ func resolvedBrowserOmnibarPillBackgroundColor( /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel + @ObservedObject private var browserProfileStore = BrowserProfileStore.shared let paneId: PaneID let isFocused: Bool let isVisibleInUI: Bool @@ -236,6 +237,7 @@ struct BrowserPanelView: View { @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 + @State private var isBrowserProfileMenuPresented = false @State private var isBrowserThemeMenuPresented = false @State private var ghosttyBackgroundGeneration: Int = 0 // Keep this below half of the compact omnibar height so it reads as a squircle, @@ -433,7 +435,7 @@ struct BrowserPanelView: View { autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") refreshEmptyStateImportBrowsers() - BrowserHistoryStore.shared.loadIfNeeded() + panel.historyStore.loadIfNeeded() #if DEBUG logBrowserFocusState(event: "view.onAppear") #endif @@ -469,6 +471,12 @@ struct BrowserPanelView: View { .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } + .onChange(of: panel.profileID) { _ in + panel.historyStore.loadIfNeeded() + if addressBarFocused { + refreshSuggestions() + } + } .onChange(of: isFocused) { focused in #if DEBUG logBrowserFocusState( @@ -541,7 +549,7 @@ struct BrowserPanelView: View { applyOmnibarEffects(effects) refreshInlineCompletion() } - .onReceive(BrowserHistoryStore.shared.$entries) { _ in + .onReceive(panel.historyStore.$entries) { _ in guard addressBarFocused else { return } refreshSuggestions() } @@ -569,10 +577,9 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - if !panel.isShowingNewTabPage { - browserThemeModeButton - developerToolsButton - } + browserProfileButton + browserThemeModeButton + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) @@ -677,6 +684,34 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserToggleDevToolsButton") } + private var browserProfileButton: some View { + Button(action: { + isBrowserProfileMenuPresented.toggle() + }) { + Image(systemName: "person.crop.circle") + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) { + browserProfilePopover + } + .safeHelp( + String( + format: String( + localized: "browser.profile.buttonHelp", + defaultValue: "Browser Profile: %@" + ), + panel.profileDisplayName + ) + ) + .accessibilityIdentifier("BrowserProfileButton") + } + private var browserThemeModeButton: some View { Button(action: { isBrowserThemeMenuPresented.toggle() @@ -693,10 +728,76 @@ struct BrowserPanelView: View { .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { browserThemeModePopover } - .safeHelp("Browser Theme: \(browserThemeMode.displayName)") + .safeHelp( + String( + format: String( + localized: "browser.theme.buttonHelp", + defaultValue: "Browser Theme: %@" + ), + browserThemeMode.displayName + ) + ) .accessibilityIdentifier("BrowserThemeModeButton") } + private var browserProfilePopover: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + ForEach(browserProfileStore.profiles) { profile in + Button { + applyBrowserProfileSelection(profile.id) + } label: { + HStack(spacing: 8) { + Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle") + .font(.system(size: 10, weight: .semibold)) + .opacity(profile.id == panel.profileID ? 1.0 : 0.0) + .frame(width: 12, alignment: .center) + Text(profile.displayName) + .font(.system(size: 12)) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + + Divider() + + Button { + isBrowserProfileMenuPresented = false + presentCreateBrowserProfilePrompt() + } label: { + Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + + if browserProfileStore.canRenameProfile(id: panel.profileID) { + Button { + isBrowserProfileMenuPresented = false + presentRenameBrowserProfilePrompt() + } label: { + Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile...")) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + } + } + .padding(8) + .frame(minWidth: 208) + } + private var browserThemeModePopover: some View { VStack(alignment: .leading, spacing: 2) { ForEach(BrowserThemeMode.allCases) { mode in @@ -1145,7 +1246,9 @@ struct BrowserPanelView: View { Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { refreshEmptyStateImportBrowsers() - BrowserDataImportCoordinator.shared.presentImportDialog() + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) } .buttonStyle(.bordered) .controlSize(.small) @@ -1327,10 +1430,73 @@ struct BrowserPanelView: View { let target = omnibarState.suggestions[idx] guard case .history(let url, _) = target.kind else { return } - guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return } + guard panel.historyStore.removeHistoryEntry(urlString: url) else { return } refreshSuggestions() } + private func applyBrowserProfileSelection(_ profileID: UUID) { + isBrowserProfileMenuPresented = false + owningWorkspace?.setPreferredBrowserProfileID(profileID) + _ = panel.switchToProfile(profileID) + } + + private func presentCreateBrowserProfilePrompt() { + let alert = NSAlert() + alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile") + alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.") + + let input = NSTextField(string: "") + input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name") + input.frame = NSRect(x: 0, y: 0, width: 260, height: 22) + alert.accessoryView = input + + alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + guard alert.runModal() == .alertFirstButtonReturn, + let profile = browserProfileStore.createProfile(named: input.stringValue) else { + return + } + + applyBrowserProfileSelection(profile.id) + } + + private func presentRenameBrowserProfilePrompt() { + guard let profile = browserProfileStore.profileDefinition(id: panel.profileID), + browserProfileStore.canRenameProfile(id: profile.id) else { + return + } + + let alert = NSAlert() + alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile") + alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.") + + let input = NSTextField(string: profile.displayName) + input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name") + input.frame = NSRect(x: 0, y: 0, width: 260, height: 22) + alert.accessoryView = input + + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + guard alert.runModal() == .alertFirstButtonReturn else { return } + _ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue) + } + private func refreshInlineCompletion() { inlineCompletion = omnibarInlineCompletionForDisplay( typedText: omnibarState.buffer, @@ -1366,9 +1532,9 @@ struct BrowserPanelView: View { let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) let historyEntries: [BrowserHistoryStore.Entry] = { if query.isEmpty { - return BrowserHistoryStore.shared.recentSuggestions(limit: 12) + return panel.historyStore.recentSuggestions(limit: 12) } - return BrowserHistoryStore.shared.suggestions(for: query, limit: 12) + return panel.historyStore.suggestions(for: query, limit: 12) }() let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12) let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil @@ -1432,7 +1598,7 @@ struct BrowserPanelView: View { let merged = buildOmnibarSuggestions( query: query, engineName: searchEngine.displayName, - historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12), + historyEntries: panel.historyStore.suggestions(for: query, limit: 12), openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12), remoteQueries: remote, resolvedURL: panel.resolveNavigableURL(from: query), diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 53eb995e..4ee2f884 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { struct SessionBrowserPanelSnapshot: Codable, Sendable { var urlString: String? + var profileID: UUID? var shouldRenderWebView: Bool var pageZoom: Double var developerToolsVisible: Bool diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 764b15ce..4ed828ab 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2450,6 +2450,7 @@ class TabManager: ObservableObject { orientation: SplitOrientation, insertFirst: Bool = false, url: URL? = nil, + preferredProfileID: UUID? = nil, focus: Bool = true ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } @@ -2458,14 +2459,24 @@ class TabManager: ObservableObject { orientation: orientation, insertFirst: insertFirst, url: url, + preferredProfileID: preferredProfileID, focus: focus )?.id } /// Create a new browser surface in a pane - func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? { + func newBrowserSurface( + tabId: UUID, + inPane paneId: PaneID, + url: URL? = nil, + preferredProfileID: UUID? = nil + ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSurface(inPane: paneId, url: url)?.id + return tab.newBrowserSurface( + inPane: paneId, + url: url, + preferredProfileID: preferredProfileID + )?.id } /// Get a browser panel by ID @@ -2480,6 +2491,7 @@ class TabManager: ObservableObject { inWorkspace tabId: UUID, url: URL? = nil, preferSplitRight: Bool = false, + preferredProfileID: UUID? = nil, insertAtEnd: Bool = false ) -> UUID? { guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } @@ -2493,7 +2505,8 @@ class TabManager: ObservableObject { inPane: targetPaneId, url: url, focus: true, - insertAtEnd: insertAtEnd + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) return browserPanel.id @@ -2519,6 +2532,7 @@ class TabManager: ObservableObject { from: splitSourcePanelId, orientation: .horizontal, url: url, + preferredProfileID: preferredProfileID, focus: true ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) @@ -2531,7 +2545,8 @@ class TabManager: ObservableObject { inPane: paneId, url: url, focus: true, - insertAtEnd: insertAtEnd + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID ) else { return nil } @@ -2541,12 +2556,17 @@ class TabManager: ObservableObject { /// Open a browser in the currently focused pane (as a new surface) @discardableResult - func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + func openBrowser( + url: URL? = nil, + preferredProfileID: UUID? = nil, + insertAtEnd: Bool = false + ) -> UUID? { guard let tabId = selectedTabId else { return nil } return openBrowser( inWorkspace: tabId, url: url, preferSplitRight: false, + preferredProfileID: preferredProfileID, insertAtEnd: insertAtEnd ) } @@ -2642,7 +2662,12 @@ class TabManager: ObservableObject { in workspace: Workspace ) -> UUID? { if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), - let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) { + let browserPanel = workspace.newBrowserSurface( + inPane: originalPane, + url: snapshot.url, + focus: true, + preferredProfileID: snapshot.profileID + ) { let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count let maxIndex = max(0, tabCount - 1) let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) @@ -2659,7 +2684,8 @@ class TabManager: ObservableObject { from: anchorPanelId, orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, - url: snapshot.url + url: snapshot.url, + preferredProfileID: snapshot.profileID )?.id { return browserPanelId } @@ -2667,7 +2693,12 @@ class TabManager: ObservableObject { guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { return nil } - return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id + return workspace.newBrowserSurface( + inPane: focusedPane, + url: snapshot.url, + focus: true, + preferredProfileID: snapshot.profileID + )?.id } /// Flash the currently focused panel so the user can visually confirm focus. diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1bc7e1ed..c61b0429 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -335,6 +335,7 @@ extension Workspace { let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), + profileID: browserPanel.profileID, shouldRenderWebView: browserPanel.shouldRenderWebView, pageZoom: Double(browserPanel.webView.pageZoom), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), @@ -516,7 +517,8 @@ extension Workspace { guard let browserPanel = newBrowserSurface( inPane: paneId, url: initialURL, - focus: false + focus: false, + preferredProfileID: snapshot.browser?.profileID ) else { return nil } @@ -901,6 +903,7 @@ enum SidebarBranchOrdering { struct ClosedBrowserPanelRestoreSnapshot { let workspaceId: UUID let url: URL? + let profileID: UUID? let originalPaneId: UUID let originalTabIndex: Int let fallbackSplitOrientation: SplitOrientation? @@ -918,6 +921,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String + private(set) var preferredBrowserProfileID: UUID? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 @@ -1330,6 +1334,35 @@ final class Workspace: Identifiable, ObservableObject { ) } panelSubscriptions[browserPanel.id] = subscription + setPreferredBrowserProfileID(browserPanel.profileID) + } + + func setPreferredBrowserProfileID(_ profileID: UUID?) { + guard let profileID else { + preferredBrowserProfileID = nil + return + } + guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return } + preferredBrowserProfileID = profileID + } + + private func resolvedNewBrowserProfileID( + preferredProfileID: UUID? = nil, + sourcePanelId: UUID? = nil + ) -> UUID { + if let preferredProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil { + return preferredProfileID + } + if let sourcePanelId, + let sourceBrowserPanel = browserPanel(for: sourcePanelId) { + return sourceBrowserPanel.profileID + } + if let preferredBrowserProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil { + return preferredBrowserProfileID + } + return BrowserProfileStore.shared.effectiveLastUsedProfileID } private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { @@ -2197,6 +2230,7 @@ final class Workspace: Identifiable, ObservableObject { orientation: SplitOrientation, insertFirst: Bool = false, url: URL? = nil, + preferredProfileID: UUID? = nil, focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel @@ -2213,9 +2247,17 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } // Create browser panel - let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) + let browserPanel = BrowserPanel( + workspaceId: id, + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: panelId + ), + initialURL: url + ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle + setPreferredBrowserProfileID(browserPanel.profileID) // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( @@ -2271,17 +2313,20 @@ final class Workspace: Identifiable, ObservableObject { url: URL? = nil, focus: Bool? = nil, insertAtEnd: Bool = false, + preferredProfileID: UUID? = nil, bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let browserPanel = BrowserPanel( workspaceId: id, + profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID), initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle + setPreferredBrowserProfileID(browserPanel.profileID) guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, @@ -2754,6 +2799,7 @@ final class Workspace: Identifiable, ObservableObject { pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( workspaceId: id, url: resolvedURL, + profileID: browserPanel.profileID, originalPaneId: pane.id, originalTabIndex: tabIndex, fallbackSplitOrientation: fallbackPlan?.orientation, @@ -3933,14 +3979,27 @@ final class Workspace: Identifiable, ObservableObject { private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return } + let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID } + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + preferredProfileID: preferredProfileID + ) else { return } _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { guard let panelId = panelIdFromSurfaceId(anchorTabId), let browser = browserPanel(for: panelId) else { return } - createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL) + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: browser.currentURL, + focus: true, + preferredProfileID: browser.profileID + ) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func promptRenamePanel(tabId: TabID) { diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift new file mode 100644 index 00000000..1f6c662c --- /dev/null +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -0,0 +1,232 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class BrowserImportMappingTests: XCTestCase { + @MainActor + func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() { + let defaultProfile = BrowserProfileDefinition( + id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, + displayName: "Default", + createdAt: .distantPast, + isBuiltInDefault: true + ) + let sourceProfiles = [ + makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true), + makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false), + ] + + let plan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: [defaultProfile], + preferredSingleDestinationProfileID: defaultProfile.id + ) + + XCTAssertEqual(plan.mode, .separateProfiles) + XCTAssertEqual(plan.entries.count, 2) + XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]]) + } + + @MainActor + func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() { + let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! + let sourceProfile = makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-single", + isDefault: true + ) + + let plan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: [sourceProfile], + destinationProfiles: [], + preferredSingleDestinationProfileID: defaultProfileID + ) + + XCTAssertEqual(plan.mode, .singleDestination) + XCTAssertEqual(plan.entries.count, 1) + XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"]) + } + + @MainActor + func testSeparatePlanReusesExistingSameNamedDestinationProfiles() { + let workID = UUID() + let destinationProfiles = [ + BrowserProfileDefinition( + id: workID, + displayName: "You", + createdAt: .distantPast, + isBuiltInDefault: false + ) + ] + let sourceProfiles = [ + makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true) + ] + + let plan = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: destinationProfiles + ) + + XCTAssertEqual(plan.entries.count, 1) + XCTAssertEqual(plan.entries[0].destination, .existing(workID)) + } + + @MainActor + func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() { + let sourceProfiles = [ + makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true), + makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false), + ] + + let plan = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: [] + ) + + XCTAssertEqual(plan.entries.count, 2) + XCTAssertEqual(plan.entries[0].destination, .createNamed("Work")) + XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)")) + } + + func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() { + let presentation = BrowserImportStep3Presentation( + plan: BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: [ + makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-presentation-separate", + isDefault: true + ) + ], + destination: .createNamed("You") + ) + ] + ) + ) + + XCTAssertTrue(presentation.showsSeparateRows) + XCTAssertFalse(presentation.showsSingleDestinationPicker) + } + + func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() { + let presentation = BrowserImportStep3Presentation( + plan: BrowserImportExecutionPlan( + mode: .mergeIntoOne, + entries: [] + ) + ) + + XCTAssertFalse(presentation.showsSeparateRows) + XCTAssertTrue(presentation.showsSingleDestinationPicker) + } + + @MainActor + func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { + let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = BrowserProfileStore(defaults: defaults) + let plan = BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: [ + makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-realize-create", + isDefault: true + ) + ], + destination: .createNamed("You") + ) + ] + ) + + let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store) + + XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"]) + XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"]) + } + + @MainActor + func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws { + let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = BrowserProfileStore(defaults: defaults) + let existing = try XCTUnwrap(store.createProfile(named: "You")) + let plan = BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: [ + makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-realize-existing", + isDefault: true + ) + ], + destination: .existing(existing.id) + ) + ] + ) + + let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store) + + XCTAssertTrue(realized.createdProfiles.isEmpty) + XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id) + } + + func testAggregateOutcomeIncludesOneMappingLinePerDestination() { + let outcome = BrowserImportOutcome( + browserName: "Helium", + scope: .cookiesAndHistory, + domainFilters: [], + createdDestinationProfileNames: ["You", "austin"], + entries: [ + BrowserImportOutcomeEntry( + sourceProfileNames: ["You"], + destinationProfileName: "You", + importedCookies: 10, + skippedCookies: 0, + importedHistoryEntries: 20, + warnings: [] + ), + BrowserImportOutcomeEntry( + sourceProfileNames: ["austin"], + destinationProfileName: "austin", + importedCookies: 5, + skippedCookies: 1, + importedHistoryEntries: 9, + warnings: [] + ), + ], + warnings: [] + ) + + let lines = BrowserImportOutcomeFormatter.lines(for: outcome) + + XCTAssertTrue(lines.contains("You -> You")) + XCTAssertTrue(lines.contains("austin -> austin")) + XCTAssertTrue(lines.contains("Created cmux profiles: You, austin")) + } + + private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile { + InstalledBrowserProfile( + displayName: displayName, + rootURL: URL(fileURLWithPath: path, isDirectory: true), + isDefault: isDefault + ) + } +} diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index fdc316b7..9d372926 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -930,6 +930,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase { ClosedBrowserPanelRestoreSnapshot( workspaceId: UUID(), url: URL(string: "https://example.com/\(index)"), + profileID: nil, originalPaneId: UUID(), originalTabIndex: index, fallbackSplitOrientation: .horizontal, @@ -1614,6 +1615,104 @@ final class BrowserInstallDetectorTests: XCTestCase { XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" })) } + func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true) + try createFile( + at: heliumRoot.appendingPathComponent("Default/History"), + contents: Data() + ) + try createFile( + at: heliumRoot.appendingPathComponent("Profile 1/Cookies"), + contents: Data() + ) + try createFile( + at: heliumRoot.appendingPathComponent("Local State"), + contents: Data( + """ + { + "profile": { + "info_cache": { + "Default": { + "name": "Personal" + }, + "Profile 1": { + "name": "Work" + } + } + } + } + """.utf8 + ) + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else { + XCTFail("Expected Helium to be detected") + return + } + + XCTAssertEqual(helium.family, .chromium) + XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"]) + XCTAssertEqual( + helium.profiles.map(\.rootURL.lastPathComponent), + ["Default", "Profile 1"] + ) + } + + func testDetectInstalledBrowsersDiscoversSafariProfiles() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + try createFile( + at: home.appendingPathComponent("Library/Safari/History.db"), + contents: Data() + ) + try createFile( + at: home.appendingPathComponent( + "Library/Safari/Profiles/Work/History.db" + ), + contents: Data() + ) + try createFile( + at: home.appendingPathComponent( + "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db" + ), + contents: Data() + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else { + XCTFail("Expected Safari to be detected") + return + } + + XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"]) + XCTAssertEqual( + safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(), + [ + home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false), + home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false), + home.appendingPathComponent( + "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel", + isDirectory: true + ).path(percentEncoded: false), + ].sorted() + ) + } + private func makeTemporaryHome() -> URL { FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)") } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 88d8f11c..fe8cbcbb 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -150,8 +150,10 @@ final class SessionPersistenceTests: XCTestCase { } func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { + let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64") let source = SessionBrowserPanelSnapshot( urlString: "https://example.com/current", + profileID: profileID, shouldRenderWebView: true, pageZoom: 1.2, developerToolsVisible: true, @@ -167,6 +169,7 @@ final class SessionPersistenceTests: XCTestCase { let data = try JSONEncoder().encode(source) let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data) XCTAssertEqual(decoded.urlString, source.urlString) + XCTAssertEqual(decoded.profileID, source.profileID) XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings) XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings) } @@ -183,6 +186,7 @@ final class SessionPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json) XCTAssertEqual(decoded.urlString, "https://example.com/current") + XCTAssertNil(decoded.profileID) XCTAssertNil(decoded.backHistoryURLStrings) XCTAssertNil(decoded.forwardHistoryURLStrings) } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift new file mode 100644 index 00000000..aceb7fff --- /dev/null +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -0,0 +1,131 @@ +import XCTest +import Foundation + +final class BrowserImportProfilesUITests: XCTestCase { + private var capturePath = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: capturePath) + } + + func testMultipleSourceProfilesDefaultToSeparateDestinations() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + XCTAssertTrue( + app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0), + "Expected Step 3 to show the separate-profiles default" + ) + XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists) + XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists) + XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists) + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["mode"] as? String, "separateProfiles") + XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory") + + let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]]) + XCTAssertEqual(entries.count, 2) + XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"]) + XCTAssertEqual(entries[0]["destinationKind"] as? String, "create") + XCTAssertEqual(entries[0]["destinationName"] as? String, "You") + XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"]) + XCTAssertEqual(entries[1]["destinationKind"] as? String, "create") + XCTAssertEqual(entries[1]["destinationName"] as? String, "austin") + } + + func testMergeModeCapturesSingleMergedDestination() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + let mergeRadio = app.radioButtons["Merge all into one cmux profile"] + XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0)) + mergeRadio.click() + + XCTAssertTrue( + app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0), + "Expected merge mode to show the single destination popup" + ) + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne") + + let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]]) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"]) + XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing") + XCTAssertEqual(entries[0]["destinationName"] as? String, "Default") + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch in the foreground for browser import UI tests" + ) + return app + } + + private func openImportWizard(_ app: XCUIApplication) { + let viewMenu = app.menuBars.menuBarItems["View"].firstMatch + XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist") + viewMenu.click() + + let importItem = app.menuItems["Import From Browser…"].firstMatch + XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist") + importItem.click() + + XCTAssertTrue( + app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0), + "Expected the import wizard to open" + ) + } + + private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { + let deadline = Date().addingTimeInterval(timeout) + let url = URL(fileURLWithPath: capturePath) + while Date() < deadline { + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return object + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return object + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } +} From 9bf6ad94571a0acaea32c302b4e1c502984b85d6 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 16 Mar 2026 22:10:15 -0700 Subject: [PATCH 39/77] Avoid blocking browser PR metadata updates (#1564) --- Sources/Panels/BrowserPanelView.swift | 94 ++++++++++++++---- Sources/TerminalController.swift | 137 +++++++++++++------------- 2 files changed, 142 insertions(+), 89 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 5c9c780a..d5433771 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -203,6 +203,35 @@ func resolvedBrowserOmnibarPillBackgroundColor( return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor } +private struct BrowserChromeStyle { + let backgroundColor: NSColor + let colorScheme: ColorScheme + let omnibarPillBackgroundColor: NSColor + + static func resolve( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor + ) -> BrowserChromeStyle { + let backgroundColor = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackgroundColor + ) + let chromeColorScheme = resolvedBrowserChromeColorScheme( + for: colorScheme, + themeBackgroundColor: backgroundColor + ) + let omnibarPillBackgroundColor = resolvedBrowserOmnibarPillBackgroundColor( + for: chromeColorScheme, + themeBackgroundColor: backgroundColor + ) + return BrowserChromeStyle( + backgroundColor: backgroundColor, + colorScheme: chromeColorScheme, + omnibarPillBackgroundColor: omnibarPillBackgroundColor + ) + } +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -220,6 +249,8 @@ struct BrowserPanelView: View { @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) + private var toggleBrowserDeveloperToolsShortcutData = Data() @State private var suggestionTask: Task<Void, Never>? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -236,7 +267,11 @@ struct BrowserPanelView: View { @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @State private var isBrowserThemeMenuPresented = false - @State private var ghosttyBackgroundGeneration: Int = 0 + @State private var browserChromeStyle = BrowserChromeStyle.resolve( + for: .light, + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() + ) + @State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut // Keep this below half of the compact omnibar height so it reads as a squircle, // not a capsule. private let omnibarPillCornerRadius: CGFloat = 10 @@ -282,24 +317,15 @@ struct BrowserPanelView: View { } private var browserChromeBackground: Color { - _ = ghosttyBackgroundGeneration - return Color(nsColor: GhosttyBackgroundTheme.currentColor()) + Color(nsColor: browserChromeStyle.backgroundColor) } private var browserChromeBackgroundColor: NSColor { - _ = ghosttyBackgroundGeneration - return resolvedBrowserChromeBackgroundColor( - for: colorScheme, - themeBackgroundColor: GhosttyBackgroundTheme.currentColor() - ) + browserChromeStyle.backgroundColor } private var browserChromeColorScheme: ColorScheme { - _ = ghosttyBackgroundGeneration - return resolvedBrowserChromeColorScheme( - for: colorScheme, - themeBackgroundColor: GhosttyBackgroundTheme.currentColor() - ) + browserChromeStyle.colorScheme } private var browserContentAccessibilityIdentifier: String { @@ -307,10 +333,12 @@ struct BrowserPanelView: View { } private var omnibarPillBackgroundColor: NSColor { - resolvedBrowserOmnibarPillBackgroundColor( - for: browserChromeColorScheme, - themeBackgroundColor: browserChromeBackgroundColor - ) + browserChromeStyle.omnibarPillBackgroundColor + } + + private var developerToolsButtonHelp: String { + let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools") + return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" } private var owningWorkspace: Workspace? { @@ -420,6 +448,8 @@ struct BrowserPanelView: View { BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) + refreshBrowserChromeStyle() + refreshToggleBrowserDeveloperToolsShortcut() let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue @@ -459,8 +489,12 @@ struct BrowserPanelView: View { panel.setBrowserThemeMode(normalizedMode) } .onChange(of: colorScheme) { _ in + refreshBrowserChromeStyle() panel.refreshAppearanceDrivenColors() } + .onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in + refreshToggleBrowserDeveloperToolsShortcut() + } .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } @@ -552,7 +586,7 @@ struct BrowserPanelView: View { } } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - ghosttyBackgroundGeneration &+= 1 + refreshBrowserChromeStyle() } } @@ -668,7 +702,7 @@ struct BrowserPanelView: View { } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools"))) + .safeHelp(developerToolsButtonHelp) .accessibilityIdentifier("BrowserToggleDevToolsButton") } @@ -907,6 +941,28 @@ struct BrowserPanelView: View { } } + private func refreshBrowserChromeStyle() { + browserChromeStyle = BrowserChromeStyle.resolve( + for: colorScheme, + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() + ) + } + + private func refreshToggleBrowserDeveloperToolsShortcut() { + toggleBrowserDeveloperToolsShortcut = decodeShortcut( + from: toggleBrowserDeveloperToolsShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + ) + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } + private func syncWebViewResponderPolicyWithViewState( reason: String, isPanelFocusedOverride: Bool? = nil diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index dccd9cb1..4515805a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13025,6 +13025,61 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func schedulePanelMetadataMutation( + args: String, + options: [String: String], + missingPanelUsage: String, + mutation: @escaping (Tab, UUID) -> Void + ) -> String { + let rawPanelArg = options["panel"] ?? options["surface"] + let surfaceIdFromOptions: UUID? + if let rawPanelArg { + if rawPanelArg.isEmpty { + return "ERROR: Missing panel id — usage: \(missingPanelUsage)" + } + guard let surfaceId = UUID(uuidString: rawPanelArg) else { + return "ERROR: Invalid panel id '\(rawPanelArg)'" + } + surfaceIdFromOptions = surfaceId + } else { + surfaceIdFromOptions = nil + } + + if let tabArg = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tabArg.isEmpty, + UUID(uuidString: tabArg) == nil, + Int(tabArg) == nil { + return "ERROR: Tab not found" + } + + if let scope = Self.explicitSocketScope(options: options) { + DispatchQueue.main.async { [weak self] in + guard let self, + let tab = self.tabForSidebarMutation(id: scope.workspaceId) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + mutation(tab, scope.panelId) + } + return "OK" + } + + DispatchQueue.main.async { [weak self] in + guard let self, + let tab = self.resolveTabForReport(args) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard let surfaceId = surfaceIdFromOptions ?? tab.focusedPanelId else { return } + guard validSurfaceIds.contains(surfaceId) else { return } + mutation(tab, surfaceId) + } + return "OK" + } + private func upsertSidebarMetadata(_ args: String, missingError: String) -> String { guard tabManager != nil else { return "ERROR: TabManager not available" } let parsed = parseOptionsNoStop(args) @@ -13611,40 +13666,13 @@ class TerminalController { } let label = String(labelRaw.prefix(16)) - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" - return - } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - + // Shell integration provides explicit workspace/panel UUIDs for browser metadata. + // Keep this telemetry path off-main so SwiftUI render passes can't deadlock the socket handler. + return schedulePanelMetadataMutation( + args: args, + options: parsed.options, + missingPanelUsage: "report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + ) { tab, surfaceId in guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[surfaceId], number: number, @@ -13663,48 +13691,17 @@ class TerminalController { status: status ) } - return result } private func clearPullRequest(_ args: String) -> String { let parsed = parseOptions(args) - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" - return - } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - + return schedulePanelMetadataMutation( + args: args, + options: parsed.options, + missingPanelUsage: "clear_pr [--tab=X] [--panel=Y]" + ) { tab, surfaceId in tab.clearPanelPullRequest(panelId: surfaceId) } - return result } private func reportPorts(_ args: String) -> String { From 150600d089ef5458cffc9f28da7f78c668a1c78b Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 16 Mar 2026 22:28:43 -0700 Subject: [PATCH 40/77] Fix #1574: remove top update banner in sidebar (#1575) * test: cover sidebar update indicator regression * fix: remove duplicate sidebar update banner --- Sources/ContentView.swift | 105 ---------------------------- cmuxUITests/UpdatePillUITests.swift | 12 ++-- 2 files changed, 8 insertions(+), 109 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7cffeba9..c7708c7c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7789,10 +7789,6 @@ struct VerticalTabsSidebar: View { Spacer() .frame(height: trafficLightPadding) - SidebarUpdateBanner(updateViewModel: updateViewModel) - .padding(.horizontal, 8) - .padding(.top, 8) - LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in TabItemView( @@ -8723,107 +8719,6 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } -private struct SidebarUpdateBanner: View { - @ObservedObject var updateViewModel: UpdateViewModel - - private var bannerVersion: String? { - if let detectedUpdateVersion = updateViewModel.detectedUpdateVersion { - return detectedUpdateVersion - } - if case .updateAvailable(let update) = updateViewModel.effectiveState { - return UpdateViewModel.normalizedDetectedUpdateVersion(from: update.appcastItem.displayVersionString) - } - return nil - } - - private var titleText: String { - guard let bannerVersion else { - return String(localized: "update.available.short", defaultValue: "Update Available") - } - return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(bannerVersion)") - } - - private var messageText: String { - if case .updateAvailable = updateViewModel.effectiveState { - let message = updateViewModel.description - if !message.isEmpty { - return message - } - } - return String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version") - } - - private var actionDisabled: Bool { - switch updateViewModel.effectiveState { - case .checking, .downloading, .extracting, .installing: - return true - default: - return false - } - } - - var body: some View { - if bannerVersion != nil { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "shippingbox.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(cmuxAccentColor()) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 4) { - Text(titleText) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - .accessibilityIdentifier("SidebarUpdateBannerTitle") - Text(messageText) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 0) - } - - HStack { - Spacer(minLength: 0) - Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) { - installDetectedUpdate() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(actionDisabled) - .accessibilityIdentifier("SidebarUpdateBannerAction") - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(cmuxAccentColor().opacity(0.12)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(cmuxAccentColor().opacity(0.28), lineWidth: 1) - ) - .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .accessibilityIdentifier("SidebarUpdateBanner") - } - } - - private func installDetectedUpdate() { - if case .updateAvailable(let update) = updateViewModel.effectiveState { - update.reply(.install) - return - } - if updateViewModel.effectiveState.isInstallable { - updateViewModel.effectiveState.confirm() - return - } - AppDelegate.shared?.attemptUpdate(nil) - } -} - private struct SidebarFooter: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index 099dfb93..cfa86670 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -124,17 +124,21 @@ final class UpdatePillUITests: XCTestCase { assertVisibleSize(noUpdatePill) } - func testSidebarUpdateBannerShowsForBackgroundDetectedUpdate() { + func testBackgroundDetectedUpdateKeepsOnlyBottomUpdatePill() { let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") systemSettings.terminate() let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9" + app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available" + app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9" launchAndActivate(app) - XCTAssertTrue(app.otherElements["SidebarUpdateBanner"].waitForExistence(timeout: 6.0)) - XCTAssertTrue(app.staticTexts["Update Available: 9.9.9"].waitForExistence(timeout: 2.0)) - XCTAssertTrue(app.buttons["SidebarUpdateBannerAction"].waitForExistence(timeout: 2.0)) + let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9") + XCTAssertTrue(pill.waitForExistence(timeout: 6.0)) + assertVisibleSize(pill) + XCTAssertFalse(app.otherElements["SidebarUpdateBanner"].exists) + XCTAssertFalse(app.buttons["SidebarUpdateBannerAction"].exists) } func testNoSparklePermissionDialogIsShown() { From dc6bcb259adc02d8fea6e85e7e2eef3f3ab34417 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:30:09 -0700 Subject: [PATCH 41/77] fix: address browser import review feedback --- Resources/Localizable.xcstrings | 36 +++++++++- Sources/Panels/BrowserPanel.swift | 69 +++++++++++-------- Sources/Panels/BrowserPanelView.swift | 42 ++++++++--- cmuxTests/GhosttyConfigTests.swift | 9 +++ .../BrowserImportProfilesUITests.swift | 28 ++++++++ 5 files changed, 145 insertions(+), 39 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7cb1f7aa..2a0026ff 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4734,6 +4734,23 @@ } } }, + "browser.import.additionalData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Additional data (bookmarks, settings, extensions)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加データ(ブックマーク、設定、拡張機能)" + } + } + } + }, "browser.import.back": { "extractionState": "manual", "localizations": { @@ -5142,7 +5159,24 @@ } } }, - "browser.import.detected.more": { + "browser.import.detected.more.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@, +1 more." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@、ほか1件。" + } + } + } + }, + "browser.import.detected.more.other": { "extractionState": "manual", "localizations": { "en": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f9e1ef7e..2860fcf7 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -12,6 +12,18 @@ import CommonCrypto import Security #endif +fileprivate func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { + var seen = Set<String>() + var result: [URL] = [] + for url in urls { + let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path + if seen.insert(canonical).inserted { + result.append(url) + } + } + return result +} + enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) @@ -5532,10 +5544,10 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { includeHistory: Bool, includeAdditionalData: Bool ) -> BrowserImportScope? { - guard includeCookies || includeHistory else { return nil } if includeAdditionalData { return .everything } + guard includeCookies || includeHistory else { return nil } if includeCookies && includeHistory { return .cookiesAndHistory } @@ -5949,13 +5961,23 @@ enum InstalledBrowserDetector { ) } let shown = names.prefix(limit).joined(separator: ", ") + let remaining = names.count - limit + if remaining == 1 { + return String( + format: String( + localized: "browser.import.detected.more.one", + defaultValue: "Detected: %@, +1 more." + ), + shown + ) + } return String( format: String( - localized: "browser.import.detected.more", + localized: "browser.import.detected.more.other", defaultValue: "Detected: %@, +%ld more." ), shown, - names.count - limit + remaining ) } @@ -6444,18 +6466,6 @@ enum InstalledBrowserDetector { ] } - private static func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { - var seen = Set<String>() - var result: [URL] = [] - for url in urls { - let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path - if seen.insert(canonical).inserted { - result.append(url) - } - } - return result - } - private static func dedupedProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { var seen = Set<String>() var result: [InstalledBrowserProfile] = [] @@ -7582,7 +7592,7 @@ enum BrowserDataImporter { domainMatches(host: host, filters: domainFilters) else { return } - let lastVisited = firefoxDate(fromUnixMicroseconds: lastVisitMicros) ?? Date() + let lastVisited = firefoxDate(fromUnixMicroseconds: lastVisitMicros) ?? .distantPast rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { @@ -7638,7 +7648,7 @@ enum BrowserDataImporter { domainMatches(host: host, filters: domainFilters) else { return } - let lastVisited = chromiumDate(fromWebKitMicroseconds: lastVisitMicros) ?? Date() + let lastVisited = chromiumDate(fromWebKitMicroseconds: lastVisitMicros) ?? .distantPast rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { @@ -7930,18 +7940,6 @@ enum BrowserDataImporter { } return Data(bytes: pointer, count: length) } - - private static func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { - var seen = Set<String>() - var result: [URL] = [] - for url in urls { - let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path - if seen.insert(canonical).inserted { - result.append(url) - } - } - return result - } } #if DEBUG @@ -8279,6 +8277,7 @@ final class BrowserDataImportCoordinator { private let cookiesCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) private let historyCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let additionalDataCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) private let domainField = NSTextField(frame: .zero) private let backButton = NSButton(title: "", target: nil, action: nil) @@ -8373,10 +8372,11 @@ final class BrowserDataImportCoordinator { case .dataTypes: let includeCookies = cookiesCheckbox.state == .on let includeHistory = historyCheckbox.state == .on + let includeAdditionalData = additionalDataCheckbox.state == .on guard let scope = BrowserImportScope.fromSelection( includeCookies: includeCookies, includeHistory: includeHistory, - includeAdditionalData: false + includeAdditionalData: includeAdditionalData ) else { validationLabel.stringValue = String( localized: "browser.import.validation.scope", @@ -8632,6 +8632,7 @@ final class BrowserDataImportCoordinator { private func setupDataTypesContainer() { cookiesCheckbox.state = .on historyCheckbox.state = .on + additionalDataCheckbox.state = .off cookiesCheckbox.title = String( localized: "browser.import.cookies", defaultValue: "Cookies (site sign-ins)" @@ -8640,6 +8641,13 @@ final class BrowserDataImportCoordinator { localized: "browser.import.history", defaultValue: "History (visited pages)" ) + additionalDataCheckbox.title = String( + localized: "browser.import.additionalData", + defaultValue: "Additional data (bookmarks, settings, extensions)" + ) + cookiesCheckbox.setAccessibilityIdentifier("BrowserImportCookiesCheckbox") + historyCheckbox.setAccessibilityIdentifier("BrowserImportHistoryCheckbox") + additionalDataCheckbox.setAccessibilityIdentifier("BrowserImportAdditionalDataCheckbox") separateProfilesRadio.title = String( localized: "browser.import.destinationMode.separate", defaultValue: "Keep profiles separate" @@ -8721,6 +8729,7 @@ final class BrowserDataImportCoordinator { dataTypesContainer.addArrangedSubview(destinationHelpLabel) dataTypesContainer.addArrangedSubview(cookiesCheckbox) dataTypesContainer.addArrangedSubview(historyCheckbox) + dataTypesContainer.addArrangedSubview(additionalDataCheckbox) dataTypesContainer.addArrangedSubview(domainRow) dataTypesContainer.addArrangedSubview(noteLabel) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 60d4bf5f..ff22ed38 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -226,6 +226,8 @@ struct BrowserPanelView: View { @State private var latestRemoteSuggestionQuery: String = "" @State private var latestRemoteSuggestions: [String] = [] @State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = [] + @State private var emptyStateImportBrowserRefreshTask: Task<Void, Never>? + @State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0 @State private var inlineCompletion: OmnibarInlineCompletion? @State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0) @State private var omnibarHasMarkedText: Bool = false @@ -981,12 +983,12 @@ struct BrowserPanelView: View { if addressBarFocused { setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } - } - } - } - .overlay { - if isWebViewBlank() { - emptyBrowserStateOverlay + } + .overlay { + if shouldShowEmptyStateImportOverlay { + emptyBrowserStateOverlay + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -1245,7 +1247,6 @@ struct BrowserPanelView: View { .fixedSize(horizontal: false, vertical: true) Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { - refreshEmptyStateImportBrowsers() BrowserDataImportCoordinator.shared.presentImportDialog( defaultDestinationProfileID: panel.profileID ) @@ -1272,6 +1273,10 @@ struct BrowserPanelView: View { .padding(.horizontal, 18) } + private var shouldShowEmptyStateImportOverlay: Bool { + !panel.shouldRenderWebView && isWebViewBlank() + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } @@ -1324,7 +1329,28 @@ struct BrowserPanelView: View { } private func refreshEmptyStateImportBrowsers() { - emptyStateImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() + emptyStateImportBrowserRefreshTask?.cancel() + emptyStateImportBrowserRefreshGeneration &+= 1 + let generation = emptyStateImportBrowserRefreshGeneration + + guard shouldShowEmptyStateImportOverlay else { + emptyStateImportBrowsers = [] + emptyStateImportBrowserRefreshTask = nil + return + } + + emptyStateImportBrowserRefreshTask = Task { + let browsers = await Task.detached(priority: .utility) { + InstalledBrowserDetector.detectInstalledBrowsers() + }.value + guard !Task.isCancelled else { return } + await MainActor.run { + guard emptyStateImportBrowserRefreshGeneration == generation, + shouldShowEmptyStateImportOverlay else { return } + emptyStateImportBrowsers = browsers + emptyStateImportBrowserRefreshTask = nil + } + } } private func openDevTools() { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9d372926..d233cb0e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1751,6 +1751,15 @@ final class BrowserImportScopeTests: XCTestCase { XCTAssertEqual(scope, .cookiesAndHistory) } + func testFromSelectionEverything() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: true + ) + XCTAssertEqual(scope, .everything) + } + func testFromSelectionRejectsEmptySelection() { let scope = BrowserImportScope.fromSelection( includeCookies: false, diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index aceb7fff..eca6d360 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -70,6 +70,34 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(entries[0]["destinationName"] as? String, "Default") } + func testAdditionalDataSelectionCapturesEverythingScope() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"] + XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0)) + cookiesCheckbox.click() + + let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"] + XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0)) + historyCheckbox.click() + + let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"] + XCTAssertTrue( + additionalDataCheckbox.waitForExistence(timeout: 5.0), + "Expected Step 3 to expose the additional data checkbox" + ) + additionalDataCheckbox.click() + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["scope"] as? String, "everything") + } + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" From e2ddf9214ceb126ade119c293677463cac0450f5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:12:31 -0700 Subject: [PATCH 42/77] test: cover browser profile follow-up regressions --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c787a69a..75f32d3b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6263,6 +6263,99 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { } } +@MainActor +final class WorkspaceBrowserProfileSelectionTests: XCTestCase { + private final class RejectingCreateTabDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { + false + } + } + + private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) + } + + func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { + let workspace = Workspace() + let profileA = try makeProfile(named: "Alpha") + let profileB = try makeProfile(named: "Beta") + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browserA = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: profileA.id + ) + ) + _ = try XCTUnwrap( + workspace.newBrowserSplit( + from: browserA.id, + orientation: .horizontal, + preferredProfileID: profileB.id, + focus: true + ) + ) + + XCTAssertEqual( + workspace.preferredBrowserProfileID, + profileB.id, + "Expected workspace preference to drift to the most recently created browser profile" + ) + + let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id)) + workspace.bonsplitController.focusPane(paneId) + workspace.bonsplitController.selectTab(leftSurfaceId) + + let created = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: false + ) + ) + + XCTAssertEqual( + created.profileID, + profileA.id, + "Expected new browser creation to inherit the selected browser profile from the target pane" + ) + } + + func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeProfile(named: "Preferred") + let unexpectedProfile = try makeProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + _ = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: false, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingCreateTabDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSurface( + inPane: paneId, + focus: false, + preferredProfileID: unexpectedProfile.id + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser creation to leave the workspace preferred profile unchanged" + ) + } +} + @MainActor final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { func testUsesFocusedTerminalWhenTerminalIsFocused() { @@ -6320,6 +6413,52 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { } } +@MainActor +final class BrowserPanelProfileIsolationTests: XCTestCase { + private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) + } + + func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { + let alternateProfile = try makeProfile(named: "Switched") + let defaultStore = BrowserHistoryStore.shared + let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) + defaultStore.clearHistory() + alternateStore.clearHistory() + defer { + defaultStore.clearHistory() + alternateStore.clearHistory() + } + + let panel = BrowserPanel(workspaceId: UUID()) + let staleWebView = panel.webView + let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) + let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) + staleWebView.loadHTMLString( + "<html><head><title>Stalestale", + baseURL: staleURL + ) + + XCTAssertTrue(panel.switchToProfile(alternateProfile.id)) + + staleDelegate.webView?(staleWebView, didFinish: nil) + drainMainQueue() + + XCTAssertTrue( + defaultStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the old profile history store" + ) + XCTAssertTrue( + alternateStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the newly selected profile history store" + ) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { From fdde470dcfc36568ecf6cebe20f8a1c79402609a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:50:43 -0700 Subject: [PATCH 43/77] fix: address browser profile review follow-ups --- Resources/Localizable.xcstrings | 8 +-- Sources/Panels/BrowserPanel.swift | 54 ++++++++++--------- Sources/Panels/BrowserPanelView.swift | 3 +- Sources/Workspace.swift | 10 ++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 54 +++++++++++++++++-- cmuxTests/GhosttyConfigTests.swift | 18 ++++--- cmuxTests/SessionPersistenceTests.swift | 2 +- .../BrowserImportProfilesUITests.swift | 7 +-- 8 files changed, 107 insertions(+), 49 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0396f6e9..68b84487 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4797,7 +4797,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザ: %@" + "value": "ブラウザー: %@" } } } @@ -37876,7 +37876,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザから取り込む…" + "value": "ブラウザーから取り込む…" } } } @@ -50658,7 +50658,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザデータを取り込む" + "value": "ブラウザーデータを取り込む" } } } @@ -50788,7 +50788,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザから取り込む" + "value": "ブラウザーから取り込む" } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 7af68022..c7a17f4d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2354,11 +2354,41 @@ final class BrowserPanel: Panel, ObservableObject { webView.onContextMenuOpenLinkInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } + configureNavigationDelegateCallbacks() webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) } + private func configureNavigationDelegateCallbacks() { + guard let navigationDelegate else { return } + let boundWebViewInstanceID = webViewInstanceID + let boundHistoryStore = historyStore + + navigationDelegate.didFinish = { [weak self] webView in + Task { @MainActor [weak self] in + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + boundHistoryStore.recordVisit(url: webView.url, title: webView.title) + self.refreshFavicon(from: webView) + self.applyBrowserThemeModeIfNeeded() + // Keep find-in-page open through load completion and refresh matches for the new DOM. + self.restoreFindStateAfterNavigation(replaySearch: true) + } + } + navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in + Task { @MainActor in + guard let self, self.isCurrentWebView(failedWebView, instanceID: boundWebViewInstanceID) else { return } + // Clear stale title/favicon from the previous page so the tab + // shows the failed URL instead of the old page's branding. + self.pageTitle = failedURL.isEmpty ? "" : failedURL + self.faviconPNGData = nil + self.lastFaviconURLString = nil + // Keep find-in-page open and clear stale counters on failed loads. + self.restoreFindStateAfterNavigation(replaySearch: false) + } + } + } + private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool { guard candidate === webView else { return false } guard let instanceID else { return true } @@ -2389,30 +2419,6 @@ final class BrowserPanel: Panel, ObservableObject { // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() - navDelegate.didFinish = { webView in - Task { @MainActor [weak self] in - self?.historyStore.recordVisit(url: webView.url, title: webView.title) - } - Task { @MainActor [weak self] in - guard let self, self.isCurrentWebView(webView) else { return } - self.refreshFavicon(from: webView) - self.applyBrowserThemeModeIfNeeded() - // Keep find-in-page open through load completion and refresh matches for the new DOM. - self.restoreFindStateAfterNavigation(replaySearch: true) - } - } - navDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in - Task { @MainActor in - guard let self, self.isCurrentWebView(failedWebView) else { return } - // Clear stale title/favicon from the previous page so the tab - // shows the failed URL instead of the old page's branding. - self.pageTitle = failedURL.isEmpty ? "" : failedURL - self.faviconPNGData = nil - self.lastFaviconURLString = nil - // Keep find-in-page open and clear stale counters on failed loads. - self.restoreFindStateAfterNavigation(replaySearch: false) - } - } navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 596820de..f0b16dc1 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1518,8 +1518,9 @@ struct BrowserPanelView: View { private func applyBrowserProfileSelection(_ profileID: UUID) { isBrowserProfileMenuPresented = false + let didApply = panel.profileID == profileID || panel.switchToProfile(profileID) + guard didApply else { return } owningWorkspace?.setPreferredBrowserProfileID(profileID) - _ = panel.switchToProfile(profileID) } private func presentCreateBrowserProfilePrompt() { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ae6831e8..25a7173d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2326,7 +2326,6 @@ final class Workspace: Identifiable, ObservableObject { ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle - setPreferredBrowserProfileID(browserPanel.profileID) // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( @@ -2350,6 +2349,7 @@ final class Workspace: Identifiable, ObservableObject { panelTitles.removeValue(forKey: browserPanel.id) return nil } + setPreferredBrowserProfileID(browserPanel.profileID) // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView @@ -2386,16 +2386,19 @@ final class Workspace: Identifiable, ObservableObject { bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) let browserPanel = BrowserPanel( workspaceId: id, - profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID), + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: sourcePanelId + ), initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle - setPreferredBrowserProfileID(browserPanel.profileID) guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, @@ -2412,6 +2415,7 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = browserPanel.id + setPreferredBrowserProfileID(browserPanel.profileID) // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. if insertAtEnd { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 75f32d3b..67f8cadf 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6271,6 +6271,12 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { } } + private final class RejectingSplitPaneDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + false + } + } + private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { try XCTUnwrap( BrowserProfileStore.shared.createProfile( @@ -6354,6 +6360,38 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { "Expected a failed browser creation to leave the workspace preferred profile unchanged" ) } + + func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeProfile(named: "Preferred") + let unexpectedProfile = try makeProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingSplitPaneDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSplit( + from: browser.id, + orientation: .horizontal, + preferredProfileID: unexpectedProfile.id, + focus: false + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser split to leave the workspace preferred profile unchanged" + ) + } } @MainActor @@ -6434,7 +6472,10 @@ final class BrowserPanelProfileIsolationTests: XCTestCase { alternateStore.clearHistory() } - let panel = BrowserPanel(workspaceId: UUID()) + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID + ) let staleWebView = panel.webView let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) @@ -6443,18 +6484,23 @@ final class BrowserPanelProfileIsolationTests: XCTestCase { baseURL: staleURL ) - XCTAssertTrue(panel.switchToProfile(alternateProfile.id)) + XCTAssertTrue( + panel.switchToProfile(alternateProfile.id), + "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + ) + defaultStore.clearHistory() + alternateStore.clearHistory() staleDelegate.webView?(staleWebView, didFinish: nil) drainMainQueue() XCTAssertTrue( defaultStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the old profile history store" + "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" ) XCTAssertTrue( alternateStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the newly selected profile history store" + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" ) } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index cab7d0f7..9ac2a8f7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2004,16 +2004,20 @@ final class BrowserInstallDetectorTests: XCTestCase { return } - XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"]) + XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"])) XCTAssertEqual( - safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(), + safari.profiles + .map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false) } + .sorted(), [ - home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false), - home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false), + home.appendingPathComponent("Library/Safari", isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), + home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), home.appendingPathComponent( "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel", isDirectory: true - ).path(percentEncoded: false), + ).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), ].sorted() ) } @@ -2024,7 +2028,9 @@ final class BrowserInstallDetectorTests: XCTestCase { private func createFile(at url: URL, contents: Data) throws { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - _ = FileManager.default.createFile(atPath: url.path, contents: contents) + guard FileManager.default.createFile(atPath: url.path, contents: contents) else { + throw CocoaError(.fileWriteUnknown) + } } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 2b72a440..6f5c7b1d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -150,7 +150,7 @@ final class SessionPersistenceTests: XCTestCase { } func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { - let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64") + let profileID = try XCTUnwrap(UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")) let source = SessionBrowserPanelSnapshot( urlString: "https://example.com/current", profileID: profileID, diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index eca6d360..cc28d425 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -138,12 +138,7 @@ final class BrowserImportProfilesUITests: XCTestCase { } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } - - guard let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - return object + return nil } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { From 832426af5641d16afeb37e214c1d07d3f6830478 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:57:48 -0700 Subject: [PATCH 44/77] Stabilize SSH remote flow after merging main --- .github/workflows/nightly.yml | 2 +- CLI/cmux.swift | 620 +++++--- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AppDelegate.swift | 1269 ++++++++++++----- Sources/BrowserWindowPortal.swift | 9 + Sources/ContentView.swift | 397 ++++-- Sources/GhosttyTerminalView.swift | 68 +- Sources/Panels/BrowserPanel.swift | 48 +- Sources/TabManager.swift | 293 +++- Sources/TerminalController.swift | 795 +++++++---- Sources/Workspace.swift | 1205 ++++++++++------ .../AppDelegateShortcutRoutingTests.swift | 154 ++ cmuxTests/CLIProcessRunnerTests.swift | 335 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 91 +- cmuxTests/SessionPersistenceTests.swift | 62 + ...erminalControllerSocketSecurityTests.swift | 14 +- .../WorkspaceRemoteConnectionTests.swift | 204 +++ cmuxUITests/AutomationSocketUITests.swift | 46 +- .../BrowserOmnibarSuggestionsUITests.swift | 142 +- .../BrowserPaneNavigationKeybindUITests.swift | 63 +- .../CloseWindowConfirmDialogUITests.swift | 45 +- cmuxUITests/CloseWorkspaceCmdDUITests.swift | 123 +- .../CloseWorkspaceConfirmDialogUITests.swift | 15 +- .../CloseWorkspacesConfirmDialogUITests.swift | 45 +- cmuxUITests/JumpToUnreadUITests.swift | 19 +- .../MenuKeyEquivalentRoutingUITests.swift | 210 +-- .../MultiWindowNotificationsUITests.swift | 206 ++- cmuxUITests/SidebarResizeUITests.swift | 18 +- daemon/remote/README.md | 19 +- daemon/remote/cmd/cmuxd-remote/cli.go | 53 +- daemon/remote/cmd/cmuxd-remote/cli_test.go | 65 +- daemon/remote/cmd/cmuxd-remote/main.go | 215 +-- daemon/remote/cmd/cmuxd-remote/main_test.go | 86 +- docs/remote-daemon-spec.md | 14 +- scripts/reload.sh | 8 +- tests/test_cli_version_memory_guard.py | 79 +- 36 files changed, 4756 insertions(+), 2285 deletions(-) create mode 100644 cmuxTests/WorkspaceRemoteConnectionTests.swift diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 287aef6a..34b94949 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -264,7 +264,7 @@ jobs: NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" - echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" >> "$GITHUB_ENV" NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 83961df7..4e770068 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -528,7 +528,7 @@ enum CLIIDFormat: String { } } -private enum SocketPasswordResolver { +enum SocketPasswordResolver { private static let service = "com.cmuxterm.app.socket-control" private static let account = "local-socket-password" private static let directoryName = "cmux" @@ -569,15 +569,21 @@ private enum SocketPasswordResolver { return normalized(value) } - private static func keychainServices(socketPath: String) -> [String] { - guard let scope = keychainScope(socketPath: socketPath) else { + static func keychainServices( + socketPath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String] { + guard let scope = keychainScope(socketPath: socketPath, environment: environment) else { return [service] } - return ["\(service).\(scope)"] + return ["\(service).\(scope)", service] } - private static func keychainScope(socketPath: String) -> String? { - if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + private static func keychainScope( + socketPath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + if let tag = normalized(environment["CMUX_TAG"]) { let scoped = sanitizeScope(tag) if !scoped.isEmpty { return scoped @@ -836,15 +842,8 @@ private enum CLISocketPathResolver { final class SocketClient { private let path: String private var socketFD: Int32 = -1 - private static let connectRetryWindowSeconds: TimeInterval = 2.0 - private static let connectRetryIntervalSeconds: TimeInterval = 0.1 - private static let retriableConnectErrnos: Set = [ - ENOENT, - ECONNREFUSED, - EAGAIN, - EINTR - ] private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 + private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12 private static let responseTimeoutSeconds: TimeInterval = { let env = ProcessInfo.processInfo.environment if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], @@ -865,69 +864,7 @@ final class SocketClient { func connect() throws { if socketFD >= 0 { return } - - let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds) - var lastError: CLIError? - - while true { - // Verify socket is owned by the current user to prevent fake-socket attacks. - var st = stat() - guard stat(path, &st) == 0 else { - let error = CLIError(message: "Socket not found at \(path)") - lastError = error - if errno == ENOENT, Date() < deadline { - Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) - continue - } - throw error - } - guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { - throw CLIError(message: "Path exists at \(path) but is not a Unix socket") - } - guard st.st_uid == getuid() else { - throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") - } - - socketFD = socket(AF_UNIX, SOCK_STREAM, 0) - if socketFD < 0 { - throw CLIError(message: "Failed to create socket") - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strncpy(buf, ptr, maxLength - 1) - } - } - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - if result == 0 { - return - } - - let connectErrno = errno - Darwin.close(socketFD) - socketFD = -1 - - let error = CLIError( - message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" - ) - lastError = error - if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline { - Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) - continue - } - throw error - } - - throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)") + try connectOnce() } func close() { @@ -949,27 +886,27 @@ final class SocketClient { var data = Data() var sawNewline = false - let start = Date() while true { - var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollFD, 1, 100) - if ready < 0 { - throw CLIError(message: "Socket read error") - } - if ready == 0 { - if sawNewline { - break - } - if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds { - throw CLIError(message: "Command timed out") - } - continue - } + try configureReceiveTimeout( + sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds + ) var buffer = [UInt8](repeating: 0, count: 8192) let count = Darwin.read(socketFD, &buffer, buffer.count) - if count <= 0 { + if count < 0 { + if errno == EINTR { + continue + } + if errno == EAGAIN || errno == EWOULDBLOCK { + if sawNewline { + break + } + throw CLIError(message: "Command timed out") + } + throw CLIError(message: "Socket read error") + } + if count == 0 { break } data.append(buffer, count: count) @@ -987,6 +924,189 @@ final class SocketClient { return response } + private func connectOnce() throws { + // Verify socket is owned by the current user to prevent fake-socket attacks. + var st = stat() + guard stat(path, &st) == 0 else { + throw CLIError(message: "Socket not found at \(path)") + } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { + throw CLIError(message: "Path exists at \(path) but is not a Unix socket") + } + guard st.st_uid == getuid() else { + throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") + } + + socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + if socketFD < 0 { + throw CLIError(message: "Failed to create socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(buf, ptr, maxLength - 1) + } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { + return + } + + let connectErrno = errno + Darwin.close(socketFD) + socketFD = -1 + throw CLIError( + message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" + ) + } + + private func configureReceiveTimeout(_ timeout: TimeInterval) throws { + var interval = timeval( + tv_sec: Int(timeout.rounded(.down)), + tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000) + ) + let result = withUnsafePointer(to: &interval) { ptr in + setsockopt( + socketFD, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + guard result == 0 else { + throw CLIError(message: "Failed to configure socket receive timeout") + } + } + + static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { + let client = SocketClient(path: path) + if (try? client.connect()) != nil { + return client + } + + guard let watchDirectory = existingWatchDirectory(forPath: path) else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + let watchFD = open(watchDirectory, O_EVTONLY) + guard watchFD >= 0 else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + + let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)") + let semaphore = DispatchSemaphore(value: 0) + var connected = false + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: watchFD, + eventMask: [.write, .rename, .delete, .attrib, .extend, .link], + queue: queue + ) + + func attemptConnect() { + guard !connected else { return } + if (try? client.connect()) != nil { + connected = true + semaphore.signal() + } + } + + source.setEventHandler { + attemptConnect() + } + source.setCancelHandler { + Darwin.close(watchFD) + } + source.resume() + queue.async { + attemptConnect() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + source.cancel() + client.close() + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + + source.cancel() + return client + } + + static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws { + if FileManager.default.fileExists(atPath: path) { + return + } + + guard let watchDirectory = existingWatchDirectory(forPath: path) else { + throw CLIError(message: "Timed out waiting for \(path)") + } + let watchFD = open(watchDirectory, O_EVTONLY) + guard watchFD >= 0 else { + throw CLIError(message: "Timed out waiting for \(path)") + } + + let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)") + let semaphore = DispatchSemaphore(value: 0) + var found = false + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: watchFD, + eventMask: [.write, .rename, .delete, .attrib, .extend, .link], + queue: queue + ) + + func checkPath() { + guard !found else { return } + if FileManager.default.fileExists(atPath: path) { + found = true + semaphore.signal() + } + } + + source.setEventHandler { + checkPath() + } + source.setCancelHandler { + Darwin.close(watchFD) + } + source.resume() + queue.async { + checkPath() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + source.cancel() + throw CLIError(message: "Timed out waiting for \(path)") + } + + source.cancel() + } + + private static func existingWatchDirectory(forPath path: String) -> String? { + let fileManager = FileManager.default + var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true) + + while !candidate.path.isEmpty { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { + return candidate.path + } + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path { + break + } + candidate = parent + } + return nil + } + func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] { let request: [String: Any] = [ "id": UUID().uuidString, @@ -1555,8 +1675,6 @@ struct CMUXCLI { let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" print("OK \(wsId)") if let commandText = commandOpt, !wsId.isEmpty { - // Wait for shell to initialize - Thread.sleep(forTimeInterval: 0.5) let text = unescapeSendText(commandText + "\\n") let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] _ = try client.sendV2(method: "surface.send_text", params: sendParams) @@ -2334,24 +2452,10 @@ struct CMUXCLI { if (try? client.connect()) == nil { client.close() try launchApp() - // Poll until socket accepts connections (up to 10 seconds) - let pollClient = SocketClient(path: socketPath) - var connected = false - for _ in 0..<100 { - if (try? pollClient.connect()) != nil { - connected = true - break - } - pollClient.close() - Thread.sleep(forTimeInterval: 0.1) - } - guard connected else { - throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") - } - // Use pollClient since it's connected - defer { pollClient.close() } + let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) + defer { launchedClient.close() } let params: [String: Any] = ["cwd": directory] - let response = try pollClient.sendV2(method: "workspace.create", params: params) + let response = try launchedClient.sendV2(method: "workspace.create", params: params) let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" if !wsRef.isEmpty { print("OK \(wsRef)") @@ -2472,26 +2576,13 @@ struct CMUXCLI { if launchIfNeeded && (try? client.connect()) == nil { client.close() try launchApp() - - let pollClient = SocketClient(path: socketPath) - var connected = false - for _ in 0..<100 { - if (try? pollClient.connect()) != nil { - connected = true - break - } - pollClient.close() - Thread.sleep(forTimeInterval: 0.1) - } - guard connected else { - throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") - } + let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) try authenticateClientIfNeeded( - pollClient, + launchedClient, explicitPassword: explicitPassword, socketPath: socketPath ) - return pollClient + return launchedClient } try client.connect() @@ -3198,7 +3289,7 @@ struct CMUXCLI { windowOverride: windowOverride ) } - private struct SSHCommandOptions { + struct SSHCommandOptions { let destination: String let port: Int? let identityFile: String? @@ -3251,17 +3342,49 @@ struct CMUXCLI { jsonOutput: Bool, idFormat: CLIIDFormat ) throws { + let sshStartedAt = Date() // Use the socket path from this invocation (supports --socket overrides). let localSocketPath = client.socketPath let remoteRelayPort = generateRemoteRelayPort() let relayID = UUID().uuidString.lowercased() let relayToken = try randomHex(byteCount: 32) let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) - prepareSSHTerminfoIfNeeded(sshOptions) - let sshCommand = buildSSHCommandText(sshOptions) + func logSSHTiming(_ stage: String, extra: String = "") { + let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000) + let suffix = extra.isEmpty ? "" : " \(extra)" + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)" + ) + } + + logSSHTiming("parsed") + let terminfoSource = localXtermGhosttyTerminfoSource() + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " + + "source=\(terminfoSource == nil ? 0 : 1)" + ) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() - let sshStartupCommand = buildSSHStartupCommand( - sshCommand: sshCommand, + let initialSSHCommand = buildSSHCommandText(sshOptions) + let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty + ? buildInteractiveRemoteShellScript( + remoteRelayPort: sshOptions.remoteRelayPort, + shellFeatures: shellFeaturesValue, + terminfoSource: terminfoSource + ) + : nil + let remoteTerminalSSHCommand = buildSSHCommandText( + sshOptions, + remoteBootstrapScript: remoteTerminalBootstrapScript + ) + let initialSSHStartupCommand = try buildSSHStartupCommand( + sshCommand: initialSSHCommand, + shellFeatures: "", + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteTerminalSSHStartupCommand = try buildSSHStartupCommand( + sshCommand: remoteTerminalSSHCommand, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort ) @@ -3279,9 +3402,10 @@ struct CMUXCLI { ) let workspaceCreateParams: [String: Any] = [ - "initial_command": sshStartupCommand, + "initial_command": initialSSHStartupCommand, ] + let workspaceCreateStartedAt = Date() let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { throw CLIError(message: "workspace.create did not return workspace_id") @@ -3292,6 +3416,10 @@ struct CMUXCLI { "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" ) + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))" + ) let configuredPayload: [String: Any] do { if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -3322,7 +3450,7 @@ struct CMUXCLI { configureParams["relay_token"] = relayToken configureParams["local_socket_path"] = sshOptions.localSocketPath } - configureParams["terminal_startup_command"] = sshStartupCommand + configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand cliDebugLog( "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + @@ -3330,6 +3458,7 @@ struct CMUXCLI { "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" ) + let configureStartedAt = Date() configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) var selectParams: [String: Any] = ["workspace_id": workspaceId] if let workspaceWindowId, !workspaceWindowId.isEmpty { @@ -3340,6 +3469,10 @@ struct CMUXCLI { cliDebugLog( "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" ) + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))" + ) } catch { cliDebugLog( "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" @@ -3355,12 +3488,15 @@ struct CMUXCLI { var payload = configuredPayload - payload["ssh_command"] = sshCommand - payload["ssh_startup_command"] = sshStartupCommand + payload["ssh_command"] = initialSSHCommand + payload["ssh_startup_command"] = initialSSHStartupCommand + payload["ssh_terminal_command"] = remoteTerminalSSHCommand + payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand payload["ssh_env_overrides"] = [ "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, ] payload["remote_relay_port"] = remoteRelayPort + logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { @@ -3456,22 +3592,24 @@ struct CMUXCLI { ) } - private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { + func buildSSHCommandText( + _ options: SSHCommandOptions, + remoteBootstrapScript: String? = nil + ) -> String { var parts = baseSSHArguments(options) - let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + let trimmedRemoteBootstrap = remoteBootstrapScript? + .trimmingCharacters(in: .whitespacesAndNewlines) if options.extraArguments.isEmpty { - // No explicit remote command provided. Use RemoteCommand to bootstrap - // the relay wrapper and then hand off to an interactive shell. + if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty { + let remoteCommand = sshPercentEscapedRemoteCommand( + encodedRemoteBootstrapCommand(trimmedRemoteBootstrap) + ) + parts += ["-o", "RemoteCommand=\(remoteCommand)"] + } if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { parts.append("-tt") } - if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { - parts += [ - "-o", - "RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))", - ] - } parts.append(options.destination) } else { parts.append(options.destination) @@ -3488,11 +3626,17 @@ struct CMUXCLI { return merged } - func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { + func buildInteractiveRemoteShellScript( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil + ) -> String { + let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource) let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" - let commonShellLines = remoteEnvExportLines + let commonShellLines = remoteTerminalLines + + remoteEnvExportLines + ["export PATH=\"$HOME/.cmux/bin:$PATH\""] + (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? []) + [ @@ -3504,10 +3648,17 @@ struct CMUXCLI { "if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi", "export ZDOTDIR=\"\(shellStateDir)\"", ] + let zshProfileLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"", + ] let zshRCLines = [ "[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"", ] + commonShellLines + let zshLoginLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"", + ] let bashRCLines = [ + "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", ] + commonShellLines let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) @@ -3524,18 +3675,28 @@ struct CMUXCLI { outerLines.append(contentsOf: zshEnvLines) outerLines += [ "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", + ] + outerLines.append(contentsOf: zshProfileLines) + outerLines += [ + "CMUXZSHPROFILE", " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", ] outerLines.append(contentsOf: zshRCLines) outerLines += [ "CMUXZSHRC", - " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zshrc\" >/dev/null 2>&1 || true", + " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", + ] + outerLines.append(contentsOf: zshLoginLines) + outerLines += [ + "CMUXZSHLOGIN", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", ] outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) outerLines += [ " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", " export ZDOTDIR=\"$cmux_shell_dir\"", - " exec \"$CMUX_LOGIN_SHELL\" -i", + " exec \"$CMUX_LOGIN_SHELL\" -il", " ;;", " bash)", " mkdir -p \"$HOME/.cmux/relay\"", @@ -3554,22 +3715,57 @@ struct CMUXCLI { " ;;", " *)", ] - outerLines.append(contentsOf: commonShellLines.map { " " + $0 }) - outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines.append(contentsOf: commonShellLines) + outerLines.append(contentsOf: relayWarmupLines) outerLines += [ - " exec \"$CMUX_LOGIN_SHELL\" -i", - " ;;", + "exec \"$CMUX_LOGIN_SHELL\" -i", + ";;", "esac", ] - let outerCommand = outerLines.joined(separator: "\n") + return outerLines.joined(separator: "\n") + } - return "/bin/sh -c \(shellQuote(outerCommand))" + func buildInteractiveRemoteShellCommand( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil + ) -> String { + let script = buildInteractiveRemoteShellScript( + remoteRelayPort: remoteRelayPort, + shellFeatures: shellFeatures, + terminfoSource: terminfoSource + ) + return "/bin/sh -c \(shellQuote(script))" + } + + private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] { + var lines: [String] = [ + "cmux_term='xterm-256color'", + "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", + " cmux_term='xterm-ghostty'", + "fi", + "export TERM=\"$cmux_term\"", + ] + guard let terminfoSource else { return lines } + let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTerminfoSource.isEmpty else { return lines } + lines += [ + "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", + " (", + " command -v tic >/dev/null 2>&1 || exit 0", + " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", + " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", + trimmedTerminfoSource, + "CMUXTERMINFO", + " ) >/dev/null 2>&1 &", + "fi", + ] + return lines } private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { let environment = ProcessInfo.processInfo.environment - let term = "xterm-ghostty" let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) @@ -3578,7 +3774,6 @@ struct CMUXCLI { let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) var exports: [String] = [ - "export TERM=\(shellQuote(term))", "export COLORTERM=\(shellQuote(colorTerm))", "export TERM_PROGRAM=\(shellQuote(termProgram))", ] @@ -3593,16 +3788,7 @@ struct CMUXCLI { private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { guard remoteRelayPort > 0 else { return [] } - return [ - "cmux_wait_attempt=0", - "while [ \"$cmux_wait_attempt\" -lt 40 ]; do", - " if [ -x \"$HOME/.cmux/bin/cmux\" ] && [ -f \"$HOME/.cmux/relay/\(remoteRelayPort).auth\" ] && CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort) \"$HOME/.cmux/bin/cmux\" ping >/dev/null 2>&1; then", - " break", - " fi", - " cmux_wait_attempt=$((cmux_wait_attempt + 1))", - " sleep 0.2", - "done", - ] + return [] } private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { @@ -3629,37 +3815,6 @@ struct CMUXCLI { return parts } - private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) { - guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return } - - let effectiveSSHOptions = effectiveSSHOptions( - options.sshOptions, - remoteRelayPort: options.remoteRelayPort - ) - var args = baseSSHArguments(options) - if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") { - args += ["-o", "ConnectTimeout=3"] - } - if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectionAttempts") { - args += ["-o", "ConnectionAttempts=1"] - } - args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination] - let installScript = """ - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - """ - args.append(installScript) - - _ = runProcess( - executablePath: "/usr/bin/ssh", - arguments: Array(args.dropFirst()), - stdinText: terminfoSource, - timeout: 4.0 - ) - } - private func localXtermGhosttyTerminfoSource() -> String? { let result = runProcess( executablePath: "/usr/bin/infocmp", @@ -3714,25 +3869,63 @@ struct CMUXCLI { return merged.joined(separator: ",") } - private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String { + func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String { + let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString() + let encodedLiteral = shellQuote(encodedScript) + return [ + "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", + "(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", + "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", + "/bin/sh \"$cmux_tmp\"", + "cmux_status=$?", + "rm -f \"$cmux_tmp\"", + "exit $cmux_status", + ].joined(separator: "; ") + } + + func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { + remoteCommand.replacingOccurrences(of: "%", with: "%%") + } + + func buildSSHStartupCommand( + sshCommand: String, + shellFeatures: String, + remoteRelayPort: Int + ) throws -> String { let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) - let script = [ - shellFeaturesBootstrap, + var scriptLines: [String] = [] + if !shellFeaturesBootstrap.isEmpty { + scriptLines.append(shellFeaturesBootstrap) + } + scriptLines += [ "CMUX_SSH_SESSION_ENDED=0", "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", - "command \(sshCommand)", + ] + scriptLines.append("command \(sshCommand)") + scriptLines += [ + "cmux_ssh_status=$?", "trap - EXIT HUP INT TERM", "cmux_ssh_session_end", - "exec ${SHELL:-/bin/zsh} -l", + "exit $cmux_ssh_status", ] - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .joined(separator: "\n") - return "/bin/zsh -ilc \(shellQuote(script))" + let script = scriptLines.joined(separator: "\n") + return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort) + } + + private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String { + let tempDir = FileManager.default.temporaryDirectory + let scriptURL = tempDir.appendingPathComponent( + "cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh" + ) + let script = "#!/bin/sh\n\(scriptBody)\n" + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) + return shellQuote(scriptURL.path) } private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { @@ -8902,7 +9095,6 @@ struct CMUXCLI { ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, @@ -8940,7 +9132,6 @@ struct CMUXCLI { ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, @@ -8977,7 +9168,6 @@ struct CMUXCLI { let paneId = created["pane_id"] as? String // Keep the leader pane focused while Claude starts teammates beside it. if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": target.workspaceId, "surface_id": surfaceId, @@ -9381,13 +9571,17 @@ struct CMUXCLI { return } let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + do { + try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow)) + try? FileManager.default.removeItem(at: signalURL) + print("OK") + return + } catch { if FileManager.default.fileExists(atPath: signalURL.path) { try? FileManager.default.removeItem(at: signalURL) print("OK") return } - Thread.sleep(forTimeInterval: 0.05) } throw CLIError(message: "wait-for timed out waiting for '\(name)'") diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b0de0d8c..2bc5eae0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; @@ -242,6 +243,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; + F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; @@ -480,6 +482,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, @@ -723,6 +726,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e5eaaa3c..26b1d4d4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2075,11 +2075,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false - private var socketListenerHealthTimer: DispatchSourceTimer? - private var socketListenerHealthCheckInFlight = false - private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) - private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast - private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -2383,7 +2378,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) stopSessionAutosaveTimer() - stopSocketListenerHealthMonitor() TerminalController.shared.stop() VSCodeServeWebController.shared.stop() BrowserProfileStore.shared.flushPendingSaves() @@ -2412,7 +2406,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installLifecycleSnapshotObserversIfNeeded() prepareStartupSessionSnapshotIfNeeded() startSessionAutosaveTimerIfNeeded() - startSocketListenerHealthMonitorIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -3005,91 +2998,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) } - private func startSocketListenerHealthMonitorIfNeeded() { - guard socketListenerHealthTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule( - deadline: .now() + Self.socketListenerHealthCheckInterval, - repeating: Self.socketListenerHealthCheckInterval - ) - timer.setEventHandler { [weak self] in - Task { @MainActor [weak self] in - self?.restartSocketListenerIfNeededForHealthCheck(source: "health.timer") - } - } - timer.resume() - socketListenerHealthTimer = timer - } - - private func stopSocketListenerHealthMonitor() { - socketListenerHealthTimer?.cancel() - socketListenerHealthTimer = nil - socketListenerHealthCheckInFlight = false - } - - private func restartSocketListenerIfNeededForHealthCheck(source: String) { - guard !socketListenerHealthCheckInFlight, - let config = socketListenerConfigurationIfEnabled() else { return } - let terminalController = TerminalController.shared - let expectedSocketPath = terminalController.activeSocketPath(preferredPath: config.path) - socketListenerHealthCheckInFlight = true - Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in - let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) - Task { @MainActor [weak self, health] in - guard let self else { return } - self.socketListenerHealthCheckInFlight = false - self.handleSocketListenerHealthCheckResult( - health, - source: source, - expectedSocketPath: expectedSocketPath - ) - } - } - } - - private func handleSocketListenerHealthCheckResult( - _ health: TerminalController.SocketListenerHealth, - source: String, - expectedSocketPath: String - ) { - guard let config = socketListenerConfigurationIfEnabled() else { return } - let currentExpectedSocketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - guard currentExpectedSocketPath == expectedSocketPath else { return } - guard !health.isHealthy else { - lastSocketListenerUnhealthyCaptureAt = .distantPast - return - } - let failureSignals = health.failureSignals - var data: [String: Any] = [ - "source": source, - "path": currentExpectedSocketPath, - "isRunning": health.isRunning ? 1 : 0, - "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, - "socketPathMatches": health.socketPathMatches ? 1 : 0, - "socketPathExists": health.socketPathExists ? 1 : 0, - "socketProbePerformed": health.socketProbePerformed ? 1 : 0, - "failureSignals": failureSignals - ] - if let socketConnectable = health.socketConnectable { - data["socketConnectable"] = socketConnectable ? 1 : 0 - } - if let socketConnectErrno = health.socketConnectErrno { - data["socketConnectErrno"] = Int(socketConnectErrno) - } - sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data) - let now = Date() - if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown { - lastSocketListenerUnhealthyCaptureAt = now - sentryCaptureWarning( - "socket.listener.unhealthy", - category: "socket", - data: data, - contextKey: "socket_listener_health" - ) - } - restartSocketListenerIfEnabled(source: source) - } - private func disableSuddenTerminationIfNeeded() { guard !didDisableSuddenTermination else { return } ProcessInfo.processInfo.disableSuddenTermination() @@ -3483,6 +3391,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + private func notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -3529,6 +3441,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" ) #endif + notifyMainWindowContextsDidChange() if window.isKeyWindow { setActiveMainWindow(window) } @@ -4688,6 +4601,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts[desiredKey] = context context.window = window + notifyMainWindowContextsDidChange() } private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { @@ -4733,6 +4647,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for key in removedKeys { mainWindowContexts.removeValue(forKey: key) } + notifyMainWindowContextsDidChange() return removed } @@ -4743,6 +4658,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for key in contextKeys { mainWindowContexts.removeValue(forKey: key) } + notifyMainWindowContextsDidChange() commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) @@ -4997,6 +4913,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context.tabManager } + private struct FocusedTerminalShortcutContext { + let tabManager: TabManager + let workspaceId: UUID + let panelId: UUID + } + + private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { + if let manager = tabManagerFor(tabId: tabId) { + return manager + } + if let preferredWindow, + let context = contextForMainWindow(preferredWindow), + context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context.tabManager + } + if let activeManager = tabManager, + activeManager.tabs.contains(where: { $0.id == tabId }) { + return activeManager + } + return nil + } + + private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let responder = targetWindow?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + guard let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { + return nil + } + return FocusedTerminalShortcutContext( + tabManager: manager, + workspaceId: workspaceId, + panelId: panelId + ) + } + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { if let context = contextForMainWindow(event.window) { return context @@ -5857,19 +5813,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.setString(payload, forType: .string) } - private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) { - let maxAttempts = 60 + private func sendTextWhenReady(_ text: String, to tab: Tab, beforeSend: (() -> Void)? = nil) { if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { beforeSend?() terminalPanel.sendText(text) return } - guard attempt < maxAttempts else { - NSLog("Command send: surface not ready after \(maxAttempts) attempts") - return + + var resolved = false + var readyObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? + + func finishIfReady() { + guard !resolved, + let terminalPanel = tab.focusedTerminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + beforeSend?() + terminalPanel.sendText(text) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend) + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in finishIfReady() } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tab.id else { return } + finishIfReady() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + NSLog("Command send: surface not ready after 3.0s") + } } } @@ -5883,7 +5870,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private let debugStressTabsPerPane = 4 private let debugStressYieldInterval = 4 private let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - private let debugStressSurfaceLoadPollNanoseconds: UInt64 = 25_000_000 @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } @@ -6110,6 +6096,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let panelId: UUID } + private func waitForDebugStressCondition( + timeout: TimeInterval, + installObservers: (@escaping () -> Void) -> [NSObjectProtocol], + evaluate: @escaping () -> Bool + ) async -> Bool { + await withCheckedContinuation { continuation in + var observers: [NSObjectProtocol] = [] + var timeoutWorkItem: DispatchWorkItem? + var finished = false + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + } + + func finish(_ result: Bool) { + guard !finished else { return } + finished = true + cleanup() + continuation.resume(returning: result) + } + + let trigger = { + if evaluate() { + finish(true) + } + } + + observers = installObservers { + DispatchQueue.main.async { + trigger() + } + } + let workItem = DispatchWorkItem { + finish(evaluate()) + } + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: workItem) + trigger() + } + } + private func loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( _ workspaces: [Workspace], tabManager: TabManager @@ -6193,8 +6223,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var mountedWorkspaceCount = 0 let selectedWorkspaceId = tabManager?.selectedTabId - for _ in 0..<4 { - forceDebugStressVisibleLayout() + let updateMountedCount = { [self] in + self.forceDebugStressVisibleLayout() mountedWorkspaceCount = 0 for workspace in workspaces { if workspace.id == selectedWorkspaceId { @@ -6209,12 +6239,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mountedWorkspaceCount += 1 } } - if mountedWorkspaceCount == workspaces.count { - break - } - await Task.yield() - try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) } + let _ = await waitForDebugStressCondition( + timeout: 0.25, + installObservers: { trigger in + [ + NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + trigger() + } + ] + }, + evaluate: { + updateMountedCount() + return mountedWorkspaceCount == workspaces.count + } + ) dlog("stress.setup.mount mounted=\(mountedWorkspaceCount)/\(workspaces.count)") return mountedWorkspaceCount @@ -6231,17 +6288,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selectedWorkspaceId = tabManager?.selectedTabId var pendingTargets = targets var attempts = 0 - var pass = 0 - - while !pendingTargets.isEmpty, Date() < deadline { - pass += 1 - forceDebugStressVisibleLayout() + var eventCount = 0 + func refreshPendingTargets() { + self.forceDebugStressVisibleLayout() var nextPending: [DebugStressTerminalLoadTarget] = [] nextPending.reserveCapacity(pendingTargets.count) - var restartedThisPass = 0 + var startedThisPass = 0 - for (targetIndex, target) in pendingTargets.enumerated() { + for target in pendingTargets { guard let terminalPanel = target.workspace.panel(for: target.tabId) as? TerminalPanel else { nextPending.append(target) continue @@ -6258,37 +6313,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldReconcileVisibleSelection { target.workspace.scheduleDebugStressTerminalGeometryReconcile() - if pass == 1 || (pass % 4) == 0 { - if target.workspace.preloadTerminalPanelForDebugStress( - tabId: target.tabId, - inPane: target.paneId - ) != nil { - restartedThisPass += 1 - attempts += 1 - } - } else { - terminalPanel.requestViewReattach() - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } - } else { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + terminalPanel.requestViewReattach() } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + startedThisPass += 1 nextPending.append(target) - - if ((targetIndex + 1) % 16) == 0 { - await Task.yield() - } } - if nextPending.count != pendingTargets.count || restartedThisPass > 0 || pass == 1 || (pass % 8) == 0 { + eventCount += 1 + if nextPending.count != pendingTargets.count || startedThisPass > 0 || eventCount == 1 { dlog( - "stress.setup.await pass=\(pass) pending=\(nextPending.count) " + - "restarted=\(restartedThisPass)" + "stress.setup.await event=\(eventCount) pending=\(nextPending.count) " + + "started=\(startedThisPass)" ) } - try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + attempts += startedThisPass pendingTargets = nextPending } + refreshPendingTargets() + let remaining = deadline.timeIntervalSinceNow + if remaining > 0, !pendingTargets.isEmpty { + let _ = await waitForDebugStressCondition( + timeout: remaining, + installObservers: { trigger in + [ + NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + trigger() + } + ] + }, + evaluate: { + refreshPendingTargets() + return pendingTargets.isEmpty + } + ) + } return (pendingTargets: pendingTargets, attempts: attempts) } @@ -6647,16 +6724,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return updates } - private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) { - let maxAttempts = 120 - guard attempt < maxAttempts else { - writeGotoSplitTestData([ - "webViewFocused": "false", - "setupError": "Timed out waiting for WKWebView focus" - ]) - return - } - + private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) { guard let browserPanel = tab.browserPanel(for: browserPanelId) else { writeGotoSplitTestData([ "webViewFocused": "false", @@ -6665,14 +6733,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - // Select the browser surface and try to focus the WKWebView. - tab.focusPanel(browserPanelId) + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? - if isWebViewFocused(browserPanel), - let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest( - tab: tab, - browserPanelId: browserPanelId - ) { + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + } + + func recordFocusedState() { + guard !resolved else { return } + guard let panel = tab.browserPanel(for: browserPanelId) else { + resolved = true + cleanup() + writeGotoSplitTestData([ + "webViewFocused": "false", + "setupError": "Browser panel missing" + ]) + return + } + + tab.focusPanel(browserPanelId) + + guard isWebViewFocused(panel), + let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest( + tab: tab, + browserPanelId: browserPanelId + ) else { + return + } + + resolved = true + cleanup() writeGotoSplitTestData([ "browserPanelId": browserPanelId.uuidString, "browserPaneId": browserPaneId.description, @@ -6686,14 +6780,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "webViewFocused": "true" ]) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { - setupFocusedInputForGotoSplitUITest(panel: browserPanel) + setupFocusedInputForGotoSplitUITest(panel: panel) } - return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.focusWebViewForGotoSplitUITest(tab: tab, browserPanelId: browserPanelId, attempt: attempt + 1) + observers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + recordFocusedState() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == browserPanelId else { return } + recordFocusedState() + }) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in recordFocusedState() } + DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { [weak self] in + guard let self else { return } + if !resolved { + cleanup() + self.writeGotoSplitTestData([ + "webViewFocused": "false", + "setupError": "Timed out waiting for WKWebView focus" + ]) + } } + + recordFocusedState() } private func isWebViewFocused(_ panel: BrowserPanel) -> Bool { @@ -6749,61 +6870,125 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func recordGotoSplitUITestWebViewFocus(panelId: UUID, key: String) { - // Give the responder chain time to settle, retrying for slow environments (e.g. VM). - recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: 0) - } + guard let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { + return + } - private func recordGotoSplitUITestWebViewFocusRetry(panelId: UUID, key: String, attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.25, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, let tabManager, let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { return } - let focused = self.isWebViewFocused(panel) - // If focus hasn't settled yet and we have retries left, try again. - if !focused && key.contains("Exit") && attempt < delays.count - 1 { - self.recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: attempt + 1) - return + guard key.contains("Exit") else { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.writeGotoSplitTestData([ + key: self.isWebViewFocused(panel) ? "true" : "false", + "\(key)PanelId": panelId.uuidString + ]) } + return + } + + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + panelsCancellable = nil + } + + @MainActor + func finish(with focused: Bool) { + guard !resolved else { return } + resolved = true + cleanup() self.writeGotoSplitTestData([ key: focused ? "true" : "false", "\(key)PanelId": panelId.uuidString ]) } - } - private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) { - let maxAttempts = 80 - guard attempt < maxAttempts else { - writeGotoSplitTestData([ - "webInputFocusSeeded": "false", - "setupError": "Timed out focusing page input for omnibar restore test" - ]) - return + @MainActor + func evaluate() { + guard !resolved, + let currentTabManager = self.tabManager, + let currentTab = currentTabManager.selectedWorkspace, + let currentPanel = currentTab.browserPanel(for: panelId) else { + return + } + guard self.isWebViewFocused(currentPanel) else { return } + finish(with: true) } + observers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard notification.object as? WKWebView === panel.webView else { return } + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == panelId else { return } + Task { @MainActor in evaluate() } + }) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in evaluate() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + Task { @MainActor in + guard !resolved else { return } + let focused = (self.tabManager?.selectedWorkspace?.browserPanel(for: panelId)).map(self.isWebViewFocused) ?? false + finish(with: focused) + } + } + Task { @MainActor in evaluate() } + } + + private func javaScriptLiteral(_ value: String?) -> String { + guard let value else { return "null" } + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayLiteral = String(data: data, encoding: .utf8), + arrayLiteral.count >= 2 else { + return "null" + } + return String(arrayLiteral.dropFirst().dropLast()) + } + + private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) { let script = """ (() => { - try { - const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true; - const readyState = String(document.readyState || ""); - if (!trackerInstalled || readyState !== "complete") { - const active = document.activeElement; - return { - focused: false, - id: "", - activeId: active && typeof active.id === "string" ? active.id : "", - activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled, - trackedStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - readyState - }; - } - + const snapshot = () => { + const active = document.activeElement; + return { + focused: false, + id: "", + secondaryId: "", + secondaryCenterX: -1, + secondaryCenterY: -1, + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState: String(document.readyState || "") + }; + }; + const seed = () => { const ensureInput = (id, value) => { const existing = document.getElementById(id); const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") @@ -6901,28 +7086,69 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent secondaryCenterY, activeId: active && typeof active.id === "string" ? active.id : "", activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled, + trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, trackedStateId: window.__cmuxAddressBarFocusState && typeof window.__cmuxAddressBarFocusState.id === "string" ? window.__cmuxAddressBarFocusState.id : "", - readyState - }; - } catch (_) { - return { - focused: false, - id: "", - secondaryId: "", - secondaryCenterX: -1, - secondaryCenterY: -1, - activeId: "", - activeTag: "", - trackerInstalled: false, - trackedStateId: "", - readyState: "" + readyState: String(document.readyState || "") }; + }; + const ready = () => + window.__cmuxAddressBarFocusTrackerInstalled === true && + String(document.readyState || "") === "complete"; + + if (ready()) { + try { + return seed(); + } catch (_) { + return snapshot(); + } } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const maybeFinish = () => { + if (!ready()) return; + try { + finish(seed()); + } catch (_) { + finish(snapshot()); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== "function") return; + const handler = () => maybeFinish(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + try { + observer = new MutationObserver(() => maybeFinish()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + addListener(document, "readystatechange", true); + addListener(window, "load", true); + const timeoutId = window.setTimeout(() => finish(snapshot()), 4000); + cleanups.push(() => window.clearTimeout(timeoutId)); + maybeFinish(); + }); })(); """ @@ -6986,43 +7212,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1) - } + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out focusing page input for omnibar restore test" + ]) } } private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { - recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0) - } - - private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.25, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, - let tabManager, - let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { return } - - self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in - let activeId = snapshot["id"] ?? "" - let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? "" - if keyPrefix == "addressBarExit", - !expectedInputId.isEmpty, - activeId != expectedInputId, - attempt < delays.count - 1 { - self.recordGotoSplitUITestActiveElementRetry( - panelId: panelId, - keyPrefix: keyPrefix, - attempt: attempt + 1 - ) - return - } + guard let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { + return + } + let expectedInputId = keyPrefix == "addressBarExit" ? gotoSplitUITestExpectedInputId() : nil + let capture: @MainActor @Sendable () -> Void = { [weak self] in + guard let self else { return } + self.evaluateGotoSplitUITestActiveElement( + panel: panel, + awaitingInputId: expectedInputId + ) { snapshot in self.writeGotoSplitTestData([ "\(keyPrefix)PanelId": panelId.uuidString, - "\(keyPrefix)ActiveElementId": activeId, + "\(keyPrefix)ActiveElementId": snapshot["id"] ?? "", "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", @@ -7031,48 +7244,119 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) } } + + if expectedInputId == nil { + DispatchQueue.main.async { + Task { @MainActor in capture() } + } + } else { + Task { @MainActor in capture() } + } } private func evaluateGotoSplitUITestActiveElement( panel: BrowserPanel, + awaitingInputId: String? = nil, completion: @escaping ([String: String]) -> Void ) { + let expectedInputIdLiteral = javaScriptLiteral(awaitingInputId) let script = """ (() => { - try { - const active = document.activeElement; - if (!active) { - return { id: "", tag: "", type: "", editable: "false" }; + const expectedInputId = \(expectedInputIdLiteral); + const snapshot = () => { + try { + const active = document.activeElement; + if (!active) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const editable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + return { + id: typeof active.id === "string" ? active.id : "", + tag, + type, + editable: editable ? "true" : "false", + trackedFocusStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + focusTrackerInstalled: + window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } catch (_) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: "false" + }; } - const tag = (active.tagName || "").toLowerCase(); - const type = (active.type || "").toLowerCase(); - const editable = - !!active.isContentEditable || - tag === "textarea" || - (tag === "input" && type !== "hidden"); - return { - id: typeof active.id === "string" ? active.id : "", - tag, - type, - editable: editable ? "true" : "false", - trackedFocusStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - focusTrackerInstalled: - window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" - }; - } catch (_) { - return { - id: "", - tag: "", - type: "", - editable: "false", - trackedFocusStateId: "", - focusTrackerInstalled: "false" - }; + }; + const matchesExpectation = (state) => + !expectedInputId || (typeof expectedInputId === "string" && state.id === expectedInputId); + + const initial = snapshot(); + if (matchesExpectation(initial)) { + return initial; } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const maybeFinish = () => { + const state = snapshot(); + if (matchesExpectation(state)) { + finish(state); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== "function") return; + const handler = () => maybeFinish(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + try { + observer = new MutationObserver(() => maybeFinish()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + addListener(document, "focusin", true); + addListener(document, "focusout", true); + addListener(document, "selectionchange", true); + addListener(document, "readystatechange", true); + addListener(window, "load", true); + const timeoutId = window.setTimeout(() => finish(snapshot()), 1500); + cleanups.push(() => window.clearTimeout(timeoutId)); + maybeFinish(); + }); })(); """ @@ -7140,17 +7424,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func recordGotoSplitZoomIfNeeded() { guard isGotoSplitUITestRecordingEnabled() else { return } - recordGotoSplitZoomRetry(attempt: 0) - } - - private func recordGotoSplitZoomRetry(attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, - let workspace = self.tabManager?.selectedWorkspace else { return } + guard let workspace = tabManager?.selectedWorkspace else { return } + func snapshot(for workspace: Workspace) -> ([String: String], Bool) { let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first let browserSnapshot = browserPanel.flatMap { @@ -7203,13 +7479,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return browserRestored && terminalRestored }() - if !settled && attempt < delays.count - 1 { - self.recordGotoSplitZoomRetry(attempt: attempt + 1) - return - } + return (updates, settled) + } + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + panelsCancellable = nil + } + + @MainActor + func finish(with updates: [String: String]) { + guard !resolved else { return } + resolved = true + cleanup() self.writeGotoSplitTestData(updates) } + + @MainActor + func evaluate() { + guard !resolved, let currentWorkspace = self.tabManager?.selectedWorkspace else { return } + let (updates, settled) = snapshot(for: currentWorkspace) + guard settled else { return } + finish(with: updates) + } + + observers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in evaluate() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + Task { @MainActor in + guard !resolved, let currentWorkspace = self.tabManager?.selectedWorkspace else { return } + finish(with: snapshot(for: currentWorkspace).0) + } + } + Task { @MainActor in evaluate() } } private func writeGotoSplitTestData(_ updates: [String: String]) { @@ -7240,16 +7573,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent try? FileManager.default.removeItem(atPath: path) - let contextDeadline = Date().addingTimeInterval(8.0) func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) { - if mainWindowContexts.count >= minCount, - mainWindowContexts.values.allSatisfy({ $0.window != nil }) { + let isReady = { + self.mainWindowContexts.count >= minCount && + self.mainWindowContexts.values.allSatisfy { $0.window != nil } + } + guard !isReady() else { completion() return } - guard Date() < contextDeadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - waitForContexts(minCount: minCount, completion) + + var resolved = false + var observer: NSObjectProtocol? + let finish = { + guard !resolved else { return } + resolved = true + if let observer { + NotificationCenter.default.removeObserver(observer) + } + completion() + } + observer = NotificationCenter.default.addObserver( + forName: .mainWindowContextsDidChange, + object: self, + queue: .main + ) { _ in + if isReady() { + finish() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + if isReady() { + finish() + } else if let observer, !resolved { + NotificationCenter.default.removeObserver(observer) + } } } @@ -7259,8 +7617,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent timeout: TimeInterval = 8.0, _ completion: @escaping (UUID) -> Void ) { - let deadline = Date().addingTimeInterval(timeout) - func resolvedSurfaceId() -> UUID? { if let surfaceId = tabManager.focusedPanelId(for: tabId) { return surfaceId @@ -7284,18 +7640,73 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .first } - func poll() { - if let surfaceId = resolvedSurfaceId() { - completion(surfaceId) - return + if let surfaceId = resolvedSurfaceId() { + completion(surfaceId) + return + } + + var resolved = false + var focusObserver: NSObjectProtocol? + var surfaceReadyObserver: NSObjectProtocol? + var tabsCancellable: AnyCancellable? + var panelsCancellable: AnyCancellable? + var observedWorkspaceId: UUID? + + func cleanup() { + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) } - guard Date() < deadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - poll() + if let surfaceReadyObserver { + NotificationCenter.default.removeObserver(surfaceReadyObserver) + } + tabsCancellable?.cancel() + panelsCancellable?.cancel() + } + + func attemptResolve() { + guard !resolved else { return } + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }), + observedWorkspaceId != workspace.id { + observedWorkspaceId = workspace.id + panelsCancellable?.cancel() + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in attemptResolve() } + } + if let surfaceId = resolvedSurfaceId() { + resolved = true + cleanup() + completion(surfaceId) } } - poll() + tabsCancellable = tabManager.$tabs + .map { _ in () } + .sink { _ in attemptResolve() } + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tabId else { return } + attemptResolve() + } + surfaceReadyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tabId else { return } + attemptResolve() + } + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + if !resolved { + cleanup() + } + } + attemptResolve() } waitForContexts(minCount: 1) { [weak self] in @@ -7384,12 +7795,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ], at: path) } - func poll() { + var resolved = false + var observers: [NSObjectProtocol] = [] + var selectedTabCancellable: AnyCancellable? + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + selectedTabCancellable?.cancel() + panelsCancellable?.cancel() + } + + func attemptFocus() { + guard !resolved else { return } guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + resolved = true + cleanup() publish(ready: false, failure: "workspace_missing") return } + panelsCancellable?.cancel() + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in attemptFocus() } guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else { + resolved = true + cleanup() publish(ready: false, failure: "terminal_missing") return } @@ -7399,11 +7831,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.keyWindow === window || NSApp.mainWindow === window }() if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() { + resolved = true + cleanup() publish(ready: true) return } guard Date() < deadline else { + resolved = true + cleanup() publish( ready: false, failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost" @@ -7416,13 +7852,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager.selectTab(tab) tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - poll() - } } - poll() + observers.append(NotificationCenter.default.addObserver( + forName: .mainWindowContextsDidChange, + object: self, + queue: .main + ) { _ in + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + candidateTabId == tabId, + candidateSurfaceId == surfaceId else { return } + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + candidateTabId == tabId, + candidateSurfaceId == surfaceId else { return } + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + workspaceId == tabId, + readySurfaceId == surfaceId else { return } + attemptFocus() + }) + selectedTabCancellable = tabManager.$selectedTabId + .map { _ in () } + .sink { _ in attemptFocus() } + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + if !resolved { + attemptFocus() + } + } + attemptFocus() } private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) { @@ -7451,16 +7931,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPingResponse": "", ], at: path) - restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + let socketPath = config.path + let socketMode = config.mode.rawValue + var observer: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? - let deadline = Date().addingTimeInterval(20.0) - func publish() { - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) - let isTimedOut = Date() >= deadline - let socketPath = config.path - let socketMode = config.mode.rawValue + func publishCurrentState(isTimedOut: Bool) { + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) let dataPath = path - DispatchQueue.global(qos: .utility).async { [weak self] in let pingResponse = health.isHealthy ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) @@ -7487,15 +7965,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPathExists": health.socketPathExists ? "1" : "0", "socketFailureSignals": failureSignals, ], at: dataPath) - guard !isTimedOut, !isReady else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - publish() + guard isReady || isTimedOut else { return } + timeoutWorkItem?.cancel() + if let observer { + NotificationCenter.default.removeObserver(observer) } } } } - publish() + observer = NotificationCenter.default.addObserver( + forName: .socketListenerDidStart, + object: TerminalController.shared, + queue: .main + ) { notification in + let startedPath = notification.userInfo?["path"] as? String + guard startedPath == socketPath else { return } + publishCurrentState(isTimedOut: false) + } + + let timeout = DispatchWorkItem { + publishCurrentState(isTimedOut: true) + } + timeoutWorkItem = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: timeout) + + restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + publishCurrentState(isTimedOut: false) } private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) { @@ -8397,13 +8893,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) } else { - let responder = event.window?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - if let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { - manager.closeOtherTabsInFocusedPaneWithConfirmation() + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() } else { tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() } @@ -8432,20 +8924,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { - let responder = event.window?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - if let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let panelId = ghosttyView.terminalSurface?.id, - let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { #if DEBUG dlog( - "shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + "shortcut.cmdW route=ghostty workspace=\(terminalContext.workspaceId.uuidString.prefix(5)) " + + "panel=\(terminalContext.panelId.uuidString.prefix(5)) selected=\(terminalContext.tabManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" ) #endif - manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId) + terminalContext.tabManager.closePanelWithConfirmation( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId + ) } else { #if DEBUG dlog("shortcut.cmdW route=focusedPanelFallback") @@ -8572,7 +9062,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { return true } - _ = performSplitShortcut(direction: .right) + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } @@ -8583,7 +9076,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { return true } - _ = performSplitShortcut(direction: .down) + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } @@ -9237,8 +9733,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func performSplitShortcut(direction: SplitDirection) -> Bool { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) let directionLabel: String switch direction { @@ -9273,7 +9771,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) - tabManager?.createSplit(direction: direction) + let didCreateSplit: Bool = { + if let terminalContext { + return terminalContext.tabManager.createSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction + ) != nil + } + return tabManager?.createSplit(direction: direction) != nil + }() #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in let keyWindow = NSApp.keyWindow @@ -9300,7 +9807,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } recordGotoSplitSplitIfNeeded(direction: direction) #endif - return true + return didCreateSplit } @discardableResult @@ -10192,8 +10699,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func recordJumpUnreadFocusFromModelIfNeeded( tabManager: TabManager, tabId: UUID, - expectedSurfaceId: UUID?, - attempt: Int = 0 + expectedSurfaceId: UUID? ) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } @@ -10202,24 +10708,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Ensure the expectation is armed even if the view doesn't become first responder. armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) - let maxAttempts = 40 - guard attempt < maxAttempts else { return } - - let isSelected = tabManager.selectedTabId == tabId - let focused = tabManager.focusedSurfaceId(for: tabId) - if isSelected, focused == expectedSurfaceId { + if tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: expectedSurfaceId, - attempt: attempt + 1 - ) + var resolved = false + var observers: [NSObjectProtocol] = [] + var cancellables: [AnyCancellable] = [] + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } + + @MainActor + func finishIfFocused() { + guard !resolved else { return } + guard tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { + return + } + resolved = true + cleanup() + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + } + + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == expectedSurfaceId else { return } + Task { @MainActor in finishIfFocused() } + }) + cancellables.append(tabManager.$selectedTabId.sink { _ in + Task { @MainActor in finishIfFocused() } + }) + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { + cancellables.append(workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in finishIfFocused() } + }) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { @MainActor in + guard !resolved else { return } + cleanup() + } + } + Task { @MainActor in finishIfFocused() } } #endif diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 07393dbe..e68dbbdc 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -3546,6 +3546,10 @@ enum BrowserWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static func postRegistryDidChange(for webView: WKWebView) { + NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView) + } + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } let windowId = ObjectIdentifier(window) @@ -3623,6 +3627,7 @@ enum BrowserWindowPortalRegistry { nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) webViewToWindowId[webViewId] = windowId pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) + postRegistryDidChange(for: webView) } static func synchronizeForAnchor(_ anchorView: NSView) { @@ -3638,6 +3643,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) + postRegistryDidChange(for: webView) } static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool { @@ -3654,6 +3660,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.hideWebView(withId: webViewId, source: source) + postRegistryDidChange(for: webView) } static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { @@ -3704,6 +3711,7 @@ enum BrowserWindowPortalRegistry { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } portalsByWindowId[windowId]?.detachWebView(withId: webViewId) + postRegistryDidChange(for: webView) } static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? { @@ -3717,6 +3725,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.forceRefreshWebView(withId: webViewId, reason: reason) + postRegistryDidChange(for: webView) } static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 245adf14..38e908f5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import Combine import ImageIO import SwiftUI import ObjectiveC @@ -1368,7 +1369,6 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? @State private var didApplyUITestSidebarSelection = false - @State private var workspaceHandoffReadyCheckTask: Task? @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @@ -1396,6 +1396,9 @@ struct ContentView: View { @State private var commandPaletteVisibleResultsFingerprint: Int? @State private var cachedCommandPaletteScope: CommandPaletteListScope? @State private var cachedCommandPaletteFingerprint: Int? + @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? @State private var commandPaletteSearchTask: Task? @State private var commandPaletteSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 @@ -2417,6 +2420,7 @@ struct ContentView: View { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") + attemptCommandPaletteFocusRestoreIfNeeded() scheduleTitlebarTextRefresh() }) @@ -2431,6 +2435,7 @@ struct ContentView: View { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") + attemptCommandPaletteFocusRestoreIfNeeded() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in @@ -2441,6 +2446,7 @@ struct ContentView: View { let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), focusedBrowser.webView === webView else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + attemptCommandPaletteFocusRestoreIfNeeded() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in @@ -2450,6 +2456,36 @@ struct ContentView: View { selectedWorkspace.focusedPanelId == panelId, selectedWorkspace.browserPanel(for: panelId) != nil else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + attemptCommandPaletteFocusRestoreIfNeeded() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher( + for: NSWindow.didBecomeKeyNotification, + object: observedWindow + )) { _ in + attemptCommandPaletteFocusRestoreIfNeeded() + attemptCommandPaletteTextSelectionIfNeeded() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSText.didBeginEditingNotification)) { notification in + guard commandPalettePendingTextSelectionBehavior != nil else { return } + guard let editor = notification.object as? NSTextView, + editor.isFieldEditor else { return } + guard let observedWindow else { return } + guard editor.window === observedWindow else { return } + attemptCommandPaletteTextSelectionIfNeeded() + }) + + view = AnyView(view.onChange(of: isCommandPaletteSearchFocused) { _, focused in + if focused { + attemptCommandPaletteTextSelectionIfNeeded() + } + }) + + view = AnyView(view.onChange(of: isCommandPaletteRenameFocused) { _, focused in + if focused { + attemptCommandPaletteTextSelectionIfNeeded() + } }) view = AnyView(view.onReceive(tabManager.$tabs) { tabs in @@ -2836,7 +2872,6 @@ struct ContentView: View { private enum BackgroundWorkspacePrimePolicy { static let timeoutSeconds: TimeInterval = 2.0 - static let pollIntervalNanoseconds: UInt64 = 50_000_000 } private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async { @@ -2850,39 +2885,26 @@ struct ContentView: View { dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") #endif - let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds) - while !Task.isCancelled { - let state = await MainActor.run { - stepBackgroundWorkspacePrime(workspaceId: workspaceId) - } - switch state { - case .pending: - if Date() < timeout { - try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds) - continue - } - await MainActor.run { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) - } -#if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - dlog( - "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + - "reason=timeout ms=\(String(format: "%.2f", elapsedMs))" - ) -#endif - return - case .completed(let reason): -#if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - dlog( - "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + - "reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))" - ) -#endif - return - } + let initialState = await MainActor.run { + stepBackgroundWorkspacePrime(workspaceId: workspaceId) } + let completionReason: String + switch initialState { + case .completed(let reason): + completionReason = reason + case .pending: + completionReason = await waitForBackgroundWorkspacePrimeCompletion( + workspaceId: workspaceId, + timeoutSeconds: BackgroundWorkspacePrimePolicy.timeoutSeconds + ) + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=\(completionReason) ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif } @MainActor @@ -2904,6 +2926,114 @@ struct ContentView: View { return .completed(reason: "surface_ready") } + @MainActor + private func waitForBackgroundWorkspacePrimeCompletion( + workspaceId: UUID, + timeoutSeconds: TimeInterval + ) async -> String { + await withCheckedContinuation { (continuation: CheckedContinuation) in + var resolved = false + var workspacePanelsCancellable: AnyCancellable? + var pendingLoadsCancellable: AnyCancellable? + var tabsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? + + @MainActor + func finish(_ reason: String) { + guard !resolved else { return } + resolved = true + workspacePanelsCancellable?.cancel() + pendingLoadsCancellable?.cancel() + tabsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + timeoutWorkItem?.cancel() + continuation.resume(returning: reason) + } + + @MainActor + func evaluate() { + switch stepBackgroundWorkspacePrime(workspaceId: workspaceId) { + case .pending: + break + case .completed(let reason): + finish(reason) + } + } + + if let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) { + workspacePanelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + } + + pendingLoadsCancellable = tabManager.$pendingBackgroundWorkspaceLoadIds + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + + tabsCancellable = tabManager.$tabs + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { notification in + guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + readyWorkspaceId == workspaceId else { return } + Task { @MainActor in + evaluate() + } + } + + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { notification in + guard let hostedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + hostedWorkspaceId == workspaceId else { return } + Task { @MainActor in + evaluate() + } + } + + let timeoutWork = DispatchWorkItem { + Task { @MainActor in + if tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + } + finish("timeout") + } + } + timeoutWorkItem = timeoutWork + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutWork) + + Task { @MainActor in + evaluate() + } + } + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs @@ -2945,8 +3075,6 @@ struct ContentView: View { retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil - workspaceHandoffReadyCheckTask?.cancel() - workspaceHandoffReadyCheckTask = nil return } @@ -2954,7 +3082,6 @@ struct ContentView: View { let generation = workspaceHandoffGeneration retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() - workspaceHandoffReadyCheckTask?.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -2970,34 +3097,19 @@ struct ContentView: View { } #endif - workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in - for delay in [0, 20_000_000, 40_000_000, 60_000_000] { - if delay > 0 { - do { - try await Task.sleep(nanoseconds: UInt64(delay)) - } catch { - return - } - } - let completed = await MainActor.run { () -> Bool in - guard workspaceHandoffGeneration == generation else { return false } - guard retiringWorkspaceId != nil else { return false } - guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false } + if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) { #if DEBUG - if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - dlog( - "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" - ) - } else { - dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") - } -#endif - completeWorkspaceHandoff(reason: "ready") - return true - } - if completed { return } + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") } +#endif + completeWorkspaceHandoff(reason: "ready") + return } workspaceHandoffFallbackTask = Task { [generation] in @@ -3031,8 +3143,6 @@ struct ContentView: View { private func completeWorkspaceHandoff(reason: String) { workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil - workspaceHandoffReadyCheckTask?.cancel() - workspaceHandoffReadyCheckTask = nil let retiring = retiringWorkspaceId // Hide portal-hosted views for the retiring workspace BEFORE clearing @@ -6239,6 +6349,7 @@ struct ContentView: View { commandPaletteVisibleResultsFingerprint = nil cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil + commandPalettePendingTextSelectionBehavior = nil commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID commandPaletteResolvedSearchScope = nil commandPaletteResolvedSearchFingerprint = nil @@ -6251,7 +6362,7 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() guard restoreFocus, let focusTarget else { return } - restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + requestCommandPaletteFocusRestore(target: focusTarget) } private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) { @@ -6386,38 +6497,42 @@ struct ContentView: View { ) } - private func restoreCommandPaletteFocus( - target: CommandPaletteRestoreFocusTarget, - attemptsRemaining: Int - ) { + private func requestCommandPaletteFocusRestore(target: CommandPaletteRestoreFocusTarget) { + commandPalettePendingDismissFocusTarget = target + commandPaletteRestoreTimeoutWorkItem?.cancel() + let timeoutWork = DispatchWorkItem { + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem = nil + } + commandPaletteRestoreTimeoutWorkItem = timeoutWork + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: timeoutWork) + attemptCommandPaletteFocusRestoreIfNeeded() + } + + private func attemptCommandPaletteFocusRestoreIfNeeded() { guard !isCommandPalettePresented else { return } - guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + guard let target = commandPalettePendingDismissFocusTarget else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteRestoreTimeoutWorkItem = nil + return + } if let window = observedWindow, !window.isKeyWindow { window.makeKeyAndOrderFront(nil) } tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) - if let context = focusedPanelContext, - context.workspace.id == target.workspaceId, - context.panelId == target.panelId { - if context.panel.restoreFocusIntent(target.intent) { - return - } - } - - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { - guard !isCommandPalettePresented else { return } - if let context = focusedPanelContext, - context.workspace.id == target.workspaceId, - context.panelId == target.panelId { - if context.panel.restoreFocusIntent(target.intent) { - return - } - } - restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + guard let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId else { + return } + guard context.panel.restoreFocusIntent(target.intent) else { return } + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteRestoreTimeoutWorkItem = nil } #if DEBUG @@ -6478,11 +6593,17 @@ struct ContentView: View { } } - private func applyCommandPaletteTextSelection( - _ behavior: CommandPaletteTextSelectionBehavior, - attemptsRemaining: Int = 20 - ) { - guard isCommandPalettePresented else { return } + private func applyCommandPaletteTextSelection(_ behavior: CommandPaletteTextSelectionBehavior) { + commandPalettePendingTextSelectionBehavior = behavior + attemptCommandPaletteTextSelectionIfNeeded() + } + + private func attemptCommandPaletteTextSelectionIfNeeded() { + guard isCommandPalettePresented else { + commandPalettePendingTextSelectionBehavior = nil + return + } + guard let behavior = commandPalettePendingTextSelectionBehavior else { return } switch behavior { case .selectAll: guard case .renameInput = commandPaletteMode else { return } @@ -6496,21 +6617,18 @@ struct ContentView: View { } guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } - if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { - let length = (editor.string as NSString).length - switch behavior { - case .selectAll: - editor.setSelectedRange(NSRange(location: 0, length: length)) - case .caretAtEnd: - editor.setSelectedRange(NSRange(location: length, length: 0)) - } + guard let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } - - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { - applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) } + commandPalettePendingTextSelectionBehavior = nil } private func refreshCommandPaletteUsageHistory() { @@ -8446,33 +8564,43 @@ enum SidebarOutsideDropResetPolicy { } enum SidebarDragFailsafePolicy { - static let pollInterval: TimeInterval = 0.05 static let clearDelay: TimeInterval = 0.15 static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { isDragActive && !isLeftMouseButtonDown } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } } @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 - private var timer: Timer? private var pendingClearWorkItem: DispatchWorkItem? private var appResignObserver: NSObjectProtocol? private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? private var onRequestClear: ((String) -> Void)? func start(onRequestClear: @escaping (String) -> Void) { self.onRequestClear = onRequestClear - if timer == nil { - let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .common) + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") } if appResignObserver == nil { appResignObserver = NotificationCenter.default.addObserver( @@ -8493,11 +8621,25 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { return event } } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } } func stop() { - timer?.invalidate() - timer = nil pendingClearWorkItem?.cancel() pendingClearWorkItem = nil if let appResignObserver { @@ -8508,18 +8650,17 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } onRequestClear = nil } - private func tick() { - let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) - guard SidebarDragFailsafePolicy.shouldRequestClear( - isDragActive: true, // Monitor only runs while drag is active. - isLeftMouseButtonDown: isLeftMouseButtonDown - ) else { return } - requestClearSoon(reason: "mouse_up_failsafe") - } - private func requestClearSoon(reason: String) { guard pendingClearWorkItem == nil else { return } #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9caeefb1..6797329f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2057,8 +2057,11 @@ class GhosttyApp { return false } return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } - return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { + return false + } + return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } case GHOSTTY_ACTION_RING_BELL: performOnMain { @@ -3242,6 +3245,15 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + NotificationCenter.default.post( + name: .terminalSurfaceDidBecomeReady, + object: self, + userInfo: [ + "surfaceId": id, + "workspaceId": tabId + ] + ) + flushPendingTextIfNeeded() // Kick an initial draw after creation/size setup. On some startup paths Ghostty can @@ -3859,6 +3871,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) + if let terminalSurface { + NotificationCenter.default.post( + name: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + userInfo: [ + "surfaceId": terminalSurface.id, + "workspaceId": terminalSurface.tabId + ] + ) + } windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didChangeScreenNotification, @@ -5599,7 +5621,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } - return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } @objc private func triggerFlash(_ sender: Any?) { @@ -7139,6 +7161,16 @@ final class GhosttySurfaceScrollView: NSView { ) } #endif + if wasVisible != visible { + NotificationCenter.default.post( + name: .terminalPortalVisibilityDidChange, + object: self, + userInfo: [ + GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any, + GhosttyNotificationKey.tabId: surfaceView.tabId as Any + ] + ) + } if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -7394,14 +7426,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) { - func retry() { - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in - self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1) - } - } - + func ensureFocus(for tabId: UUID, surfaceId: UUID) { let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 @@ -7414,10 +7439,10 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + - "reason=not_visible attempts=\(attemptsRemaining)" + "reason=not_visible" ) #endif - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible") return } guard !isHiddenForFocus, hasUsablePortalGeometry else { @@ -7425,17 +7450,17 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + - "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)" + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny") return } guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab") return } @@ -7444,13 +7469,13 @@ final class GhosttySurfaceScrollView: NSView { let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") return } guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, tab.bonsplitController.focusedPaneId == paneId else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") return } @@ -7460,7 +7485,7 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + - "attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))" + "firstResponder=\(String(describing: window.firstResponder))" ) #endif restoreSearchFocus(window: window) @@ -7489,13 +7514,12 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + - "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " + - "attempts=\(attemptsRemaining)" + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) #endif if !isSurfaceViewFirstResponder() { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder") } else { reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder") } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 98acf59a..234466cc 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2478,14 +2478,52 @@ final class BrowserPanel: Panel, ObservableObject { // Downloads save to a temp file synchronously (no NSSavePanel during WebKit // callbacks), then show NSSavePanel after the download completes. let dlDelegate = BrowserDownloadDelegate() - dlDelegate.onDownloadStarted = { [weak self] _ in - self?.beginDownloadActivity() + dlDelegate.onDownloadStarted = { [weak self] filename in + guard let self else { return } + self.beginDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "started", + "filename": filename + ] + ] + ) } dlDelegate.onDownloadReadyToSave = { [weak self] in - self?.endDownloadActivity() + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "ready_to_save" + ] + ] + ) } - dlDelegate.onDownloadFailed = { [weak self] _ in - self?.endDownloadActivity() + dlDelegate.onDownloadFailed = { [weak self] error in + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "failed", + "error": error.localizedDescription + ] + ] + ) } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cca457ab..97271038 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1069,20 +1069,63 @@ class TabManager: ObservableObject { return newWorkspace } - private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) { - let maxAttempts = 60 + @MainActor + private func sendWelcomeWhenReady(to workspace: Workspace) { if let terminalPanel = workspace.focusedTerminalPanel, terminalPanel.surface.surface != nil { - // Wait a bit more for the shell prompt to be ready DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) terminalPanel.sendText("cmux welcome\n") } return } - guard attempt < maxAttempts else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1) + + var resolved = false + var readyObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? + + func finishIfReady() { + guard !resolved, + let terminalPanel = workspace.focusedTerminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + terminalPanel.sendText("cmux welcome\n") + } + } + + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + finishIfReady() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == workspace.id else { return } + Task { @MainActor in + finishIfReady() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + Task { @MainActor in + if let readyObserver, !resolved { + NotificationCenter.default.removeObserver(readyObserver) + } + if !resolved { + panelsCancellable?.cancel() + } + } } } @@ -2748,13 +2791,22 @@ class TabManager: ObservableObject { // MARK: - Split Creation /// Create a new split in the current tab - func createSplit(direction: SplitDirection) { + @discardableResult + func createSplit(direction: SplitDirection) -> UUID? { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), - let focusedPanelId = tab.focusedPanelId else { return } + let focusedPanelId = tab.focusedPanelId else { return nil } + return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) + } + + /// Create a new split from an explicit source panel. + @discardableResult + func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { + guard let tab = tabs.first(where: { $0.id == tabId }), + tab.panels[surfaceId] != nil else { return nil } tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) + return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) } /// Create a new browser split from the currently focused panel. @@ -3267,31 +3319,150 @@ class TabManager: ObservableObject { } #if DEBUG + @MainActor + private func waitForWorkspacePanelsCondition( + tab: Workspace, + timeoutSeconds: TimeInterval, + condition: @escaping (Workspace) -> Bool + ) async -> Bool { + guard !condition(tab) else { return true } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + var resolved = false + var cancellable: AnyCancellable? + + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + cancellable?.cancel() + cont.resume(returning: value) + } + + func evaluate() { + if condition(tab) { + finish(true) + } + } + + cancellable = tab.$panels + .map { _ in () } + .sink { _ in evaluate() } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + finish(condition(tab)) + } + } + evaluate() + } + } + + @MainActor + private func waitForTerminalPanelCondition( + tab: Workspace, + panelId: UUID, + timeoutSeconds: TimeInterval, + condition: @escaping (TerminalPanel) -> Bool + ) async -> Bool { + if let panel = tab.terminalPanel(for: panelId), condition(panel) { + return true + } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + var resolved = false + var panelsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + + @MainActor + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + panelsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + cont.resume(returning: value) + } + + @MainActor + func evaluate() { + guard let panel = tab.terminalPanel(for: panelId) else { + finish(false) + return + } + panel.surface.requestBackgroundSurfaceStartIfNeeded() + if condition(panel) { + finish(true) + } + } + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + readySurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { note in + guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID, + hostedSurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + if let panel = tab.terminalPanel(for: panelId) { + finish(condition(panel)) + } else { + finish(false) + } + } + } + evaluate() + } + } + @MainActor private func waitForTerminalPanelReadyForUITest( tab: Workspace, panelId: UUID, timeoutSeconds: TimeInterval = 6.0 ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { - let deadline = Date().addingTimeInterval(timeoutSeconds) var attached = false var hasSurface = false var firstResponder = false - while Date() < deadline { - guard let panel = tab.terminalPanel(for: panelId) else { - return (false, false, false) - } - + let _ = await waitForTerminalPanelCondition( + tab: tab, + panelId: panelId, + timeoutSeconds: timeoutSeconds + ) { panel in panel.surface.requestBackgroundSurfaceStartIfNeeded() attached = panel.hostedView.window != nil hasSurface = panel.surface.surface != nil firstResponder = panel.hostedView.isSurfaceViewFirstResponder() - - if attached, hasSurface { - return (attached, hasSurface, firstResponder) - } - try? await Task.sleep(nanoseconds: 50_000_000) + return attached && hasSurface } return (attached, hasSurface, firstResponder) @@ -3895,7 +4066,16 @@ class TabManager: ObservableObject { for panelId in tab.panels.keys where panelId != leftPanelId { tab.closePanel(panelId, force: true) } - try? await Task.sleep(nanoseconds: 80_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 1 + } + if !collapsed { + write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"]) + return + } } guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { @@ -3912,12 +4092,12 @@ class TabManager: ObservableObject { tab.focusPanel(rightPanel.id) // Wait for the split terminal surface to be attached before sending exit. // Without this, very early writes can be dropped during initial surface creation. - let readyDeadline = Date().addingTimeInterval(2.0) - while Date() < readyDeadline { - let attached = rightPanel.hostedView.window != nil - let hasSurface = rightPanel.surface.surface != nil - if attached && hasSurface { break } - try? await Task.sleep(nanoseconds: 50_000_000) + _ = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: rightPanel.id, + timeoutSeconds: 2.0 + ) { panel in + panel.hostedView.window != nil && panel.surface.surface != nil } // Use an explicit shell exit command for deterministic child-exit behavior across // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. @@ -4064,12 +4244,13 @@ class TabManager: ObservableObject { tab.closePanel(bottomRight.id, force: true) exitPanelId = leftPanelId - let closeDeadline = Date().addingTimeInterval(2.0) - while Date() < closeDeadline { - if tab.panels.count == 2 { break } - try? await Task.sleep(nanoseconds: 50_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 } - if tab.panels.count != 2 { + if !collapsed { write([ "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", "done": "1", @@ -4102,12 +4283,13 @@ class TabManager: ObservableObject { for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { tab.focusPanel(panelId) tab.closePanel(panelId, force: true) - let deadline = Date().addingTimeInterval(1.0) - while Date() < deadline { - if tab.panels[panelId] == nil { break } - try? await Task.sleep(nanoseconds: 25_000_000) + let closed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 1.0 + ) { workspace in + workspace.panels[panelId] == nil } - if tab.panels[panelId] != nil { + if !closed { write([ "setupError": "Failed to close bottom pane \(panelId.uuidString)", "done": "1", @@ -4117,12 +4299,13 @@ class TabManager: ObservableObject { } exitPanelId = leftPanelId - let closeDeadline = Date().addingTimeInterval(2.0) - while Date() < closeDeadline { - if tab.panels.count == 2 { break } - try? await Task.sleep(nanoseconds: 50_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 } - if tab.panels.count != 2 { + if !collapsed { write([ "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", "done": "1", @@ -4157,7 +4340,6 @@ class TabManager: ObservableObject { return } self.ensureFocusedTerminalFirstResponder() - try? await Task.sleep(nanoseconds: 80_000_000) } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil @@ -4275,20 +4457,19 @@ class TabManager: ObservableObject { var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false if shouldWaitForSurface { - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(5.0) - while Date() < readyDeadline { - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return - } - panel.surface.requestBackgroundSurfaceStartIfNeeded() + let ready = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: exitPanelId, + timeoutSeconds: 5.0 + ) { panel in attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) + return attachedBeforeTrigger && hasSurfaceBeforeTrigger + } + if !ready, + tab.terminalPanel(for: exitPanelId) == nil { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return } } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil @@ -4538,4 +4719,6 @@ extension Notification.Name { static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") + static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange") + static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0a94df81..f87ebafa 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4,6 +4,14 @@ import Foundation import Bonsplit import WebKit +extension Notification.Name { + static let socketListenerDidStart = Notification.Name("cmux.socketListenerDidStart") + static let terminalSurfaceDidBecomeReady = Notification.Name("cmux.terminalSurfaceDidBecomeReady") + static let terminalSurfaceHostedViewDidMoveToWindow = Notification.Name("cmux.terminalSurfaceHostedViewDidMoveToWindow") + static let mainWindowContextsDidChange = Notification.Name("cmux.mainWindowContextsDidChange") + static let browserDownloadEventDidArrive = Notification.Name("cmux.browserDownloadEventDidArrive") +} + /// Unix socket-based controller for programmatic terminal control /// Allows automated testing and external control of terminal tabs @MainActor @@ -13,9 +21,6 @@ class TerminalController { let acceptLoopAlive: Bool let socketPathMatches: Bool let socketPathExists: Bool - let socketProbePerformed: Bool - let socketConnectable: Bool? - let socketConnectErrno: Int32? var failureSignals: [String] { var signals: [String] = [] @@ -23,9 +28,6 @@ class TerminalController { if !acceptLoopAlive { signals.append("accept_loop_dead") } if !socketPathMatches { signals.append("socket_path_mismatch") } if !socketPathExists { signals.append("socket_missing") } - if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false { - signals.append("socket_unreachable") - } return signals } @@ -43,6 +45,7 @@ class TerminalController { private nonisolated(unsafe) var activeAcceptLoopGeneration: UInt64 = 0 private nonisolated(unsafe) var nextAcceptLoopGeneration: UInt64 = 0 private nonisolated(unsafe) var pendingAcceptLoopRearmGeneration: UInt64? + private nonisolated(unsafe) var pendingAcceptLoopResumeGeneration: UInt64? private nonisolated(unsafe) var listenerStartInProgress = false private nonisolated let listenerStateLock = NSLock() private var clientHandlers: [Int32: Thread] = [:] @@ -76,9 +79,36 @@ class TerminalController { let acceptLoopAlive: Bool let activeGeneration: UInt64 let pendingRearmGeneration: UInt64? + let pendingResumeGeneration: UInt64? let listenerStartInProgress: Bool } + enum AcceptFailureRecoveryAction: Equatable { + case retryImmediately + case resumeAfterDelay(delayMs: Int) + case rearmAfterDelay(delayMs: Int) + + var delayMs: Int { + switch self { + case .retryImmediately: + return 0 + case .resumeAfterDelay(let delayMs), .rearmAfterDelay(let delayMs): + return delayMs + } + } + + var debugLabel: String { + switch self { + case .retryImmediately: + return "retry_immediately" + case .resumeAfterDelay: + return "resume_after_delay" + case .rearmAfterDelay: + return "rearm_after_delay" + } + } + } + private enum SocketBindAttemptResult { case success(path: String) case pathTooLong(path: String) @@ -167,8 +197,24 @@ class TerminalController { private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] private let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() + private var browserDownloadObserver: NSObjectProtocol? - private init() {} + private init() { + browserDownloadObserver = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: nil, + queue: .main + ) { [weak self] note in + guard let surfaceId = note.userInfo?["surfaceId"] as? UUID, + let event = note.userInfo?["event"] as? [String: Any] else { return } + Task { @MainActor [weak self] in + guard let self else { return } + var queue = self.v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + queue.append(event) + self.v2BrowserDownloadEventsBySurface[surfaceId] = queue + } + } + } private nonisolated func withListenerState(_ body: () -> T) -> T { listenerStateLock.lock() @@ -185,6 +231,7 @@ class TerminalController { acceptLoopAlive: acceptLoopAlive, activeGeneration: activeAcceptLoopGeneration, pendingRearmGeneration: pendingAcceptLoopRearmGeneration, + pendingResumeGeneration: pendingAcceptLoopResumeGeneration, listenerStartInProgress: listenerStartInProgress ) } @@ -633,6 +680,31 @@ class TerminalController { ) } + nonisolated static func acceptFailureRecoveryAction( + errnoCode: Int32, + consecutiveFailures: Int + ) -> AcceptFailureRecoveryAction { + let classification = acceptErrorClassification(errnoCode: errnoCode) + if classification == "immediate_retry" { + return .retryImmediately + } + + if classification == "fatal" + || shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: consecutiveFailures) { + return .rearmAfterDelay( + delayMs: acceptFailureRearmDelayMilliseconds( + consecutiveFailures: consecutiveFailures + ) + ) + } + + return .resumeAfterDelay( + delayMs: acceptFailureBackoffMilliseconds( + consecutiveFailures: consecutiveFailures + ) + ) + } + nonisolated static func shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: Int) -> Bool { guard consecutiveFailures > 0 else { return false } if consecutiveFailures <= 3 { @@ -683,66 +755,33 @@ class TerminalController { } } - private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) { - let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0) - guard probeSocket >= 0 else { - return (false, errno) - } - defer { close(probeSocket) } + private nonisolated static func makeSocketTimeout(_ timeout: TimeInterval) -> timeval { + let normalizedTimeout = max(timeout, 0) + let seconds = floor(normalizedTimeout) + let microseconds = (normalizedTimeout - seconds) * 1_000_000 + return timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) + } - let existingFlags = fcntl(probeSocket, F_GETFL, 0) - if existingFlags >= 0 { - _ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK) + private nonisolated static func configureSocketTimeouts(_ fd: Int32, timeout: TimeInterval) { + var socketTimeout = makeSocketTimeout(timeout) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) } - - guard var addr = unixSocketAddress(path: path) else { - return (false, ENAMETOOLONG) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) } - let connectResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - if connectResult == 0 { - return (true, nil) - } - let connectErrno = errno - if connectErrno == EINPROGRESS { - var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0) - for attempt in 0.. 0 { - var socketError: Int32 = 0 - var socketErrorLength = socklen_t(MemoryLayout.size) - let status = getsockopt( - probeSocket, - SOL_SOCKET, - SO_ERROR, - &socketError, - &socketErrorLength - ) - if status == 0 && socketError == 0 { - return (true, nil) - } - if status == 0 { - return (false, socketError) - } - return (false, errno) - } - - let pollErrno = errno - if pollResult == 0 || pollErrno == EINTR { - if attempt + 1 < Self.socketProbePollAttempts { - usleep(Self.socketProbePollRetryBackoffUs) - continue - } - return (false, pollResult == 0 ? ETIMEDOUT : pollErrno) - } - return (false, pollErrno) - } - } - return (false, connectErrno) } private nonisolated static func bindListenerSocket(_ socket: Int32, path: String) -> SocketBindAttemptResult { @@ -920,6 +959,7 @@ class TerminalController { let generation = withListenerState { isRunning = true pendingAcceptLoopRearmGeneration = nil + pendingAcceptLoopResumeGeneration = nil nextAcceptLoopGeneration &+= 1 let generation = nextAcceptLoopGeneration activeAcceptLoopGeneration = generation @@ -940,6 +980,11 @@ class TerminalController { "backlog": Self.socketListenBacklog ] ) + NotificationCenter.default.post( + name: .socketListenerDidStart, + object: self, + userInfo: ["path": activeSocketPath] + ) // Wire batched port scanner results back to workspace state. PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in @@ -965,19 +1010,12 @@ class TerminalController { var st = stat() let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK - let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists - let connectability = shouldProbeConnection - ? Self.probeSocketConnectability(path: expectedSocketPath) - : (isConnectable: nil, errnoCode: nil) return SocketListenerHealth( isRunning: snapshot.isRunning, acceptLoopAlive: snapshot.acceptLoopAlive, socketPathMatches: pathMatches, - socketPathExists: exists, - socketProbePerformed: shouldProbeConnection, - socketConnectable: connectability.isConnectable, - socketConnectErrno: connectability.errnoCode + socketPathExists: exists ) } @@ -989,6 +1027,7 @@ class TerminalController { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + Self.configureSocketTimeouts(fd, timeout: timeout) #if os(macOS) var noSigPipe: Int32 = 1 @@ -1045,22 +1084,19 @@ class TerminalController { } guard wroteAll else { return nil } - let deadline = Date().addingTimeInterval(timeout) var buffer = [UInt8](repeating: 0, count: 4096) var response = "" - while Date() < deadline { - var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollDescriptor, 1, 100) - if ready < 0 { + while true { + let count = read(fd, &buffer, buffer.count) + if count < 0 { + let readErrno = errno + if readErrno == EAGAIN || readErrno == EWOULDBLOCK { + break + } return nil } - if ready == 0 { - continue - } - - let count = read(fd, &buffer, buffer.count) - if count <= 0 { + if count == 0 { break } if let chunk = String(bytes: buffer[0.. 0 { - usleep(useconds_t(backoffMs * 1_000)) + if case .resumeAfterDelay(let delayMs) = recoveryAction { + exitReason = "accept_backoff_resume" + resumeRequested = true + withListenerState { + pendingAcceptLoopResumeGeneration = generation + } + scheduleAcceptLoopResume( + listenerSocket: listenerSocket, + generation: generation, + errnoCode: errnoCode, + consecutiveFailures: consecutiveFailures, + delayMs: delayMs + ) + break } + continue } @@ -1394,6 +1456,51 @@ class TerminalController { } } + private nonisolated func scheduleAcceptLoopResume( + listenerSocket: Int32, + generation: UInt64, + errnoCode: Int32, + consecutiveFailures: Int, + delayMs: Int + ) { + let deadline = DispatchTime.now() + .milliseconds(delayMs) + DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + let shouldResume = self.withListenerState { + guard self.pendingAcceptLoopResumeGeneration == generation else { return false } + guard self.activeAcceptLoopGeneration == generation else { + self.pendingAcceptLoopResumeGeneration = nil + return false + } + guard self.isRunning, self.serverSocket == listenerSocket else { + self.pendingAcceptLoopResumeGeneration = nil + return false + } + self.pendingAcceptLoopResumeGeneration = nil + return true + } + guard shouldResume else { return } + + sentryBreadcrumb( + "socket.listener.resume.requested", + category: "socket", + data: self.socketListenerEventData( + stage: "accept_resume", + errnoCode: errnoCode, + extra: [ + "generation": generation, + "consecutiveFailures": consecutiveFailures, + "resumeDelayMs": delayMs + ] + ) + ) + + Thread.detachNewThread { [weak self] in + self?.acceptLoop(listenerSocket: listenerSocket, generation: generation) + } + } + } + private nonisolated func scheduleListenerRearm( generation: UInt64, errnoCode: Int32, @@ -6224,24 +6331,7 @@ class TerminalController { contentWorld: WKContentWorld ) -> V2JavaScriptResult { let timeoutSeconds = max(0.01, timeout) - let resultLock = NSLock() - let completionSignal = DispatchSemaphore(value: 0) - var done = false - var resultValue: Any? - var resultError: String? - - let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in - resultLock.lock() - if !done { - done = true - resultValue = value - resultError = error - completionSignal.signal() - } - resultLock.unlock() - } - - let evaluator = { + let evaluator: (@escaping (Any?, String?) -> Void) -> Void = { finish in if preferAsync, #available(macOS 11.0, *) { webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in switch result { @@ -6262,32 +6352,163 @@ class TerminalController { } } + let outcome: (Any?, String?)? if Thread.isMainThread { - evaluator() - let deadline = Date().addingTimeInterval(timeoutSeconds) - while true { - resultLock.lock() - let isDone = done - resultLock.unlock() - if isDone { - break + outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in + evaluator { value, error in + finish((value, error)) } - if Date() >= deadline { - return .failure("Timed out waiting for JavaScript result") - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } } else { - DispatchQueue.main.async(execute: evaluator) - if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut { - return .failure("Timed out waiting for JavaScript result") + outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in + DispatchQueue.main.async { + evaluator { value, error in + finish((value, error)) + } + } } } - if let resultError { + guard let outcome else { + return .failure("Timed out waiting for JavaScript result") + } + if let resultError = outcome.1 { return .failure(resultError) } - return .success(resultValue) + return .success(outcome.0) + } + + private func v2AwaitCallback( + timeout: TimeInterval, + start: (@escaping (T) -> Void) -> Void + ) -> T? { + if Thread.isMainThread { + let runLoop = CFRunLoopGetCurrent() + var resolved = false + var timedOut = false + var result: T? + + let finish: (T) -> Void = { value in + guard !resolved else { return } + resolved = true + result = value + CFRunLoopStop(runLoop) + } + + start(finish) + guard !resolved else { return result } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + guard !resolved else { return } + resolved = true + timedOut = true + CFRunLoopStop(runLoop) + } + + CFRunLoopRun() + return timedOut ? nil : result + } + + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var result: T? + start { value in + lock.lock() + result = value + lock.unlock() + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return nil + } + lock.lock() + defer { lock.unlock() } + return result + } + + private func v2WaitForBrowserCondition( + _ webView: WKWebView, + surfaceId: UUID, + conditionScript: String, + timeoutMs: Int + ) -> Bool { + let timeout = Double(timeoutMs) / 1000.0 + let waitScript = """ + (() => { + const __cmuxEvaluate = () => { + try { + return !!(\(conditionScript)); + } catch (_) { + return false; + } + }; + + if (__cmuxEvaluate()) { + return true; + } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const recheck = () => { + if (__cmuxEvaluate()) { + finish(true); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== 'function') return; + const handler = () => recheck(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + + try { + observer = new MutationObserver(() => recheck()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + + addListener(document, 'readystatechange', true); + addListener(window, 'load', true); + addListener(window, 'pageshow', true); + addListener(window, 'hashchange', true); + addListener(window, 'popstate', true); + + const timeoutId = window.setTimeout(() => { + finish(false); + }, \(timeoutMs)); + cleanups.push(() => window.clearTimeout(timeoutId)); + recheck(); + }); + })() + """ + + switch v2RunBrowserJavaScript( + webView, + surfaceId: surfaceId, + script: waitScript, + timeout: timeout + 1.0, + useEval: false + ) { + case .success(let value): + return (value as? Bool) == true + case .failure: + return false + } } private func v2BrowserSelector(_ params: [String: Any]) -> String? { @@ -6965,6 +7186,7 @@ class TerminalController { } let script = scriptBuilder(v2JSONLiteral(selector)) let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3) + let selectorCondition = "document.querySelector(\(v2JSONLiteral(selector))) !== null" for attempt in 1...retryAttempts { switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) { @@ -6991,7 +7213,21 @@ class TerminalController { let errorText = (value as? [String: Any])?["error"] as? String if errorText == "not_found", attempt < retryAttempts { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.08)) + let waitTimeoutMs = max(80, (retryAttempts - attempt) * 80) + guard v2WaitForBrowserCondition( + browserPanel.webView, + surfaceId: surfaceId, + conditionScript: selectorCondition, + timeoutMs: waitTimeoutMs + ) else { + return v2BrowserElementNotFoundResult( + actionName: actionName, + selector: selector, + attempts: attempt, + surfaceId: surfaceId, + browserPanel: browserPanel + ) + } continue } if errorText == "not_found" { @@ -7321,7 +7557,6 @@ class TerminalController { private func v2BrowserWait(params: [String: Any]) -> V2CallResult { let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000) - let timeout = Double(timeoutMs) / 1000.0 let selectorRaw = v2BrowserSelector(params) let conditionScriptBase: String = { @@ -7398,45 +7633,21 @@ class TerminalController { conditionScript = conditionScriptBase } - let deadline = Date().addingTimeInterval(timeout) - let pollInterval = 0.05 - let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" - - while true { - switch v2RunBrowserJavaScript( - webView, - surfaceId: surfaceIdOut, - script: wrappedScript, - timeout: max(0.5, pollInterval + 0.25), - useEval: false - ) { - case .success(let value): - if let b = value as? Bool, b { - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceIdOut.uuidString, - "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), - "waited": true - ]) - } - case .failure(let message): - return .err( - code: "js_error", - message: message, - data: [ - "condition": conditionScript, - "timeout_ms": timeoutMs - ] - ) - } - - if Date() >= deadline { - return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) - } - - Thread.sleep(forTimeInterval: pollInterval) + if v2WaitForBrowserCondition( + webView, + surfaceId: surfaceIdOut, + conditionScript: conditionScript, + timeoutMs: timeoutMs + ) { + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceIdOut.uuidString, + "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), + "waited": true + ]) } + return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) } private func v2BrowserClick(params: [String: Any]) -> V2CallResult { @@ -7761,22 +7972,16 @@ class TerminalController { private func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - var done = false - var imageData: Data? - browserPanel.takeSnapshot { image in - imageData = image.flatMap { self.v2PNGData(from: $0) } - done = true + let snapshotResult: Data?? = v2AwaitCallback(timeout: 5.0) { finish in + browserPanel.takeSnapshot { image in + finish(image.flatMap { self.v2PNGData(from: $0) }) + } } - let deadline = Date().addingTimeInterval(5.0) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - - guard done else { + guard let snapshotResult else { return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) } - guard let imageData else { + guard let imageData = snapshotResult else { return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) } @@ -8712,45 +8917,122 @@ class TerminalController { let path = v2String(params, "path") if let path { - let deadline = Date().addingTimeInterval(timeout) let fm = FileManager.default - while Date() < deadline { - if fm.fileExists(atPath: path), - let attrs = try? fm.attributesOfItem(atPath: path), - let size = attrs[.size] as? NSNumber, - size.intValue > 0 { - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "path": path, - "downloaded": true - ]) + let pathIsReady = { + guard fm.fileExists(atPath: path), + let attrs = try? fm.attributesOfItem(atPath: path), + let size = attrs[.size] as? NSNumber else { + return false } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + return size.intValue > 0 } - return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) - } - - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let entries = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - if let first = entries.first { - var remaining = entries - remaining.removeFirst() - v2BrowserDownloadEventsBySurface[surfaceId] = remaining + if pathIsReady() { return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "download": first + "path": path, + "downloaded": true ]) } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + + let watchedPath = URL(fileURLWithPath: path).deletingLastPathComponent().path + let fd = open(watchedPath, O_EVTONLY) + guard fd >= 0 else { + return .err(code: "internal_error", message: "Failed to watch download path", data: ["path": path]) + } + defer { close(fd) } + + let ready = v2AwaitCallback(timeout: timeout) { finish in + var source: DispatchSourceFileSystemObject? + var timeoutWorkItem: DispatchWorkItem? + var finished = false + let finishOnce: (Bool) -> Void = { value in + guard !finished else { return } + finished = true + timeoutWorkItem?.cancel() + source?.cancel() + finish(value) + } + source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .attrib, .link, .rename], + queue: .main + ) + source?.setEventHandler { + if pathIsReady() { + finishOnce(true) + } + } + source?.setCancelHandler { + source = nil + } + source?.resume() + timeoutWorkItem = DispatchWorkItem { + finishOnce(pathIsReady()) + } + if let timeoutWorkItem { + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + } + if pathIsReady() { + finishOnce(true) + } + } ?? false + guard ready else { + return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) + } + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "path": path, + "downloaded": true + ]) } - return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + + if let first = v2BrowserDownloadEventsBySurface[surfaceId]?.first { + var remaining = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + remaining.removeFirst() + v2BrowserDownloadEventsBySurface[surfaceId] = remaining + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "download": first + ]) + } + + let downloadEvent = v2AwaitCallback(timeout: timeout) { finish in + var observer: NSObjectProtocol? + observer = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: nil, + queue: .main + ) { note in + guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, + candidateSurfaceId == surfaceId, + let event = note.userInfo?["event"] as? [String: Any] else { + return + } + if let observer { + NotificationCenter.default.removeObserver(observer) + } + finish(event) + } + } + guard let downloadEvent else { + return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + } + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "download": downloadEvent + ]) } } @@ -8772,41 +9054,27 @@ class TerminalController { } private func v2BrowserCookieStoreAll(_ store: WKHTTPCookieStore, timeout: TimeInterval = 3.0) -> [HTTPCookie]? { - var done = false - var cookies: [HTTPCookie] = [] - store.getAllCookies { items in - cookies = items - done = true + v2AwaitCallback(timeout: timeout) { finish in + store.getAllCookies { items in + finish(items) + } } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done ? cookies : nil } private func v2BrowserCookieStoreSet(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - var done = false - store.setCookie(cookie) { - done = true - } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done + v2AwaitCallback(timeout: timeout) { finish in + store.setCookie(cookie) { + finish(true) + } + } ?? false } private func v2BrowserCookieStoreDelete(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - var done = false - store.delete(cookie) { - done = true - } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done + v2AwaitCallback(timeout: timeout) { finish in + store.delete(cookie) { + finish(true) + } + } ?? false } private func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? { @@ -12270,13 +12538,43 @@ class TerminalController { private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { if let surface = terminalPanel.surface.surface { return surface } - // This can be transient during bonsplit tree restructuring when the SwiftUI - // view is temporarily detached and then reattached (surface creation is - // gated on view/window/bounds). Pump the runloop briefly to allow pending - // attach retries to execute. - let deadline = Date().addingTimeInterval(timeout) - while terminalPanel.surface.surface == nil && Date() < deadline { - RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + let terminalSurface = terminalPanel.surface + terminalSurface.requestBackgroundSurfaceStartIfNeeded() + _ = v2AwaitCallback(timeout: timeout) { finish in + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + let finishOnce: () -> Void = { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + finish(()) + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: terminalSurface, + queue: .main + ) { _ in + finishOnce() + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + queue: .main + ) { _ in + Task { @MainActor in + if terminalSurface.surface != nil { + finishOnce() + } + } + } + + if terminalSurface.surface != nil { + finishOnce() + } } return terminalPanel.surface.surface @@ -14644,6 +14942,9 @@ class TerminalController { } deinit { + if let browserDownloadObserver { + NotificationCenter.default.removeObserver(browserDownloadObserver) + } stop() } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 40971d17..2c69140c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -716,14 +716,14 @@ final class WorkspaceRemoteDaemonPendingCallRegistry { } func remove(_ call: PendingCall) { - queue.sync { + _ = queue.sync { pendingCalls.removeValue(forKey: call.id) } } func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { - queue.sync { + _ = queue.sync { pendingCalls.removeValue(forKey: call.id) } // A response can win the race immediately before timeout cleanup removes the call. @@ -749,6 +749,18 @@ final class WorkspaceRemoteDaemonPendingCallRegistry { private final class WorkspaceRemoteDaemonRPCClient { private static let maxStdoutBufferBytes = 256 * 1024 + static let requiredProxyStreamCapability = "proxy.stream.push" + + enum StreamEvent { + case data(Data) + case eof(Data) + case error(String) + } + + private struct StreamSubscription { + let queue: DispatchQueue + let handler: (StreamEvent) -> Void + } private let configuration: WorkspaceRemoteConfiguration private let remotePath: String @@ -766,6 +778,7 @@ private final class WorkspaceRemoteDaemonRPCClient { private var stdoutBuffer = Data() private var stderrBuffer = "" + private var streamSubscriptions: [String: StreamSubscription] = [:] init( configuration: WorkspaceRemoteConfiguration, @@ -824,15 +837,16 @@ private final class WorkspaceRemoteDaemonRPCClient { self.shouldReportTermination = true self.stdoutBuffer = Data() self.stderrBuffer = "" + self.streamSubscriptions.removeAll(keepingCapacity: false) } pendingCalls.reset() do { let hello = try call(method: "hello", params: [:], timeout: 8.0) let capabilities = (hello["capabilities"] as? [String]) ?? [] - guard capabilities.contains("proxy.stream") else { + guard capabilities.contains(Self.requiredProxyStreamCapability) else { throw NSError(domain: "cmux.remote.daemon.rpc", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", ]) } } catch { @@ -875,23 +889,44 @@ private final class WorkspaceRemoteDaemonRPCClient { ) } - func readStream(streamID: String, maxBytes: Int = 32768, timeoutMs: Int = 250) throws -> (data: Data, eof: Bool) { - let result = try call( - method: "proxy.read", - params: [ - "stream_id": streamID, - "max_bytes": maxBytes, - "timeout_ms": timeoutMs, - ], - timeout: max(2.0, TimeInterval(timeoutMs) / 1000.0 + 2.0) - ) - let encoded = (result["data_base64"] as? String) ?? "" - let decoded = encoded.isEmpty ? Data() : (Data(base64Encoded: encoded) ?? Data()) - let eof = (result["eof"] as? Bool) ?? false - return (decoded, eof) + func attachStream( + streamID: String, + queue: DispatchQueue, + onEvent: @escaping (StreamEvent) -> Void + ) throws { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 17, userInfo: [ + NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", + ]) + } + + stateQueue.sync { + streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) + } + + do { + _ = try call( + method: "proxy.stream.subscribe", + params: ["stream_id": trimmedStreamID], + timeout: 8.0 + ) + } catch { + unregisterStream(streamID: trimmedStreamID) + throw error + } + } + + func unregisterStream(streamID: String) { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { return } + _ = stateQueue.sync { + streamSubscriptions.removeValue(forKey: trimmedStreamID) + } } func closeStream(streamID: String) { + unregisterStream(streamID: streamID) _ = try? call( method: "proxy.close", params: ["stream_id": streamID], @@ -1004,17 +1039,12 @@ private final class WorkspaceRemoteDaemonRPCClient { continue } - let responseID: Int = { - if let intValue = payload["id"] as? Int { - return intValue - } - if let numberValue = payload["id"] as? NSNumber { - return numberValue.intValue - } - return -1 - }() - guard responseID >= 0 else { continue } - _ = pendingCalls.resolve(id: responseID, payload: payload) + if let responseID = Self.responseID(in: payload) { + _ = pendingCalls.resolve(id: responseID, payload: payload) + continue + } + + consumeEventPayload(payload) } } @@ -1027,6 +1057,44 @@ private final class WorkspaceRemoteDaemonRPCClient { } } + private func consumeEventPayload(_ payload: [String: Any]) { + guard let eventName = (payload["event"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !eventName.isEmpty, + let streamID = (payload["stream_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !streamID.isEmpty else { + return + } + + let subscription: StreamSubscription? + let event: StreamEvent? + switch eventName { + case "proxy.stream.data": + subscription = streamSubscriptions[streamID] + event = .data(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.eof": + subscription = streamSubscriptions.removeValue(forKey: streamID) + event = .eof(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.error": + subscription = streamSubscriptions.removeValue(forKey: streamID) + let detail = ((payload["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + ?? "stream error" + event = .error(detail) + + default: + return + } + + guard let subscription, let event else { return } + subscription.queue.async { + subscription.handler(event) + } + } + private func handleProcessTermination(_ process: Process) { let shouldNotify: Bool = { guard self.process === process else { return false } @@ -1041,6 +1109,7 @@ private final class WorkspaceRemoteDaemonRPCClient { stdoutHandle = nil stderrHandle?.readabilityHandler = nil stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) signalPendingFailureLocked(detail) guard shouldNotify else { return } @@ -1067,6 +1136,7 @@ private final class WorkspaceRemoteDaemonRPCClient { stdinHandle = nil stdoutHandle = nil stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) } @@ -1087,6 +1157,21 @@ private final class WorkspaceRemoteDaemonRPCClient { pendingCalls.failAll(message) } + private static func responseID(in payload: [String: Any]) -> Int? { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return nil + } + + private static func decodeBase64Data(_ value: Any?) -> Data { + guard let encoded = value as? String, !encoded.isEmpty else { return Data() } + return Data(base64Encoded: encoded) ?? Data() + } + private static func encodeJSON(_ object: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: object, options: []) } @@ -1095,7 +1180,9 @@ private final class WorkspaceRemoteDaemonRPCClient { let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" // Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup. let command = "sh -c \(shellSingleQuoted(script))" - return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command] + return ["-T", "-S", "none"] + + sshCommonArguments(configuration: configuration, batchMode: true) + + ["-o", "RequestTTY=no", configuration.destination, command] } private static let batchSSHControlOptionKeys: Set = [ @@ -1411,7 +1498,6 @@ 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 @@ -1433,10 +1519,6 @@ 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 } @@ -1677,6 +1759,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { let targetHost = Self.normalizedProxyTargetHost(host) let streamID = try rpcClient.openStream(host: targetHost, port: port) self.streamID = streamID + try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in + self?.handleRemoteStreamEvent(streamID: streamID, event: event) + } connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in guard let self else { return } if let error { @@ -1686,7 +1771,6 @@ private final class WorkspaceRemoteDaemonProxyTunnel { if !pendingPayload.isEmpty { self.forwardToRemote(pendingPayload, allowAfterEOF: true) } - self.scheduleRemoteReadLoop() }) } catch { sendAndClose(failureResponse) @@ -1710,40 +1794,27 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } } - private func scheduleRemoteReadLoop() { - guard let streamID else { return } - readQueue.async { [weak self] in - self?.pollRemoteOnce(streamID: streamID) - } - } - - 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>) { + private func handleRemoteStreamEvent( + streamID: String, + event: WorkspaceRemoteDaemonRPCClient.StreamEvent + ) { guard !isClosed else { return } guard self.streamID == streamID else { return } - let readResult: (data: Data, eof: Bool) - switch result { - case .success(let value): - readResult = value - case .failure(let error): - close(reason: "proxy.read failed: \(error.localizedDescription)") - return - } + switch event { + case .data(let data): + forwardRemotePayloadToLocal(data, eof: false) - let localData = rewriteRemoteResponseIfNeeded(readResult.data, eof: readResult.eof) + case .eof(let data): + forwardRemotePayloadToLocal(data, eof: true) + + case .error(let detail): + close(reason: "proxy.stream failed: \(detail)") + } + } + + private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { + let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) if !localData.isEmpty { connection.send(content: localData, completion: .contentProcessed { [weak self] error in guard let self else { return } @@ -1751,19 +1822,15 @@ private final class WorkspaceRemoteDaemonProxyTunnel { self.close(reason: "proxy client send error: \(error)") return } - if readResult.eof { + if eof { self.close(reason: nil) - } else { - self.scheduleRemoteReadLoop() } }) return } - if readResult.eof { + if eof { close(reason: nil) - } else { - scheduleRemoteReadLoop() } } @@ -2671,7 +2738,7 @@ private final class WorkspaceRemoteCLIRelayServer { ]) } - return try queue.sync { + return queue.sync { if let localPort { listener.newConnectionHandler = nil listener.stateUpdateHandler = nil @@ -2730,7 +2797,7 @@ private final class WorkspaceRemoteCLIRelayServer { } } -private final class WorkspaceRemoteSessionController { +final class WorkspaceRemoteSessionController { private struct CommandResult { let status: Int32 let stdout: String @@ -2742,6 +2809,11 @@ private final class WorkspaceRemoteSessionController { let goArch: String } + private struct RemoteBootstrapState { + let platform: RemotePlatform + let binaryExists: Bool + } + private struct DaemonHello { let name: String let version: String @@ -2768,10 +2840,10 @@ private final class WorkspaceRemoteSessionController { private var reverseRelayStderrBuffer = "" private var reconnectRetryCount = 0 private var reconnectWorkItem: DispatchWorkItem? - private var heartbeatWorkItem: DispatchWorkItem? private var heartbeatCount: Int = 0 + private var connectionAttemptStartedAt: Date? - private static let heartbeatInterval: TimeInterval = 3.0 + private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { self.workspace = workspace @@ -2807,7 +2879,6 @@ private final class WorkspaceRemoteSessionController { reconnectRetryCount = 0 reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil - stopHeartbeatLocked(reset: true) stopReverseRelayLocked() proxyLease?.release() @@ -2823,9 +2894,9 @@ private final class WorkspaceRemoteSessionController { private func beginConnectionAttemptLocked() { guard !isStopping else { return } + connectionAttemptStartedAt = Date() debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") reconnectWorkItem = nil - stopHeartbeatLocked(reset: true) let connectDetail: String let bootstrapDetail: String if reconnectRetryCount > 0 { @@ -2839,9 +2910,9 @@ private final class WorkspaceRemoteSessionController { publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) do { let hello = try bootstrapDaemonLocked() - guard hello.capabilities.contains("proxy.stream") else { + guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { throw NSError(domain: "cmux.remote.daemon", code: 43, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", ]) } daemonReady = true @@ -2855,7 +2926,7 @@ private final class WorkspaceRemoteSessionController { capabilities: hello.capabilities, remotePath: hello.remotePath ) - prepareRemoteCLISessionLocked(remotePath: hello.remotePath) + recordHeartbeatActivityLocked() startReverseRelayLocked(remotePath: hello.remotePath) startProxyLocked() } catch { @@ -2895,10 +2966,6 @@ private final class WorkspaceRemoteSessionController { proxyLease = lease } - private func prepareRemoteCLISessionLocked(remotePath: String) { - createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) - } - private func startReverseRelayLocked(remotePath: String) { guard !isStopping else { return } guard daemonReady else { return } @@ -2916,13 +2983,15 @@ private final class WorkspaceRemoteSessionController { reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil + var relayServer: WorkspaceRemoteCLIRelayServer? do { - let relayServer = try ensureCLIRelayServerLocked( + let server = try ensureCLIRelayServerLocked( localSocketPath: localSocketPath, relayID: relayID, relayToken: relayToken ) - let localRelayPort = try relayServer.start() + relayServer = server + let localRelayPort = try server.start() Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) let process = Process() @@ -2933,23 +3002,6 @@ private final class WorkspaceRemoteSessionController { process.standardOutput = FileHandle.nullDevice process.standardError = stderrPipe - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - guard let self else { return } - if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { - self.reverseRelayStderrBuffer.append(chunk) - if self.reverseRelayStderrBuffer.count > 8192 { - self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) - } - } - } - } - process.terminationHandler = { [weak self] terminated in self?.queue.async { self?.handleReverseRelayTerminationLocked(process: terminated) @@ -2957,20 +3009,43 @@ private final class WorkspaceRemoteSessionController { } try process.run() + if let startupFailure = Self.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe + ) { + let retryDelay = 2.0 + let retrySeconds = max(1, Int(retryDelay.rounded())) + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(startupFailure)" + ) + relayServer?.stop() + publishDaemonStatus( + .error, + detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" + ) + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) + return + } + installReverseRelayStderrHandlerLocked(stderrPipe) reverseRelayProcess = process cliRelayServer = relayServer reverseRelayStderrPipe = stderrPipe reverseRelayStderrBuffer = "" - writeRemoteRelayDaemonPathLocked(remotePath: remotePath) do { - try writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) } catch { - debugLog("remote.relay.auth.error \(error.localizedDescription)") + debugLog("remote.relay.metadata.error \(error.localizedDescription)") stopReverseRelayLocked() scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) return } - writeRemoteSocketAddrLocked(relayPort: relayPort) + recordHeartbeatActivityLocked() debugLog( "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + "target=\(configuration.displayTarget)" @@ -2980,12 +3055,31 @@ private final class WorkspaceRemoteSessionController { "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(error.localizedDescription)" ) - cliRelayServer?.stop() + relayServer?.stop() cliRelayServer = nil scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) } } + private func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + } + private func handleReverseRelayTerminationLocked(process: Process) { guard reverseRelayProcess === process else { return } let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) @@ -3045,7 +3139,7 @@ private final class WorkspaceRemoteSessionController { reconnectWorkItem = nil reconnectRetryCount = 0 guard proxyEndpoint != endpoint else { - startHeartbeatLocked() + recordHeartbeatActivityLocked() return } proxyEndpoint = endpoint @@ -3055,11 +3149,10 @@ private final class WorkspaceRemoteSessionController { .connected, detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" ) - startHeartbeatLocked() + recordHeartbeatActivityLocked() case .error(let detail): debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") proxyEndpoint = nil - stopHeartbeatLocked(reset: false) publishProxyEndpoint(nil) publishPortsSnapshotLocked() publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") @@ -3161,44 +3254,9 @@ 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 } - + private func recordHeartbeatActivityLocked() { 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?) { @@ -3217,7 +3275,7 @@ private final class WorkspaceRemoteSessionController { var args: [String] = ["-N", "-T", "-S", "none"] args += sshCommonArguments(batchMode: true) args += [ - "-o", "ExitOnForwardFailure=no", + "-o", "ExitOnForwardFailure=yes", "-o", "RequestTTY=no", "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", configuration.destination, @@ -3227,6 +3285,7 @@ private final class WorkspaceRemoteSessionController { private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" + private static let remotePlatformProbeExistsMarker = "__CMUX_REMOTE_EXISTS__=" private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { @@ -3354,9 +3413,13 @@ private final class WorkspaceRemoteSessionController { let stdoutHandle = stdoutPipe.fileHandleForReading let stderrHandle = stderrPipe.fileHandleForReading let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") + let exitSemaphore = DispatchSemaphore(value: 0) var stdoutData = Data() var stderrData = Data() let captureGroup = DispatchGroup() + process.terminationHandler = { _ in + exitSemaphore.signal() + } captureGroup.enter() DispatchQueue.global(qos: .utility).async { let data = stdoutHandle.readDataToEndOfFile() @@ -3395,17 +3458,11 @@ private final class WorkspaceRemoteSessionController { try? pipe.fileHandleForWriting.close() } - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { process.terminate() - let terminateDeadline = Date().addingTimeInterval(2.0) - while process.isRunning && Date() < terminateDeadline { - Thread.sleep(forTimeInterval: 0.01) - } - if process.isRunning { + let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success + if !terminatedGracefully, process.isRunning { _ = Darwin.kill(process.processIdentifier, SIGKILL) process.waitUntilExit() } @@ -3433,24 +3490,42 @@ private final class WorkspaceRemoteSessionController { private func bootstrapDaemonLocked() throws -> DaemonHello { debugLog("remote.bootstrap.begin \(debugConfigSummary())") - let platform = try resolveRemotePlatformLocked() let version = Self.remoteDaemonVersion() + let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) + let platform = bootstrapState.platform let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - let forceDevOverrideInstall = Self.allowLocalDaemonBuildFallback() + let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() + let forceExplicitOverrideInstall = explicitOverrideBinary != nil debugLog( "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + - "version=\(version) remotePath=\(remotePath) devOverride=\(forceDevOverrideInstall ? 1 : 0)" + "version=\(version) remotePath=\(remotePath) " + + "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + + "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" ) - let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath) + let hadExistingBinary = bootstrapState.binaryExists debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") - if forceDevOverrideInstall || !hadExistingBinary { + if forceExplicitOverrideInstall || !hadExistingBinary { let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) } - var hello = try helloRemoteDaemonLocked(remotePath: remotePath) - if !forceDevOverrideInstall, hadExistingBinary, !hello.capabilities.contains("proxy.stream") { + var hello: DaemonHello + do { + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } catch { + guard hadExistingBinary else { + throw error + } + debugLog( + "remote.bootstrap.helloRetry remotePath=\(remotePath) " + + "detail=\(error.localizedDescription)" + ) + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) @@ -3461,29 +3536,13 @@ private final class WorkspaceRemoteSessionController { "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" ) - return hello - } - - private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedRemotePath.isEmpty else { return } - - let script = """ - mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" - ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" - ln -sf "$HOME/.cmux/bin/cmuxd-remote-current" "$HOME/.cmux/bin/cmux" - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - debugLog( - "remote.relay.wrapper.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" - ) - } - } catch { - debugLog("remote.relay.wrapper.error \(error.localizedDescription)") + if let connectionAttemptStartedAt { + debugLog( + "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + + "\(debugConfigSummary())" + ) } + return hello } private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { @@ -3499,74 +3558,31 @@ private final class WorkspaceRemoteSessionController { return relayServer } - private func writeRemoteSocketAddrLocked(relayPort: Int) { - let script = """ - mkdir -p "$HOME/.cmux" - printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - debugLog( - "remote.relay.socketAddr.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" - ) - } - } catch { - debugLog("remote.relay.socketAddr.error \(error.localizedDescription)") - } - } - - private func writeRemoteRelayDaemonPathLocked(remotePath: String) { - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedRemotePath.isEmpty else { return } - - let script = """ - mkdir -p "$HOME/.cmux/relay" - printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - debugLog( - "remote.relay.daemonPath.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" - ) - } - } catch { - debugLog("remote.relay.daemonPath.error \(error.localizedDescription)") - } - } - - private func writeRemoteRelayAuthLocked(relayPort: Int, relayID: String, relayToken: String) throws { - let authPayload = """ - {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} - """ - let script = """ - umask 077 - mkdir -p "$HOME/.cmux/relay" - chmod 700 "$HOME/.cmux/relay" - cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' - \(authPayload) - CMUXRELAYAUTH - chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" - """ + private func installRemoteRelayMetadataLocked( + remotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) throws { + let script = Self.remoteRelayMetadataInstallScript( + daemonRemotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote relay auth: \(detail)", + NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", ]) } } private func removeRemoteRelayMetadataLocked() { guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let script = """ - rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" - """ + let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) let command = "sh -c \(Self.shellSingleQuoted(script))" do { _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) @@ -3575,19 +3591,42 @@ private final class WorkspaceRemoteSessionController { } } - private func resolveRemotePlatformLocked() throws -> RemotePlatform { + static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { + """ + relay_socket='127.0.0.1:\(relayPort)' + socket_addr_file="$HOME/.cmux/socket_addr" + if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then + rm -f "$socket_addr_file" + fi + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + } + + private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { let script = """ - printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$(uname -s)" - printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$(uname -m)" + cmux_uname_os="$(uname -s)" + cmux_uname_arch="$(uname -m)" + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$cmux_uname_os" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$cmux_uname_arch" + case "$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" in + linux|darwin|freebsd) cmux_go_os="$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" ;; + *) exit 70 ;; + esac + case "$(printf '%s' "$cmux_uname_arch" | tr '[:upper:]' '[:lower:]')" in + x86_64|amd64) cmux_go_arch=amd64 ;; + aarch64|arm64) cmux_go_arch=arm64 ;; + armv7l) cmux_go_arch=arm ;; + *) exit 71 ;; + esac + cmux_remote_path="$HOME/.cmux/bin/cmuxd-remote/\(version)/${cmux_go_os}-${cmux_go_arch}/cmuxd-remote" + if [ -x "$cmux_remote_path" ]; then + printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' + else + printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' + fi """ let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", - ]) - } let lines = result.stdout .split(separator: "\n", omittingEmptySubsequences: false) @@ -3598,8 +3637,9 @@ private final class WorkspaceRemoteSessionController { let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } guard let unameOS, let unameArch else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "remote platform probe returned invalid output", + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", ]) } @@ -3610,15 +3650,19 @@ private final class WorkspaceRemoteSessionController { ]) } - return RemotePlatform(goOS: goOS, goArch: goArch) - } + let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } + if result.status != 0, binaryExists == nil { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", + ]) + } - private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool { - let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi" - let command = "sh -c \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - guard result.status == 0 else { return false } - return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" + return RemoteBootstrapState( + platform: RemotePlatform(goOS: goOS, goArch: goArch), + binaryExists: binaryExists ?? false + ) } static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" @@ -3988,6 +4032,70 @@ private final class WorkspaceRemoteSessionController { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } + static func remoteCLIWrapperScript() -> String { + """ + #!/usr/bin/env bash + set -euo pipefail + + daemon="$HOME/.cmux/bin/cmuxd-remote-current" + socket_path="${CMUX_SOCKET_PATH:-}" + if [ -z "$socket_path" ] && [ -r "$HOME/.cmux/socket_addr" ]; then + socket_path="$(tr -d '\\r\\n' < "$HOME/.cmux/socket_addr")" + fi + + if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then + relay_port="${socket_path##*:}" + relay_map="$HOME/.cmux/relay/${relay_port}.daemon_path" + if [ -r "$relay_map" ]; then + mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" + if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then + daemon="$mapped_daemon" + fi + fi + fi + + exec "$daemon" "$@" + """ + } + + static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + wrapper_tmp="$HOME/.cmux/bin/.cmux-wrapper.tmp.$$" + cat > "$wrapper_tmp" <<'CMUXWRAPPER' + \(remoteCLIWrapperScript()) + CMUXWRAPPER + chmod 755 "$wrapper_tmp" + mv -f "$wrapper_tmp" "$HOME/.cmux/bin/cmux" + """ + } + + static func remoteRelayMetadataInstallScript( + daemonRemotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + return """ + umask 077 + mkdir -p "$HOME/.cmux" "$HOME/.cmux/relay" + chmod 700 "$HOME/.cmux/relay" + \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' + \(authPayload) + CMUXRELAYAUTH + chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" + """ + } + private static func mapUnameOS(_ raw: String) -> String? { switch raw.lowercased() { case "linux": @@ -4017,10 +4125,57 @@ private final class WorkspaceRemoteSessionController { private static func remoteDaemonVersion() -> String { let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - if let bundleVersion, !bundleVersion.isEmpty { - return bundleVersion + let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" + guard allowLocalDaemonBuildFallback(), + let sourceFingerprint = remoteDaemonSourceFingerprint(), + !sourceFingerprint.isEmpty else { + return baseVersion } - return "dev" + return "\(baseVersion)-dev-\(sourceFingerprint)" + } + + private static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() + + private static func remoteDaemonSourceFingerprint() -> String? { + cachedRemoteDaemonSourceFingerprint + } + + private static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { + guard let repoRoot = findRepoRoot() else { return nil } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: daemonRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var relativePaths: [String] = [] + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + resourceValues.isRegularFile == true else { + continue + } + + let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") + if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { + relativePaths.append(relativePath) + } + } + + guard !relativePaths.isEmpty else { return nil } + + let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in + let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) + guard let fileData = try? Data(contentsOf: fileURL) else { return } + partialResult.append(Data(relativePath.utf8)) + partialResult.append(0) + partialResult.append(fileData) + partialResult.append(0) + }) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) } private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { @@ -4102,6 +4257,30 @@ private final class WorkspaceRemoteSessionController { return nil } + static func reverseRelayStartupFailureDetail( + process: Process, + stderrPipe: Pipe, + gracePeriod: TimeInterval = reverseRelayStartupGracePeriod + ) -> String? { + if process.isRunning { + let originalTerminationHandler = process.terminationHandler + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { terminated in + originalTerminationHandler?(terminated) + exitSemaphore.signal() + } + if !process.isRunning { + exitSemaphore.signal() + } + guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { + return nil + } + } + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" + } + private static func meaningfulErrorLine(in text: String) -> String? { let lines = text .split(separator: "\n") @@ -4650,6 +4829,16 @@ final class Workspace: Identifiable, ObservableObject { || lowered.contains("daemon transport") } + private var preservesSSHTerminalConnection: Bool { + activeRemoteTerminalSessionCount > 0 + && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + private var hasProxyOnlyRemoteSidebarError: Bool { + guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } + return entry.lowercased().contains("remote proxy unavailable") + } + var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } @@ -4928,8 +5117,15 @@ final class Workspace: Identifiable, ObservableObject { private var debugLastDidMoveTabTimestamp: TimeInterval = 0 private var debugDidMoveTabEventCount: UInt64 = 0 #endif - private var geometryReconcileScheduled = false - private var geometryReconcileNeedsRerun = false + private var layoutFollowUpObservers: [NSObjectProtocol] = [] + private var layoutFollowUpPanelsCancellable: AnyCancellable? + private var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? + private var layoutFollowUpReason: String? + private var layoutFollowUpTerminalFocusPanelId: UUID? + private var layoutFollowUpBrowserPanelId: UUID? + private var layoutFollowUpBrowserExitFocusPanelId: UUID? + private var layoutFollowUpNeedsGeometryPass = false + private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -5672,13 +5868,17 @@ final class Workspace: Identifiable, ObservableObject { ] } else { let proxyState: String - switch remoteConnectionState { - case .connecting: - proxyState = "connecting" - case .error: + if hasProxyOnlyRemoteSidebarError { proxyState = "error" - default: - proxyState = "unavailable" + } else { + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } } payload["proxy"] = [ "state": proxyState, @@ -5835,18 +6035,29 @@ final class Workspace: Identifiable, ObservableObject { disconnectRemoteConnection(clearConfiguration: true) } - fileprivate func applyRemoteConnectionStateUpdate( + func applyRemoteConnectionStateUpdate( _ state: WorkspaceRemoteConnectionState, detail: String?, target: String ) { - remoteConnectionState = state + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false + let preserveConnectedStateForRetry = + state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError + let effectiveState: WorkspaceRemoteConnectionState + if state == .error && proxyOnlyError && preservesSSHTerminalConnection { + effectiveState = .connected + } else if preserveConnectedStateForRetry { + effectiveState = .connected + } else { + effectiveState = state + } + + remoteConnectionState = effectiveState remoteConnectionDetail = detail applyBrowserRemoteWorkspaceStatusToPanels() - let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) - if state == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let proxyOnlyError = Self.isProxyOnlyRemoteError(trimmedDetail) + if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" @@ -5878,7 +6089,7 @@ final class Workspace: Identifiable, ObservableObject { return } - if state != .error { + if !preserveConnectedStateForRetry && state != .error { statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) remoteLastErrorFingerprint = nil } @@ -7329,27 +7540,9 @@ final class Workspace: Identifiable, ObservableObject { if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { - scheduleTerminalFirstResponderReassert(panelId: panelId) - } - } - - /// A terminal click can arrive while AppKit and bonsplit already look converged, which takes - /// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus - /// on the next couple of turns so stale callbacks from split churn can't leave keyboard input - /// attached to the wrong surface (#1147). - private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, - self.focusedPanelId == panelId, - let terminalPanel = self.terminalPanel(for: panelId) else { - return - } - - terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId) - self.scheduleTerminalFirstResponderReassert( - panelId: panelId, - remainingPasses: remainingPasses - 1 + beginEventDrivenLayoutFollowUp( + reason: "workspace.focusPanel.terminal", + terminalFocusPanelId: panelId ) } } @@ -7470,22 +7663,18 @@ final class Workspace: Identifiable, ObservableObject { focusPanel(panelId) reconcileTerminalPortalVisibilityForCurrentRenderedLayout() reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") - scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4) - scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: 4, - reason: "workspace.toggleSplitZoom" - ) - scheduleTerminalGeometryReconcile() if let browserPanel = browserPanel(for: panelId) { browserPanel.preparePortalHostReplacementForNextDistinctClaim( inPane: paneId, reason: "workspace.toggleSplitZoom" ) - scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4) - if wasSplitZoomed && !bonsplitController.isSplitZoomed { - scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4) - } } + beginEventDrivenLayoutFollowUp( + reason: "workspace.toggleSplitZoom", + browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, + browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, + includeGeometry: true + ) return true } @@ -7668,6 +7857,243 @@ final class Workspace: Identifiable, ObservableObject { } } + private func beginEventDrivenLayoutFollowUp( + reason: String, + browserPanelId: UUID? = nil, + browserExitFocusPanelId: UUID? = nil, + terminalFocusPanelId: UUID? = nil, + includeGeometry: Bool = false + ) { + layoutFollowUpReason = reason + if let browserPanelId { + layoutFollowUpBrowserPanelId = browserPanelId + } + if let browserExitFocusPanelId { + layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId + } + if let terminalFocusPanelId { + layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId + } + layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry + + if layoutFollowUpTimeoutWorkItem == nil { + installLayoutFollowUpObservers() + } + refreshLayoutFollowUpTimeout() + attemptEventDrivenLayoutFollowUp() + } + + private func installLayoutFollowUpObservers() { + guard layoutFollowUpTimeoutWorkItem == nil else { return } + + func enqueueAttempt() { + DispatchQueue.main.async { [weak self] in + self?.attemptEventDrivenLayoutFollowUp() + } + } + + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalPortalVisibilityDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserPortalRegistryDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpPanelsCancellable = $panels + .map { _ in () } + .sink { _ in + enqueueAttempt() + } + } + + private func refreshLayoutFollowUpTimeout() { + layoutFollowUpTimeoutWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.clearLayoutFollowUp() + } + layoutFollowUpTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } + + private func clearLayoutFollowUp() { + layoutFollowUpTimeoutWorkItem?.cancel() + layoutFollowUpTimeoutWorkItem = nil + layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } + layoutFollowUpObservers.removeAll() + layoutFollowUpPanelsCancellable?.cancel() + layoutFollowUpPanelsCancellable = nil + layoutFollowUpReason = nil + layoutFollowUpTerminalFocusPanelId = nil + layoutFollowUpBrowserPanelId = nil + layoutFollowUpBrowserExitFocusPanelId = nil + layoutFollowUpNeedsGeometryPass = false + } + + private func flushWorkspaceWindowLayouts() { + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + } + + private func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool { + let anchorView = browserPanel.portalAnchorView + return + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + } + + private func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { + browserPortalAnchorReady(for: browserPanel) && + browserPanel.webView.window != nil && + browserPanel.webView.superview != nil && + BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) + } + + private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { + guard let browserPanel = browserPanel(for: panelId), + let paneId = paneId(forPanelId: panelId), + let tabId = surfaceIdFromPanelId(panelId) else { + return false + } + let selectionConverged = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId + return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) + } + + private func attemptEventDrivenLayoutFollowUp() { + guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } + isAttemptingLayoutFollowUp = true + defer { isAttemptingLayoutFollowUp = false } + + flushWorkspaceWindowLayouts() + + if layoutFollowUpNeedsGeometryPass { + layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() + } + + if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { + if let terminalPanel = terminalPanel(for: terminalFocusPanelId), + focusedPanelId == terminalFocusPanelId { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) + if terminalPanel.hostedView.isSurfaceViewFirstResponder() { + layoutFollowUpTerminalFocusPanelId = nil + } + } else if terminalPanel(for: terminalFocusPanelId) == nil { + layoutFollowUpTerminalFocusPanelId = nil + } + } + + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() + + let reason = layoutFollowUpReason ?? "workspace.layout" + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() + + if let browserPanelId = layoutFollowUpBrowserPanelId { + if let browserPanel = browserPanel(for: browserPanelId) { + if browserPortalAnchorReady(for: browserPanel) { + BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + } + if browserPortalReady(for: browserPanel) { + layoutFollowUpBrowserPanelId = nil + } + } else { + layoutFollowUpBrowserPanelId = nil + } + } + + if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { + if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { + if browserPanel(for: browserExitFocusPanelId) != nil { + focusPanel(browserExitFocusPanelId) + scheduleFocusReconcile() + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } + + let terminalFocusPending: Bool = { + guard let panelId = layoutFollowUpTerminalFocusPanelId, + let terminalPanel = terminalPanel(for: panelId) else { + return false + } + return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + }() + let browserPanelPending: Bool = { + guard let panelId = layoutFollowUpBrowserPanelId, + let browserPanel = browserPanel(for: panelId) else { + return false + } + return !browserPortalReady(for: browserPanel) + }() + let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil + let needsMoreWork = + layoutFollowUpNeedsGeometryPass || + terminalPortalPending || + browserVisibilityPending || + terminalFocusPending || + browserPanelPending || + browserExitPending + + if !needsMoreWork { + clearLayoutFollowUp() + } + } + /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. private func reconcileTerminalGeometryPass() -> Bool { @@ -7707,39 +8133,11 @@ final class Workspace: Identifiable, ObservableObject { return needsFollowUpPass } - private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { - guard remainingPasses > 0 else { - geometryReconcileScheduled = false - geometryReconcileNeedsRerun = false - return - } - - let needsFollowUpPass = reconcileTerminalGeometryPass() - let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass - - if shouldRunAgain, remainingPasses > 1 { - geometryReconcileNeedsRerun = false - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) - } - return - } - - geometryReconcileScheduled = false - geometryReconcileNeedsRerun = false - } - private func scheduleTerminalGeometryReconcile() { - guard !geometryReconcileScheduled else { - geometryReconcileNeedsRerun = true - return - } - geometryReconcileScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) - } + beginEventDrivenLayoutFollowUp( + reason: "workspace.geometry", + includeGeometry: true + ) } private func renderedVisiblePanelIdsForCurrentLayout() -> Set { @@ -7801,26 +8199,6 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - - if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { - self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: remainingPasses - 1 - ) - } - } - } - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() @@ -7883,107 +8261,6 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: Int, - reason: String - ) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - - if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { - self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: remainingPasses - 1, - reason: reason - ) - } - } - } - - // Browser panes host WKWebView in the window portal. After pane zoom toggles, - // force a few post-layout sync passes so the portal does not outlive the omnibar chrome. - private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, let browserPanel = self.browserPanel(for: panelId) else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - let anchorView = browserPanel.portalAnchorView - let anchorReady = - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - - if anchorReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: "workspace.toggleSplitZoom" - ) - } - - let portalNeedsFollowUpPass = - !anchorReady || - browserPanel.webView.window == nil || - browserPanel.webView.superview == nil - if portalNeedsFollowUpPass { - self.scheduleBrowserPortalReconcileAfterSplitZoom( - panelId: panelId, - remainingPasses: remainingPasses - 1 - ) - } - } - } - - // Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is - // still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles - // so the SwiftUI chrome does not remain hidden until another browser focus command runs. - private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, self.browserPanel(for: panelId) != nil else { return } - guard let paneId = self.paneId(forPanelId: panelId), - let tabId = self.surfaceIdFromPanelId(panelId) else { return } - - let selectionConverged = - self.bonsplitController.focusedPaneId == paneId && - self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId - let anchorReady: Bool = { - guard let browserPanel = self.browserPanel(for: panelId) else { return false } - let anchorView = browserPanel.portalAnchorView - return - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - }() - - if !selectionConverged { - self.focusPanel(panelId) - self.scheduleFocusReconcile() - } - - if !selectionConverged || !anchorReady { - self.scheduleBrowserSplitZoomExitFocusReassert( - panelId: panelId, - remainingPasses: remainingPasses - 1 - ) - } - } - } - private func scheduleMovedTerminalRefresh(panelId: UUID) { guard terminalPanel(for: panelId) != nil else { return } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 820cdb0b..b9c25ae7 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -452,6 +452,149 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") } + func testCmdDRoutesSplitToEventWindowWhenKeyWindowIsDifferent() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let firstWindow = window(withId: firstWindowId), + let secondWindow = window(withId: secondWindowId), + let firstWorkspace = firstManager.selectedWorkspace, + let secondWorkspace = secondManager.selectedWorkspace else { + XCTFail("Expected both window contexts to exist") + return + } + + firstWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let firstSurfaceCount = firstWorkspace.panels.count + let secondSurfaceCount = secondWorkspace.panels.count + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, // kVK_ANSI_D + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+D must not create a split in the stale key window") + XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+D should create a split in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active") + } + + func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId) else { + XCTFail("Expected split terminal panels") + return + } + + let originalPanelIds = Set(workspace.panels.keys) + + guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let leftPaneBefore = workspace.paneId(forPanelId: leftPanel.id), + let rightPaneBefore = workspace.paneId(forPanelId: rightPanel.id) else { + XCTFail("Expected split pane IDs") + return + } + let layoutBefore = workspace.bonsplitController.layoutSnapshot() + guard let leftPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == leftPaneBefore.id.uuidString })?.frame, + let rightPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == rightPaneBefore.id.uuidString })?.frame else { + XCTFail("Expected pane frames before shortcut split") + return + } + XCTAssertLessThan(leftPaneBeforeFrame.x, rightPaneBeforeFrame.x, "Expected baseline layout to start left-to-right") + + guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else { + XCTFail("Expected left terminal surface view") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + workspace.focusPanel(rightPanel.id) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane") + leftPanel.hostedView.suppressReparentFocus() + XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) + leftPanel.hostedView.clearSuppressReparentFocus() + XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder") + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change") + XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID") + XCTAssertEqual(leftSurfaceView.terminalSurface?.id, leftPanel.id, "Expected focused Ghostty view to keep its surface ID") + + XCTAssertTrue( + appDelegate.performSplitShortcut(direction: .right, preferredWindow: window), + "Split shortcut should use the focused terminal surface even when selectedTabId is stale" + ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15)) + + let newPanelIds = Set(workspace.panels.keys) + .subtracting(originalPanelIds) + .subtracting([rightPanel.id]) + guard newPanelIds.count == 1, let newPanelId = newPanelIds.first else { + XCTFail("Expected exactly one shortcut-created split panel") + return + } + guard let newPaneId = workspace.paneId(forPanelId: newPanelId), + let rightPaneAfter = workspace.paneId(forPanelId: rightPanel.id) else { + XCTFail("Expected pane IDs after shortcut split") + return + } + let layoutAfter = workspace.bonsplitController.layoutSnapshot() + guard let newPaneFrame = layoutAfter.panes.first(where: { $0.paneId == newPaneId.id.uuidString })?.frame, + let rightPaneAfterFrame = layoutAfter.panes.first(where: { $0.paneId == rightPaneAfter.id.uuidString })?.frame else { + XCTFail("Expected pane frames after shortcut split") + return + } + XCTAssertEqual(layoutAfter.panes.count, 3, "Cmd+D should create a third pane") + XCTAssertLessThan( + newPaneFrame.x, + rightPaneAfterFrame.x, + "Cmd+D should split the focused left terminal pane, not the stale selected right pane" + ) + } + func testCmdCtrlWPromptsBeforeClosingWindow() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -2672,6 +2815,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) } + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { + var stack: [NSView] = [hostedView] + while let current = stack.popLast() { + if let surfaceView = current as? GhosttyNSView { + return surfaceView + } + stack.append(contentsOf: current.subviews) + } + return nil + } + private func mainWindowIds() -> Set { Set(NSApp.windows.compactMap { window in guard let raw = window.identifier?.rawValue, diff --git a/cmuxTests/CLIProcessRunnerTests.swift b/cmuxTests/CLIProcessRunnerTests.swift index d3831dee..9253e9b7 100644 --- a/cmuxTests/CLIProcessRunnerTests.swift +++ b/cmuxTests/CLIProcessRunnerTests.swift @@ -4,6 +4,11 @@ import XCTest @testable import cmux final class CLIProcessRunnerTests: XCTestCase { + private func writeExecutable(_ contents: String, to url: URL) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + func testRunProcessTimesOutHungChild() { let startedAt = Date() let result = CLIProcessRunner.runProcess( @@ -102,5 +107,335 @@ final class CLIProcessRunnerTests: XCTestCase { XCTAssertTrue(result.stdout.contains("REAL=\(home.path)"), result.stdout) XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").path)"), result.stdout) } + + func testInteractiveRemoteShellCommandDoesNotWaitForRelayReadinessBeforeLaunchingShell() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-no-relay-wait-\(UUID().uuidString)") + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try "precmd() { print -r -- \"READY SOCKET=$CMUX_SOCKET_PATH\"; exit }\n" + .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + + let cli = CMUXCLI(args: []) + let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64006, shellFeatures: "") + let startedAt = Date() + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + timeout: 2 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("READY SOCKET=127.0.0.1:64006"), result.stdout) + XCTAssertLessThan(Date().timeIntervalSince(startedAt), 1.5, "interactive shell startup should not wait for relay readiness") + } + + func testInteractiveRemoteShellCommandDefaultsToXterm256ColorWithoutPreparedGhosttyTerminfo() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-term-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try "precmd() { print -r -- \"TERM=$TERM\"; exit }\n" + .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + + let cli = CMUXCLI(args: []) + let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "") + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("TERM=xterm-256color"), result.stdout) + } + + func testInteractiveRemoteShellCommandSourcesZprofileBeforeLaunchingInteractiveZsh() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zprofile-\(UUID().uuidString)") + let brewBin = home.appendingPathComponent("testbrew/bin") + try fileManager.createDirectory(at: brewBin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try "export PATH=\"$HOME/testbrew/bin:$PATH\"\n" + .write(to: home.appendingPathComponent(".zprofile"), atomically: true, encoding: .utf8) + try "precmd() { print -r -- \"PATH=$PATH\"; exit }\n" + .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + + let cli = CMUXCLI(args: []) + let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "") + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("PATH=\(brewBin.path):"), result.stdout) + } + + func testInteractiveRemoteShellCommandWithInlineTerminfoParsesAndLaunchesZsh() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-inline-terminfo-\(UUID().uuidString)") + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try "precmd() { print -r -- \"READY TERM=$TERM\"; exit }\n" + .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + + let cli = CMUXCLI(args: []) + let command = cli.buildInteractiveRemoteShellCommand( + remoteRelayPort: 0, + shellFeatures: "", + terminfoSource: "xterm-ghostty|ghostty,clear=\\E[H\\E[2J" + ) + let result = CLIProcessRunner.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", command], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("READY TERM="), result.stdout) + XCTAssertFalse(result.stderr.contains("unexpected end of file"), result.stderr) + } + + func testRemoteCLIWrapperPrefersRelaySpecificDaemonMapping() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let binDir = home.appendingPathComponent(".cmux/bin") + let wrapperURL = binDir.appendingPathComponent("cmux") + let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current") + let mappedDaemonURL = binDir.appendingPathComponent("cmuxd-remote-64005") + let daemonPathURL = relayDir.appendingPathComponent("64005.daemon_path") + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try writeExecutable("#!/bin/sh\necho current \"$@\"\n", to: currentDaemonURL) + try writeExecutable("#!/bin/sh\necho mapped \"$@\"\n", to: mappedDaemonURL) + try writeExecutable(Workspace.remoteCLIWrapperScript(), to: wrapperURL) + try mappedDaemonURL.path.write(to: daemonPathURL, atomically: true, encoding: .utf8) + + let result = CLIProcessRunner.runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "CMUX_SOCKET_PATH=127.0.0.1:64005", + wrapperURL.path, + "ping", + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "mapped ping") + } + + func testRemoteCLIWrapperInstallScriptDoesNotClobberLegacySymlinkedDaemonTarget() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-install-\(UUID().uuidString)") + let binDir = home.appendingPathComponent(".cmux/bin") + let daemonDir = binDir.appendingPathComponent("cmuxd-remote/0.62.1/darwin-arm64") + let daemonURL = daemonDir.appendingPathComponent("cmuxd-remote") + let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current") + let wrapperURL = binDir.appendingPathComponent("cmux") + try fileManager.createDirectory(at: daemonDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + try writeExecutable("#!/bin/sh\necho daemon \"$@\"\n", to: daemonURL) + try fileManager.createSymbolicLink(atPath: currentDaemonURL.path, withDestinationPath: daemonURL.path) + try fileManager.createSymbolicLink(atPath: wrapperURL.path, withDestinationPath: currentDaemonURL.path) + + let installScript = Workspace.remoteCLIWrapperInstallScript( + daemonRemotePath: ".cmux/bin/cmuxd-remote/0.62.1/darwin-arm64/cmuxd-remote" + ) + let installResult = CLIProcessRunner.runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + installScript, + ], + timeout: 5 + ) + + XCTAssertFalse(installResult.timedOut, installResult.stderr) + XCTAssertEqual(installResult.status, 0, installResult.stderr) + XCTAssertEqual( + try String(contentsOf: daemonURL, encoding: .utf8), + "#!/bin/sh\necho daemon \"$@\"\n" + ) + XCTAssertEqual( + try fileManager.destinationOfSymbolicLink(atPath: currentDaemonURL.path), + daemonURL.path + ) + let wrapperAttributes = try fileManager.attributesOfItem(atPath: wrapperURL.path) + XCTAssertEqual(wrapperAttributes[.type] as? FileAttributeType, .typeRegular) + + let wrapperResult = CLIProcessRunner.runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + wrapperURL.path, + "serve", + "--stdio", + ], + timeout: 5 + ) + + XCTAssertFalse(wrapperResult.timedOut, wrapperResult.stderr) + XCTAssertEqual(wrapperResult.status, 0, wrapperResult.stderr) + XCTAssertEqual(wrapperResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "daemon serve --stdio") + } + + func testSSHStartupCommandBootstrapsOverRemoteCommandWithoutStealingInteractiveInput() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-ssh-pty-\(UUID().uuidString)") + let fakeBin = tempRoot.appendingPathComponent("bin") + let argvURL = tempRoot.appendingPathComponent("ssh-argv.txt") + let remoteCommandURL = tempRoot.appendingPathComponent("ssh-remote-command.txt") + try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempRoot) } + + try writeExecutable( + """ + #!/bin/sh + printf '%s\\n' "$@" > '\(argvURL.path)' + remote_command='' + while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ] && [ "$#" -ge 2 ]; then + case "$2" in + RemoteCommand=*) + remote_command=${2#RemoteCommand=} + ;; + esac + shift 2 + continue + fi + shift + done + printf '%s' "$remote_command" > '\(remoteCommandURL.path)' + if [ -n "$remote_command" ]; then + exec /bin/sh -lc "$remote_command" + fi + exec /bin/sh + """, + to: fakeBin.appendingPathComponent("ssh") + ) + + let cli = CMUXCLI(args: []) + let sshCommand = cli.buildSSHCommandText( + CMUXCLI.SSHCommandOptions( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + workspaceName: nil, + sshOptions: [], + extraArguments: [], + localSocketPath: "", + remoteRelayPort: 64007 + ), + remoteBootstrapScript: """ + printf '%s\\n' 'BOOTSTRAPPED %{255}' + exec /bin/sh + """ + ) + let startupCommand = try cli.buildSSHStartupCommand( + sshCommand: sshCommand, + shellFeatures: "cursor:blink,path,title", + remoteRelayPort: 64007 + ) + let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + let result = CLIProcessRunner.runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(fakeBin.path):\(currentPath)", + "STARTUP=\(startupCommand)", + "/usr/bin/python3", + "-c", + """ +import os, pty, select, subprocess, time +startup = os.environ["STARTUP"] +env = os.environ.copy() +master, slave = pty.openpty() +proc = subprocess.Popen([startup], stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True) +os.close(slave) +time.sleep(0.4) +os.write(master, b"echo READY\\nexit\\n") +time.sleep(0.8) +out = b"" +deadline = time.time() + 1.5 +while time.time() < deadline: + r, _, _ = select.select([master], [], [], 0.2) + if not r: + break + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + out += chunk +try: + proc.terminate() +except ProcessLookupError: + pass +try: + proc.wait(timeout=1) +except Exception: + proc.kill() +print(out.decode("utf-8", "replace"), end="") +""", + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("BOOTSTRAPPED %{255}"), result.stdout) + XCTAssertTrue(result.stdout.contains("READY"), result.stdout) + let argv = try String(contentsOf: argvURL, encoding: .utf8) + XCTAssertTrue(argv.contains("RemoteCommand="), argv) + let remoteCommand = try String(contentsOf: remoteCommandURL, encoding: .utf8) + XCTAssertFalse(remoteCommand.contains("%{255}"), remoteCommand) + XCTAssertTrue(remoteCommand.contains("base64"), remoteCommand) + } + + func testEncodedRemoteBootstrapCommandEscapesPercentsForSSHRemoteCommand() throws { + let cli = CMUXCLI(args: []) + let remoteCommand = cli.sshPercentEscapedRemoteCommand( + cli.encodedRemoteBootstrapCommand( + """ + printf '%s\\n' 'BOOTSTRAPPED %{255}' + exit 0 + """ + ) + ) + + let result = CLIProcessRunner.runProcess( + executablePath: "/usr/bin/ssh", + arguments: [ + "-G", + "-o", + "RemoteCommand=\(remoteCommand)", + "cmux-macmini", + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("host cmux-macmini"), result.stdout) + } } #endif diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 875ee6a6..b1c3445a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -15147,6 +15147,32 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { return fd } + private func acceptSingleClient( + on listenerFD: Int32, + handler: @escaping (_ clientFD: Int32) -> Void + ) -> XCTestExpectation { + let handled = expectation(description: "socket client handled") + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.fulfill() + return + } + defer { + Darwin.close(clientFD) + handled.fulfill() + } + handler(clientFD) + } + return handled + } + @MainActor func testSocketListenerHealthRecognizesSocketPath() throws { let path = makeTempSocketPath() @@ -15173,21 +15199,64 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { XCTAssertFalse(health.isHealthy) } + func testProbeSocketCommandReturnsFirstLineResponse() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + let response = "PONG\nextra\n" + _ = response.withCString { ptr in + write(clientFD, ptr, strlen(ptr)) + } + } + + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) + + XCTAssertEqual(response, "PONG") + wait(for: [handled], timeout: 1.0) + } + + func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let releaseServer = DispatchSemaphore(value: 0) + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + _ = releaseServer.wait(timeout: .now() + 1.0) + } + + let startedAt = Date() + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) + let elapsed = Date().timeIntervalSince(startedAt) + releaseServer.signal() + + XCTAssertNil(response) + XCTAssertGreaterThanOrEqual(elapsed, 0.18) + XCTAssertLessThan(elapsed, 0.8) + wait(for: [handled], timeout: 1.0) + } + func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { let health = TerminalController.SocketListenerHealth( isRunning: true, acceptLoopAlive: true, socketPathMatches: true, - socketPathExists: true, - socketProbePerformed: true, - socketConnectable: true, - socketConnectErrno: nil + socketPathExists: true ) XCTAssertTrue(health.isHealthy) XCTAssertTrue(health.failureSignals.isEmpty) - XCTAssertTrue(health.socketProbePerformed) - XCTAssertEqual(health.socketConnectable, true) - XCTAssertNil(health.socketConnectErrno) } func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { @@ -15195,15 +15264,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: false, acceptLoopAlive: false, socketPathMatches: false, - socketPathExists: false, - socketProbePerformed: false, - socketConnectable: nil, - socketConnectErrno: nil + socketPathExists: false ) XCTAssertFalse(health.isHealthy) - XCTAssertFalse(health.socketProbePerformed) - XCTAssertNil(health.socketConnectable) - XCTAssertNil(health.socketConnectErrno) XCTAssertEqual( health.failureSignals, ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 983bed33..68119b7f 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -874,6 +874,40 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testAcceptFailureRecoveryActionResumesAfterDelayForTransientErrors() { + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EPROTO, + consecutiveFailures: 1 + ), + .resumeAfterDelay(delayMs: 10) + ) + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EMFILE, + consecutiveFailures: 3 + ), + .resumeAfterDelay(delayMs: 40) + ) + } + + func testAcceptFailureRecoveryActionRearmsForFatalAndPersistentFailures() { + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EBADF, + consecutiveFailures: 1 + ), + .rearmAfterDelay(delayMs: 100) + ) + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EPROTO, + consecutiveFailures: 50 + ), + .rearmAfterDelay(delayMs: 5_000) + ) + } + func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() { XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1)) XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2)) @@ -919,3 +953,31 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } } + +final class SidebarDragFailsafePolicyTests: XCTestCase { + func testRequestsClearWhenMonitorStartsAfterMouseRelease() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: false + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: true + ) + ) + } + + func testRequestsClearForLeftMouseUpEventsOnly() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClear( + forMouseEventType: .leftMouseUp + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClear( + forMouseEventType: .leftMouseDragged + ) + ) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 5d94e6c3..3ff8ce80 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -149,12 +149,14 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: path) { - return - } - usleep(20_000) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: path) + }, + object: NSObject() + ) + if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { + return } XCTFail("Timed out waiting for socket at \(path)") throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift new file mode 100644 index 00000000..5bf2fc3c --- /dev/null +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -0,0 +1,204 @@ +import XCTest + +#if canImport(cmux) +@testable import cmux +#elseif canImport(cmux_DEV) +@testable import cmux_DEV +#endif + +final class WorkspaceRemoteConnectionTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private func runProcess( + executablePath: String, + arguments: [String], + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessRunResult( + status: -1, + stdout: "", + stderr: String(describing: error), + timedOut: false + ) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") + let authURL = relayDir.appendingPathComponent("64008.auth") + let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") + + XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) + XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + defer { try? fileManager.removeItem(at: home) } + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64008), + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + } + + func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") + let authURL = relayDir.appendingPathComponent("64009.auth") + let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") + + XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) + XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + defer { try? fileManager.removeItem(at: home) } + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64009), + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + } + + func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws { + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "echo 'remote port forwarding failed for listen port 64009' >&2; exit 1"] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + try process.run() + + let detail = WorkspaceRemoteSessionController.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe, + gracePeriod: 1.0 + ) + + XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009") + } + + @MainActor + func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64007, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + + let proxyError = "Remote proxy to cmux-macmini unavailable: Failed to start local daemon proxy: daemon RPC timeout waiting for hello response (retry in 3s)" + workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "cmux-macmini") + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteConnectionDetail, proxyError) + XCTAssertEqual( + workspace.statusEntries["remote.error"]?.value, + "Remote proxy unavailable (cmux-macmini): \(proxyError)" + ) + XCTAssertEqual(workspace.logEntries.last?.source, "remote-proxy") + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "error" + ) + + workspace.applyRemoteConnectionStateUpdate(.connecting, detail: "Connecting to cmux-macmini", target: "cmux-macmini") + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual( + workspace.statusEntries["remote.error"]?.value, + "Remote proxy unavailable (cmux-macmini): \(proxyError)" + ) + + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to cmux-macmini via shared local proxy 127.0.0.1:9999", + target: "cmux-macmini" + ) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertNil(workspace.statusEntries["remote.error"]) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "unavailable" + ) + } +} diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index 825207e5..ee2c189e 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -69,31 +69,35 @@ final class AutomationSocketUITests: XCTestCase { } private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: socketPath) == exists { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return FileManager.default.fileExists(atPath: socketPath) == exists + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: self.socketPath) == exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func resolveSocketPath(timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: socketPath) { - return socketPath - } - if let found = findSocketInTmp() { - return found - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var resolvedPath: String? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + if FileManager.default.fileExists(atPath: self.socketPath) { + resolvedPath = self.socketPath + return true + } + if let found = self.findSocketInTmp() { + resolvedPath = found + return true + } + return false + }, + object: NSObject() + ) + if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { + return resolvedPath } - if FileManager.default.fileExists(atPath: socketPath) { - return socketPath - } - return findSocketInTmp() + return resolvedPath } private func findSocketInTmp() -> String? { diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index 01b045c3..f1632666 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -96,15 +96,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { // After committing the autocompletion candidate, the omnibar should contain the URL. // Note: example.com may redirect to example.org in some environments. - let deadline = Date().addingTimeInterval(8.0) - while Date() < deadline { - let value = (omnibar.value as? String) ?? "" - if value.contains("example.com") || value.contains("example.org") { - return - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - XCTFail("Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))") + XCTAssertTrue( + waitForCondition(timeout: 8.0) { + containsExampleDomain((omnibar.value as? String) ?? "") + }, + "Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))" + ) } func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() { @@ -135,18 +132,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) // Note: example.com may redirect to example.org in some environments. - func containsExampleDomain(_ value: String) -> Bool { - value.contains("example.com") || value.contains("example.org") - } - - let deadline = Date().addingTimeInterval(8.0) - while Date() < deadline { - let value = (omnibar.value as? String) ?? "" - if containsExampleDomain(value) { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } + XCTAssertTrue( + waitForCondition(timeout: 8.0) { + containsExampleDomain((omnibar.value as? String) ?? "") + }, + "Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))" + ) XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? "")) // Type a new query to open the popup, then Escape should revert to the current URL. @@ -289,30 +280,19 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) // Wait for navigation to finish so we can verify focus is held through page load. - let loaded = Date().addingTimeInterval(8.0) var loadObserved = false - while Date() < loaded { - let value = (omnibar.value as? String) ?? "" - if value.lowercased().contains("example.com") { - loadObserved = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + loadObserved = waitForCondition(timeout: 8.0) { + ((omnibar.value as? String) ?? "").lowercased().contains("example.com") } XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)") let valueAfterLoad = (omnibar.value as? String) ?? "" omnibar.typeText("zx") - let typed = Date().addingTimeInterval(5.0) var valueCaptured = false - while Date() < typed { + valueCaptured = waitForCondition(timeout: 5.0) { let value = (omnibar.value as? String) ?? "" - if value.contains("zx") && value != valueAfterLoad { - valueCaptured = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains("zx") && value != valueAfterLoad } XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))") @@ -346,15 +326,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { omnibar.typeText("example.com") app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - let loadedDeadline = Date().addingTimeInterval(8.0) - var loaded = false - while Date() < loadedDeadline { + let loaded = waitForCondition(timeout: 8.0) { let value = ((omnibar.value as? String) ?? "").lowercased() - if value.contains("example.com") || value.contains("example.org") { - loaded = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + return containsExampleDomain(value) } XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") @@ -362,18 +336,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) app.typeText("lo") - let typedDeadline = Date().addingTimeInterval(7.0) var observedValue = "" - var startsWithTypedPrefix = false - while Date() < typedDeadline { + let startsWithTypedPrefix = waitForCondition(timeout: 7.0) { observedValue = ((omnibar.value as? String) ?? "").lowercased() - if observedValue.hasPrefix("lo") { - startsWithTypedPrefix = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return observedValue.hasPrefix("lo") } - XCTAssertTrue( startsWithTypedPrefix, "Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)" @@ -411,19 +378,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0)) var gmailRowIndex: Int? - let gmailDeadline = Date().addingTimeInterval(4.0) - while Date() < gmailDeadline { + _ = waitForCondition(timeout: 4.0) { for (index, row) in rows.enumerated() where row.exists { let rowValue = (row.value as? String) ?? "" if rowValue.localizedCaseInsensitiveContains("gmail") { gmailRowIndex = index - break + return true } } - if gmailRowIndex != nil { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } guard let gmailRowIndex else { let rowValues = rows.enumerated().compactMap { index, row -> String? in @@ -447,15 +410,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - let deadline = Date().addingTimeInterval(8.0) - var committedToGmail = false - while Date() < deadline { + let committedToGmail = waitForCondition(timeout: 8.0) { let value = (omnibar.value as? String) ?? "" - if value.localizedCaseInsensitiveContains("gmail.com") { - committedToGmail = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + return value.localizedCaseInsensitiveContains("gmail.com") } XCTAssertTrue(committedToGmail, "Expected Enter to commit Gmail autocomplete target. value=\(String(describing: omnibar.value))") } @@ -557,18 +514,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { omnibar.typeText("exam") let typedPrefix = "exam" - let inlineDeadline = Date().addingTimeInterval(3.0) var valueBeforeCmdA = "" - while Date() < inlineDeadline { + let sawInlineCompletion = waitForCondition(timeout: 3.0) { valueBeforeCmdA = (omnibar.value as? String) ?? "" let normalized = valueBeforeCmdA.lowercased() - if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return normalized.hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count } XCTAssertTrue( - valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count, + sawInlineCompletion, "Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" ) @@ -688,14 +641,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isSuggestionRowSelected(row) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + isSuggestionRowSelected(row) } - return isSuggestionRowSelected(row) } private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool { @@ -734,26 +682,18 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + let attempts = max(1, Int(ceil(timeout))) + for _ in 0.. Bool { + value.contains("example.com") || value.contains("example.org") + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index e024151c..dcdcb220 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -925,40 +925,23 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" - if value.contains("example.com") || value.contains("example.org") { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains("example.com") || value.contains("example.org") } - let value = (omnibar.value as? String) ?? "" - return value.contains("example.com") || value.contains("example.org") } private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" - if value.contains(expectedSubstring) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains(expectedSubstring) } - let value = (omnibar.value as? String) ?? "" - return value.contains(expectedSubstring) } private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if element.exists && element.isHittable { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + element.exists && element.isHittable } - return element.exists && element.isHittable } private var autofocusRacePageURL: String { @@ -989,31 +972,17 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return predicate(data) } - if let data = loadData(), predicate(data) { - return true - } - return false } private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { @@ -1028,4 +997,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } } diff --git a/cmuxUITests/CloseWindowConfirmDialogUITests.swift b/cmuxUITests/CloseWindowConfirmDialogUITests.swift index f64078d4..9ae8c87c 100644 --- a/cmuxUITests/CloseWindowConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWindowConfirmDialogUITests.swift @@ -68,36 +68,33 @@ final class CloseWindowConfirmDialogUITests: XCTestCase { } private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWindowAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWindowAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWindowAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !isCloseWindowAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return !isCloseWindowAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !self.isCloseWindowAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !app.windows.firstMatch.exists { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return !app.windows.firstMatch.exists + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !app.windows.firstMatch.exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWindowAlert(app: XCUIApplication) { diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index b9061916..7389a5e3 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -604,23 +604,25 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true } - if app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true } - if app.staticTexts["Close workspace?"].exists { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return false + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists || + app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists || + app.staticTexts["Close workspace?"].exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseTabAlertPresent(app: app) { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseTabAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseTabAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } // Must match the defaultValue for dialog.closeTab.title in TabManager. @@ -651,65 +653,72 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count == count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.windows.count == count + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.windows.count == count + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForWindowCount(app: XCUIApplication, atLeast count: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.windows.count >= count + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.windows.count >= count + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForNoWindowsOrAppNotRunningForeground(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.state != .runningForeground { return true } - if app.windows.count == 0 { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.state != .runningForeground || app.windows.count == 0 + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.state != .runningForeground || app.windows.count == 0 + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 - if value >= expected { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 - return value >= expected + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let value = self.loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 + return value >= expected + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if loadJSON(atPath: path) != nil { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return loadJSON(atPath: path) != nil + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.loadJSON(atPath: path) != nil + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadJSON(atPath: path), data[key] == expected { - return data - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var matchedData: [String: String]? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = self.loadJSON(atPath: path), data[key] == expected else { + return false + } + matchedData = data + return true + }, + object: NSObject() + ) + guard XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed else { + return nil } - if let data = loadJSON(atPath: path), data[key] == expected { - return data - } - return nil + return matchedData } private func assertCtrlDPreconditionsBeforeTrigger( diff --git a/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift index 47bfb9f3..d277a58e 100644 --- a/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift @@ -36,14 +36,13 @@ final class CloseWorkspaceConfirmDialogUITests: XCTestCase { } private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWorkspaceAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWorkspaceAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWorkspaceAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWorkspaceAlert(app: XCUIApplication) { diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift index c6604cb5..6bdb5284 100644 --- a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -110,25 +110,23 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return socketCommand("ping") == "PONG" + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.socketCommand("ping") == "PONG" + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if workspaceCount() == expectedCount { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return workspaceCount() == expectedCount + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.workspaceCount() == expectedCount + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func workspaceCount() -> Int { @@ -182,14 +180,13 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWorkspacesAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWorkspacesAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWorkspacesAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) { diff --git a/cmuxUITests/JumpToUnreadUITests.swift b/cmuxUITests/JumpToUnreadUITests.swift index a55f4afa..59d2ba39 100644 --- a/cmuxUITests/JumpToUnreadUITests.swift +++ b/cmuxUITests/JumpToUnreadUITests.swift @@ -50,17 +50,14 @@ final class JumpToUnreadUITests: XCTestCase { } private func waitForJumpUnreadData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = self.loadJumpUnreadData() else { return false } + return keys.allSatisfy { data[$0] != nil } + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func loadJumpUnreadData() -> [String: String]? { diff --git a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift index 1f249d61..64d48cee 100644 --- a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift @@ -126,44 +126,24 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase { } private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadGotoSplit() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadGotoSplit(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadGotoSplit() else { return false } + return predicate(data) } - if let data = loadGotoSplit(), predicate(data) { - return true - } - return false } private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 - if value >= expected { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value >= expected } - let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 - return value >= expected } private func loadGotoSplit() -> [String: String]? { @@ -280,13 +260,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") // Wait for the app-side repro loop to finish. - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -329,13 +303,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -373,13 +341,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -423,13 +385,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -474,13 +430,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -523,13 +473,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -638,13 +582,12 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { } // Also guard against a delayed blanking: watch for ~1.5s and fail if it goes blank for sustained streak. - let deadline = Date().addingTimeInterval(1.5) var blankStreak = 0 - var sampleIndex = 0 - while Date() < deadline { - sampleIndex += 1 + for sampleIndex in 1...9 { guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else { - RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + if sampleIndex < 9 { + RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + } continue } if stats.isProbablyBlank { @@ -657,7 +600,9 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)") return } - RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + if sampleIndex < 9 { + RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + } } } @@ -852,76 +797,54 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForAnyData(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if loadData() != nil { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + loadData() != nil } - return loadData() != nil } private func waitForSettledData(timeout: TimeInterval) -> [String: String]? { - let deadline = Date().addingTimeInterval(timeout) var last: [String: String]? - while Date() < deadline { - if let data = loadData() { - last = data + _ = waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + last = data - if let setupError = data["setupError"], !setupError.isEmpty { - return data - } - - let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1 - let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1 - let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1 - let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1 - let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1 - let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1 - let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1 - let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1 - - let settled = - finalPaneCount == 2 && - missingSelected == 0 && - missingMapping == 0 && - emptyPanels == 0 && - selectedTerminalCount == 2 && - selectedTerminalAttached == 2 && - selectedTerminalZeroSize == 0 && - selectedTerminalSurfaceNil == 0 - - if settled { - return data - } - - // `recordSplitCloseRightFinalState` streams attempts; give it time to converge. - // If the bug is present it will never converge to "settled". - let attempt = Int(data["finalAttempt"] ?? "") ?? -1 - if attempt >= 20 { - return data - } + if let setupError = data["setupError"], !setupError.isEmpty { + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } + let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1 + let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1 + let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1 + let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1 + let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1 + let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1 + let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1 + let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1 + let settled = + finalPaneCount == 2 && + missingSelected == 0 && + missingMapping == 0 && + emptyPanels == 0 && + selectedTerminalCount == 2 && + selectedTerminalAttached == 2 && + selectedTerminalZeroSize == 0 && + selectedTerminalSurfaceNil == 0 + if settled { + return true + } + + let attempt = Int(data["finalAttempt"] ?? "") ?? -1 + return attempt >= 20 + } return last } @@ -942,14 +865,23 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { // MARK: - Automation Socket Client (UI Tests) private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + socketCommand("ping") == "PONG" } - return socketCommand("ping") == "PONG" + } + + private func waitForVisualDone(timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + loadData()?["visualDone"] == "1" + } + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func socketCommand(_ cmd: String) -> String? { diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 2c2bba0b..cf7bd1c2 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -399,12 +399,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { @@ -425,82 +422,49 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), - let current = data["focusToken"], - !current.isEmpty, - current != token { - return true + waitForCondition(timeout: timeout) { + guard let data = loadData(), + let current = data["focusToken"], + !current.isEmpty else { + return false } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return current != token } - if let data = loadData(), - let current = data["focusToken"], - !current.isEmpty, - current != token { - return true - } - return false } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return keys.allSatisfy { (data[$0] ?? "").isEmpty == false } } - if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { - return true - } - return false } private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return predicate(data) } - if let data = loadData(), predicate(data) { - return true - } - return false } private func waitForSocketPong(timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) var lastResponse: String? - while Date() < deadline { + _ = waitForCondition(timeout: timeout) { lastResponse = socketCommand("ping") - if lastResponse == "PONG" { - return "PONG" - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return lastResponse == "PONG" } - return socketCommand("ping") ?? lastResponse + return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse) } private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("is_terminal_focused \(surfaceId)") == "true" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + socketCommand("is_terminal_focused \(surfaceId)") == "true" } - return socketCommand("is_terminal_focused \(surfaceId)") == "true" } private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { - let deadline = Date().addingTimeInterval(timeout) var lastStdout: String? var lastStderr: String? - while Date() < deadline { + let didSucceed = waitForCondition(timeout: timeout) { let result = runCmuxCommand( socketPath: socketPath, arguments: ["ping"], @@ -515,24 +479,22 @@ final class MultiWindowNotificationsUITests: XCTestCase { lastStderr = stderr } if result.terminationStatus == 0, stdout == "PONG" { - return ("PONG", stderr) + return true } if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" { - return ("PONG", stderr) + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false + } + if didSucceed { + return ("PONG", lastStderr) } - let result = runCmuxCommand( - socketPath: socketPath, - arguments: ["ping"], - responseTimeoutSeconds: 2.0 - ) + let result = runCmuxCommand(socketPath: socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0) let stdout = result.stdout.isEmpty ? nil : result.stdout let stderr = result.stderr.isEmpty ? nil : result.stderr - if isSocketPermissionFailure(stderr), - waitForSocketPong(timeout: 0.5) == "PONG" { + if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" { return ("PONG", stderr) } return (stdout ?? lastStdout, stderr ?? lastStderr) @@ -543,41 +505,30 @@ final class MultiWindowNotificationsUITests: XCTestCase { app: XCUIApplication, timeout: TimeInterval ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) var sawCompletion = false - while Date() < deadline { + let completed = waitForCondition(timeout: timeout) { if app.state == .runningForeground { return false } if FileManager.default.fileExists(atPath: statusPath) { sawCompletion = true - break + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { + guard completed || sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { return false } - let postCompletionDeadline = Date().addingTimeInterval(0.75) - while Date() < postCompletionDeadline { - if app.state == .runningForeground { - return false - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return waitForCondition(timeout: 0.75) { + app.state != .runningForeground } - return app.state != .runningForeground } private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.state != .runningForeground { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + app.state != .runningForeground } - return app.state != .runningForeground } private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { @@ -600,25 +551,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) { - return surfaceId - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var surfaceId: String? + _ = waitForCondition(timeout: timeout) { + surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) + return surfaceId != nil } - return firstSurfaceId(forWorkspaceId: workspaceId) + return surfaceId ?? firstSurfaceId(forWorkspaceId: workspaceId) } private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { - return surfaceId - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var surfaceId: String? + _ = waitForCondition(timeout: timeout) { + surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + return surfaceId != nil } - return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { @@ -938,24 +893,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { fallbackCandidates = [] } - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + var resolvedPath: String? + _ = waitForCondition(timeout: timeout) { for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, // prefer it even before workspace contents are fully initialized. if socketRespondsToPing(at: candidate) { - return candidate + resolvedPath = candidate + return true } } for candidate in fallbackCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } if socketRespondsToPing(at: candidate), socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { - return candidate + resolvedPath = candidate + return true } } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false + } + if let resolvedPath { + return resolvedPath } for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } @@ -1108,6 +1068,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + var socketTimeout = timeval( + tv_sec: Int(responseTimeout.rounded(.down)), + tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded()) + ) #if os(macOS) var noSigPipe: Int32 = 1 @@ -1121,6 +1085,24 @@ final class MultiWindowNotificationsUITests: XCTestCase { ) } #endif + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } var addr = sockaddr_un() memset(&addr, 0, MemoryLayout.size) @@ -1164,19 +1146,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } - let deadline = Date().addingTimeInterval(responseTimeout) var buf = [UInt8](repeating: 0, count: 4096) var accum = "" - while Date() < deadline { - var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollDescriptor, 1, 100) - if ready < 0 { + while true { + let n = read(fd, &buf, buf.count) + if n < 0 { + let code = errno + if code == EAGAIN || code == EWOULDBLOCK { + break + } return nil } - if ready == 0 { - continue - } - let n = read(fd, &buf, buf.count) if n <= 0 { break } if let chunk = String(bytes: buf[0.. Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if element.exists, element.isHittable { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard element.exists, element.isHittable else { return false } let frame = element.frame - if frame.width > 1, frame.height > 1 { - return true - } - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return false + return frame.width > 1 && frame.height > 1 + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } } diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 07a2afaf..9bf4c758 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -17,18 +17,19 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin 3. `proxy.open` 4. `proxy.close` 5. `proxy.write` -6. `proxy.read` -7. `session.open` -8. `session.close` -9. `session.attach` -10. `session.resize` -11. `session.detach` -12. `session.status` +6. `proxy.stream.subscribe` +7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events +8. `session.open` +9. `session.close` +10. `session.attach` +11. `session.resize` +12. `session.detach` +13. `session.status` Current integration in cmux: 1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote proxy transport. -3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`. +3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. 4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). `workspace.remote.configure` contract notes: @@ -67,7 +68,7 @@ Socket discovery order: 2. `CMUX_SOCKET_PATH` environment variable 3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) -For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. +For TCP addresses, the CLI dials once and only refreshes `~/.cmux/socket_addr` a single time if the first address was stale. Relay metadata is published only after the reverse forward is ready, so steady-state use does not rely on polling. Authenticated relay details: 1. Each SSH workspace gets its own relay ID and relay token. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index fbdd87f5..b38d1b21 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -122,7 +122,7 @@ doneFlags: } // refreshAddr is set when the address came from socket_addr file (not env/flag), - // allowing retry loops to pick up updated relay ports. + // allowing one stale-address refresh if another workspace has replaced socket_addr. var refreshAddr func() string if socketPath == "" { socketPath = readSocketAddrFile() @@ -477,11 +477,17 @@ func currentRelayAuth(socketPath string) *relayAuthState { // dialSocket connects to the cmux socket. If addr contains a colon and doesn't // start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. -// For TCP connections, it retries briefly to allow the SSH reverse forward to establish. -// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. +// For TCP connections, refreshAddr is used only to recover from a stale socket_addr +// rewrite, not to poll for relay readiness. func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { - conn, connectedAddr, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) + conn, connectedAddr, err := dialTCP(addr) + if err != nil && refreshAddr != nil && isConnectionRefused(err) { + if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr { + addr = refreshedAddr + conn, connectedAddr, err = dialTCP(addr) + } + } if err != nil { return nil, err } @@ -496,40 +502,13 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { return net.Dial("unix", addr) } -// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. -// This handles the case where the SSH reverse relay hasn't finished establishing yet. -// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses -// (e.g. when socket_addr is rewritten by a new relay process). -func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, error) { - deadline := time.Now().Add(timeout) - interval := 250 * time.Millisecond - printed := false - for { - conn, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err == nil { - setTCPNoDelay(conn) - return conn, addr, nil - } - if time.Now().After(deadline) { - return nil, addr, err - } - // Only retry on connection refused (relay not ready yet) - if !isConnectionRefused(err) { - return nil, addr, err - } - if !printed { - fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) - printed = true - } - time.Sleep(interval) - // Re-read socket_addr in case the relay port has changed - if refreshAddr != nil { - if newAddr := refreshAddr(); newAddr != "" && newAddr != addr { - addr = newAddr - fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr) - } - } +func dialTCP(addr string) (net.Conn, string, error) { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + return nil, addr, err } + setTCPNoDelay(conn) + return conn, addr, nil } func isConnectionRefused(err error) bool { diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index d9a09390..e90a94e9 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -255,39 +255,51 @@ func mustHex(t *testing.T, value string) []byte { return data } -func TestDialTCPRetrySuccess(t *testing.T) { - // Get a free port, then close the listener so connection is refused initially. - ln, err := net.Listen("tcp", "127.0.0.1:0") +func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) { + staleListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - t.Fatalf("listen: %v", err) + t.Fatalf("listen stale: %v", err) } - addr := ln.Addr().String() - ln.Close() + staleAddr := staleListener.Addr().String() + staleListener.Close() - // Start a listener after a delay so the retry logic finds it. + readyListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen ready: %v", err) + } + defer readyListener.Close() + + accepted := make(chan struct{}) go func() { - time.Sleep(400 * time.Millisecond) - ln2, err := net.Listen("tcp", addr) - if err != nil { - return - } - defer ln2.Close() - conn, err := ln2.Accept() - if err != nil { + defer close(accepted) + conn, acceptErr := readyListener.Accept() + if acceptErr != nil { return } conn.Close() }() - conn, _, err := dialTCPRetry(addr, 3*time.Second, nil) + refreshCalls := 0 + start := time.Now() + conn, err := dialSocket(staleAddr, func() string { + refreshCalls++ + return readyListener.Addr().String() + }) + elapsed := time.Since(start) if err != nil { - t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) + t.Fatalf("dialSocket should refresh to updated address, got: %v", err) } conn.Close() + <-accepted + if refreshCalls != 1 { + t.Fatalf("refreshAddr should be called once, got %d", refreshCalls) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("dialSocket should fail over without polling, took %v", elapsed) + } } -func TestDialTCPRetryTimeout(t *testing.T) { - // Get a free port and close it — nothing will ever listen. +func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) @@ -295,14 +307,21 @@ func TestDialTCPRetryTimeout(t *testing.T) { addr := ln.Addr().String() ln.Close() + refreshCalls := 0 start := time.Now() - _, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + _, err = dialSocket(addr, func() string { + refreshCalls++ + return addr + }) elapsed := time.Since(start) if err == nil { - t.Fatal("dialTCPRetry should fail when nothing is listening") + t.Fatal("dialSocket should fail when the relay address stays stale") } - if elapsed < 500*time.Millisecond { - t.Fatalf("should have retried for ~600ms, only took %v", elapsed) + if refreshCalls != 1 { + t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("dialSocket should fail fast without polling, took %v", elapsed) } } diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index c0ba5874..4d696609 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -39,12 +39,30 @@ type rpcResponse struct { Error *rpcError `json:"error,omitempty"` } +type rpcEvent struct { + Event string `json:"event"` + StreamID string `json:"stream_id,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + Error string `json:"error,omitempty"` +} + +type streamState struct { + conn net.Conn + readerStarted bool +} + +type stdioFrameWriter struct { + mu sync.Mutex + writer *bufio.Writer +} + type rpcServer struct { mu sync.Mutex nextStreamID uint64 nextSessionID uint64 - streams map[string]net.Conn + streams map[string]*streamState sessions map[string]*sessionState + frameWriter *stdioFrameWriter } type sessionAttachment struct { @@ -114,17 +132,20 @@ func usage(w io.Writer) { } func runStdioServer(stdin io.Reader, stdout io.Writer) error { + writer := &stdioFrameWriter{ + writer: bufio.NewWriter(stdout), + } server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, + frameWriter: writer, } defer server.closeAll() reader := bufio.NewReaderSize(stdin, 64*1024) - writer := bufio.NewWriter(stdout) - defer writer.Flush() + defer writer.writer.Flush() for { line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) @@ -135,7 +156,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { return readErr } if oversized { - if err := writeResponse(writer, rpcResponse{ + if err := writer.writeResponse(rpcResponse{ OK: false, Error: &rpcError{ Code: "invalid_request", @@ -154,7 +175,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { var req rpcRequest if err := json.Unmarshal(line, &req); err != nil { - if err := writeResponse(writer, rpcResponse{ + if err := writer.writeResponse(rpcResponse{ OK: false, Error: &rpcError{ Code: "invalid_request", @@ -167,7 +188,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { } resp := server.handleRequest(req) - if err := writeResponse(writer, resp); err != nil { + if err := writer.writeResponse(resp); err != nil { return err } } @@ -226,18 +247,28 @@ func discardUntilNewline(reader *bufio.Reader) error { } } -func writeResponse(w *bufio.Writer, resp rpcResponse) error { - payload, err := json.Marshal(resp) +func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error { + return w.writeJSONFrame(resp) +} + +func (w *stdioFrameWriter) writeEvent(event rpcEvent) error { + return w.writeJSONFrame(event) +} + +func (w *stdioFrameWriter) writeJSONFrame(payload any) error { + data, err := json.Marshal(payload) if err != nil { return err } - if _, err := w.Write(payload); err != nil { + w.mu.Lock() + defer w.mu.Unlock() + if _, err := w.writer.Write(data); err != nil { return err } - if err := w.WriteByte('\n'); err != nil { + if err := w.writer.WriteByte('\n'); err != nil { return err } - return w.Flush() + return w.writer.Flush() } func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { @@ -266,6 +297,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { "proxy.http_connect", "proxy.socks5", "proxy.stream", + "proxy.stream.push", }, }, } @@ -283,8 +315,8 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { return s.handleProxyClose(req) case "proxy.write": return s.handleProxyWrite(req) - case "proxy.read": - return s.handleProxyRead(req) + case "proxy.stream.subscribe": + return s.handleProxyStreamSubscribe(req) case "session.open": return s.handleSessionOpen(req) case "session.close": @@ -358,7 +390,7 @@ func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { s.mu.Lock() streamID := fmt.Sprintf("s-%d", s.nextStreamID) s.nextStreamID++ - s.streams[streamID] = conn + s.streams[streamID] = &streamState{conn: conn} s.mu.Unlock() return rpcResponse{ @@ -384,7 +416,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { } s.mu.Lock() - conn, exists := s.streams[streamID] + state, exists := s.streams[streamID] if exists { delete(s.streams, streamID) } @@ -401,7 +433,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { } } - _ = conn.Close() + _ = state.conn.Close() return rpcResponse{ ID: req.ID, OK: true, @@ -446,7 +478,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { } } - conn, found := s.getStream(streamID) + state, found := s.getStream(streamID) if !found { return rpcResponse{ ID: req.ID, @@ -457,6 +489,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { }, } } + conn := state.conn timeoutMs := 8000 if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { @@ -511,7 +544,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { } } -func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { +func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { streamID, ok := getStringParam(req.Params, "stream_id") if !ok || streamID == "" { return rpcResponse{ @@ -519,33 +552,15 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { OK: false, Error: &rpcError{ Code: "invalid_params", - Message: "proxy.read requires stream_id", + Message: "proxy.stream.subscribe requires stream_id", }, } } - maxBytes := 32768 - if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax { - maxBytes = parsed - } - if maxBytes <= 0 || maxBytes > 262144 { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "max_bytes must be in range 1-262144", - }, - } - } - - timeoutMs := 50 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { - timeoutMs = parsed - } - - conn, found := s.getStream(streamID) + s.mu.Lock() + state, found := s.streams[streamID] if !found { + s.mu.Unlock() return rpcResponse{ ID: req.ID, OK: false, @@ -555,51 +570,23 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { }, } } + alreadySubscribed := state.readerStarted + if !alreadySubscribed { + state.readerStarted = true + } + conn := state.conn + s.mu.Unlock() - _ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)) - defer conn.SetReadDeadline(time.Time{}) - buffer := make([]byte, maxBytes) - n, readErr := conn.Read(buffer) - data := buffer[:max(0, n)] - - if readErr != nil { - if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() { - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "data_base64": "", - "eof": false, - }, - } - } - if readErr == io.EOF { - s.dropStream(streamID) - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "data_base64": base64.StdEncoding.EncodeToString(data), - "eof": true, - }, - } - } - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: readErr.Error(), - }, - } + if !alreadySubscribed { + go s.streamPump(streamID, conn) } return rpcResponse{ ID: req.ID, OK: true, Result: map[string]any{ - "data_base64": base64.StdEncoding.EncodeToString(data), - "eof": false, + "subscribed": true, + "already_subscribed": alreadySubscribed, }, } } @@ -951,31 +938,31 @@ func sessionSnapshot(sessionID string, session *sessionState) map[string]any { } } -func (s *rpcServer) getStream(streamID string) (net.Conn, bool) { +func (s *rpcServer) getStream(streamID string) (*streamState, bool) { s.mu.Lock() defer s.mu.Unlock() - conn, ok := s.streams[streamID] - return conn, ok + state, ok := s.streams[streamID] + return state, ok } func (s *rpcServer) dropStream(streamID string) { s.mu.Lock() - conn, ok := s.streams[streamID] + state, ok := s.streams[streamID] if ok { delete(s.streams, streamID) } s.mu.Unlock() if ok { - _ = conn.Close() + _ = state.conn.Close() } } func (s *rpcServer) closeAll() { s.mu.Lock() streams := make([]net.Conn, 0, len(s.streams)) - for id, conn := range s.streams { + for id, state := range s.streams { delete(s.streams, id) - streams = append(streams, conn) + streams = append(streams, state.conn) } for id := range s.sessions { delete(s.sessions, id) @@ -986,6 +973,62 @@ func (s *rpcServer) closeAll() { } } +func (s *rpcServer) streamPump(streamID string, conn net.Conn) { + defer func() { + if recovered := recover(); recovered != nil { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: fmt.Sprintf("stream panic: %v", recovered), + }) + s.dropStream(streamID) + } + }() + + buffer := make([]byte, 32768) + for { + n, readErr := conn.Read(buffer) + data := append([]byte(nil), buffer[:max(0, n)]...) + if len(data) > 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.data", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } + + if readErr == nil { + if n == 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: "read made no progress", + }) + s.dropStream(streamID) + return + } + continue + } + + if readErr == io.EOF { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.eof", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } else if !errors.Is(readErr, net.ErrClosed) { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: readErr.Error(), + }) + } + + s.dropStream(streamID) + return + } +} + func getStringParam(params map[string]any, key string) (string, bool) { if params == nil { return "", false diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 9ee08f07..3216373d 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "encoding/base64" "encoding/json" @@ -9,10 +10,40 @@ import ( "net" "strconv" "strings" + "sync" "testing" "time" ) +type notifyingBuffer struct { + mu sync.Mutex + buffer bytes.Buffer + notify chan struct{} +} + +func newNotifyingBuffer() *notifyingBuffer { + return ¬ifyingBuffer{notify: make(chan struct{}, 1)} +} + +func (b *notifyingBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + n, err := b.buffer.Write(p) + if n > 0 { + select { + case b.notify <- struct{}{}: + default: + } + } + return n, err +} + +func (b *notifyingBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +} + func TestRunVersion(t *testing.T) { var out bytes.Buffer code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) @@ -55,6 +86,16 @@ func TestRunStdioHelloAndPing(t *testing.T) { if len(capabilities) < 2 { t.Fatalf("hello should return capabilities: %v", firstResult) } + var sawPushCapability bool + for _, capability := range capabilities { + if capability == "proxy.stream.push" { + sawPushCapability = true + break + } + } + if !sawPushCapability { + t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult) + } var second map[string]any if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { @@ -168,11 +209,15 @@ func TestProxyStreamRoundTrip(t *testing.T) { _, _ = conn.Write([]byte("pong")) }() + eventOutput := newNotifyingBuffer() server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, + frameWriter: &stdioFrameWriter{ + writer: bufio.NewWriter(eventOutput), + }, } defer server.closeAll() @@ -209,24 +254,39 @@ func TestProxyStreamRoundTrip(t *testing.T) { readResp := server.handleRequest(rpcRequest{ ID: 3, - Method: "proxy.read", + Method: "proxy.stream.subscribe", Params: map[string]any{ - "stream_id": streamID, - "max_bytes": 8, - "timeout_ms": 1000, + "stream_id": streamID, }, }) if !readResp.OK { - t.Fatalf("proxy.read failed: %+v", readResp) + t.Fatalf("proxy.stream.subscribe failed: %+v", readResp) } - readResult, _ := readResp.Result.(map[string]any) - dataBase64, _ := readResult["data_base64"].(string) + select { + case <-eventOutput.notify: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for proxy.stream.data event") + } + + lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + t.Fatalf("proxy.stream.data event output was empty") + } + + var event map[string]any + if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { + t.Fatalf("failed to decode stream event: %v", err) + } + if got := event["event"]; got != "proxy.stream.data" { + t.Fatalf("unexpected stream event=%v payload=%v", got, event) + } + dataBase64, _ := event["data_base64"].(string) data, decodeErr := base64.StdEncoding.DecodeString(dataBase64) if decodeErr != nil { - t.Fatalf("proxy.read returned invalid base64: %v", decodeErr) + t.Fatalf("proxy.stream.data returned invalid base64: %v", decodeErr) } if string(data) != "pong" { - t.Fatalf("proxy.read payload=%q, want %q", string(data), "pong") + t.Fatalf("proxy.stream.data payload=%q, want %q", string(data), "pong") } closeResp := server.handleRequest(rpcRequest{ @@ -305,7 +365,7 @@ func TestProxyOpenInvalidParams(t *testing.T) { server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, } defer server.closeAll() @@ -331,7 +391,7 @@ func TestSessionResizeCoordinator(t *testing.T) { server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, } defer server.closeAll() @@ -421,7 +481,7 @@ func TestSessionInvalidParamsAndNotFound(t *testing.T) { server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, } defer server.closeAll() diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 57e0f443..3c8bb0c8 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -32,7 +32,7 @@ This is a **living implementation spec** (also called an **execution spec**): a ### 3.2 Bootstrap + Daemon - `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. - `DONE` daemon `hello` handshake is enforced. -- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). +- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) plus pushed `proxy.stream.*` events. - `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. - `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`). - `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. @@ -45,9 +45,9 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. - `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. - `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. -- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). -- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. -- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` relay process uses `-S none` / standalone SSH transport (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=yes` so dead reverse binds fail fast instead of publishing bad relay metadata. +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation. +- `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite. - `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:` so parallel sessions pin to their own relay instead of racing on shared socket_addr. - `DONE` relay startup writes `~/.cmux/relay/.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. - `DONE` relay startup writes `~/.cmux/relay/.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. @@ -86,8 +86,8 @@ This is a **living implementation spec** (also called an **execution spec**): a 5. `DONE` re-apply proxy config on reconnect/state updates. ### 4.3 Remote Daemon + Transport -1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). -2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC. +1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) with pushed `proxy.stream.data/eof/error` events. +2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC without polling reads. 3. `DONE` removed remote service-port discovery/probing from browser routing path. ### 4.4 Explicit Non-Goal @@ -131,7 +131,7 @@ Recompute effective size on: | M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | | M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | | M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | -| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented | +| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented | | M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | | M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | | M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | diff --git a/scripts/reload.sh b/scripts/reload.sh index 11edfd92..5abcab9a 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -367,6 +367,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_ENABLE 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_ENABLE string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_MODE automation" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_MODE string automation" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ @@ -464,9 +468,9 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py index 6252ea5e..46d1497e 100644 --- a/tests/test_cli_version_memory_guard.py +++ b/tests/test_cli_version_memory_guard.py @@ -9,6 +9,7 @@ from __future__ import annotations import glob import os import plistlib +import re import shutil import subprocess import tempfile @@ -96,7 +97,7 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: env.pop("CMUX_COMMIT", None) proc = subprocess.Popen( - [cli_path, *args], + ["/usr/bin/time", "-l", cli_path, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -104,54 +105,42 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: ) started = time.time() - peak_rss_kb = 0 - failure_reason: str | None = None - - while True: - exit_code = proc.poll() - if exit_code is not None: - stdout, stderr = proc.communicate() - return { - "exit_code": exit_code, - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "elapsed": time.time() - started, - "peak_rss_kb": peak_rss_kb, - "failure_reason": None, - } - - try: - rss_kb = int( - subprocess.check_output( - ["ps", "-o", "rss=", "-p", str(proc.pid)], - text=True, - ).strip() - or "0" - ) - except subprocess.CalledProcessError: - rss_kb = 0 - - peak_rss_kb = max(peak_rss_kb, rss_kb) + try: + stdout, stderr = proc.communicate(timeout=TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() elapsed = time.time() - started + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": 0, + "failure_reason": f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)", + } - if rss_kb > RSS_LIMIT_KB: - failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)" - elif elapsed > TIMEOUT_SECONDS: - failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" + elapsed = time.time() - started + peak_rss_kb = 0 + rss_match = re.search(r"(\d+)\s+maximum resident set size", stderr) + if rss_match: + peak_rss_raw = int(rss_match.group(1)) + peak_rss_kb = peak_rss_raw if peak_rss_raw <= RSS_LIMIT_KB * 16 else peak_rss_raw // 1024 - if failure_reason: - proc.kill() - stdout, stderr = proc.communicate() - return { - "exit_code": proc.returncode, - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "elapsed": elapsed, - "peak_rss_kb": peak_rss_kb, - "failure_reason": failure_reason, - } + failure_reason: str | None = None + if peak_rss_kb > RSS_LIMIT_KB: + failure_reason = f"rss limit exceeded ({peak_rss_kb} KB > {RSS_LIMIT_KB} KB)" + elif elapsed > TIMEOUT_SECONDS: + failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" - time.sleep(0.05) + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": peak_rss_kb, + "failure_reason": failure_reason, + } def main() -> int: From 60aab29e390ac7978ac1187aff3e66e116af5aaf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 00:00:40 -0700 Subject: [PATCH 45/77] Make remote proxy close idempotent --- daemon/remote/cmd/cmuxd-remote/main.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 4d696609..386f6577 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -425,10 +425,9 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { if !exists { return rpcResponse{ ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", + OK: true, + Result: map[string]any{ + "closed": true, }, } } From b0d994c99ffb6f9ce4f3b1df43d57f0cb43516f1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 00:14:28 -0700 Subject: [PATCH 46/77] Fix UI test helper closure captures --- .../BrowserOmnibarSuggestionsUITests.swift | 8 ++--- .../BrowserPaneNavigationKeybindUITests.swift | 6 ++-- .../MenuKeyEquivalentRoutingUITests.swift | 36 ++++++++++--------- .../MultiWindowNotificationsUITests.swift | 30 ++++++++-------- 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index f1632666..1f6a85c8 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -98,7 +98,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { // Note: example.com may redirect to example.org in some environments. XCTAssertTrue( waitForCondition(timeout: 8.0) { - containsExampleDomain((omnibar.value as? String) ?? "") + self.containsExampleDomain((omnibar.value as? String) ?? "") }, "Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))" ) @@ -134,7 +134,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { // Note: example.com may redirect to example.org in some environments. XCTAssertTrue( waitForCondition(timeout: 8.0) { - containsExampleDomain((omnibar.value as? String) ?? "") + self.containsExampleDomain((omnibar.value as? String) ?? "") }, "Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))" ) @@ -328,7 +328,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { let loaded = waitForCondition(timeout: 8.0) { let value = ((omnibar.value as? String) ?? "").lowercased() - return containsExampleDomain(value) + return self.containsExampleDomain(value) } XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") @@ -642,7 +642,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - isSuggestionRowSelected(row) + self.isSuggestionRowSelected(row) } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index dcdcb220..1c2bd61b 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -973,14 +973,14 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadData() else { return false } + guard let data = self.loadData() else { return false } return keys.allSatisfy { data[$0] != nil } } } - private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadData() else { return false } + guard let data = self.loadData() else { return false } return predicate(data) } } diff --git a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift index 64d48cee..8ff0ab47 100644 --- a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift @@ -4,6 +4,16 @@ import CoreGraphics import ImageIO import Darwin +private extension XCTestCase { + func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } +} + final class MenuKeyEquivalentRoutingUITests: XCTestCase { private var gotoSplitPath = "" private var keyequivPath = "" @@ -127,21 +137,21 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase { private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadGotoSplit() else { return false } + guard let data = self.loadGotoSplit() else { return false } return keys.allSatisfy { data[$0] != nil } } } - private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { + private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadGotoSplit() else { return false } + guard let data = self.loadGotoSplit() else { return false } return predicate(data) } } private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 + let value = self.loadKeyequiv()[key].flatMap(Int.init) ?? 0 return value >= expected } } @@ -798,14 +808,14 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadData() else { return false } + guard let data = self.loadData() else { return false } return keys.allSatisfy { data[$0] != nil } } } private func waitForAnyData(timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - loadData() != nil + self.loadData() != nil } } @@ -813,7 +823,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { var last: [String: String]? _ = waitForCondition(timeout: timeout) { - guard let data = loadData() else { return false } + guard let data = self.loadData() else { return false } last = data if let setupError = data["setupError"], !setupError.isEmpty { @@ -866,24 +876,16 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { private func waitForSocketPong(timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - socketCommand("ping") == "PONG" + self.socketCommand("ping") == "PONG" } } private func waitForVisualDone(timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - loadData()?["visualDone"] == "1" + self.loadData()?["visualDone"] == "1" } } - private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in predicate() }, - object: nil - ) - return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed - } - private func socketCommand(_ cmd: String) -> String? { if socketClient == nil { socketClient = ControlSocketClient(path: socketPath) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index cf7bd1c2..d433dc94 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -423,7 +423,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadData(), + guard let data = self.loadData(), let current = data["focusToken"], !current.isEmpty else { return false @@ -434,14 +434,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadData() else { return false } + guard let data = self.loadData() else { return false } return keys.allSatisfy { (data[$0] ?? "").isEmpty == false } } } - private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { waitForCondition(timeout: timeout) { - guard let data = loadData() else { return false } + guard let data = self.loadData() else { return false } return predicate(data) } } @@ -449,7 +449,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func waitForSocketPong(timeout: TimeInterval) -> String? { var lastResponse: String? _ = waitForCondition(timeout: timeout) { - lastResponse = socketCommand("ping") + lastResponse = self.socketCommand("ping") return lastResponse == "PONG" } return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse) @@ -457,7 +457,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - socketCommand("is_terminal_focused \(surfaceId)") == "true" + self.socketCommand("is_terminal_focused \(surfaceId)") == "true" } } @@ -465,8 +465,8 @@ final class MultiWindowNotificationsUITests: XCTestCase { var lastStdout: String? var lastStderr: String? let didSucceed = waitForCondition(timeout: timeout) { - let result = runCmuxCommand( - socketPath: socketPath, + let result = self.runCmuxCommand( + socketPath: self.socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0 ) @@ -481,8 +481,8 @@ final class MultiWindowNotificationsUITests: XCTestCase { if result.terminationStatus == 0, stdout == "PONG" { return true } - if isSocketPermissionFailure(stderr), - waitForSocketPong(timeout: 0.5) == "PONG" { + if self.isSocketPermissionFailure(stderr), + self.waitForSocketPong(timeout: 0.5) == "PONG" { return true } return false @@ -553,7 +553,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { var surfaceId: String? _ = waitForCondition(timeout: timeout) { - surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) + surfaceId = self.firstSurfaceId(forWorkspaceId: workspaceId) return surfaceId != nil } return surfaceId ?? firstSurfaceId(forWorkspaceId: workspaceId) @@ -562,7 +562,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { var surfaceId: String? _ = waitForCondition(timeout: timeout) { - surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + surfaceId = self.firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) return surfaceId != nil } return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) @@ -899,15 +899,15 @@ final class MultiWindowNotificationsUITests: XCTestCase { guard FileManager.default.fileExists(atPath: candidate) else { continue } // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, // prefer it even before workspace contents are fully initialized. - if socketRespondsToPing(at: candidate) { + if self.socketRespondsToPing(at: candidate) { resolvedPath = candidate return true } } for candidate in fallbackCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate), - socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + if self.socketRespondsToPing(at: candidate), + self.socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { resolvedPath = candidate return true } From aac8a41ba237a04d94db5275f23aae0375861a6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 00:50:02 -0700 Subject: [PATCH 47/77] Fix browser import follow-up review comments --- Sources/Workspace.swift | 3 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 39 ++++++++----------- cmuxTests/GhosttyConfigTests.swift | 5 ++- .../BrowserImportProfilesUITests.swift | 20 ++++++++++ 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 25a7173d..a14cbf69 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1388,7 +1388,8 @@ final class Workspace: Identifiable, ObservableObject { return preferredProfileID } if let sourcePanelId, - let sourceBrowserPanel = browserPanel(for: sourcePanelId) { + let sourceBrowserPanel = browserPanel(for: sourcePanelId), + BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil { return sourceBrowserPanel.profileID } if let preferredBrowserProfileID, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 67f8cadf..6ce551a0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -65,6 +65,15 @@ private func drainMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } +@MainActor +private func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) +} + final class SplitShortcutTransientFocusGuardTests: XCTestCase { func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { XCTAssertTrue( @@ -6277,18 +6286,10 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { } } - private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) - } - func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { let workspace = Workspace() - let profileA = try makeProfile(named: "Alpha") - let profileB = try makeProfile(named: "Beta") + let profileA = try makeTemporaryBrowserProfile(named: "Alpha") + let profileB = try makeTemporaryBrowserProfile(named: "Beta") let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) let browserA = try XCTUnwrap( workspace.newBrowserSurface( @@ -6332,8 +6333,8 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { let workspace = Workspace() - let preferredProfile = try makeProfile(named: "Preferred") - let unexpectedProfile = try makeProfile(named: "Unexpected") + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) _ = try XCTUnwrap( @@ -6363,8 +6364,8 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { let workspace = Workspace() - let preferredProfile = try makeProfile(named: "Preferred") - let unexpectedProfile = try makeProfile(named: "Unexpected") + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) let browser = try XCTUnwrap( @@ -6453,16 +6454,8 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { @MainActor final class BrowserPanelProfileIsolationTests: XCTestCase { - private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) - } - func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { - let alternateProfile = try makeProfile(named: "Switched") + let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched") let defaultStore = BrowserHistoryStore.shared let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) defaultStore.clearHistory() diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9ac2a8f7..4ceb61d1 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2029,7 +2029,10 @@ final class BrowserInstallDetectorTests: XCTestCase { private func createFile(at url: URL, contents: Data) throws { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) guard FileManager.default.createFile(atPath: url.path, contents: contents) else { - throw CocoaError(.fileWriteUnknown) + throw CocoaError( + .fileWriteUnknown, + userInfo: [NSFilePathErrorKey: url.path] + ) } } } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index cc28d425..feb55471 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -98,6 +98,22 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } + func testWaitForCapturedSelectionReadsCaptureWrittenAtTimeoutBoundary() throws { + let payload: [String: Any] = [ + "mode": "boundary-write", + "entries": [] + ] + let payloadData = try JSONSerialization.data(withJSONObject: payload) + let captureURL = URL(fileURLWithPath: capturePath) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.19) { + try? payloadData.write(to: captureURL) + } + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 0.2)) + XCTAssertEqual(capture["mode"] as? String, "boundary-write") + } + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -138,6 +154,10 @@ final class BrowserImportProfilesUITests: XCTestCase { } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return object + } return nil } From 8cd36775f08ad6c008c7783c42f53b72f02c8fd4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 00:59:45 -0700 Subject: [PATCH 48/77] Add remote CLI relay regressions --- daemon/remote/cmd/cmuxd-remote/cli_test.go | 191 ++++++++++++++++++++- 1 file changed, 187 insertions(+), 4 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index e90a94e9..a8b9f623 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -43,12 +43,21 @@ func captureStdout(t *testing.T, fn func()) string { return string(output) } +func makeShortUnixSocketPath(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "cmuxd-") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "cmux.sock") +} + // startMockSocket creates a Unix socket that accepts one connection, // reads a line, and responds with the given canned response. func startMockSocket(t *testing.T, response string) string { t.Helper() - dir := t.TempDir() - sockPath := filepath.Join(dir, "cmux.sock") + sockPath := makeShortUnixSocketPath(t) ln, err := net.Listen("unix", sockPath) if err != nil { @@ -77,8 +86,7 @@ func startMockSocket(t *testing.T, response string) string { // back as a successful JSON-RPC response with the method name in the result. func startMockV2Socket(t *testing.T) string { t.Helper() - dir := t.TempDir() - sockPath := filepath.Join(dir, "cmux.sock") + sockPath := makeShortUnixSocketPath(t) ln, err := net.Listen("unix", sockPath) if err != nil { @@ -115,6 +123,50 @@ func startMockV2Socket(t *testing.T) string { return sockPath } +func startMockV2SocketWithRequestCapture(t *testing.T) (string, <-chan map[string]any) { + t.Helper() + sockPath := makeShortUnixSocketPath(t) + requests := make(chan map[string]any, 8) + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n == 0 { + return + } + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err != nil { + _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + return + } + requests <- req + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + _, _ = conn.Write(append(payload, '\n')) + }(conn) + } + }() + + return sockPath, requests +} + func startMockV2TCPSocketWithResult(t *testing.T, result any) string { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -618,6 +670,137 @@ func TestCLIBrowserSubcommand(t *testing.T) { } } +func TestCLINewPaneDefaultsDirectionAndForwardsExtraFlags(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + code := runCLI([]string{ + "--socket", sockPath, "--json", + "new-pane", + "--workspace", "ws-1", + "--type", "browser", + "--url", "https://example.com", + }) + if code != 0 { + t.Fatalf("new-pane should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "pane.create" { + t.Fatalf("expected pane.create, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "ws-1" { + t.Fatalf("expected workspace_id ws-1, got %v", got) + } + if got := params["direction"]; got != "right" { + t.Fatalf("expected default direction right, got %v", got) + } + if got := params["type"]; got != "browser" { + t.Fatalf("expected type browser, got %v", got) + } + if got := params["url"]; got != "https://example.com" { + t.Fatalf("expected url to be forwarded, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for new-pane request") + } +} + +func TestCLIListPanelsUsesSurfaceList(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-panels", "--workspace", "ws-1"}) + if code != 0 { + t.Fatalf("list-panels should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "surface.list" { + t.Fatalf("expected surface.list, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "ws-1" { + t.Fatalf("expected workspace_id ws-1, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for list-panels request") + } +} + +func TestCLIFocusPanelUsesSurfaceFocus(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + code := runCLI([]string{"--socket", sockPath, "--json", "focus-panel", "--workspace", "ws-1", "--panel", "surface-1"}) + if code != 0 { + t.Fatalf("focus-panel should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "surface.focus" { + t.Fatalf("expected surface.focus, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "ws-1" { + t.Fatalf("expected workspace_id ws-1, got %v", got) + } + if got := params["surface_id"]; got != "surface-1" { + t.Fatalf("expected surface_id surface-1, got %v", got) + } + if _, ok := params["panel_id"]; ok { + t.Fatalf("did not expect panel_id in params: %v", params) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for focus-panel request") + } +} + +func TestCLIBrowserOpenUsesOpenSplitAndWorkspaceEnv(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + t.Setenv("CMUX_WORKSPACE_ID", "env-ws") + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "browser.open_split" { + t.Fatalf("expected browser.open_split, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "env-ws" { + t.Fatalf("expected workspace_id env-ws, got %v", got) + } + if got := params["url"]; got != "https://example.com" { + t.Fatalf("expected positional url to be forwarded, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for browser open request") + } +} + +func TestCLIBrowserGetURLUsesCurrentMethodAndSurfaceEnv(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + t.Setenv("CMUX_SURFACE_ID", "env-sf") + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "get-url"}) + if code != 0 { + t.Fatalf("browser get-url should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "browser.url.get" { + t.Fatalf("expected browser.url.get, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["surface_id"]; got != "env-sf" { + t.Fatalf("expected surface_id env-sf, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for browser get-url request") + } +} + func TestCLINoArgs(t *testing.T) { code := runCLI([]string{}) if code != 2 { From bbdb626ef37150c4bb3da8e469582e1adc78d21a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 00:59:52 -0700 Subject: [PATCH 49/77] Fix nightly remote daemon and SSH relay wiring --- .github/workflows/nightly.yml | 26 ++++----- daemon/remote/cmd/cmuxd-remote/cli.go | 77 ++++++++++++++++++++------- 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 34b94949..197de4be 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -263,8 +263,10 @@ jobs: else NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi + NIGHTLY_MARKETING_VERSION="${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" - echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" >> "$GITHUB_ENV" + echo "NIGHTLY_MARKETING_VERSION=${NIGHTLY_MARKETING_VERSION}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${NIGHTLY_MARKETING_VERSION}" >> "$GITHUB_ENV" NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV" @@ -282,7 +284,7 @@ jobs: /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist" /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${feed_url}" "$app_plist" - /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$app_plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${NIGHTLY_MARKETING_VERSION}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$app_plist" /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$app_plist" @@ -296,13 +298,13 @@ jobs: echo "Nightly app name: cmux NIGHTLY" echo "Nightly bundle ID: com.cmuxterm.app.nightly" - echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" + echo "Nightly marketing version: ${NIGHTLY_MARKETING_VERSION}" echo "Nightly build number: ${NIGHTLY_BUILD}" echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" - name: Build remote daemon nightly assets and inject manifest - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') run: | set -euo pipefail ./scripts/build_remote_daemon_release_assets.sh \ @@ -311,13 +313,13 @@ jobs: --repo "manaflow-ai/cmux" \ --output-dir "remote-daemon-assets" MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" - for APP_PLIST in \ - "build-arm/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" \ - "build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" - do - plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true - plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" - done + APP_PLIST="build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" + if [ ! -f "$APP_PLIST" ]; then + echo "Missing nightly app Info.plist at $APP_PLIST" >&2 + exit 1 + fi + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" - name: Import signing cert if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') @@ -463,7 +465,7 @@ jobs: cp appcast.xml appcast-universal.xml - name: Attest remote daemon nightly assets - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index b38d1b21..2b2bf585 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -41,6 +41,10 @@ type commandSpec struct { flagKeys []string // noParams means the command takes no parameters at all. noParams bool + // paramKeyOverrides remaps specific flags for compatibility aliases. + paramKeyOverrides map[string]string + // defaultParams are applied before flags/env fallbacks. + defaultParams map[string]any } var commands = []commandSpec{ @@ -59,12 +63,12 @@ var commands = []commandSpec{ {name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}}, {name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}}, {name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true}, - {name: "list-panels", proto: protoV2, v2Method: "panel.list", flagKeys: []string{"workspace"}}, - {name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}}, + {name: "list-panels", proto: protoV2, v2Method: "surface.list", flagKeys: []string{"workspace"}}, + {name: "focus-panel", proto: protoV2, v2Method: "surface.focus", flagKeys: []string{"panel", "workspace"}, paramKeyOverrides: map[string]string{"panel": "surface_id"}}, {name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}}, {name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, - {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace"}}, - {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}}, + {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace", "direction", "type", "url"}, defaultParams: map[string]any{"direction": "right"}}, + {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane", "type", "url"}}, {name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, {name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}}, {name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, @@ -191,7 +195,10 @@ func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr fun // execV2 sends a v2 JSON-RPC request over the socket. func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { - params := make(map[string]any) + params := make(map[string]any, len(spec.defaultParams)) + for key, value := range spec.defaultParams { + params[key] = value + } if !spec.noParams { parsed, err := parseFlags(args, spec.flagKeys) @@ -203,6 +210,9 @@ func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool for _, key := range spec.flagKeys { if val, ok := parsed.flags[key]; ok { paramKey := flagToParamKey(key) + if override, ok := spec.paramKeyOverrides[key]; ok { + paramKey = override + } params[paramKey] = val } } @@ -212,17 +222,8 @@ func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool params["initial_command"] = parsed.positional[0] } - // Fall back to env vars for common IDs - if _, ok := params["workspace_id"]; !ok { - if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { - params["workspace_id"] = envWs - } - } - if _, ok := params["surface_id"]; !ok { - if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { - params["surface_id"] = envSf - } - } + applyWorkspaceEnvFallback(params) + applySurfaceEnvFallback(params) } resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) @@ -275,25 +276,36 @@ func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshA var method string var flagKeys []string + var allowPositionalURL bool + var useWorkspaceEnv bool + var useSurfaceEnv bool switch sub { case "open", "open-split", "new": - method = "browser.open" + method = "browser.open_split" flagKeys = []string{"url", "workspace", "surface"} + allowPositionalURL = true + useWorkspaceEnv = true case "navigate": method = "browser.navigate" flagKeys = []string{"url", "surface"} + allowPositionalURL = true + useSurfaceEnv = true case "back": method = "browser.back" flagKeys = []string{"surface"} + useSurfaceEnv = true case "forward": method = "browser.forward" flagKeys = []string{"surface"} + useSurfaceEnv = true case "reload": method = "browser.reload" flagKeys = []string{"surface"} + useSurfaceEnv = true case "get-url": - method = "browser.get_url" + method = "browser.url.get" flagKeys = []string{"surface"} + useSurfaceEnv = true default: fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub) return 2 @@ -311,6 +323,17 @@ func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshA params[paramKey] = val } } + if allowPositionalURL { + if _, ok := params["url"]; !ok && len(parsed.positional) > 0 { + params["url"] = strings.Join(parsed.positional, " ") + } + } + if useWorkspaceEnv { + applyWorkspaceEnvFallback(params) + } + if useSurfaceEnv { + applySurfaceEnvFallback(params) + } resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) if err != nil { @@ -325,6 +348,24 @@ func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshA return 0 } +func applyWorkspaceEnvFallback(params map[string]any) { + if _, ok := params["workspace_id"]; ok { + return + } + if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { + params["workspace_id"] = envWs + } +} + +func applySurfaceEnvFallback(params map[string]any) { + if _, ok := params["surface_id"]; ok { + return + } + if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { + params["surface_id"] = envSf + } +} + func defaultRelayOutput(resp string) string { var result any if err := json.Unmarshal([]byte(resp), &result); err != nil { From a561a272c1d12a9cd33c2fa38fe6d8eae4baface Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:00:14 -0700 Subject: [PATCH 50/77] Migrate CI/CD to WarpBuild, consolidate test jobs (#1501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate CI/CD to WarpBuild, consolidate test jobs Replace all macOS runner labels across workflows: - depot-macos-latest → warp-macos-15-arm64-6x - macos-15 → warp-macos-15-arm64-6x - macos-14 → warp-macos-14-arm64-6x Consolidates tests + tests-depot into a single tests job that runs unit tests, regressions, UI tests, and lag tests sequentially on one WarpBuild runner. Ubuntu jobs remain on ubuntu-latest. Co-Authored-By: Claude Opus 4.6 * Upgrade stale zig on runners that have an outdated version pre-installed WarpBuild macos-14 ships zig 0.15.1 but the project requires 0.15.2. The install step skipped because zig was found, just outdated. Co-Authored-By: Claude Opus 4.6 * Pin zig 0.15.2 via direct tarball instead of Homebrew Homebrew's zig bottle for macOS 14 (Sonoma) is stuck at 0.15.1 but the ghostty submodule requires 0.15.2. Download zig directly from ziglang.org to guarantee the correct version on all runner images. Co-Authored-By: Claude Opus 4.6 * Fix zig tarball URL: arch-os order is aarch64-macos, not macos-aarch64 Co-Authored-By: Claude Opus 4.6 * Create /usr/local/bin and /usr/local/lib before copying zig WarpBuild runners don't have /usr/local/lib by default. Co-Authored-By: Claude Opus 4.6 * Add 20-min timeout to WarpBuild jobs Co-Authored-By: Claude Opus 4.6 * Fix UI test hang: stream output instead of variable capture, use GitHub runner for macOS 14 The OUTPUT=$(...) pattern buffers all xcodebuild output into a bash variable. For the full cmux scheme (build + UI tests), this can be hundreds of MB, causing the shell to hang. Replace with tee streaming. macOS 14 on WarpBuild consistently hangs (unit tests timeout at 20min vs 4min on macOS 15, same M4 Pro hardware). Use GitHub-hosted macos-14 runner for compat tests instead, which works on main today. Co-Authored-By: Claude Opus 4.6 * Split UI tests to GitHub-hosted runner (WarpBuild can't activate GUI apps) WarpBuild macOS VMs leave XCUIApplication stuck in "Running Background" state, causing every UI test to burn ~62s waiting for activation and timing out the job. Root cause: WarpBuild ephemeral VMs don't provide a full GUI session for app activation. Split CI into parallel jobs: - tests: WarpBuild (unit tests + regressions, ~6 min) - tests-ui: GitHub-hosted macos-15 (UI tests + lag regression) Co-Authored-By: Claude Opus 4.6 * Move tests-ui to WarpBuild with TCC permission grants Grant accessibility, post-event, and screen capture TCC permissions to Xcode and XCTest processes on WarpBuild ephemeral VMs. This should fix "Failed to activate application (Running Background)" errors that prevent XCUITests from bringing the app to foreground. Co-Authored-By: Claude Opus 4.6 * Add GUI session diagnostics and DevToolsSecurity for WarpBuild UI tests Add session diagnostics (who, console user, GUI domain, WindowServer, loginwindow) to understand WarpBuild VM session state. Also enable DevToolsSecurity and security authorizationdb for XCTest process control. Try bootstrapping GUI session if missing. Co-Authored-By: Claude Opus 4.6 * Fix TCC permissions: use Xcode-Helper + user DB (CircleCI approach) Previous TCC grants used wrong client IDs (com.apple.dt.Xcode) and only wrote to the system database. CircleCI's proven approach grants: - kTCCServiceAccessibility to com.apple.dt.Xcode-Helper (not Xcode) - kTCCServiceDeveloperTool to com.apple.Terminal - Both system AND user-level TCC databases Co-Authored-By: Claude Opus 4.6 * Reduce UI test timeout to 15s for WarpBuild expected failures WarpBuild Virtualization.framework VMs cannot activate macOS GUI apps (XCUIApplication stuck "Running Background"). Tests still execute and report expected failures. But the 62s per-test activation timeout makes 30+ tests take 30+ minutes total. Set per-test timeout to 15s so expected failures resolve quickly. Full interactive UI test coverage runs via test-e2e.yml on GitHub-hosted runners with proper display support. Co-Authored-By: Claude Opus 4.6 * Replace XCUITest run with build + lag regression on WarpBuild WarpBuild Virtualization.framework VMs cannot activate macOS GUI apps (XCUIApplication stuck "Running Background" with 62s activation timeout per test). Tried TCC permissions, DevToolsSecurity, virtual display, reduced timeouts, nothing fixes the framework-level issue. Replace tests-ui job with tests-build-and-lag: - Build the full cmux scheme (verifies compilation) - Run workspace churn typing-lag regression (socket-based, no GUI) - XCUITests run via test-e2e.yml on GitHub-hosted runners Co-Authored-By: Claude Opus 4.6 * Move macOS 14 compat to WarpBuild (no GitHub-hosted runners) Co-Authored-By: Claude Opus 4.6 * Add diagnostic workflow to probe WarpBuild GUI activation Tests multiple app activation approaches on WarpBuild VMs: - open -a, NSWorkspace, NSRunningApplication.activate, osascript - Virtual display state before/after CGVirtualDisplay - TCC/accessibility permissions, Quartz session info - VM type detection This is a workflow_dispatch-only diagnostic to determine if XCUITest can work on WarpBuild with the right configuration. Co-Authored-By: Claude Opus 4.6 * Trigger GUI probe on branch push (workflow_dispatch needs main) Co-Authored-By: Claude Opus 4.6 * Rewrite GUI probe with Swift (Python lacks AppKit on WarpBuild) v1 failed because WarpBuild's Python isn't a framework build and can't import AppKit/Quartz. v2 uses a compiled Swift binary to test NSRunningApplication.activate(), osascript, Quartz session state, display info, and AX trust. Co-Authored-By: Claude Opus 4.6 * GUI probe v3: try 5 approaches to unlock WarpBuild screen 1. defaults write (screensaver, loginwindow, pmset) 2. automationmodetool enable-automationmode-without-authentication 3. CGSSessionSetScreenLocked private API + System Events keystroke 4. sysadminctl -screenLock off + keychain unlock 5. CGEvent simulation (mouse move + Return key to dismiss lock) Each approach is followed by an activation check to see if it worked. Co-Authored-By: Claude Opus 4.6 * Test GUI activation on macOS 14, 15, and 26 (Tahoe) Co-Authored-By: Claude Opus 4.6 * Add DerivedData and GhosttyKit caching to CI workflows Major caching improvements across ci.yml and ci-macos-compat.yml: - Cache GhosttyKit.xcframework keyed on ghostty submodule SHA (skip download on cache hit) - Cache DerivedData keyed on OS + Xcode version + Package.resolved + project.pbxproj (enables incremental builds across runs) - Remove explicit DerivedData wipe (rely on cache key invalidation) - Use download-prebuilt-ghosttykit.sh in compat workflow too This should significantly speed up macOS 14 compat tests which were taking 20+ min due to full recompilation every run. Co-Authored-By: Claude Opus 4.6 * Bump macOS 14 compat timeout to 45 min for cold cache seeding The DerivedData cache wasn't saved because the job timed out at 30 min, causing the post-job cache save step to be skipped. 45 min gives enough headroom for the first uncached run to complete and seed the cache. Subsequent runs should be much faster with incremental builds. Co-Authored-By: Claude Opus 4.6 * Use Depot runners for E2E tests (WarpBuild has screen lock on macOS 15/26) WarpBuild VMs on macOS 15 and 26 have CGSSessionScreenIsLocked=1, which prevents XCUIApplication activation. Depot runners have working GUI activation. Can switch back to WarpBuild once they fix the VM images. Co-Authored-By: Claude Opus 4.6 (1M context) * Skip smoke test on macOS 14 compat, remove GUI diagnostic workflow macOS 14 was slow because it built the full app (cmux scheme) on top of unit tests (cmux-unit scheme). Unit tests are the real compat check; smoke test runs on macOS 15 only. Also removes the temporary test-warpbuild-gui.yml diagnostic workflow. Co-Authored-By: Claude Opus 4.6 (1M context) * Replace Sonoma with Tahoe in compat matrix, drop macOS 14 Swap macOS 14 (Sonoma) for macOS 26 (Tahoe). Smoke test runs on macOS 15 only (WarpBuild screen lock blocks app activation on 26). Co-Authored-By: Claude Opus 4.6 (1M context) * Drop macOS 26 from compat matrix (zig 0.15.2 linker failure) Zig 0.15.2 can't link against the macOS 26 (Tahoe) SDK: undefined symbols for basic libc functions (_abort, _free, _fork, etc.). The zig toolchain needs an update to support Tahoe. Keep macOS 15 only for now. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Lawrence Chen Co-authored-by: Claude Opus 4.6 --- .github/workflows/build-ghosttykit.yml | 24 ++-- .github/workflows/ci-macos-compat.yml | 70 +++++++----- .github/workflows/ci.yml | 151 +++++++++++++++---------- .github/workflows/nightly.yml | 17 ++- .github/workflows/release.yml | 17 ++- .github/workflows/test-depot.yml | 17 ++- .github/workflows/test-e2e.yml | 29 +++-- tests/test_ci_self_hosted_guard.sh | 28 +++-- 8 files changed, 227 insertions(+), 126 deletions(-) diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index ec787452..80850f50 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -8,9 +8,10 @@ on: jobs: build-ghosttykit: - # Never run Depot jobs for fork pull requests (avoid billing on external PRs). + # Never run WarpBuild jobs for fork pull requests (avoid billing on external PRs). if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -61,13 +62,18 @@ jobs: if: steps.check-release.outputs.exists == 'false' run: | set -euo pipefail - if ! command -v zig >/dev/null 2>&1; then - if command -v brew >/dev/null 2>&1; then - brew install zig - else - echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2 - exit 1 - fi + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 2b7e06c9..4682df7b 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -13,8 +13,12 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15] + include: + - os: warp-macos-15-arm64-6x + timeout: 20 + smoke: true runs-on: ${{ matrix.os }} + timeout-minutes: ${{ matrix.timeout }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -39,45 +43,48 @@ jobs: echo "Selected: $XCODE_APP" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" - xcodebuild -version + XCODE_VER="$(xcodebuild -version | head -1)" + echo "XCODE_VER=$XCODE_VER" >> "$GITHUB_ENV" + echo "$XCODE_VER" xcrun --sdk macosx --show-sdk-path sw_vers + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty') }} + - name: Download pre-built GhosttyKit.xcframework - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: steps.cache-ghosttykit.outputs.cache-hit != 'true' run: | - set -euo pipefail - GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) - TAG="xcframework-$GHOSTTY_SHA" - URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - echo "Downloading xcframework for ghostty $GHOSTTY_SHA" - MAX_RETRIES=30 - RETRY_DELAY=20 - for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - done - tar xzf GhosttyKit.xcframework.tar.gz - rm GhosttyKit.xcframework.tar.gz - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - - name: Clean DerivedData - run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + key: deriveddata-${{ matrix.os }}-${{ env.XCODE_VER }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-${{ matrix.os }}-${{ env.XCODE_VER }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-${{ matrix.os }}-${{ env.XCODE_VER }}- - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -147,6 +154,7 @@ jobs: fi - name: Create virtual display + if: matrix.smoke run: | set -euo pipefail echo "=== Display before ===" @@ -162,6 +170,7 @@ jobs: system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)" - name: Build app for smoke test + if: matrix.smoke run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" @@ -171,6 +180,7 @@ jobs: -destination "platform=macOS" build - name: Smoke test + if: matrix.smoke run: | set -euo pipefail chmod +x scripts/smoke-test-ci.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34a52dbc..6ba43368 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Validate Depot runner guards + - name: Validate WarpBuild runner guards run: ./tests/test_ci_self_hosted_guard.sh - name: Validate create-dmg version pinning @@ -50,7 +50,10 @@ jobs: run: bun tsc --noEmit tests: - runs-on: macos-15 + # Never run WarpBuild jobs for fork pull requests (avoid billing on external PRs). + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -60,35 +63,57 @@ jobs: - name: Select Xcode run: | set -euo pipefail - XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1 || true)" - if [ -z "$XCODE_APP" ]; then - XCODE_APP="/Applications/Xcode.app" + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi fi - XCODE_DIR="$XCODE_APP/Contents/Developer" - if [ ! -d "$XCODE_DIR" ]; then - echo "No Xcode found under /Applications" >&2 - exit 1 - fi - echo "Selected: $XCODE_APP" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version - xcrun --sdk macosx --show-sdk-path + + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty') }} - name: Download pre-built GhosttyKit.xcframework + if: steps.cache-ghosttykit.outputs.cache-hit != 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - - name: Clean DerivedData - run: | - # Remove stale build cache to avoid incremental build errors - rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + key: deriveddata-tests-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-tests-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-tests- - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -183,10 +208,14 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py - tests-depot: - # Never run Depot jobs for fork pull requests (avoid billing on external PRs). + tests-build-and-lag: + # Build the full cmux scheme and run the lag regression on WarpBuild. + # XCUITests cannot run on WarpBuild (Virtualization.framework limitation: + # XCUIApplication stuck "Running Background", 62s activation timeout per + # test). Interactive UI tests run via test-e2e.yml on GitHub-hosted runners. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -211,25 +240,49 @@ jobs: export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit-lag + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty') }} + - name: Download pre-built GhosttyKit.xcframework + if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - - name: Clean DerivedData - run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + key: deriveddata-build-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-build-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-build- - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .ci-source-packages - key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} - restore-keys: spm- + key: spm-build-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-build- - name: Resolve Swift packages run: | @@ -251,6 +304,15 @@ jobs: sleep $((attempt * 5)) done + - name: Build app + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" build + - name: Create virtual display run: | set -euo pipefail @@ -261,41 +323,6 @@ jobs: echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" sleep 3 - - name: Run UI tests - run: | - set -euo pipefail - SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - # SidebarResizeUITests hangs on headless runners (mouse drag simulation - # doesn't work without a physical display, even with virtual display). - # Skip it in CI; it runs fine on local machines. - run_ui_tests() { - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -maximum-test-execution-time-allowance 120 \ - -only-testing:cmuxUITests \ - -skip-testing:cmuxUITests/SidebarResizeUITests test 2>&1 - } - - # xcodebuild exits 65 even for expected failures (XCTExpectFailure). - # Capture output and fail only if there are unexpected failures. - set +e - OUTPUT=$(run_ui_tests) - EXIT_CODE=$? - set -e - - echo "$OUTPUT" - if [ "$EXIT_CODE" -ne 0 ]; then - SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) - if echo "$SUMMARY" | grep -q "(0 unexpected)"; then - echo "All failures are expected, treating as pass" - else - echo "Unexpected test failures detected" - exit 1 - fi - fi - - name: Run workspace churn typing-lag regression run: | set -euo pipefail diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8f7e48de..9ffc7fbb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -99,7 +99,8 @@ jobs: build-sign-notarize-nightly: needs: decide if: needs.decide.outputs.should_build == 'true' - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout build ref uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -151,8 +152,18 @@ jobs: - name: Install build deps if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi npm install --global "create-dmg@${CREATE_DMG_VERSION}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff6b33b1..73346b50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,8 @@ env: jobs: build-sign-notarize: - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -99,8 +100,18 @@ jobs: - name: Install build deps if: steps.guard_release_assets.outputs.skip_all != 'true' run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi npm install --global "create-dmg@${CREATE_DMG_VERSION}" diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 536c5a14..c6edb871 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -28,7 +28,8 @@ on: jobs: tests: - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -84,8 +85,18 @@ jobs: - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - name: Create virtual display diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index a61e5eee..54d145bd 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -20,17 +20,18 @@ on: default: true type: boolean runner: - description: "Runner OS (macos-15 or macos-26)" + description: "Runner OS (Depot runners for GUI activation support)" required: false - default: "macos-15" + default: "depot-macos-latest" type: choice options: - - macos-15 - - macos-26 + - depot-macos-latest + - depot-macos-14 jobs: e2e: - runs-on: ${{ inputs.runner || 'macos-15' }} + runs-on: ${{ inputs.runner || 'depot-macos-latest' }} + timeout-minutes: 20 env: TEST_REF: ${{ inputs.ref || github.ref }} steps: @@ -92,8 +93,18 @@ jobs: - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - name: Create virtual display @@ -161,8 +172,8 @@ jobs: uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .ci-source-packages - key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} - restore-keys: spm-${{ inputs.runner || 'macos-15' }}- + key: spm-${{ inputs.runner || 'depot-macos-latest' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-${{ inputs.runner || 'depot-macos-latest' }}- - name: Resolve Swift packages run: | diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index 3b4f7f65..c3a5281c 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Regression test for https://github.com/manaflow-ai/cmux/issues/385. -# Ensures Depot-hosted UI tests are never run for fork pull requests. +# Ensures paid/gated CI jobs are never run for fork pull requests. set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -9,21 +9,35 @@ WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository" if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then - echo "FAIL: Missing fork pull_request guard for tests in $WORKFLOW_FILE" + echo "FAIL: Missing fork pull_request guard in $WORKFLOW_FILE" echo "Expected line:" echo " $EXPECTED_IF" exit 1 fi +# tests: must use WarpBuild runner with fork guard (paid runner) if ! awk ' - /^ tests-depot:/ { in_tests=1; next } + /^ tests:/ { in_tests=1; next } in_tests && /^ [^[:space:]]/ { in_tests=0 } - in_tests && /runs-on: depot-macos-latest/ { saw_depot=1 } + in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 } in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } - END { exit !(saw_depot && saw_guard) } + END { exit !(saw_warp && saw_guard) } ' "$WORKFLOW_FILE"; then - echo "FAIL: tests-depot block must keep both depot-macos-latest runner and fork guard" + echo "FAIL: tests block must keep both warp-macos-15-arm64-6x runner and fork guard" exit 1 fi -echo "PASS: tests-depot Depot runner fork guard is present" +# tests-build-and-lag: must use WarpBuild runner with fork guard (paid runner) +if ! awk ' + /^ tests-build-and-lag:/ { in_tests=1; next } + in_tests && /^ [^[:space:]]/ { in_tests=0 } + in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 } + in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_warp && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: tests-build-and-lag block must keep both warp-macos-15-arm64-6x runner and fork guard" + exit 1 +fi + +echo "PASS: tests WarpBuild runner fork guard is present" +echo "PASS: tests-build-and-lag WarpBuild runner fork guard is present" From 66174ceb2650a0a6ecd57ed263b4864c6fb97a1d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 01:36:09 -0700 Subject: [PATCH 51/77] Fix release browser portal compile --- Sources/Panels/BrowserPanel.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 234466cc..5a214652 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -5172,13 +5172,6 @@ extension BrowserPanel { return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" } - func hideBrowserPortalView(source: String) { - BrowserWindowPortalRegistry.hide( - webView: webView, - source: source - ) - } - } #endif @@ -5270,6 +5263,15 @@ private extension BrowserPanel { } } +extension BrowserPanel { + func hideBrowserPortalView(source: String) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: source + ) + } +} + extension WKWebView { func cmuxInspectorObject() -> NSObject? { let selector = NSSelectorFromString("_inspector") From 1fc4bcba1170ea5824326eec9204c355d52796fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:44:42 -0700 Subject: [PATCH 52/77] Add macOS 26 (Tahoe) compat tests, skip zig build via stub (#1590) Zig 0.15.2's MachO linker can't resolve libSystem on macOS 26 (the version number jump from 15 to 26 breaks zig's SDK handling). The unit tests don't need the CLI helper binary at runtime, so we skip the zig build on macOS 26 by setting CMUX_SKIP_ZIG_BUILD=1, which creates a stub binary to satisfy the Xcode Run Script file check. Smoke test (full app build + launch) is skipped on macOS 26 since it needs the real CLI helper. Co-authored-by: Lawrence Chen Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/ci-macos-compat.yml | 8 ++++++++ scripts/build-ghostty-cli-helper.sh | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 4682df7b..a0c72b11 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -17,6 +17,11 @@ jobs: - os: warp-macos-15-arm64-6x timeout: 20 smoke: true + skip_zig: false + - os: warp-macos-26-arm64-6x + timeout: 20 + smoke: false + skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26 runs-on: ${{ matrix.os }} timeout-minutes: ${{ matrix.timeout }} steps: @@ -62,6 +67,7 @@ jobs: ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig + if: ${{ !matrix.skip_zig }} run: | ZIG_REQUIRED="0.15.2" if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then @@ -114,6 +120,8 @@ jobs: done - name: Run unit tests + env: + CMUX_SKIP_ZIG_BUILD: ${{ matrix.skip_zig && '1' || '0' }} run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index ac8cda4b..d38e641c 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -53,6 +53,16 @@ if [[ -z "$OUTPUT_PATH" ]]; then exit 1 fi +# Allow CI to skip the zig build (e.g., macOS 26 where zig 0.15.2 can't link). +# Creates a stub binary so the Xcode Run Script file-existence check passes. +if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then + echo "Skipping zig CLI helper build (CMUX_SKIP_ZIG_BUILD=1)" + mkdir -p "$(dirname "$OUTPUT_PATH")" + printf '#!/bin/sh\necho "ghostty CLI helper stub (zig build skipped)" >&2\nexit 1\n' > "$OUTPUT_PATH" + chmod +x "$OUTPUT_PATH" + exit 0 +fi + if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then echo "--universal and --target are mutually exclusive" >&2 usage >&2 From ffcd3fdfaa84b4dc5b33e4d7ff7e8573fa1f2390 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 01:51:57 -0700 Subject: [PATCH 53/77] Tighten browser import sheet UI --- Resources/Localizable.xcstrings | 60 ++--- Sources/Panels/BrowserPanel.swift | 208 +++++++++++++----- cmuxTests/BrowserImportMappingTests.swift | 17 ++ .../BrowserImportProfilesUITests.swift | 6 +- 4 files changed, 202 insertions(+), 89 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 68b84487..257a60d5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4740,13 +4740,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Bookmarks, settings, and extensions import are not available yet." + "value": "Bookmarks, settings, and extensions are not available yet." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。" + "value": "ブックマーク、設定、拡張機能はまだ利用できません。" } } } @@ -5029,13 +5029,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "cmux destination" + "value": "Destination" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux の保存先" + "value": "保存先" } } } @@ -5080,13 +5080,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Imported cookies and history go into the selected cmux browser profile." + "value": "Imported data goes into the selected cmux profile." } }, "ja": { "stringUnit": { "state": "translated", - "value": "インポートしたCookieと履歴は、選択したcmuxブラウザープロファイルに保存されます。" + "value": "インポートしたデータは、選択した cmux プロファイルに保存されます。" } } } @@ -5097,13 +5097,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "All selected source profiles will be merged into the chosen cmux browser profile." + "value": "All selected source profiles go into one cmux profile." } }, "ja": { "stringUnit": { "state": "translated", - "value": "選択した元プロファイルはすべて、選んだ cmux ブラウザープロファイルにまとめて取り込まれます。" + "value": "選択した元プロファイルは、1つの cmux プロファイルにまとめて取り込まれます。" } } } @@ -5114,13 +5114,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Missing cmux profiles are created when import starts." + "value": "Missing cmux profiles are created on import." } }, "ja": { "stringUnit": { "state": "translated", - "value": "不足している cmux プロファイルは、インポート開始時に作成されます。" + "value": "不足している cmux プロファイルは、インポート時に作成されます。" } } } @@ -5131,13 +5131,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Merge all into one cmux profile" + "value": "Merge into one" } }, "ja": { "stringUnit": { "state": "translated", - "value": "すべてを1つの cmux プロファイルにまとめる" + "value": "1つにまとめる" } } } @@ -5148,13 +5148,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Keep profiles separate" + "value": "Separate profiles" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プロファイルを分けたまま取り込む" + "value": "分けて取り込む" } } } @@ -5233,13 +5233,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Limit to" + "value": "Domains" } }, "ja": { "stringUnit": { "state": "translated", - "value": "対象ドメイン" + "value": "ドメイン" } } } @@ -5250,13 +5250,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Optional domains only (e.g. github.com, openai.com)" + "value": "Optional domains, comma-separated" } }, "ja": { "stringUnit": { "state": "translated", - "value": "任意のドメインのみ(例: github.com, openai.com)" + "value": "任意のドメインをカンマ区切りで指定" } } } @@ -5505,13 +5505,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Source" + "value": "Browser" } }, "ja": { "stringUnit": { "state": "translated", - "value": "インポート元" + "value": "ブラウザー" } } } @@ -5539,13 +5539,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Source Profiles" + "value": "Profiles" } }, "ja": { "stringUnit": { "state": "translated", - "value": "元プロファイル" + "value": "プロファイル" } } } @@ -5556,13 +5556,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + "value": "Select one or more profiles." } }, "ja": { "stringUnit": { "state": "translated", - "value": "元プロファイルを1つ以上選択してください。3 / 3 で、分けたまま取り込むか、1つの cmux プロファイルにまとめるかを選べます。" + "value": "1つ以上のプロファイルを選択してください。" } } } @@ -5607,13 +5607,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Step 3 of 3: Choose what to import from %@ and where to put it." + "value": "Step 3 of 3" } }, "ja": { "stringUnit": { "state": "translated", - "value": "3 / 3: %@ から何をインポートし、どこに保存するかを選択します。" + "value": "3 / 3" } } } @@ -5624,13 +5624,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Step 1 of 3: Choose the browser to import from." + "value": "Step 1 of 3" } }, "ja": { "stringUnit": { "state": "translated", - "value": "1 / 3: インポート元のブラウザーを選択します。" + "value": "1 / 3" } } } @@ -5641,13 +5641,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Step 2 of 3: Choose source profiles from %@." + "value": "Step 2 of 3" } }, "ja": { "stringUnit": { "state": "translated", - "value": "2 / 3: %@ の元プロファイルを選択します。" + "value": "2 / 3" } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index c7a17f4d..9e2e5504 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -6837,6 +6837,18 @@ struct BrowserImportStep3Presentation: Equatable { } } +struct BrowserImportSourceProfilesPresentation: Equatable { + let scrollHeight: CGFloat + let showsHelpText: Bool + + init(profileCount: Int) { + let visibleRows = min(max(profileCount, 1), 5) + let contentHeight = CGFloat(visibleRows * 26 + 14) + scrollHeight = max(76, contentHeight) + showsHelpText = profileCount > 1 + } +} + enum BrowserImportPlanResolver { @MainActor static func defaultPlan( @@ -8378,6 +8390,7 @@ final class BrowserDataImportCoordinator { private let sourceProfilesEmptyLabel = NSTextField(wrappingLabelWithString: "") private let sourceProfilesHelpLabel = NSTextField(labelWithString: "") private let sourceProfilesScrollView = NSScrollView() + private var sourceProfilesScrollHeightConstraint: NSLayoutConstraint? private let dataTypesContainer = NSStackView() private let validationLabel = NSTextField(labelWithString: "") private let destinationModeContainer = NSStackView() @@ -8387,6 +8400,7 @@ final class BrowserDataImportCoordinator { private let mergeDestinationRow = NSStackView() private let mergeDestinationPopup = NSPopUpButton(frame: .zero, pullsDown: false) private let destinationHelpLabel = NSTextField(wrappingLabelWithString: "") + private let additionalDataNoteLabel = NSTextField(wrappingLabelWithString: "") private let cookiesCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) private let historyCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) @@ -8412,7 +8426,7 @@ final class BrowserDataImportCoordinator { ?? fallbackDestinationProfileID self.mergeDestinationProfileID = self.initialDestinationProfileID self.panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), + contentRect: NSRect(x: 0, y: 0, width: 560, height: 292), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -8538,6 +8552,7 @@ final class BrowserDataImportCoordinator { guard selectedSourceProfiles.count > 1 else { return } destinationMode = sender == separateProfilesRadio ? .separateProfiles : .mergeIntoOne rebuildStep3DestinationUI() + updatePanelSize() } @objc @@ -8560,6 +8575,13 @@ final class BrowserDataImportCoordinator { validationLabel.isHidden = true } + @objc + private func handleImportOptionChanged(_ sender: NSButton) { + validationLabel.isHidden = true + updateAdditionalDataNoteVisibility() + updatePanelSize() + } + private func setupUI() { panel.title = String( localized: "browser.import.title", @@ -8570,7 +8592,7 @@ final class BrowserDataImportCoordinator { panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 620, height: 420)) + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 292)) contentView.translatesAutoresizingMaskIntoConstraints = false panel.contentView = contentView @@ -8580,9 +8602,9 @@ final class BrowserDataImportCoordinator { defaultValue: "Import Browser Data" ) ) - titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .semibold) + titleLabel.font = NSFont.systemFont(ofSize: 22, weight: .semibold) - stepLabel.font = NSFont.systemFont(ofSize: 15, weight: .medium) + stepLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) stepLabel.textColor = .secondaryLabelColor setupSourceContainer() @@ -8594,6 +8616,7 @@ final class BrowserDataImportCoordinator { validationLabel.isHidden = true validationLabel.lineBreakMode = .byWordWrapping validationLabel.maximumNumberOfLines = 3 + validationLabel.translatesAutoresizingMaskIntoConstraints = false backButton.target = self backButton.action = #selector(handleBack) @@ -8631,23 +8654,32 @@ final class BrowserDataImportCoordinator { validationLabel, ]) contentStack.orientation = .vertical - contentStack.spacing = 10 + contentStack.spacing = 8 contentStack.alignment = .leading contentStack.translatesAutoresizingMaskIntoConstraints = false + sourceContainer.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesContainer.translatesAutoresizingMaskIntoConstraints = false + dataTypesContainer.translatesAutoresizingMaskIntoConstraints = false + guard let panelContent = panel.contentView else { return } panelContent.addSubview(contentStack) panelContent.addSubview(buttonRow) NSLayoutConstraint.activate([ - contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 18), - contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), - contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 16), + contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 18), + contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -18), buttonRow.topAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 14), - buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), - buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), - buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -16), + buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 18), + buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -18), + buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -14), + + sourceContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + sourceProfilesContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + dataTypesContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + validationLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor), ]) } @@ -8663,23 +8695,27 @@ final class BrowserDataImportCoordinator { labelWithString: String(localized: "browser.import.source", defaultValue: "Source") ) sourceLabel.alignment = .right - sourceLabel.frame.size.width = 80 + sourceLabel.frame.size.width = 64 + + sourcePopup.setContentHuggingPriority(.defaultLow, for: .horizontal) + sourcePopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let sourceRow = NSStackView(views: [sourceLabel, sourcePopup]) sourceRow.orientation = .horizontal sourceRow.spacing = 8 sourceRow.alignment = .centerY + sourceRow.distribution = .fill let detectedLabel = NSTextField( wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers) ) - detectedLabel.font = NSFont.systemFont(ofSize: 12) + detectedLabel.font = NSFont.systemFont(ofSize: 11) detectedLabel.textColor = .secondaryLabelColor detectedLabel.maximumNumberOfLines = 2 detectedLabel.preferredMaxLayoutWidth = 500 sourceContainer.orientation = .vertical - sourceContainer.spacing = 10 + sourceContainer.spacing = 8 sourceContainer.alignment = .leading sourceContainer.addArrangedSubview(sourceRow) sourceContainer.addArrangedSubview(detectedLabel) @@ -8692,17 +8728,17 @@ final class BrowserDataImportCoordinator { defaultValue: "Source Profiles" ) ) - sourceProfilesTitle.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + sourceProfilesTitle.font = NSFont.systemFont(ofSize: 12, weight: .semibold) sourceProfilesList.orientation = .vertical sourceProfilesList.spacing = 6 sourceProfilesList.alignment = .leading sourceProfilesList.translatesAutoresizingMaskIntoConstraints = false - sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 13) + sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 12) sourceProfilesEmptyLabel.textColor = .secondaryLabelColor sourceProfilesEmptyLabel.maximumNumberOfLines = 0 - sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 520 + sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 500 sourceProfilesDocumentView.frame = NSRect(x: 0, y: 0, width: 1, height: 1) sourceProfilesDocumentView.translatesAutoresizingMaskIntoConstraints = false @@ -8721,19 +8757,22 @@ final class BrowserDataImportCoordinator { sourceProfilesScrollView.documentView = sourceProfilesDocumentView sourceProfilesScrollView.translatesAutoresizingMaskIntoConstraints = false sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true - sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 180).isActive = true + sourceProfilesScrollHeightConstraint = sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 76) + sourceProfilesScrollHeightConstraint?.isActive = true + sourceProfilesScrollView.widthAnchor.constraint(equalTo: sourceProfilesContainer.widthAnchor).isActive = true - sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 12) + sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 11) sourceProfilesHelpLabel.textColor = .secondaryLabelColor sourceProfilesHelpLabel.maximumNumberOfLines = 2 sourceProfilesHelpLabel.lineBreakMode = .byWordWrapping + sourceProfilesHelpLabel.preferredMaxLayoutWidth = 500 sourceProfilesHelpLabel.stringValue = String( localized: "browser.import.sourceProfiles.help", defaultValue: "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." ) sourceProfilesContainer.orientation = .vertical - sourceProfilesContainer.spacing = 10 + sourceProfilesContainer.spacing = 8 sourceProfilesContainer.alignment = .leading sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle) sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView) @@ -8758,6 +8797,12 @@ final class BrowserDataImportCoordinator { localized: "browser.import.additionalData", defaultValue: "Additional data (bookmarks, settings, extensions)" ) + cookiesCheckbox.target = self + cookiesCheckbox.action = #selector(handleImportOptionChanged(_:)) + historyCheckbox.target = self + historyCheckbox.action = #selector(handleImportOptionChanged(_:)) + additionalDataCheckbox.target = self + additionalDataCheckbox.action = #selector(handleImportOptionChanged(_:)) cookiesCheckbox.setAccessibilityIdentifier("BrowserImportCookiesCheckbox") historyCheckbox.setAccessibilityIdentifier("BrowserImportHistoryCheckbox") additionalDataCheckbox.setAccessibilityIdentifier("BrowserImportAdditionalDataCheckbox") @@ -8782,25 +8827,29 @@ final class BrowserDataImportCoordinator { mergeDestinationPopup.target = self mergeDestinationPopup.action = #selector(handleMergeDestinationChanged(_:)) + mergeDestinationPopup.setContentHuggingPriority(.defaultLow, for: .horizontal) + mergeDestinationPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) separateDestinationRows.orientation = .vertical - separateDestinationRows.spacing = 8 + separateDestinationRows.spacing = 6 separateDestinationRows.alignment = .leading mergeDestinationRow.orientation = .horizontal - mergeDestinationRow.spacing = 8 + mergeDestinationRow.spacing = 6 mergeDestinationRow.alignment = .centerY - destinationHelpLabel.font = NSFont.systemFont(ofSize: 12) + destinationHelpLabel.font = NSFont.systemFont(ofSize: 11) destinationHelpLabel.textColor = .secondaryLabelColor - destinationHelpLabel.maximumNumberOfLines = 3 - destinationHelpLabel.preferredMaxLayoutWidth = 540 + destinationHelpLabel.maximumNumberOfLines = 2 + destinationHelpLabel.preferredMaxLayoutWidth = 500 domainField.placeholderString = String( localized: "browser.import.domain.placeholder", defaultValue: "Optional domains only (e.g. github.com, openai.com)" ) domainField.stringValue = "" + domainField.setContentHuggingPriority(.defaultLow, for: .horizontal) + domainField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let destinationTitleLabel = NSTextField( labelWithString: String( @@ -8808,32 +8857,32 @@ final class BrowserDataImportCoordinator { defaultValue: "cmux destination" ) ) - destinationTitleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + destinationTitleLabel.font = NSFont.systemFont(ofSize: 12, weight: .semibold) let domainLabel = NSTextField( labelWithString: String(localized: "browser.import.domain", defaultValue: "Limit to") ) domainLabel.alignment = .right - domainLabel.frame.size.width = 80 + domainLabel.frame.size.width = 72 let domainRow = NSStackView(views: [domainLabel, domainField]) domainRow.orientation = .horizontal domainRow.spacing = 8 domainRow.alignment = .centerY + domainRow.distribution = .fill - let noteLabel = NSTextField( - wrappingLabelWithString: String( - localized: "browser.import.additionalData.note", - defaultValue: "Bookmarks, settings, and extensions import are not available yet." - ) + additionalDataNoteLabel.stringValue = String( + localized: "browser.import.additionalData.note", + defaultValue: "Bookmarks, settings, and extensions import are not available yet." ) - noteLabel.font = NSFont.systemFont(ofSize: 12) - noteLabel.textColor = .secondaryLabelColor - noteLabel.maximumNumberOfLines = 2 - noteLabel.preferredMaxLayoutWidth = 540 + additionalDataNoteLabel.font = NSFont.systemFont(ofSize: 11) + additionalDataNoteLabel.textColor = .secondaryLabelColor + additionalDataNoteLabel.maximumNumberOfLines = 2 + additionalDataNoteLabel.preferredMaxLayoutWidth = 500 + additionalDataNoteLabel.isHidden = true dataTypesContainer.orientation = .vertical - dataTypesContainer.spacing = 8 + dataTypesContainer.spacing = 6 dataTypesContainer.alignment = .leading dataTypesContainer.addArrangedSubview(destinationTitleLabel) dataTypesContainer.addArrangedSubview(destinationModeContainer) @@ -8843,13 +8892,14 @@ final class BrowserDataImportCoordinator { dataTypesContainer.addArrangedSubview(cookiesCheckbox) dataTypesContainer.addArrangedSubview(historyCheckbox) dataTypesContainer.addArrangedSubview(additionalDataCheckbox) + dataTypesContainer.addArrangedSubview(additionalDataNoteLabel) dataTypesContainer.addArrangedSubview(domainRow) - dataTypesContainer.addArrangedSubview(noteLabel) } private func configureInitialState() { step = .source refreshSourceProfilesList() + updateAdditionalDataNoteVisibility() updateStepUI() } @@ -8858,7 +8908,7 @@ final class BrowserDataImportCoordinator { case .source: stepLabel.stringValue = String( localized: "browser.import.step.source", - defaultValue: "Step 1 of 3: Choose the browser to import from." + defaultValue: "Step 1 of 3" ) sourceContainer.isHidden = false sourceProfilesContainer.isHidden = true @@ -8868,11 +8918,8 @@ final class BrowserDataImportCoordinator { primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") case .sourceProfiles: stepLabel.stringValue = String( - format: String( - localized: "browser.import.step.sourceProfiles", - defaultValue: "Step 2 of 3: Choose source profiles from %@." - ), - selectedBrowser().displayName + localized: "browser.import.step.sourceProfiles", + defaultValue: "Step 2 of 3" ) sourceContainer.isHidden = true sourceProfilesContainer.isHidden = false @@ -8883,11 +8930,8 @@ final class BrowserDataImportCoordinator { case .dataTypes: rebuildStep3DestinationUI() stepLabel.stringValue = String( - format: String( - localized: "browser.import.step.dataTypes", - defaultValue: "Step 3 of 3: Choose what to import from %@ and where to put it." - ), - selectedBrowser().displayName + localized: "browser.import.step.dataTypes", + defaultValue: "Step 3 of 3" ) sourceContainer.isHidden = true sourceProfilesContainer.isHidden = true @@ -8899,6 +8943,7 @@ final class BrowserDataImportCoordinator { defaultValue: "Start Import" ) } + updatePanelSize() } private func selectedBrowser() -> InstalledBrowserCandidate { @@ -8925,6 +8970,7 @@ final class BrowserDataImportCoordinator { browser.displayName ) sourceProfilesList.addArrangedSubview(sourceProfilesEmptyLabel) + updateSourceProfilesPresentation(for: browser) return } @@ -8940,6 +8986,8 @@ final class BrowserDataImportCoordinator { sourceProfilesList.addArrangedSubview(checkbox) sourceProfileCheckboxes.append(checkbox) } + + updateSourceProfilesPresentation(for: browser) } private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { @@ -9055,16 +9103,16 @@ final class BrowserDataImportCoordinator { localized: "browser.import.destinationProfile.separateHelp", defaultValue: "Missing cmux profiles are created when import starts." ) + destinationHelpLabel.isHidden = false } else if plan.entries.count > 1 { destinationHelpLabel.stringValue = String( localized: "browser.import.destinationProfile.mergeHelp", defaultValue: "All selected source profiles will be merged into the chosen cmux browser profile." ) + destinationHelpLabel.isHidden = false } else { - destinationHelpLabel.stringValue = String( - localized: "browser.import.destinationProfile.help", - defaultValue: "Imported cookies and history go into the selected cmux browser profile." - ) + destinationHelpLabel.stringValue = "" + destinationHelpLabel.isHidden = true } } @@ -9081,7 +9129,7 @@ final class BrowserDataImportCoordinator { guard let sourceProfile = entry.sourceProfiles.first else { continue } let sourceLabel = NSTextField(labelWithString: sourceProfile.displayName) sourceLabel.alignment = .right - sourceLabel.frame.size.width = 140 + sourceLabel.frame.size.width = 110 let popup = NSPopUpButton(frame: .zero, pullsDown: false) popup.target = self @@ -9101,11 +9149,14 @@ final class BrowserDataImportCoordinator { } else { popup.selectItem(at: 0) } + popup.setContentHuggingPriority(.defaultLow, for: .horizontal) + popup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let row = NSStackView(views: [sourceLabel, popup]) row.orientation = .horizontal - row.spacing = 8 + row.spacing = 6 row.alignment = .centerY + row.distribution = .fill separateDestinationRows.addArrangedSubview(row) } } @@ -9137,7 +9188,7 @@ final class BrowserDataImportCoordinator { ) ) destinationLabel.alignment = .right - destinationLabel.frame.size.width = 140 + destinationLabel.frame.size.width = 110 mergeDestinationRow.addArrangedSubview(destinationLabel) mergeDestinationRow.addArrangedSubview(mergeDestinationPopup) @@ -9211,6 +9262,51 @@ final class BrowserDataImportCoordinator { return base.isEmpty ? "profile-\(index)" : base } + private func updateSourceProfilesPresentation(for browser: InstalledBrowserCandidate) { + let presentation = BrowserImportSourceProfilesPresentation(profileCount: browser.profiles.count) + sourceProfilesScrollHeightConstraint?.constant = presentation.scrollHeight + sourceProfilesHelpLabel.isHidden = !presentation.showsHelpText + } + + private func updateAdditionalDataNoteVisibility() { + additionalDataNoteLabel.isHidden = additionalDataCheckbox.state != .on + } + + private func updatePanelSize() { + let contentSize = preferredContentSize() + let targetFrame = panel.frameRect(forContentRect: NSRect(origin: .zero, size: contentSize)) + + guard panel.frame.size != targetFrame.size else { return } + if !panel.isVisible { + panel.setContentSize(contentSize) + return + } + + var frame = panel.frame + frame.origin.x -= (targetFrame.width - frame.width) / 2 + frame.origin.y -= (targetFrame.height - frame.height) / 2 + frame.size = targetFrame.size + panel.setFrame(frame, display: true) + } + + private func preferredContentSize() -> NSSize { + switch step { + case .source: + return NSSize(width: 560, height: 292) + case .sourceProfiles: + let presentation = BrowserImportSourceProfilesPresentation(profileCount: selectedBrowser().profiles.count) + let helpHeight: CGFloat = presentation.showsHelpText ? 24 : 0 + let height = 214 + presentation.scrollHeight + helpHeight + return NSSize(width: 560, height: min(max(height, 292), 360)) + case .dataTypes: + var height: CGFloat = currentExecutionPlan().mode == .separateProfiles ? 412 : 374 + if additionalDataCheckbox.state == .on { + height += 24 + } + return NSSize(width: 560, height: height) + } + } + private func finishModal(with response: NSApplication.ModalResponse) { guard !didFinishModal else { return } didFinishModal = true diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 1f6c662c..2f122921 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -127,6 +127,23 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(presentation.showsSingleDestinationPicker) } + func testSourceProfilesPresentationShrinksListForSmallProfileCounts() { + let presentation = BrowserImportSourceProfilesPresentation(profileCount: 2) + + XCTAssertEqual(presentation.scrollHeight, 76) + XCTAssertTrue(presentation.showsHelpText) + } + + func testSourceProfilesPresentationCapsListHeightAndHidesHelpForSingleProfile() { + let singleProfilePresentation = BrowserImportSourceProfilesPresentation(profileCount: 1) + let manyProfilesPresentation = BrowserImportSourceProfilesPresentation(profileCount: 9) + + XCTAssertEqual(singleProfilePresentation.scrollHeight, 76) + XCTAssertFalse(singleProfilePresentation.showsHelpText) + XCTAssertEqual(manyProfilesPresentation.scrollHeight, 144) + XCTAssertTrue(manyProfilesPresentation.showsHelpText) + } + @MainActor func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index feb55471..8ba0e7d6 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -19,10 +19,10 @@ final class BrowserImportProfilesUITests: XCTestCase { app.buttons["Next"].click() XCTAssertTrue( - app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0), + app.radioButtons["Separate profiles"].waitForExistence(timeout: 5.0), "Expected Step 3 to show the separate-profiles default" ) - XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists) + XCTAssertTrue(app.radioButtons["Merge into one"].exists) XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists) XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists) @@ -49,7 +49,7 @@ final class BrowserImportProfilesUITests: XCTestCase { app.buttons["Next"].click() app.buttons["Next"].click() - let mergeRadio = app.radioButtons["Merge all into one cmux profile"] + let mergeRadio = app.radioButtons["Merge into one"] XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0)) mergeRadio.click() From 96bd2463b8da4fb8b9e75d57ba90edf50bb0aded Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 01:53:23 -0700 Subject: [PATCH 54/77] Add regression tests for SSH remote CLI follow-ups --- cmuxTests/GhosttyConfigTests.swift | 34 +++++ daemon/remote/cmd/cmuxd-remote/main_test.go | 156 ++++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 367d8d73..9cfd242c 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -867,6 +867,40 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { XCTAssertEqual(rewritten, original) } + func testBuffersSplitLoopbackAliasHeadersUntilFullRequestArrives() { + var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter( + aliasHost: "cmux-loopback.localtest.me" + ) + + let firstChunk = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: cmux-loop" + ).utf8 + ) + let secondChunk = Data( + ( + "back.localtest.me:3000\r\n" + + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + + "\r\n" + + "body=1" + ).utf8 + ) + + let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false) + let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: false) + + XCTAssertTrue(firstOutput.isEmpty) + + let text = String(decoding: secondOutput, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) + XCTAssertTrue(text.hasSuffix("\r\n\r\nbody=1")) + XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) + } + func testRewritesLoopbackResponseHeadersBackToAlias() { let original = Data( ( diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 3216373d..15301033 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -8,6 +8,9 @@ import ( "io" "math" "net" + "os" + "os/exec" + "path/filepath" "strconv" "strings" "sync" @@ -44,6 +47,35 @@ func (b *notifyingBuffer) String() string { return b.buffer.String() } +type eofWithPayloadConn struct { + payload []byte + readOnce bool +} + +func (c *eofWithPayloadConn) Read(p []byte) (int, error) { + if c.readOnce { + return 0, io.EOF + } + c.readOnce = true + n := copy(p, c.payload) + return n, io.EOF +} + +func (c *eofWithPayloadConn) Write(p []byte) (int, error) { + return len(p), nil +} + +func (c *eofWithPayloadConn) Close() error { return nil } +func (c *eofWithPayloadConn) LocalAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} +func (c *eofWithPayloadConn) RemoteAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} +func (c *eofWithPayloadConn) SetDeadline(time.Time) error { return nil } +func (c *eofWithPayloadConn) SetReadDeadline(time.Time) error { return nil } +func (c *eofWithPayloadConn) SetWriteDeadline(time.Time) error { return nil } + func TestRunVersion(t *testing.T) { var out bytes.Buffer code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) @@ -55,6 +87,46 @@ func TestRunVersion(t *testing.T) { } } +func TestWrapperBinaryDispatchesIntoCLI(t *testing.T) { + if os.Getenv("CMUXD_REMOTE_MAIN_HELPER") == "1" { + separator := 0 + for i, arg := range os.Args { + if arg == "--" { + separator = i + break + } + } + if separator == 0 { + t.Fatal("helper process missing -- separator") + } + os.Args = append([]string{os.Args[0]}, os.Args[separator+1:]...) + main() + return + } + + sockPath := startMockSocket(t, "PONG") + wrapperPath := filepath.Join(t.TempDir(), "cmuxd-remote-current") + if err := os.Symlink(os.Args[0], wrapperPath); err != nil { + t.Fatalf("symlink wrapper path: %v", err) + } + + cmd := exec.Command( + wrapperPath, + "-test.run=TestWrapperBinaryDispatchesIntoCLI", + "--", + "--socket", sockPath, "ping", + ) + cmd.Env = append(os.Environ(), "CMUXD_REMOTE_MAIN_HELPER=1") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("wrapper invocation failed: %v\n%s", err, output) + } + + if got := strings.TrimSpace(string(output)); got != "PONG" { + t.Fatalf("wrapper invocation output = %q, want %q", got, "PONG") + } +} + func TestRunStdioHelloAndPing(t *testing.T) { input := strings.NewReader( `{"id":1,"method":"hello","params":{}}` + "\n" + @@ -307,6 +379,90 @@ func TestProxyStreamRoundTrip(t *testing.T) { } } +func TestProxyStreamEOFPayloadIsNotDuplicatedAcrossDataAndEOFEvents(t *testing.T) { + eventOutput := newNotifyingBuffer() + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{ + "stream-1": { + conn: &eofWithPayloadConn{payload: []byte("tail")}, + }, + }, + sessions: map[string]*sessionState{}, + frameWriter: &stdioFrameWriter{ + writer: bufio.NewWriter(eventOutput), + }, + } + defer server.closeAll() + + resp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.stream.subscribe", + Params: map[string]any{"stream_id": "stream-1"}, + }) + if !resp.OK { + t.Fatalf("proxy.stream.subscribe failed: %+v", resp) + } + + deadline := time.Now().Add(2 * time.Second) + for strings.Count(strings.TrimSpace(eventOutput.String()), "\n")+boolToInt(strings.TrimSpace(eventOutput.String()) != "") < 2 { + remaining := time.Until(deadline) + if remaining <= 0 { + t.Fatalf("timed out waiting for proxy stream events: %q", eventOutput.String()) + } + select { + case <-eventOutput.notify: + case <-time.After(remaining): + t.Fatalf("timed out waiting for proxy stream events: %q", eventOutput.String()) + } + } + + lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") + if len(lines) != 2 { + t.Fatalf("expected exactly 2 stream events, got %d: %q", len(lines), eventOutput.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("decode first event: %v", err) + } + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("decode second event: %v", err) + } + + if got := first["event"]; got != "proxy.stream.data" { + t.Fatalf("first event = %v, want proxy.stream.data", got) + } + if got := second["event"]; got != "proxy.stream.eof" { + t.Fatalf("second event = %v, want proxy.stream.eof", got) + } + + firstPayload, err := base64.StdEncoding.DecodeString(first["data_base64"].(string)) + if err != nil { + t.Fatalf("decode first payload: %v", err) + } + secondPayload, err := base64.StdEncoding.DecodeString(second["data_base64"].(string)) + if err != nil { + t.Fatalf("decode second payload: %v", err) + } + + if string(firstPayload) != "tail" { + t.Fatalf("proxy.stream.data payload = %q, want %q", string(firstPayload), "tail") + } + if len(secondPayload) != 0 { + t.Fatalf("proxy.stream.eof payload = %q, want empty payload after data event", string(secondPayload)) + } +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { params := map[string]any{ "port": 80.9, From dfcbaa3220e843b768601eddaa24812f88573d0f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 01:58:50 -0700 Subject: [PATCH 55/77] Fix SSH remote CLI and loopback proxy follow-ups --- Sources/Workspace.swift | 61 ++++++++++++++++++--- daemon/remote/cmd/cmuxd-remote/main.go | 27 +++++++-- daemon/remote/cmd/cmuxd-remote/main_test.go | 10 +++- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2c69140c..fe97b2a4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1399,6 +1399,41 @@ enum RemoteLoopbackHTTPRequestRewriter { } } +struct RemoteLoopbackHTTPRequestStreamRewriter { + private let aliasHost: String + private var pendingHeaderBytes = Data() + private var hasForwardedHeaders = false + + init(aliasHost: String) { + self.aliasHost = aliasHost + } + + mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { + guard !hasForwardedHeaders else { return data } + + pendingHeaderBytes.append(data) + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard pendingHeaderBytes.range(of: marker) != nil else { + guard eof else { return Data() } + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost + ) + } + + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost + ) + } +} + enum RemoteLoopbackHTTPResponseRewriter { private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) private static let canonicalLoopbackHost = "localhost" @@ -1507,6 +1542,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { private var streamID: String? private var localInputEOF = false private var rewritesLoopbackHTTPHeaders = false + private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? private var pendingRemoteHTTPHeaderBytes = Data() private var hasForwardedRemoteHTTPHeaders = false @@ -1556,7 +1592,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { self.handshakeBuffer.append(data) self.processHandshakeBuffer() } else { - self.forwardToRemote(data) + self.forwardToRemote(data, eof: isComplete) } } @@ -1565,6 +1601,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { // drain upstream response bytes (for example curl closing write-side after // sending an HTTP request through SOCKS/CONNECT). self.localInputEOF = true + if self.streamID != nil, data?.isEmpty ?? true { + self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) + } if self.streamID == nil { self.close(reason: nil) } @@ -1756,6 +1795,11 @@ private final class WorkspaceRemoteDaemonProxyTunnel { rewritesLoopbackHTTPHeaders = BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) + loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders + ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) + : nil + pendingRemoteHTTPHeaderBytes = Data() + hasForwardedRemoteHTTPHeaders = false let targetHost = Self.normalizedProxyTargetHost(host) let streamID = try rpcClient.openStream(host: targetHost, port: port) self.streamID = streamID @@ -1777,17 +1821,18 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } } - private func forwardToRemote(_ data: Data, allowAfterEOF: Bool = false) { + private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { guard !isClosed else { return } guard !localInputEOF || allowAfterEOF else { return } guard let streamID else { return } do { - let outgoingData = rewritesLoopbackHTTPHeaders - ? RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: data, - aliasHost: Self.remoteLoopbackProxyAliasHost - ) - : data + let outgoingData: Data + if rewritesLoopbackHTTPHeaders { + outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data + } else { + outgoingData = data + } + guard !outgoingData.isEmpty else { return } try rpcClient.writeStream(streamID: streamID, data: outgoingData) } catch { close(reason: "proxy.write failed: \(error.localizedDescription)") diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 386f6577..78c647a3 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -15,6 +15,7 @@ import ( "path/filepath" "sort" "strconv" + "strings" "sync" "time" ) @@ -82,14 +83,32 @@ type sessionState struct { const maxRPCFrameBytes = 4 * 1024 * 1024 func main() { - // Busybox-style: if invoked as "cmux" (via symlink), act as CLI relay. - base := filepath.Base(os.Args[0]) - if base == "cmux" { + if shouldRunCLIForInvocation(os.Args[0], os.Args[1:]) { os.Exit(runCLI(os.Args[1:])) } os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } +func shouldRunCLIForInvocation(argv0 string, args []string) bool { + base := filepath.Base(argv0) + if base == "cmux" { + return true + } + if !strings.HasPrefix(base, "cmuxd-remote") || len(args) == 0 { + return false + } + return !isDaemonEntryCommand(args[0]) +} + +func isDaemonEntryCommand(arg string) bool { + switch arg { + case "version", "serve", "cli": + return true + default: + return false + } +} + func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) == 0 { usage(stderr) @@ -1013,7 +1032,7 @@ func (s *rpcServer) streamPump(streamID string, conn net.Conn) { _ = s.frameWriter.writeEvent(rpcEvent{ Event: "proxy.stream.eof", StreamID: streamID, - DataBase64: base64.StdEncoding.EncodeToString(data), + DataBase64: "", }) } else if !errors.Is(readErr, net.ErrClosed) { _ = s.frameWriter.writeEvent(rpcEvent{ diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 15301033..531dbc9b 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -443,7 +443,7 @@ func TestProxyStreamEOFPayloadIsNotDuplicatedAcrossDataAndEOFEvents(t *testing.T if err != nil { t.Fatalf("decode first payload: %v", err) } - secondPayload, err := base64.StdEncoding.DecodeString(second["data_base64"].(string)) + secondPayload, err := decodeOptionalBase64(second["data_base64"]) if err != nil { t.Fatalf("decode second payload: %v", err) } @@ -463,6 +463,14 @@ func boolToInt(value bool) int { return 0 } +func decodeOptionalBase64(value any) ([]byte, error) { + encoded, ok := value.(string) + if !ok || encoded == "" { + return nil, nil + } + return base64.StdEncoding.DecodeString(encoded) +} + func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { params := map[string]any{ "port": 80.9, From de138fa5c025cee7f2ca74296af9e273287f80d5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:04:01 -0700 Subject: [PATCH 56/77] Fix remote daemon build script using relative output path after cd (#1595) The Go build runs in a subshell that cd's to daemon/remote/, but OUTPUT_DIR was relative to the repo root. Resolve to absolute path after mkdir so go build -o writes to the correct location. Co-authored-by: Lawrence Chen Co-authored-by: Claude Opus 4.6 (1M context) --- scripts/build_remote_daemon_release_assets.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh index e9519372..6765fb38 100755 --- a/scripts/build_remote_daemon_release_assets.sh +++ b/scripts/build_remote_daemon_release_assets.sh @@ -66,6 +66,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" DAEMON_ROOT="${REPO_ROOT}/daemon/remote" mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json DAEMON_GO_LDFLAGS="-s -w -X main.version=${VERSION}" From 3c549b4cb8bca8dfd5f8401c0d5244831dba33fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 02:06:50 -0700 Subject: [PATCH 57/77] Fix browser import UI test harness --- .../BrowserImportProfilesUITests.swift | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index 8ba0e7d6..a5b4df7a 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -1,6 +1,23 @@ import XCTest import Foundation +private func browserImportPollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + final class BrowserImportProfilesUITests: XCTestCase { private var capturePath = "" @@ -121,11 +138,7 @@ final class BrowserImportProfilesUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath - app.launch() - XCTAssertTrue( - ensureForegroundAfterLaunch(app, timeout: 12.0), - "Expected app to launch in the foreground for browser import UI tests" - ) + launchAndActivate(app) return app } @@ -145,30 +158,33 @@ final class BrowserImportProfilesUITests: XCTestCase { } private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { - let deadline = Date().addingTimeInterval(timeout) let url = URL(fileURLWithPath: capturePath) - while Date() < deadline { + let foundCapture = browserImportPollUntil(timeout: timeout) { if let data = try? Data(contentsOf: url), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return object + return !object.isEmpty } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - if let data = try? Data(contentsOf: url), + if foundCapture, + let data = try? Data(contentsOf: url), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return object } return nil } - private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { - if app.wait(for: .runningForeground, timeout: timeout) { - return true - } - if app.state == .runningBackground { + private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { + app.launch() + let activated = browserImportPollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } + app.activate() + return app.state == .runningForeground + } + if !activated { app.activate() - return app.wait(for: .runningForeground, timeout: 6.0) } - return false } } From 7bb75647264dd8a4461727c3f902c32e7b73f061 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 02:18:58 -0700 Subject: [PATCH 58/77] Stabilize browser import menu test flow --- Sources/cmuxApp.swift | 5 ++++- cmuxUITests/BrowserImportProfilesUITests.swift | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2b9fb5ec..d58503a2 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -588,7 +588,10 @@ struct cmuxApp: App { } Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) { - BrowserDataImportCoordinator.shared.presentImportDialog() + // Defer modal presentation until after AppKit finishes menu tracking. + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } } splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index a5b4df7a..1edf69a3 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -123,7 +123,7 @@ final class BrowserImportProfilesUITests: XCTestCase { let payloadData = try JSONSerialization.data(withJSONObject: payload) let captureURL = URL(fileURLWithPath: capturePath) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.19) { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.18) { try? payloadData.write(to: captureURL) } @@ -151,10 +151,10 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist") importItem.click() - XCTAssertTrue( - app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0), - "Expected the import wizard to open" - ) + let wizardOpened = browserImportPollUntil(timeout: 5.0) { + app.buttons["Next"].exists || app.windows["Import Browser Data"].exists + } + XCTAssertTrue(wizardOpened, "Expected the import wizard to open") } private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { From f97716939a04ef190909aa2751c2d364f0a3ba1c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 02:29:39 -0700 Subject: [PATCH 59/77] Add browser import UI test launch hook --- Sources/AppDelegate.swift | 5 +++ .../BrowserImportProfilesUITests.swift | 31 ++----------------- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a77cf00a..b84d7c57 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2314,6 +2314,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } + if env["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } } #endif } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index 1edf69a3..c8d95f08 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -31,7 +31,6 @@ final class BrowserImportProfilesUITests: XCTestCase { func testMultipleSourceProfilesDefaultToSeparateDestinations() throws { let app = launchApp() - openImportWizard(app) app.buttons["Next"].click() app.buttons["Next"].click() @@ -62,7 +61,6 @@ final class BrowserImportProfilesUITests: XCTestCase { func testMergeModeCapturesSingleMergedDestination() throws { let app = launchApp() - openImportWizard(app) app.buttons["Next"].click() app.buttons["Next"].click() @@ -90,7 +88,6 @@ final class BrowserImportProfilesUITests: XCTestCase { func testAdditionalDataSelectionCapturesEverythingScope() throws { let app = launchApp() - openImportWizard(app) app.buttons["Next"].click() app.buttons["Next"].click() @@ -115,42 +112,20 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } - func testWaitForCapturedSelectionReadsCaptureWrittenAtTimeoutBoundary() throws { - let payload: [String: Any] = [ - "mode": "boundary-write", - "entries": [] - ] - let payloadData = try JSONSerialization.data(withJSONObject: payload) - let captureURL = URL(fileURLWithPath: capturePath) - - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.18) { - try? payloadData.write(to: captureURL) - } - - let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 0.2)) - XCTAssertEqual(capture["mode"] as? String, "boundary-write") - } - private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] = "1" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath launchAndActivate(app) + waitForImportWizard(app) return app } - private func openImportWizard(_ app: XCUIApplication) { - let viewMenu = app.menuBars.menuBarItems["View"].firstMatch - XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist") - viewMenu.click() - - let importItem = app.menuItems["Import From Browser…"].firstMatch - XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist") - importItem.click() - + private func waitForImportWizard(_ app: XCUIApplication) { let wizardOpened = browserImportPollUntil(timeout: 5.0) { app.buttons["Next"].exists || app.windows["Import Browser Data"].exists } From d8a968c6239357fe2bed0e7f747619321e1de326 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:01:02 -0700 Subject: [PATCH 60/77] Address SSH follow-up PR review comments --- Sources/Workspace.swift | 41 +++++++++++++++++++++++++----- cmuxTests/GhosttyConfigTests.swift | 35 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index fe97b2a4..37ce19c6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1293,8 +1293,23 @@ enum RemoteLoopbackHTTPRequestRewriter { private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - guard let headerRange = data.range(of: headerDelimiter) else { return data } - let headerData = Data(data[.. Data { + let headerData: Data + let remainder: Data + + if let headerRange = data.range(of: headerDelimiter) { + headerData = Data(data[.. Bool { @@ -1400,6 +1415,9 @@ enum RemoteLoopbackHTTPRequestRewriter { } struct RemoteLoopbackHTTPRequestStreamRewriter { + private static let maxHeaderBytes = 64 * 1024 + private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) + private let aliasHost: String private var pendingHeaderBytes = Data() private var hasForwardedHeaders = false @@ -1412,15 +1430,26 @@ struct RemoteLoopbackHTTPRequestStreamRewriter { guard !hasForwardedHeaders else { return data } pendingHeaderBytes.append(data) - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard pendingHeaderBytes.range(of: marker) != nil else { + if pendingHeaderBytes.count > Self.maxHeaderBytes { + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { guard eof else { return Data() } hasForwardedHeaders = true let payload = pendingHeaderBytes pendingHeaderBytes = Data() return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( data: payload, - aliasHost: aliasHost + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true ) } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9cfd242c..628f365c 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -901,6 +901,41 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) } + func testFlushesBufferedLoopbackAliasHeadersOnEOFWhenHeadersRemainIncomplete() { + var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter( + aliasHost: "cmux-loopback.localtest.me" + ) + + let firstChunk = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: cmux-loop" + ).utf8 + ) + let secondChunk = Data( + ( + "back.localtest.me:3000\r\n" + + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + + "body=1" + ).utf8 + ) + + let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false) + let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: true) + let thirdOutput = streamRewriter.rewriteNextChunk(Data(), eof: true) + + XCTAssertTrue(firstOutput.isEmpty) + + let text = String(decoding: secondOutput, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) + XCTAssertTrue(text.hasSuffix("\r\nbody=1")) + XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) + XCTAssertTrue(thirdOutput.isEmpty) + } + func testRewritesLoopbackResponseHeadersBackToAlias() { let original = Data( ( From b9de0f044642eeea10b0e76fa78dfd05676e9227 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:01:50 -0700 Subject: [PATCH 61/77] Add browser import hint debug variants --- Resources/Localizable.xcstrings | 187 +++++++++++ Sources/AppDelegate.swift | 32 ++ Sources/Panels/BrowserPanel.swift | 105 ++++++ Sources/Panels/BrowserPanelView.swift | 194 +++++++++-- Sources/cmuxApp.swift | 307 +++++++++++++++++- cmuxTests/BrowserImportMappingTests.swift | 44 +++ .../BrowserImportProfilesUITests.swift | 45 +++ 7 files changed, 892 insertions(+), 22 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 257a60d5..0f8f83df 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -5669,6 +5669,125 @@ } } }, + "browser.import.hint.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Hint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヒントを隠す" + } + } + } + }, + "browser.import.hint.import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート…" + } + } + } + }, + "browser.import.hint.settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザー設定" + } + } + } + }, + "browser.import.hint.settingsFootnote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can always find this in Settings > Browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あとでいつでも「設定 > ブラウザー」で見つけられます。" + } + } + } + }, + "browser.import.hint.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, + "browser.import.hint.toolbar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート" + } + } + } + }, + "browser.import.hint.toolbar.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, "browser.import.validation.scope": { "extractionState": "manual", "localizations": { @@ -50827,6 +50946,74 @@ } } }, + "settings.browser.import.hint.note.hidden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The blank-tab import hint is hidden. Turn it back on here any time." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空タブのインポート案内は非表示です。ここでいつでも再表示できます。" + } + } + } + }, + "settings.browser.import.hint.note.settingsOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blank tabs are currently using Settings only mode from the debug window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在、空タブはデバッグウィンドウの「設定のみ」モードになっています。" + } + } + } + }, + "settings.browser.import.hint.note.visible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blank browser tabs can show this import suggestion. Hide or re-enable it here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のブラウザータブにこのインポート案内を表示できます。ここで非表示や再表示を切り替えられます。" + } + } + } + }, + "settings.browser.import.hint.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show import hint on blank browser tabs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のブラウザータブにインポート案内を表示" + } + } + } + }, "settings.browser.history.clearButton": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b84d7c57..7b5abd04 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2306,6 +2306,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. // If there are no windows shortly after launch, force-create one so XCUITest can proceed. if isRunningUnderXCTest { + if let rawVariant = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] { + UserDefaults.standard.set( + BrowserImportHintSettings.variant(for: rawVariant).rawValue, + forKey: BrowserImportHintSettings.variantKey + ) + } + if let rawShow = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { + UserDefaults.standard.set( + rawShow == "1", + forKey: BrowserImportHintSettings.showOnBlankTabsKey + ) + } + if let rawDismissed = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { + UserDefaults.standard.set( + rawDismissed == "1", + forKey: BrowserImportHintSettings.dismissedKey + ) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self else { return } if NSApp.windows.isEmpty { @@ -2314,6 +2332,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } + if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) + } + } + if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + self?.openPreferencesWindow( + debugSource: "uiTest.browserImportHint", + navigationTarget: .browser + ) + } + } if env["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { BrowserDataImportCoordinator.shared.presentImportDialog() diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9e2e5504..dc943d31 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -198,6 +198,111 @@ enum BrowserThemeSettings { } } +enum BrowserImportHintVariant: String, CaseIterable, Identifiable { + case inlineStrip + case floatingCard + case toolbarChip + case settingsOnly + + var id: String { rawValue } +} + +enum BrowserImportHintBlankTabPlacement: Equatable { + case hidden + case inlineStrip + case floatingCard + case toolbarChip +} + +enum BrowserImportHintSettingsStatus: Equatable { + case visible + case hidden + case settingsOnly +} + +struct BrowserImportHintPresentation: Equatable { + let blankTabPlacement: BrowserImportHintBlankTabPlacement + let settingsStatus: BrowserImportHintSettingsStatus + + init( + variant: BrowserImportHintVariant, + showOnBlankTabs: Bool, + isDismissed: Bool + ) { + if variant == .settingsOnly { + blankTabPlacement = .hidden + settingsStatus = .settingsOnly + return + } + + if !showOnBlankTabs || isDismissed { + blankTabPlacement = .hidden + settingsStatus = .hidden + return + } + + switch variant { + case .inlineStrip: + blankTabPlacement = .inlineStrip + case .floatingCard: + blankTabPlacement = .floatingCard + case .toolbarChip: + blankTabPlacement = .toolbarChip + case .settingsOnly: + blankTabPlacement = .hidden + } + settingsStatus = .visible + } +} + +enum BrowserImportHintSettings { + static let variantKey = "browserImportHintVariant" + static let showOnBlankTabsKey = "browserImportHintShowOnBlankTabs" + static let dismissedKey = "browserImportHintDismissed" + static let defaultVariant: BrowserImportHintVariant = .inlineStrip + static let defaultShowOnBlankTabs = true + static let defaultDismissed = false + + static func variant(for rawValue: String?) -> BrowserImportHintVariant { + guard let rawValue, let variant = BrowserImportHintVariant(rawValue: rawValue) else { + return defaultVariant + } + return variant + } + + static func variant(defaults: UserDefaults = .standard) -> BrowserImportHintVariant { + variant(for: defaults.string(forKey: variantKey)) + } + + static func showOnBlankTabs(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showOnBlankTabsKey) == nil { + return defaultShowOnBlankTabs + } + return defaults.bool(forKey: showOnBlankTabsKey) + } + + static func isDismissed(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: dismissedKey) == nil { + return defaultDismissed + } + return defaults.bool(forKey: dismissedKey) + } + + static func presentation(defaults: UserDefaults = .standard) -> BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: variant(defaults: defaults), + showOnBlankTabs: showOnBlankTabs(defaults: defaults), + isDismissed: isDismissed(defaults: defaults) + ) + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.set(defaultVariant.rawValue, forKey: variantKey) + defaults.set(defaultShowOnBlankTabs, forKey: showOnBlankTabsKey) + defaults.set(defaultDismissed, forKey: dismissedKey) + } +} + struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { let id: UUID var displayName: String diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f0b16dc1..4f1bcc62 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -250,6 +250,9 @@ struct BrowserPanelView: View { @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) private var toggleBrowserDeveloperToolsShortcutData = Data() @State private var suggestionTask: Task? @@ -267,6 +270,7 @@ struct BrowserPanelView: View { @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 + @State private var isBrowserImportHintPopoverPresented = false @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @@ -321,6 +325,18 @@ struct BrowserPanelView: View { BrowserThemeSettings.mode(for: browserThemeModeRaw) } + private var browserImportHintVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: browserImportHintVariant, + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + private var browserChromeBackground: Color { Color(nsColor: browserChromeStyle.backgroundColor) } @@ -346,6 +362,14 @@ struct BrowserPanelView: View { return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" } + private var browserImportHintSummary: String { + InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers) + } + + private var shouldShowToolbarImportHintChip: Bool { + shouldShowEmptyStateImportOverlay && browserImportHintPresentation.blankTabPlacement == .toolbarChip + } + private var owningWorkspace: Workspace? { guard let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: panel.workspaceId) else { @@ -459,6 +483,10 @@ struct BrowserPanelView: View { if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue } + let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + if browserImportHintVariantRaw != resolvedHintVariant.rawValue { + browserImportHintVariantRaw = resolvedHintVariant.rawValue + } panel.refreshAppearanceDrivenColors() panel.setBrowserThemeMode(browserThemeMode) applyPendingAddressBarFocusRequestIfNeeded() @@ -613,6 +641,9 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + if shouldShowToolbarImportHintChip { + browserImportHintToolbarChip + } browserProfileButton browserThemeModeButton developerToolsButton @@ -776,6 +807,29 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserThemeModeButton") } + private var browserImportHintToolbarChip: some View { + Button(action: { + isBrowserImportHintPopoverPresented.toggle() + }) { + HStack(spacing: 4) { + Image(systemName: "square.and.arrow.down.on.square") + .font(.system(size: 10, weight: .medium)) + Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(devToolsColorOption.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) { + browserImportHintPopover + } + .safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data")) + .accessibilityIdentifier("BrowserImportHintToolbarChip") + } + private var browserProfilePopover: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) @@ -1018,9 +1072,16 @@ struct BrowserPanelView: View { setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } + .overlay(alignment: .topLeading) { + if shouldShowEmptyStateImportOverlay, + browserImportHintPresentation.blankTabPlacement == .inlineStrip { + emptyBrowserStateInlineStrip + } + } .overlay { - if shouldShowEmptyStateImportOverlay { - emptyBrowserStateOverlay + if shouldShowEmptyStateImportOverlay, + browserImportHintPresentation.blankTabPlacement == .floatingCard { + emptyBrowserStateCardOverlay } } } @@ -1288,28 +1349,11 @@ struct BrowserPanelView: View { #endif } - private var emptyBrowserStateOverlay: some View { + private var emptyBrowserStateCardOverlay: some View { VStack { Spacer(minLength: 22) - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data")) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - - Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } - .buttonStyle(.bordered) - .controlSize(.small) - } + browserImportHintBody .padding(12) .frame(maxWidth: 360, alignment: .leading) .background( @@ -1329,10 +1373,118 @@ struct BrowserPanelView: View { .padding(.horizontal, 18) } + private var emptyBrowserStateInlineStrip: some View { + VStack(alignment: .leading, spacing: 0) { + browserImportHintBody + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: 520, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.84)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous).stroke( + Color(nsColor: .separatorColor).opacity(0.35), + lineWidth: 1 + ) + ) + .shadow(color: Color.black.opacity(0.05), radius: 6, y: 2) + + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .padding(.top, 14) + } + + private var browserImportHintPopover: some View { + browserImportHintBody + .padding(12) + .frame(width: 300, alignment: .leading) + } + + private var browserImportHintBody: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportHintSummary) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + browserImportHintPrimaryButton + browserImportHintSettingsButton + browserImportHintDismissButton + } + + VStack(alignment: .leading, spacing: 8) { + browserImportHintPrimaryButton + HStack(spacing: 10) { + browserImportHintSettingsButton + browserImportHintDismissButton + } + } + } + } + .accessibilityElement(children: .contain) + } + + private var browserImportHintPrimaryButton: some View { + Button(String(localized: "browser.import.hint.import", defaultValue: "Import…")) { + presentImportDialogFromHint() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + private var browserImportHintSettingsButton: some View { + Button(String(localized: "browser.import.hint.settings", defaultValue: "Browser Settings")) { + openBrowserImportSettings() + } + .buttonStyle(.plain) + .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintSettingsButton") + } + + private var browserImportHintDismissButton: some View { + Button(String(localized: "browser.import.hint.dismiss", defaultValue: "Hide Hint")) { + dismissBrowserImportHint() + } + .buttonStyle(.plain) + .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintDismissButton") + } + private var shouldShowEmptyStateImportOverlay: Bool { !panel.shouldRenderWebView && isWebViewBlank() } + private func presentImportDialogFromHint() { + isBrowserImportHintPopoverPresented = false + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + + private func openBrowserImportSettings() { + isBrowserImportHintPopoverPresented = false + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + + private func dismissBrowserImportHint() { + showBrowserImportHintOnBlankTabs = false + isBrowserImportHintDismissed = true + isBrowserImportHintPopoverPresented = false + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d58503a2..15ceaa47 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -337,6 +337,10 @@ struct cmuxApp: App { DebugWindowControlsWindowController.shared.show() } + Button("Browser Import Hint Debug…") { + BrowserImportHintDebugWindowController.shared.show() + } + Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1060,6 +1064,7 @@ struct cmuxApp: App { } private func openAllDebugWindows() { + BrowserImportHintDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1074,6 +1079,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.browser-popup", "cmux.settingsAboutTitlebarDebug", "cmux.debugWindowControls", + "cmux.browserImportHintDebug", "cmux.sidebarDebug", "cmux.menubarDebug", "cmux.backgroundDebug", @@ -1689,6 +1695,9 @@ private struct DebugWindowControlsView: View { GroupBox("Open") { VStack(alignment: .leading, spacing: 8) { + Button("Browser Import Hint Debug…") { + BrowserImportHintDebugWindowController.shared.show() + } Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1702,6 +1711,7 @@ private struct DebugWindowControlsView: View { MenuBarExtraDebugWindowController.shared.show() } Button("Open All Debug Windows") { + BrowserImportHintDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1905,6 +1915,210 @@ private struct DebugWindowControlsView: View { } } +private final class BrowserImportHintDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BrowserImportHintDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 420), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Browser Import Hint Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.browserImportHintDebug") + window.center() + window.contentView = NSHostingView(rootView: BrowserImportHintDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BrowserImportHintDebugView: View { + @AppStorage(BrowserImportHintSettings.variantKey) + private var variantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) + private var showOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) + private var isDismissed = BrowserImportHintSettings.defaultDismissed + + private var selectedVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: variantRaw) + } + + private var variantSelection: Binding { + Binding( + get: { selectedVariant.rawValue }, + set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue } + ) + } + + private var showOnBlankTabsBinding: Binding { + Binding( + get: { showOnBlankTabs }, + set: { newValue in + showOnBlankTabs = newValue + if newValue { + isDismissed = false + } + } + ) + } + + private var presentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: selectedVariant, + showOnBlankTabs: showOnBlankTabs, + isDismissed: isDismissed + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Browser Import Hint") + .font(.headline) + + Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.") + .font(.caption) + .foregroundStyle(.secondary) + + GroupBox("Variant") { + VStack(alignment: .leading, spacing: 10) { + Picker("Blank Tab Style", selection: variantSelection) { + ForEach(BrowserImportHintVariant.allCases) { variant in + Text(title(for: variant)).tag(variant.rawValue) + } + } + .pickerStyle(.menu) + + Text(description(for: selectedVariant)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + } + + GroupBox("State") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Show on blank browser tabs", isOn: showOnBlankTabsBinding) + Toggle("Pretend the user dismissed it", isOn: $isDismissed) + + Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))") + .font(.caption) + .foregroundStyle(.secondary) + Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + + GroupBox("Quick Actions") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button("Open Browser Settings") { + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + Button("Open Import Dialog") { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } + + Button("Reset Hint Debug State") { + BrowserImportHintSettings.reset() + } + } + .padding(.top, 2) + } + + GroupBox("Ideas") { + VStack(alignment: .leading, spacing: 6) { + Text("Inline strip: default candidate, visible but quieter than the old floating card.") + Text("Floating card: strongest nudge, useful when we want more explanation.") + Text("Toolbar chip: most subtle, best when the hint should stay out of the content area.") + Text("Settings only: no in-browser nudge, Browser settings becomes the only permanent home.") + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func title(for variant: BrowserImportHintVariant) -> String { + switch variant { + case .inlineStrip: + return "Inline Strip" + case .floatingCard: + return "Floating Card" + case .toolbarChip: + return "Toolbar Chip" + case .settingsOnly: + return "Settings Only" + } + } + + private func description(for variant: BrowserImportHintVariant) -> String { + switch variant { + case .inlineStrip: + return "Shows a thin hint bar at the top of blank browser tabs." + case .floatingCard: + return "Shows the fuller callout card inside blank browser tabs." + case .toolbarChip: + return "Moves the hint into a small toolbar chip beside the browser controls." + case .settingsOnly: + return "Hides the blank-tab hint and leaves Browser settings as the only home." + } + } + + private func placementTitle(_ placement: BrowserImportHintBlankTabPlacement) -> String { + switch placement { + case .hidden: + return "Hidden" + case .inlineStrip: + return "Inline Strip" + case .floatingCard: + return "Floating Card" + case .toolbarChip: + return "Toolbar Chip" + } + } + + private func settingsStatusTitle(_ status: BrowserImportHintSettingsStatus) -> String { + switch status { + case .visible: + return "Visible" + case .hidden: + return "Hidden" + case .settingsOnly: + return "Settings Only" + } + } +} + private final class AboutWindowController: NSWindowController, NSWindowDelegate { static let shared = AboutWindowController() @@ -2035,6 +2249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } enum SettingsNavigationTarget: String { + case browser case keyboardShortcuts } @@ -3103,6 +3318,9 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @@ -3204,6 +3422,30 @@ struct SettingsView: View { ) } + private var browserImportHintVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: browserImportHintVariant, + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + + private var browserImportHintVisibilityBinding: Binding { + Binding( + get: { showBrowserImportHintOnBlankTabs }, + set: { newValue in + showBrowserImportHintOnBlankTabs = newValue + if newValue { + isBrowserImportHintDismissed = false + } + } + ) + } + private var socketModeSelection: Binding { Binding( get: { socketControlMode }, @@ -3266,6 +3508,17 @@ struct SettingsView: View { InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) } + private var browserImportHintSettingsNote: String { + switch browserImportHintPresentation.settingsStatus { + case .visible: + return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.") + case .hidden: + return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.") + case .settingsOnly: + return String(localized: "settings.browser.import.hint.note.settingsOnly", defaultValue: "Blank tabs are currently using Settings only mode from the debug window.") + } + } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -4187,6 +4440,8 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) + .id(SettingsNavigationTarget.browser) + .accessibilityIdentifier("SettingsBrowserSection") SettingsCard { SettingsPickerRow( String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), @@ -4361,7 +4616,38 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.import", defaultValue: "Import From Browser")) + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) + ) + } + HStack(spacing: 8) { Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { BrowserDataImportCoordinator.shared.presentImportDialog() @@ -4376,7 +4662,22 @@ struct SettingsView: View { .buttonStyle(.bordered) .controlSize(.small) } + .accessibilityIdentifier("SettingsBrowserImportActions") + + Toggle( + String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), + isOn: browserImportHintVisibilityBinding + ) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportHintToggle") + + Text(browserImportHintSettingsNote) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 14) + .padding(.vertical, 10) SettingsCardDivider() @@ -4520,6 +4821,7 @@ struct SettingsView: View { BrowserHistoryStore.shared.loadIfNeeded() notificationStore.refreshAuthorizationStatus() browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue + browserImportHintVariantRaw = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist refreshDetectedImportBrowsers() @@ -4633,6 +4935,9 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 2f122921..6eed3932 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -144,6 +144,50 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(manyProfilesPresentation.showsHelpText) } + func testBrowserImportHintPresentationDefaultsToInlineStrip() { + let presentation = BrowserImportHintPresentation( + variant: .inlineStrip, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .inlineStrip) + XCTAssertEqual(presentation.settingsStatus, .visible) + } + + func testBrowserImportHintPresentationHidesBlankTabHintWhenDismissed() { + let presentation = BrowserImportHintPresentation( + variant: .floatingCard, + showOnBlankTabs: true, + isDismissed: true + ) + + XCTAssertEqual(presentation.blankTabPlacement, .hidden) + XCTAssertEqual(presentation.settingsStatus, .hidden) + } + + func testBrowserImportHintPresentationUsesToolbarChipWhenEnabled() { + let presentation = BrowserImportHintPresentation( + variant: .toolbarChip, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) + XCTAssertEqual(presentation.settingsStatus, .visible) + } + + func testBrowserImportHintPresentationSettingsOnlyVariantStaysInSettings() { + let presentation = BrowserImportHintPresentation( + variant: .settingsOnly, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .hidden) + XCTAssertEqual(presentation.settingsStatus, .settingsOnly) + } + @MainActor func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index c8d95f08..ab30b3e1 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -112,6 +112,32 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } + func testBlankBrowserImportHintCanOpenBrowserSettings() { + let app = launchAppForBlankImportHint() + + let settingsButton = app.buttons["BrowserImportHintSettingsButton"] + XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) + settingsButton.click() + + XCTAssertTrue( + app.otherElements["SettingsBrowserSection"].waitForExistence(timeout: 5.0), + "Expected Browser Settings to open from the blank-tab import hint" + ) + } + + func testBlankBrowserImportHintCanBeDismissed() { + let app = launchAppForBlankImportHint() + + let dismissButton = app.buttons["BrowserImportHintDismissButton"] + XCTAssertTrue(dismissButton.waitForExistence(timeout: 5.0)) + dismissButton.click() + + XCTAssertTrue( + browserImportPollUntil(timeout: 2.0) { !dismissButton.exists }, + "Expected the blank-tab import hint to disappear after dismissal" + ) + } + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -125,6 +151,18 @@ final class BrowserImportProfilesUITests: XCTestCase { return app } + private func launchAppForBlankImportHint() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] = "inlineStrip" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1" + launchAndActivate(app) + waitForBlankImportHint(app) + return app + } + private func waitForImportWizard(_ app: XCUIApplication) { let wizardOpened = browserImportPollUntil(timeout: 5.0) { app.buttons["Next"].exists || app.windows["Import Browser Data"].exists @@ -132,6 +170,13 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(wizardOpened, "Expected the import wizard to open") } + private func waitForBlankImportHint(_ app: XCUIApplication) { + let hintOpened = browserImportPollUntil(timeout: 5.0) { + app.buttons["BrowserImportHintDismissButton"].exists + } + XCTAssertTrue(hintOpened, "Expected the blank browser import hint to appear") + } + private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { let url = URL(fileURLWithPath: capturePath) let foundCapture = browserImportPollUntil(timeout: timeout) { From e15825826f36ea007c0262f375f5585888dc4e21 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 17 Mar 2026 03:07:38 -0700 Subject: [PATCH 62/77] fix: restore Sparkle automatic update checks (#1597) --- Resources/Info.plist | 8 +++ Sources/Update/UpdateController.swift | 63 +++++++++++++++---- Sources/Update/UpdateDelegate.swift | 10 ++- Sources/Update/UpdateDriver.swift | 8 +-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 40 ++++++++++++ 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index 41572e05..c96a632f 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -146,8 +146,16 @@ + SUAutomaticallyUpdate + + SUEnableAutomaticChecks + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml + SUScheduledCheckInterval + 86400 + SUSendProfileInfo + SUPublicEDKey $(SPARKLE_PUBLIC_KEY) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 7cc9beb9..ef1176bf 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -3,6 +3,47 @@ import Cocoa import Combine import SwiftUI +enum UpdateSettings { + static let automaticChecksKey = "SUEnableAutomaticChecks" + static let automaticallyUpdateKey = "SUAutomaticallyUpdate" + static let scheduledCheckIntervalKey = "SUScheduledCheckInterval" + static let sendProfileInfoKey = "SUSendProfileInfo" + static let migrationKey = "cmux.sparkle.automaticChecksMigration.v1" + static let scheduledCheckInterval: TimeInterval = 60 * 60 * 24 + + static func apply(to defaults: UserDefaults) { + defaults.register(defaults: [ + automaticChecksKey: true, + automaticallyUpdateKey: false, + scheduledCheckIntervalKey: scheduledCheckInterval, + sendProfileInfoKey: false, + ]) + + guard !defaults.bool(forKey: migrationKey) else { return } + + // Repair older installs that may have ended up with automatic checks disabled + // before the updater defaults were embedded in Info.plist. + defaults.set(true, forKey: automaticChecksKey) + + if let interval = defaults.object(forKey: scheduledCheckIntervalKey) as? NSNumber { + if interval.doubleValue <= 0 { + defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey) + } + } else { + defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey) + } + + if defaults.object(forKey: automaticallyUpdateKey) == nil { + defaults.set(false, forKey: automaticallyUpdateKey) + } + if defaults.object(forKey: sendProfileInfoKey) == nil { + defaults.set(false, forKey: sendProfileInfoKey) + } + + defaults.set(true, forKey: migrationKey) + } +} + /// Controller for managing Sparkle updates in cmux. class UpdateController { private(set) var updater: SPUUpdater @@ -27,13 +68,8 @@ class UpdateController { } init() { - // cmux checks for updates in the background, but keeps automatic download and - // profile submission disabled so all install intent stays user-driven. let defaults = UserDefaults.standard - defaults.register(defaults: [ - "SUSendProfileInfo": false, - "SUAutomaticallyUpdate": false, - ]) + UpdateSettings.apply(to: defaults) let hostBundle = Bundle.main self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle) @@ -63,19 +99,22 @@ class UpdateController { // delegate now suppresses Sparkle's permission UI entirely. if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" { let defaults = UserDefaults.standard - defaults.removeObject(forKey: "SUEnableAutomaticChecks") - defaults.removeObject(forKey: "SUSendProfileInfo") - defaults.removeObject(forKey: "SUAutomaticallyUpdate") + defaults.removeObject(forKey: UpdateSettings.automaticChecksKey) + defaults.removeObject(forKey: UpdateSettings.automaticallyUpdateKey) + defaults.removeObject(forKey: UpdateSettings.scheduledCheckIntervalKey) + defaults.removeObject(forKey: UpdateSettings.sendProfileInfoKey) + defaults.removeObject(forKey: UpdateSettings.migrationKey) defaults.synchronize() UpdateLogStore.shared.append("reset sparkle permission defaults (ui test)") } #endif do { - updater.automaticallyChecksForUpdates = true - updater.automaticallyDownloadsUpdates = false - updater.sendsSystemProfile = false try updater.start() didStartUpdater = true + let interval = Int(updater.updateCheckInterval.rounded()) + UpdateLogStore.shared.append( + "updater started (autoChecks=\(updater.automaticallyChecksForUpdates), interval=\(interval)s, autoDownloads=\(updater.automaticallyDownloadsUpdates))" + ) } catch { userDriver.viewModel.state = .error(.init( error: error, diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index b3adfc15..7de114d3 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -33,7 +33,15 @@ extension UpdateDriver: SPUUpdaterDelegate { let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL) UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")") recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback) - return infoFeedURL + return resolved.url + } + + func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) { + UpdateLogStore.shared.append("next update check scheduled in \(Int(delay.rounded()))s") + } + + func updaterWillNotScheduleUpdateCheck(_ updater: SPUUpdater) { + UpdateLogStore.shared.append("automatic update checks disabled; no scheduled check") } /// Called when an update is scheduled to install silently, diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 04dedebd..289df890 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -27,11 +27,11 @@ class UpdateDriver: NSObject, SPUUserDriver { return } #endif - // Never show Sparkle's permission UI. cmux relies on its in-app update pill instead, - // and defaults to manual update checks unless explicitly enabled elsewhere. - UpdateLogStore.shared.append("auto-deny update permission (no UI)") + // Never show Sparkle's permission UI. cmux always enables scheduled checks and keeps + // automatic downloads disabled so installs remain user-driven. + UpdateLogStore.shared.append("auto-allow update permission (no UI)") DispatchQueue.main.async { - reply(SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false)) + reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false)) } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 60018a44..5ec9aae7 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5190,6 +5190,46 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class UpdateSettingsTests: XCTestCase { + func testApplyEnablesAutomaticChecksAndDailySchedule() { + let defaults = makeDefaults() + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey)) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey)) + } + + func testApplyRepairsLegacyDisabledAutomaticChecksOnce() { + let defaults = makeDefaults() + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey) + defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey) + + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + UpdateSettings.apply(to: defaults) + + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + } + + private func makeDefaults() -> UserDefaults { + let suiteName = "UpdateSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + final class SidebarRemoteErrorCopySupportTests: XCTestCase { func testMenuLabelIsNilWhenThereAreNoErrors() { XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) From 9807cb087b0c042a5938236a6885edef429138fb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:14:23 -0700 Subject: [PATCH 63/77] Stabilize browser import hint UI tests --- Sources/Panels/BrowserPanelView.swift | 1 + .../BrowserImportProfilesUITests.swift | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 4f1bcc62..8192fba8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1443,6 +1443,7 @@ struct BrowserPanelView: View { } .buttonStyle(.bordered) .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintImportButton") } private var browserImportHintSettingsButton: some View { diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index ab30b3e1..62f85537 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -120,7 +120,7 @@ final class BrowserImportProfilesUITests: XCTestCase { settingsButton.click() XCTAssertTrue( - app.otherElements["SettingsBrowserSection"].waitForExistence(timeout: 5.0), + app.switches["SettingsBrowserImportHintToggle"].waitForExistence(timeout: 5.0), "Expected Browser Settings to open from the blank-tab import hint" ) } @@ -141,13 +141,16 @@ final class BrowserImportProfilesUITests: XCTestCase { private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" - app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] = "1" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] = "inlineStrip" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1" launchAndActivate(app) - waitForImportWizard(app) + openImportWizardFromBlankImportHint(app) return app } @@ -172,11 +175,21 @@ final class BrowserImportProfilesUITests: XCTestCase { private func waitForBlankImportHint(_ app: XCUIApplication) { let hintOpened = browserImportPollUntil(timeout: 5.0) { - app.buttons["BrowserImportHintDismissButton"].exists + app.buttons["BrowserImportHintImportButton"].exists } XCTAssertTrue(hintOpened, "Expected the blank browser import hint to appear") } + private func openImportWizardFromBlankImportHint(_ app: XCUIApplication) { + waitForBlankImportHint(app) + + let importButton = app.buttons["BrowserImportHintImportButton"] + XCTAssertTrue(importButton.waitForExistence(timeout: 5.0)) + importButton.click() + + waitForImportWizard(app) + } + private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { let url = URL(fileURLWithPath: capturePath) let foundCapture = browserImportPollUntil(timeout: timeout) { From c5ae8dc9ebf1c935707ba420b20ef4329da300fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:23:18 -0700 Subject: [PATCH 64/77] Defer browser import dialog presentation --- Sources/Panels/BrowserPanelView.swift | 8 +++++--- Sources/cmuxApp.swift | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 8192fba8..7108d183 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1470,9 +1470,11 @@ struct BrowserPanelView: View { private func presentImportDialogFromHint() { isBrowserImportHintPopoverPresented = false - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } } private func openBrowserImportSettings() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 15ceaa47..5c5dd445 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2037,7 +2037,9 @@ private struct BrowserImportHintDebugView: View { AppDelegate.presentPreferencesWindow(navigationTarget: .browser) } Button("Open Import Dialog") { - BrowserDataImportCoordinator.shared.presentImportDialog() + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } } } @@ -4650,8 +4652,10 @@ struct SettingsView: View { HStack(spacing: 8) { Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { - BrowserDataImportCoordinator.shared.presentImportDialog() - refreshDetectedImportBrowsers() + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + refreshDetectedImportBrowsers() + } } .buttonStyle(.bordered) .controlSize(.small) From 8d8fadbb27a92d0a45e31ea082d7acb43f2e5e03 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Mar 2026 04:03:49 -0700 Subject: [PATCH 65/77] Add hidden CLI command for live terminal debugging (#1599) * Add hidden terminal debug CLI command * Expand orphan terminal debug metadata * Remove stray CLIProcessRunner test target wiring * Tighten debug terminal diagnostics handling --------- Co-authored-by: Lawrence Chen --- CLI/cmux.swift | 194 +++++++++++ GhosttyTabs.xcodeproj/project.pbxproj | 2 +- Sources/GhosttyTerminalView.swift | 92 ++++++ Sources/TerminalController.swift | 262 +++++++++++++++ cmuxTests/CLIProcessRunnerTests.swift | 441 -------------------------- 5 files changed, 549 insertions(+), 442 deletions(-) delete mode 100644 cmuxTests/CLIProcessRunnerTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4e770068..c6495bbb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1849,6 +1849,18 @@ struct CMUXCLI { } } + case "debug-terminals": + let unexpected = commandArgs.filter { $0 != "--" } + if let extra = unexpected.first { + throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'") + } + let payload = try client.sendV2(method: "debug.terminals") + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + print(formatDebugTerminalsPayload(payload, idFormat: idFormat)) + } + case "trigger-flash": let tfWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) @@ -2706,6 +2718,14 @@ struct CMUXCLI { return nil } + private func doubleFromAny(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let f = value as? Float { return Double(f) } + if let n = value as? NSNumber { return n.doubleValue } + if let s = value as? String { return Double(s) } + return nil + } + private func parseBoolString(_ raw: String) -> Bool? { switch raw.lowercased() { case "1", "true", "yes", "on": @@ -2968,6 +2988,160 @@ struct CMUXCLI { } } + private func debugString(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { return nil } + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + return String(describing: value) + } + + private func debugBool(_ value: Any?) -> Bool? { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let string = value as? String { + return parseBoolString(string) + } + return nil + } + + private func debugFlag(_ value: Any?) -> String { + guard let bool = debugBool(value) else { return "nil" } + return bool ? "1" : "0" + } + + private func formatDebugRect(_ value: Any?) -> String? { + guard let rect = value as? [String: Any], + let x = doubleFromAny(rect["x"]), + let y = doubleFromAny(rect["y"]), + let width = doubleFromAny(rect["width"]), + let height = doubleFromAny(rect["height"]) else { + return nil + } + return String(format: "{%.1f,%.1f %.1fx%.1f}", x, y, width, height) + } + + private func formatDebugPorts(_ value: Any?) -> String { + guard let array = value as? [Any], !array.isEmpty else { return "[]" } + let ports = array + .compactMap { intFromAny($0) } + .map(String.init) + return ports.isEmpty ? "[]" : ports.joined(separator: ",") + } + + private func formatDebugList(_ value: Any?) -> String? { + guard let array = value as? [Any], !array.isEmpty else { return nil } + let items = array.compactMap { item -> String? in + if let string = item as? String { + return string + } + return debugString(item) + } + guard !items.isEmpty else { return nil } + return items.joined(separator: ">") + } + + private func formatDebugAge(_ value: Any?) -> String? { + guard let seconds = doubleFromAny(value) else { return nil } + return String(format: "%.3fs", seconds) + } + + private func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String { + let terminals = payload["terminals"] as? [[String: Any]] ?? [] + guard !terminals.isEmpty else { return "No terminal surfaces" } + + return terminals.map { item in + let index = intFromAny(item["index"]) ?? 0 + let surface = formatHandle(item, kind: "surface", idFormat: idFormat) ?? "?" + let window = formatHandle(item, kind: "window", idFormat: idFormat) ?? "nil" + let workspace = formatHandle(item, kind: "workspace", idFormat: idFormat) ?? "nil" + let pane = formatHandle(item, kind: "pane", idFormat: idFormat) ?? "nil" + let bonsplitTab = debugString(item["bonsplit_tab_id"]) ?? "nil" + let lastKnownWorkspace = debugString(item["last_known_workspace_ref"]) ?? debugString(item["last_known_workspace_id"]) ?? "nil" + let titleSuffix: String = { + guard let title = debugString(item["surface_title"]), !title.isEmpty else { return "" } + let escaped = title.replacingOccurrences(of: "\"", with: "\\\"") + return " \"\(escaped)\"" + }() + let branchLabel: String = { + guard let branch = debugString(item["git_branch"]), !branch.isEmpty else { return "nil" } + return debugBool(item["git_dirty"]) == true ? "\(branch)*" : branch + }() + let teardownLabel: String = { + guard debugBool(item["teardown_requested"]) == true else { return "nil" } + let reason = debugString(item["teardown_requested_reason"]) ?? "requested" + let age = formatDebugAge(item["teardown_requested_age_seconds"]) ?? "unknown" + return "\(reason)@\(age)" + }() + let portalHostLabel: String = { + let hostId = debugString(item["portal_host_id"]) ?? "nil" + let area = doubleFromAny(item["portal_host_area"]).map { String(format: "%.1f", $0) } ?? "nil" + let inWindow = debugFlag(item["portal_host_in_window"]) + return "\(hostId)/win=\(inWindow)/area=\(area)" + }() + let windowMetaLabel: String = { + let title = debugString(item["window_title"]) ?? "nil" + let windowClass = debugString(item["window_class"]) ?? "nil" + let controllerClass = debugString(item["window_controller_class"]) ?? "nil" + let delegateClass = debugString(item["window_delegate_class"]) ?? "nil" + return "title=\(title) class=\(windowClass) controller=\(controllerClass) delegate=\(delegateClass)" + }() + + let line1 = + "[\(index)] \(surface)\(titleSuffix) " + + "mapped=\(debugFlag(item["mapped"])) tree=\(debugFlag(item["tree_visible"])) " + + "window=\(window) workspace=\(workspace) pane=\(pane) bonsplitTab=\(bonsplitTab) " + + "ctx=\(debugString(item["surface_context"]) ?? "nil")" + + let line2 = + " runtime=\(debugFlag(item["runtime_surface_ready"])) " + + "focused=\(debugFlag(item["surface_focused"])) " + + "selected=\(debugFlag(item["surface_selected_in_pane"])) " + + "pinned=\(debugFlag(item["surface_pinned"])) " + + "terminal=\(debugString(item["terminal_object_ptr"]) ?? "nil") " + + "hosted=\(debugString(item["hosted_view_ptr"]) ?? "nil") " + + "ghostty=\(debugString(item["ghostty_surface_ptr"]) ?? "nil") " + + "portal=\(debugString(item["portal_binding_state"]) ?? "nil")#\(debugString(item["portal_binding_generation"]) ?? "nil") " + + "teardown=\(teardownLabel)" + + let line3 = + " tty=\(debugString(item["tty"]) ?? "nil") " + + "cwd=\(debugString(item["current_directory"]) ?? debugString(item["requested_working_directory"]) ?? "nil") " + + "branch=\(branchLabel) " + + "ports=\(formatDebugPorts(item["listening_ports"])) " + + "visible=\(debugFlag(item["hosted_view_visible_in_ui"])) " + + "inWindow=\(debugFlag(item["hosted_view_in_window"])) " + + "superview=\(debugFlag(item["hosted_view_has_superview"])) " + + "hidden=\(debugFlag(item["hosted_view_hidden"])) " + + "ancestorHidden=\(debugFlag(item["hosted_view_hidden_or_ancestor_hidden"])) " + + "firstResponder=\(debugFlag(item["surface_view_first_responder"])) " + + "windowNum=\(debugString(item["window_number"]) ?? "nil") " + + "windowKey=\(debugFlag(item["window_key"])) " + + "frame=\(formatDebugRect(item["hosted_view_frame_in_window"]) ?? "nil")" + + let line4 = + " created=\(formatDebugAge(item["surface_age_seconds"]) ?? "nil") " + + "runtimeCreated=\(formatDebugAge(item["runtime_surface_age_seconds"]) ?? "nil") " + + "lastWorkspace=\(lastKnownWorkspace) " + + "initialCommand=\(debugString(item["initial_command"]) ?? "nil") " + + "portalHost=\(portalHostLabel)" + + let line5 = + " window=\(windowMetaLabel) " + + "chain=\(formatDebugList(item["hosted_view_superview_chain"]) ?? "nil")" + + return [line1, line2, line3, line4, line5].joined(separator: "\n") + } + .joined(separator: "\n") + } + private func runMoveSurface( commandArgs: [String], client: SocketClient, @@ -6192,6 +6366,13 @@ struct CMUXCLI { cmux surface-health cmux surface-health --workspace workspace:2 """ + case "debug-terminals": + return """ + Usage: cmux debug-terminals + + Print live Ghostty terminal runtime metadata across all windows and workspaces. + Intended for debugging stray or detached terminal views. + """ case "trigger-flash": return """ Usage: cmux trigger-flash [--workspace ] [--surface ] [--panel ] @@ -10965,6 +11146,19 @@ struct CMUXCLI { to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets. """ } + +#if DEBUG + func debugUsageTextForTesting() -> String { + usage() + } + + func debugFormatDebugTerminalsPayloadForTesting( + _ payload: [String: Any], + idFormat: CLIIDFormat = .refs + ) -> String { + formatDebugTerminalsPayload(payload, idFormat: idFormat) + } +#endif } @main diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2bc5eae0..1571043e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -250,7 +250,7 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; - DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; /* End PBXFileReference section */ diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6dd560c4..fa51c0be 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2489,6 +2489,30 @@ final class GhosttyMetalLayer: CAMetalLayer { } } +final class TerminalSurfaceRegistry { + static let shared = TerminalSurfaceRegistry() + + private let lock = NSLock() + private let surfaces = NSHashTable.weakObjects() + + private init() {} + + func register(_ surface: TerminalSurface) { + lock.lock() + defer { lock.unlock() } + surfaces.add(surface) + } + + func allSurfaces() -> [TerminalSurface] { + lock.lock() + let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface } + lock.unlock() + return objects.sorted { lhs, rhs in + lhs.id.uuidString < rhs.id.uuidString + } + } +} + // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) final class TerminalSurface: Identifiable, ObservableObject { @@ -2538,6 +2562,11 @@ final class TerminalSurface: Identifiable, ObservableObject { private var lastPixelHeight: UInt32 = 0 private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 + private let debugMetadataLock = NSLock() + private let createdAt: Date = Date() + private var runtimeSurfaceCreatedAt: Date? + private var teardownRequestedAt: Date? + private var teardownRequestReason: String? private var pendingTextQueue: [Data] = [] private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 @@ -2623,6 +2652,7 @@ final class TerminalSurface: Identifiable, ObservableObject { self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view hostedView.attachSurface(self) + TerminalSurfaceRegistry.shared.register(self) } @@ -2679,6 +2709,47 @@ final class TerminalSurface: Identifiable, ObservableObject { portalLifecycleState.rawValue } + private func withDebugMetadataLock(_ body: () -> T) -> T { + debugMetadataLock.lock() + defer { debugMetadataLock.unlock() } + return body() + } + + func debugCreatedAt() -> Date { + withDebugMetadataLock { createdAt } + } + + func debugRuntimeSurfaceCreatedAt() -> Date? { + withDebugMetadataLock { runtimeSurfaceCreatedAt } + } + + func debugTeardownRequest() -> (requestedAt: Date?, reason: String?) { + withDebugMetadataLock { (teardownRequestedAt, teardownRequestReason) } + } + + func debugLastKnownWorkspaceId() -> UUID { + tabId + } + + func debugSurfaceContextLabel() -> String { + cmuxSurfaceContextName(surfaceContext) + } + + func debugInitialCommand() -> String? { + initialCommand + } + + func debugPortalHostLease() -> (hostId: String?, inWindow: Bool?, area: CGFloat?) { + guard let activePortalHostLease else { + return (nil, nil, nil) + } + return ( + hostId: String(describing: activePortalHostLease.hostId), + inWindow: activePortalHostLease.inWindow, + area: activePortalHostLease.area + ) + } + func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard portalLifecycleState == .live else { return false } if let expectedSurfaceId, expectedSurfaceId != id { @@ -2774,9 +2845,28 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } + private func recordTeardownRequest(reason: String) { + withDebugMetadataLock { + if teardownRequestedAt == nil { + teardownRequestedAt = Date() + } + if let existing = teardownRequestReason, !existing.isEmpty { + return + } + teardownRequestReason = reason + } + } + + private func recordRuntimeSurfaceCreation() { + withDebugMetadataLock { + runtimeSurfaceCreatedAt = Date() + } + } + func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } + recordTeardownRequest(reason: reason) portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG @@ -2805,6 +2895,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// before deinit; deinit will skip the free if already torn down. @MainActor func teardownSurface() { + recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") let callbackContext = surfaceCallbackContext @@ -3198,6 +3289,7 @@ final class TerminalSurface: Identifiable, ObservableObject { return } guard let createdSurface = surface else { return } + recordRuntimeSurfaceCreation() // Session scrollback replay must be one-shot. Reusing it on a later runtime // surface recreation would inject stale restored output into a live shell. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f87ebafa..de1e96f3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2090,6 +2090,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceRefresh(params: params)) case "surface.health": return v2Result(id: id, self.v2SurfaceHealth(params: params)) + case "debug.terminals": + return v2Result(id: id, self.v2DebugTerminals(params: params)) case "surface.send_text": return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": @@ -2432,6 +2434,7 @@ class TerminalController { "tab.action", "surface.refresh", "surface.health", + "debug.terminals", "surface.send_text", "surface.send_key", "surface.read_text", @@ -4917,6 +4920,265 @@ class TerminalController { return .ok(payload) } + private func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { + var payload: [String: Any]? + + v2MainSync { + guard let app = AppDelegate.shared else { return } + + struct MappedTerminalLocation { + let windowIndex: Int + let windowId: UUID + let window: NSWindow? + let workspaceIndex: Int + let workspaceSelected: Bool + let workspace: Workspace + let terminalPanel: TerminalPanel + let paneId: PaneID? + let paneIndex: Int? + let surfaceIndex: Int + let selectedInPane: Bool? + let bonsplitTabId: TabID? + } + + func nonEmpty(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func rectPayload(_ rect: CGRect) -> [String: Double] { + [ + "x": Double(rect.origin.x), + "y": Double(rect.origin.y), + "width": Double(rect.size.width), + "height": Double(rect.size.height) + ] + } + + func objectPointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + func ghosttyPointerString(_ surface: ghostty_surface_t?) -> String { + guard let surface else { return "nil" } + return String(describing: surface) + } + + func className(_ object: AnyObject?) -> String? { + guard let object else { return nil } + return String(describing: type(of: object)) + } + + let iso8601Formatter = ISO8601DateFormatter() + let now = Date() + + func iso8601String(_ date: Date?) -> String? { + guard let date else { return nil } + return iso8601Formatter.string(from: date) + } + + func ageSeconds(since date: Date?) -> Double? { + guard let date else { return nil } + return (now.timeIntervalSince(date) * 1000).rounded() / 1000 + } + + @MainActor + func superviewClassChain(for view: NSView, limit: Int = 8) -> [String] { + var chain: [String] = [String(describing: type(of: view))] + var currentSuperview = view.superview + while chain.count < limit, let nextSuperview = currentSuperview { + chain.append(String(describing: type(of: nextSuperview))) + currentSuperview = nextSuperview.superview + } + if currentSuperview != nil { + chain.append("...") + } + return chain + } + + let windows = app.scriptableMainWindows() + let windowIndexById = Dictionary( + uniqueKeysWithValues: windows.enumerated().map { ($0.element.windowId, $0.offset) } + ) + + @MainActor + func resolvedWindowMetadata(for window: NSWindow?) -> (windowId: UUID?, windowIndex: Int?) { + guard let window else { return (nil, nil) } + + if let match = windows.enumerated().first(where: { _, state in + guard let stateWindow = state.window else { return false } + return stateWindow === window || stateWindow.windowNumber == window.windowNumber + }) { + return (match.element.windowId, match.offset) + } + + guard let raw = window.identifier?.rawValue else { return (nil, nil) } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix), + let parsedWindowId = UUID(uuidString: String(raw.dropFirst(prefix.count))) else { + return (nil, nil) + } + return (parsedWindowId, windowIndexById[parsedWindowId]) + } + + var mappedLocations: [ObjectIdentifier: MappedTerminalLocation] = [:] + for (windowIndex, state) in windows.enumerated() { + let tabManager = state.tabManager + for (workspaceIndex, workspace) in tabManager.tabs.enumerated() { + let paneIndexById = Dictionary( + uniqueKeysWithValues: workspace.bonsplitController.allPaneIds.enumerated().map { + ($0.element.id, $0.offset) + } + ) + var selectedInPaneByPanelId: [UUID: Bool] = [:] + for paneId in workspace.bonsplitController.allPaneIds { + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + for tab in workspace.bonsplitController.tabs(inPane: paneId) { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + } + } + + for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() { + guard let terminalPanel = panel as? TerminalPanel else { continue } + mappedLocations[ObjectIdentifier(terminalPanel.surface)] = MappedTerminalLocation( + windowIndex: windowIndex, + windowId: state.windowId, + window: state.window, + workspaceIndex: workspaceIndex, + workspaceSelected: workspace.id == tabManager.selectedTabId, + workspace: workspace, + terminalPanel: terminalPanel, + paneId: workspace.paneId(forPanelId: terminalPanel.id), + paneIndex: workspace.paneId(forPanelId: terminalPanel.id).flatMap { paneIndexById[$0.id] }, + surfaceIndex: surfaceIndex, + selectedInPane: selectedInPaneByPanelId[terminalPanel.id], + bonsplitTabId: workspace.surfaceIdFromPanelId(terminalPanel.id) + ) + } + } + } + + let surfaces = TerminalSurfaceRegistry.shared.allSurfaces() + let terminals: [[String: Any]] = surfaces.enumerated().map { index, terminalSurface in + let mapped = mappedLocations[ObjectIdentifier(terminalSurface)] + let hostedView = terminalSurface.hostedView + let hostedWindow = mapped?.window ?? hostedView.window + let fallbackWindowMetadata = resolvedWindowMetadata(for: hostedWindow) + let resolvedWindowId = mapped?.windowId ?? fallbackWindowMetadata.windowId + let resolvedWindowIndex = mapped?.windowIndex ?? fallbackWindowMetadata.windowIndex + let workspace = mapped?.workspace + let panelId = mapped?.terminalPanel.id ?? terminalSurface.id + let portalState = hostedView.portalBindingGuardState() + let portalHostLease = terminalSurface.debugPortalHostLease() + let gitBranchState = workspace?.panelGitBranches[panelId] + let listeningPorts = (workspace?.surfaceListeningPorts[panelId] ?? []).sorted() + let title = workspace?.panelTitle(panelId: panelId) + let paneId = mapped?.paneId + let treeVisible = mapped?.bonsplitTabId != nil && paneId != nil + let ttyName = workspace?.surfaceTTYNames[panelId] + let currentDirectory = nonEmpty(workspace?.panelDirectories[panelId] ?? mapped?.terminalPanel.directory) + let teardownRequest = terminalSurface.debugTeardownRequest() + let lastKnownWorkspaceId = terminalSurface.debugLastKnownWorkspaceId() + + var item: [String: Any] = [ + "index": index, + "mapped": mapped != nil, + "tree_visible": treeVisible, + "window_index": v2OrNull(resolvedWindowIndex), + "window_id": v2OrNull(resolvedWindowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: resolvedWindowId), + "window_number": v2OrNull(hostedWindow?.windowNumber), + "window_key": hostedWindow?.isKeyWindow ?? false, + "window_main": hostedWindow?.isMainWindow ?? false, + "window_visible": hostedWindow?.isVisible ?? false, + "window_occluded": hostedWindow.map { !$0.occlusionState.contains(.visible) } ?? false, + "window_identifier": v2OrNull(hostedWindow?.identifier?.rawValue), + "window_title": v2OrNull(nonEmpty(hostedWindow?.title)), + "window_class": v2OrNull(className(hostedWindow)), + "window_delegate_class": v2OrNull(className(hostedWindow?.delegate as AnyObject?)), + "window_controller_class": v2OrNull(className(hostedWindow?.windowController)), + "window_level": v2OrNull(hostedWindow?.level.rawValue), + "window_frame": hostedWindow.map { rectPayload($0.frame) } ?? NSNull(), + "workspace_index": v2OrNull(mapped?.workspaceIndex), + "workspace_id": v2OrNull(workspace?.id.uuidString), + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace?.id), + "workspace_title": v2OrNull(workspace?.title), + "workspace_selected": v2OrNull(mapped?.workspaceSelected), + "pane_index": v2OrNull(mapped?.paneIndex), + "pane_id": v2OrNull(paneId?.id.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneId?.id), + "surface_index": v2OrNull(mapped?.surfaceIndex), + "surface_index_in_pane": v2OrNull(workspace?.indexInPane(forPanelId: panelId)), + "surface_id": panelId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: panelId), + "surface_title": v2OrNull(title), + "surface_focused": v2OrNull(workspace.map { panelId == $0.focusedPanelId }), + "surface_selected_in_pane": v2OrNull(mapped?.selectedInPane), + "surface_pinned": v2OrNull(workspace.map { $0.isPanelPinned(panelId) }), + "surface_context": terminalSurface.debugSurfaceContextLabel(), + "surface_created_at": v2OrNull(iso8601String(terminalSurface.debugCreatedAt())), + "surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugCreatedAt())), + "runtime_surface_created_at": v2OrNull(iso8601String(terminalSurface.debugRuntimeSurfaceCreatedAt())), + "runtime_surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugRuntimeSurfaceCreatedAt())), + "bonsplit_tab_id": v2OrNull(mapped?.bonsplitTabId?.uuid.uuidString), + "terminal_object_ptr": objectPointerString(terminalSurface), + "ghostty_surface_ptr": ghosttyPointerString(terminalSurface.surface), + "runtime_surface_ready": terminalSurface.surface != nil, + "hosted_view_ptr": objectPointerString(hostedView), + "hosted_view_class": className(hostedView) ?? "nil", + "hosted_view_in_window": hostedView.window != nil, + "hosted_view_has_superview": hostedView.superview != nil, + "hosted_view_hidden": hostedView.isHidden, + "hosted_view_hidden_or_ancestor_hidden": hostedView.isHiddenOrHasHiddenAncestor, + "hosted_view_alpha": hostedView.alphaValue, + "hosted_view_visible_in_ui": hostedView.debugPortalVisibleInUI, + "hosted_view_superview_chain": superviewClassChain(for: hostedView), + "surface_view_first_responder": hostedView.isSurfaceViewFirstResponder(), + "hosted_view_frame": rectPayload(hostedView.frame), + "hosted_view_bounds": rectPayload(hostedView.bounds), + "hosted_view_frame_in_window": rectPayload(hostedView.debugPortalFrameInWindow), + "portal_binding_state": portalState.state, + "portal_binding_generation": v2OrNull(portalState.generation), + "portal_host_id": v2OrNull(portalHostLease.hostId), + "portal_host_in_window": v2OrNull(portalHostLease.inWindow), + "portal_host_area": v2OrNull(portalHostLease.area.map(Double.init)), + "tty": v2OrNull(ttyName), + "current_directory": v2OrNull(currentDirectory), + "requested_working_directory": v2OrNull(nonEmpty(terminalSurface.requestedWorkingDirectory)), + "initial_command": v2OrNull(nonEmpty(terminalSurface.debugInitialCommand())), + "git_branch": v2OrNull(nonEmpty(gitBranchState?.branch)), + "git_dirty": v2OrNull(gitBranchState?.isDirty), + "listening_ports": listeningPorts, + "key_state_indicator": v2OrNull(nonEmpty(terminalSurface.currentKeyStateIndicatorText)), + "last_known_workspace_id": lastKnownWorkspaceId.uuidString, + "last_known_workspace_ref": v2Ref(kind: .workspace, uuid: lastKnownWorkspaceId), + "teardown_requested": teardownRequest.requestedAt != nil, + "teardown_requested_at": v2OrNull(iso8601String(teardownRequest.requestedAt)), + "teardown_requested_age_seconds": v2OrNull(ageSeconds(since: teardownRequest.requestedAt)), + "teardown_requested_reason": v2OrNull(nonEmpty(teardownRequest.reason)) + ] + + if title == nil, let fallbackTitle = mapped?.terminalPanel.displayTitle, !fallbackTitle.isEmpty { + item["surface_title"] = fallbackTitle + } + return item + } + + payload = [ + "count": terminals.count, + "terminals": terminals + ] + } + + guard let payload else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + return .ok(payload) + } + private func v2SurfaceSendText(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/cmuxTests/CLIProcessRunnerTests.swift b/cmuxTests/CLIProcessRunnerTests.swift deleted file mode 100644 index 9253e9b7..00000000 --- a/cmuxTests/CLIProcessRunnerTests.swift +++ /dev/null @@ -1,441 +0,0 @@ -import XCTest - -#if canImport(cmux) -@testable import cmux - -final class CLIProcessRunnerTests: XCTestCase { - private func writeExecutable(_ contents: String, to url: URL) throws { - try contents.write(to: url, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) - } - - func testRunProcessTimesOutHungChild() { - let startedAt = Date() - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", "sleep 5"], - timeout: 0.2 - ) - - XCTAssertTrue(result.timedOut) - XCTAssertEqual(result.status, 124) - XCTAssertLessThan(Date().timeIntervalSince(startedAt), 2.0) - } - - func testInteractiveRemoteShellCommandHonorsZDOTDIRFromRealZshenv() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zdotdir-\(UUID().uuidString)") - let userZdotdir = home.appendingPathComponent("user-zdotdir") - let relayDir = home.appendingPathComponent(".cmux/relay") - let binDir = home.appendingPathComponent(".cmux/bin") - try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "export ZDOTDIR=\"$HOME/user-zdotdir\"\n" - .write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) - try """ - precmd() { - print -r -- "REAL=$CMUX_REAL_ZDOTDIR ZDOTDIR=$ZDOTDIR SOCKET=$CMUX_SOCKET_PATH PATH=$PATH" - exit - } - """ - .write(to: userZdotdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - try "#!/bin/sh\nexit 0\n" - .write(to: binDir.appendingPathComponent("cmux"), atomically: true, encoding: .utf8) - try "".write( - to: relayDir.appendingPathComponent("64003.auth"), - atomically: true, - encoding: .utf8 - ) - try fileManager.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: binDir.appendingPathComponent("cmux").path - ) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64003, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("REAL=\(userZdotdir.path)"), result.stdout) - XCTAssertTrue(result.stdout.contains("SOCKET=127.0.0.1:64003"), result.stdout) - XCTAssertTrue(result.stdout.contains("PATH=\(binDir.path):"), result.stdout) - XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64003.shell").path)"), result.stdout) - } - - func testInteractiveRemoteShellCommandKeepsDefaultZDOTDIRWithoutRecursing() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zdotdir-default-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".cmux/relay") - let binDir = home.appendingPathComponent(".cmux/bin") - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"REAL=$CMUX_REAL_ZDOTDIR ZDOTDIR=$ZDOTDIR\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - try "#!/bin/sh\nexit 0\n" - .write(to: binDir.appendingPathComponent("cmux"), atomically: true, encoding: .utf8) - try "".write( - to: relayDir.appendingPathComponent("64004.auth"), - atomically: true, - encoding: .utf8 - ) - try fileManager.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: binDir.appendingPathComponent("cmux").path - ) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64004, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse(result.stderr.contains("too many open files"), result.stderr) - XCTAssertTrue(result.stdout.contains("REAL=\(home.path)"), result.stdout) - XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").path)"), result.stdout) - } - - func testInteractiveRemoteShellCommandDoesNotWaitForRelayReadinessBeforeLaunchingShell() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-no-relay-wait-\(UUID().uuidString)") - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"READY SOCKET=$CMUX_SOCKET_PATH\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64006, shellFeatures: "") - let startedAt = Date() - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 2 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("READY SOCKET=127.0.0.1:64006"), result.stdout) - XCTAssertLessThan(Date().timeIntervalSince(startedAt), 1.5, "interactive shell startup should not wait for relay readiness") - } - - func testInteractiveRemoteShellCommandDefaultsToXterm256ColorWithoutPreparedGhosttyTerminfo() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-term-fallback-\(UUID().uuidString)") - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"TERM=$TERM\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("TERM=xterm-256color"), result.stdout) - } - - func testInteractiveRemoteShellCommandSourcesZprofileBeforeLaunchingInteractiveZsh() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zprofile-\(UUID().uuidString)") - let brewBin = home.appendingPathComponent("testbrew/bin") - try fileManager.createDirectory(at: brewBin, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "export PATH=\"$HOME/testbrew/bin:$PATH\"\n" - .write(to: home.appendingPathComponent(".zprofile"), atomically: true, encoding: .utf8) - try "precmd() { print -r -- \"PATH=$PATH\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("PATH=\(brewBin.path):"), result.stdout) - } - - func testInteractiveRemoteShellCommandWithInlineTerminfoParsesAndLaunchesZsh() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-inline-terminfo-\(UUID().uuidString)") - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"READY TERM=$TERM\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand( - remoteRelayPort: 0, - shellFeatures: "", - terminfoSource: "xterm-ghostty|ghostty,clear=\\E[H\\E[2J" - ) - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("READY TERM="), result.stdout) - XCTAssertFalse(result.stderr.contains("unexpected end of file"), result.stderr) - } - - func testRemoteCLIWrapperPrefersRelaySpecificDaemonMapping() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".cmux/relay") - let binDir = home.appendingPathComponent(".cmux/bin") - let wrapperURL = binDir.appendingPathComponent("cmux") - let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current") - let mappedDaemonURL = binDir.appendingPathComponent("cmuxd-remote-64005") - let daemonPathURL = relayDir.appendingPathComponent("64005.daemon_path") - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try writeExecutable("#!/bin/sh\necho current \"$@\"\n", to: currentDaemonURL) - try writeExecutable("#!/bin/sh\necho mapped \"$@\"\n", to: mappedDaemonURL) - try writeExecutable(Workspace.remoteCLIWrapperScript(), to: wrapperURL) - try mappedDaemonURL.path.write(to: daemonPathURL, atomically: true, encoding: .utf8) - - let result = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "CMUX_SOCKET_PATH=127.0.0.1:64005", - wrapperURL.path, - "ping", - ], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "mapped ping") - } - - func testRemoteCLIWrapperInstallScriptDoesNotClobberLegacySymlinkedDaemonTarget() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-install-\(UUID().uuidString)") - let binDir = home.appendingPathComponent(".cmux/bin") - let daemonDir = binDir.appendingPathComponent("cmuxd-remote/0.62.1/darwin-arm64") - let daemonURL = daemonDir.appendingPathComponent("cmuxd-remote") - let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current") - let wrapperURL = binDir.appendingPathComponent("cmux") - try fileManager.createDirectory(at: daemonDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try writeExecutable("#!/bin/sh\necho daemon \"$@\"\n", to: daemonURL) - try fileManager.createSymbolicLink(atPath: currentDaemonURL.path, withDestinationPath: daemonURL.path) - try fileManager.createSymbolicLink(atPath: wrapperURL.path, withDestinationPath: currentDaemonURL.path) - - let installScript = Workspace.remoteCLIWrapperInstallScript( - daemonRemotePath: ".cmux/bin/cmuxd-remote/0.62.1/darwin-arm64/cmuxd-remote" - ) - let installResult = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "/bin/sh", - "-c", - installScript, - ], - timeout: 5 - ) - - XCTAssertFalse(installResult.timedOut, installResult.stderr) - XCTAssertEqual(installResult.status, 0, installResult.stderr) - XCTAssertEqual( - try String(contentsOf: daemonURL, encoding: .utf8), - "#!/bin/sh\necho daemon \"$@\"\n" - ) - XCTAssertEqual( - try fileManager.destinationOfSymbolicLink(atPath: currentDaemonURL.path), - daemonURL.path - ) - let wrapperAttributes = try fileManager.attributesOfItem(atPath: wrapperURL.path) - XCTAssertEqual(wrapperAttributes[.type] as? FileAttributeType, .typeRegular) - - let wrapperResult = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - wrapperURL.path, - "serve", - "--stdio", - ], - timeout: 5 - ) - - XCTAssertFalse(wrapperResult.timedOut, wrapperResult.stderr) - XCTAssertEqual(wrapperResult.status, 0, wrapperResult.stderr) - XCTAssertEqual(wrapperResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "daemon serve --stdio") - } - - func testSSHStartupCommandBootstrapsOverRemoteCommandWithoutStealingInteractiveInput() throws { - let fileManager = FileManager.default - let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-ssh-pty-\(UUID().uuidString)") - let fakeBin = tempRoot.appendingPathComponent("bin") - let argvURL = tempRoot.appendingPathComponent("ssh-argv.txt") - let remoteCommandURL = tempRoot.appendingPathComponent("ssh-remote-command.txt") - try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: tempRoot) } - - try writeExecutable( - """ - #!/bin/sh - printf '%s\\n' "$@" > '\(argvURL.path)' - remote_command='' - while [ "$#" -gt 0 ]; do - if [ "$1" = '-o' ] && [ "$#" -ge 2 ]; then - case "$2" in - RemoteCommand=*) - remote_command=${2#RemoteCommand=} - ;; - esac - shift 2 - continue - fi - shift - done - printf '%s' "$remote_command" > '\(remoteCommandURL.path)' - if [ -n "$remote_command" ]; then - exec /bin/sh -lc "$remote_command" - fi - exec /bin/sh - """, - to: fakeBin.appendingPathComponent("ssh") - ) - - let cli = CMUXCLI(args: []) - let sshCommand = cli.buildSSHCommandText( - CMUXCLI.SSHCommandOptions( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - workspaceName: nil, - sshOptions: [], - extraArguments: [], - localSocketPath: "", - remoteRelayPort: 64007 - ), - remoteBootstrapScript: """ - printf '%s\\n' 'BOOTSTRAPPED %{255}' - exec /bin/sh - """ - ) - let startupCommand = try cli.buildSSHStartupCommand( - sshCommand: sshCommand, - shellFeatures: "cursor:blink,path,title", - remoteRelayPort: 64007 - ) - let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" - let result = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "PATH=\(fakeBin.path):\(currentPath)", - "STARTUP=\(startupCommand)", - "/usr/bin/python3", - "-c", - """ -import os, pty, select, subprocess, time -startup = os.environ["STARTUP"] -env = os.environ.copy() -master, slave = pty.openpty() -proc = subprocess.Popen([startup], stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True) -os.close(slave) -time.sleep(0.4) -os.write(master, b"echo READY\\nexit\\n") -time.sleep(0.8) -out = b"" -deadline = time.time() + 1.5 -while time.time() < deadline: - r, _, _ = select.select([master], [], [], 0.2) - if not r: - break - try: - chunk = os.read(master, 65536) - except OSError: - break - if not chunk: - break - out += chunk -try: - proc.terminate() -except ProcessLookupError: - pass -try: - proc.wait(timeout=1) -except Exception: - proc.kill() -print(out.decode("utf-8", "replace"), end="") -""", - ], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("BOOTSTRAPPED %{255}"), result.stdout) - XCTAssertTrue(result.stdout.contains("READY"), result.stdout) - let argv = try String(contentsOf: argvURL, encoding: .utf8) - XCTAssertTrue(argv.contains("RemoteCommand="), argv) - let remoteCommand = try String(contentsOf: remoteCommandURL, encoding: .utf8) - XCTAssertFalse(remoteCommand.contains("%{255}"), remoteCommand) - XCTAssertTrue(remoteCommand.contains("base64"), remoteCommand) - } - - func testEncodedRemoteBootstrapCommandEscapesPercentsForSSHRemoteCommand() throws { - let cli = CMUXCLI(args: []) - let remoteCommand = cli.sshPercentEscapedRemoteCommand( - cli.encodedRemoteBootstrapCommand( - """ - printf '%s\\n' 'BOOTSTRAPPED %{255}' - exit 0 - """ - ) - ) - - let result = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/ssh", - arguments: [ - "-G", - "-o", - "RemoteCommand=\(remoteCommand)", - "cmux-macmini", - ], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("host cmux-macmini"), result.stdout) - } -} -#endif From d369778f7ff571522c250cbba4a50fecd79fdf45 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 04:48:21 -0700 Subject: [PATCH 66/77] Scroll settings hint to import controls --- Sources/ContentView.swift | 12 +++++++++++ Sources/Panels/BrowserPanel.swift | 2 +- Sources/Panels/BrowserPanelView.swift | 20 ++++++++++++++++++- Sources/cmuxApp.swift | 4 ++++ .../AppDelegateShortcutRoutingTests.swift | 18 +++++++++++++++++ cmuxTests/BrowserImportMappingTests.swift | 15 +++++++------- .../BrowserImportProfilesUITests.swift | 17 ++++++++++++++-- 7 files changed, 77 insertions(+), 11 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c7708c7c..a50f275c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8944,6 +8944,7 @@ private final class FeedbackComposerMessageEditorView: NSView { } private enum SidebarHelpMenuAction { + case importBrowserData case keyboardShortcuts case docs case changelog @@ -9514,6 +9515,12 @@ private struct SidebarHelpMenuButton: View { accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", isExternalLink: false ) + helpOptionButton( + title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"), + action: .importBrowserData, + accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", + isExternalLink: false + ) if docsURL != nil { helpOptionButton( title: String(localized: "about.docs", defaultValue: "Docs"), @@ -9618,6 +9625,11 @@ private struct SidebarHelpMenuButton: View { private func perform(_ action: SidebarHelpMenuAction) { switch action { + case .importBrowserData: + isPopoverPresented = false + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } case .keyboardShortcuts: isPopoverPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dc943d31..dd65fad9 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -259,7 +259,7 @@ enum BrowserImportHintSettings { static let variantKey = "browserImportHintVariant" static let showOnBlankTabsKey = "browserImportHintShowOnBlankTabs" static let dismissedKey = "browserImportHintDismissed" - static let defaultVariant: BrowserImportHintVariant = .inlineStrip + static let defaultVariant: BrowserImportHintVariant = .toolbarChip static let defaultShowOnBlankTabs = true static let defaultDismissed = false diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7108d183..0fc8446b 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -873,6 +873,14 @@ struct BrowserPanelView: View { } .buttonStyle(.plain) + Button { + presentImportDialogFromProfileMenu() + } label: { + Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + if browserProfileStore.canRenameProfile(id: panel.profileID) { Button { isBrowserProfileMenuPresented = false @@ -1470,6 +1478,16 @@ struct BrowserPanelView: View { private func presentImportDialogFromHint() { isBrowserImportHintPopoverPresented = false + // Let the popover fully dismiss before entering the modal import flow. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + } + + private func presentImportDialogFromProfileMenu() { + isBrowserProfileMenuPresented = false DispatchQueue.main.async { BrowserDataImportCoordinator.shared.presentImportDialog( defaultDestinationProfileID: panel.profileID @@ -1479,7 +1497,7 @@ struct BrowserPanelView: View { private func openBrowserImportSettings() { isBrowserImportHintPopoverPresented = false - AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + AppDelegate.presentPreferencesWindow(navigationTarget: .browserImport) } private func dismissBrowserImportHint() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 5c5dd445..4140cbd6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2252,6 +2252,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { enum SettingsNavigationTarget: String { case browser + case browserImport case keyboardShortcuts } @@ -4659,6 +4660,7 @@ struct SettingsView: View { } .buttonStyle(.bordered) .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportChooseButton") Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { refreshDetectedImportBrowsers() @@ -4680,6 +4682,8 @@ struct SettingsView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } + .id(SettingsNavigationTarget.browserImport) + .accessibilityIdentifier("SettingsBrowserImportSection") .padding(.horizontal, 14) .padding(.vertical, 10) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 820cdb0b..8b569de5 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2550,6 +2550,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(activateApplicationCallCount, 1) } + func testPresentPreferencesWindowForwardsBrowserImportNavigationTarget() { + var receivedNavigationTarget: SettingsNavigationTarget? + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + navigationTarget: .browserImport, + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTarget = navigationTarget + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(receivedNavigationTarget, .browserImport) + XCTAssertEqual(activateApplicationCallCount, 1) + } + private func makeKeyDownEvent( key: String, modifiers: NSEvent.ModifierFlags, diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 6eed3932..e4d5f54f 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -144,14 +144,15 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(manyProfilesPresentation.showsHelpText) } - func testBrowserImportHintPresentationDefaultsToInlineStrip() { - let presentation = BrowserImportHintPresentation( - variant: .inlineStrip, - showOnBlankTabs: true, - isDismissed: false - ) + func testBrowserImportHintSettingsDefaultToToolbarChip() throws { + let suiteName = "BrowserImportHintDefaults-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } - XCTAssertEqual(presentation.blankTabPlacement, .inlineStrip) + let presentation = BrowserImportHintSettings.presentation(defaults: defaults) + + XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) XCTAssertEqual(presentation.settingsStatus, .visible) } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index 62f85537..d959de30 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -119,9 +119,22 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) settingsButton.click() + let importSection = app.otherElements["SettingsBrowserImportSection"] XCTAssertTrue( - app.switches["SettingsBrowserImportHintToggle"].waitForExistence(timeout: 5.0), - "Expected Browser Settings to open from the blank-tab import hint" + importSection.waitForExistence(timeout: 5.0), + "Expected Browser Settings to scroll to the import section" + ) + + let chooseButton = app.buttons["SettingsBrowserImportChooseButton"] + XCTAssertTrue( + chooseButton.waitForExistence(timeout: 5.0), + "Expected Browser Settings to expose the import actions" + ) + XCTAssertTrue( + browserImportPollUntil(timeout: 5.0) { + importSection.isHittable && chooseButton.isHittable + }, + "Expected Browser Settings to scroll directly to the import controls" ) } From 9484371d5fd9886045a89752c0d304111be2fa4c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 05:01:38 -0700 Subject: [PATCH 67/77] Update bonsplit for empty tab bar double clicks --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 02fa188c..58228539 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795 +Subproject commit 582285396509b1cbdeb515f34bd49944732bd643 From 60d005b793040c01ce998c913966f09473d719b7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 15:05:10 -0700 Subject: [PATCH 68/77] Update bonsplit after merge --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 58228539..efa23f4c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 582285396509b1cbdeb515f34bd49944732bd643 +Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d From 66f8f8b0228edde82a382505dfa64388b24ffee6 Mon Sep 17 00:00:00 2001 From: leon leung <8357465+s010s@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:16:12 +0800 Subject: [PATCH 69/77] fix(Workspace): fix EXC_BAD_ACCESS caused by over-releasing ghostty font (#1496) The ghostty_surface_quicklook_font function returns an unretained pointer to a font object. Using takeRetainedValue() transferred non-existent ownership to ARC, leading to an over-release and an eventual EXC_BAD_ACCESS (SIGSEGV) crash when creating new surfaces (like cmd+t or cmd+d) on certain systems such as Intel Macs. Replaced takeRetainedValue() with takeUnretainedValue() to correctly manage memory. Co-authored-by: LeonLeung --- Sources/Workspace.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 37ce19c6..045ca8dd 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -26,7 +26,7 @@ func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { return nil } - let ctFont = Unmanaged.fromOpaque(quicklookFont).takeRetainedValue() + let ctFont = Unmanaged.fromOpaque(quicklookFont).takeUnretainedValue() let points = Float(CTFontGetSize(ctFont)) guard points > 0 else { return nil } return points From c4742a4ba1159516c7f11ed2f3baf399ca742f03 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 16:46:10 -0700 Subject: [PATCH 70/77] Refine browser import minimal UI --- Resources/Localizable.xcstrings | 187 +++++++++++++ Sources/Panels/BrowserPanel.swift | 24 +- Sources/Panels/BrowserPanelView.swift | 86 +++++- Sources/cmuxApp.swift | 247 ++++++++++++++++++ cmuxTests/BrowserImportMappingTests.swift | 61 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 50 ++++ 6 files changed, 648 insertions(+), 7 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0f8f83df..2a7b1e4a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -805,6 +805,193 @@ } } }, + "debug.menu.browserToolbarButtonSpacing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Toolbar Button Spacing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーツールバーのボタン間隔" + } + } + } + }, + "debug.menu.browserProfilePopoverDebug": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Profile Popover Debug…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイルポップオーバーのデバッグ…" + } + } + } + }, + "debug.windows.browserProfilePopover.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Profile Popover Debug" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイルポップオーバーのデバッグ" + } + } + } + }, + "debug.browserProfilePopover.heading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Profile Popover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイルポップオーバー" + } + } + } + }, + "debug.browserProfilePopover.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tune the profile popover padding live while comparing it against the browser toolbar menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーツールバーのメニューと見比べながら、プロファイルポップオーバーの余白をライブで調整します。" + } + } + } + }, + "debug.browserProfilePopover.group.padding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Padding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "余白" + } + } + } + }, + "debug.browserProfilePopover.label.horizontal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Horizontal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "水平" + } + } + } + }, + "debug.browserProfilePopover.label.vertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + } + } + }, + "debug.browserProfilePopover.group.preview": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレビュー" + } + } + } + }, + "debug.browserProfilePopover.reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } + } + }, + "debug.browserProfilePopover.liveNote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changes apply live to the browser profile popover." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更はブラウザープロファイルポップオーバーにライブで反映されます。" + } + } + } + }, "debug.devBuildBanner.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dd65fad9..67d9e2d0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8384,6 +8384,21 @@ final class BrowserDataImportCoordinator { return wizard.runModal() } +#if DEBUG + func debugMakeImportWizardWindow( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]? = nil, + defaultDestinationProfileID: UUID? = nil + ) -> NSWindow { + let wizard = ImportWizardWindowController( + browsers: browsers, + destinationProfiles: destinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) + return wizard.debugPanelWindow + } +#endif + #if DEBUG private struct CapturedImportSelection: Encodable { struct Entry: Encodable { @@ -8555,6 +8570,10 @@ final class BrowserDataImportCoordinator { return selection } +#if DEBUG + var debugPanelWindow: NSWindow { panel } +#endif + func windowWillClose(_ notification: Notification) { finishModal(with: .cancel) } @@ -8864,7 +8883,9 @@ final class BrowserDataImportCoordinator { sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true sourceProfilesScrollHeightConstraint = sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 76) sourceProfilesScrollHeightConstraint?.isActive = true - sourceProfilesScrollView.widthAnchor.constraint(equalTo: sourceProfilesContainer.widthAnchor).isActive = true + let sourceProfilesScrollWidthConstraint = sourceProfilesScrollView.widthAnchor.constraint( + equalTo: sourceProfilesContainer.widthAnchor + ) sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 11) sourceProfilesHelpLabel.textColor = .secondaryLabelColor @@ -8882,6 +8903,7 @@ final class BrowserDataImportCoordinator { sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle) sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView) sourceProfilesContainer.addArrangedSubview(sourceProfilesHelpLabel) + sourceProfilesScrollWidthConstraint.isActive = true sourceProfilesContainer.setHuggingPriority(.defaultLow, for: .vertical) sourceProfilesContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 0fc8446b..136cb802 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -110,6 +110,45 @@ enum BrowserDevToolsButtonDebugSettings { } } +enum BrowserToolbarAccessorySpacingDebugSettings { + static let key = "browserToolbarAccessorySpacing" + static let defaultSpacing = 2 + static let supportedValues = [0, 2, 4, 6, 8] + + static func resolved(_ rawValue: Int) -> Int { + supportedValues.contains(rawValue) ? rawValue : defaultSpacing + } + + static func current(defaults: UserDefaults = .standard) -> Int { + resolved(defaults.object(forKey: key) as? Int ?? defaultSpacing) + } +} + +enum BrowserProfilePopoverDebugSettings { + static let horizontalPaddingKey = "browserProfilePopoverHorizontalPadding" + static let verticalPaddingKey = "browserProfilePopoverVerticalPadding" + static let defaultHorizontalPadding = 12.0 + static let defaultVerticalPadding = 10.0 + static let horizontalPaddingRange = 8.0...20.0 + static let verticalPaddingRange = 4.0...14.0 + + static func resolvedHorizontalPadding(_ rawValue: Double) -> Double { + horizontalPaddingRange.contains(rawValue) ? rawValue : defaultHorizontalPadding + } + + static func resolvedVerticalPadding(_ rawValue: Double) -> Double { + verticalPaddingRange.contains(rawValue) ? rawValue : defaultVerticalPadding + } + + static func currentHorizontalPadding(defaults: UserDefaults = .standard) -> Double { + resolvedHorizontalPadding((defaults.object(forKey: horizontalPaddingKey) as? NSNumber)?.doubleValue ?? defaultHorizontalPadding) + } + + static func currentVerticalPadding(defaults: UserDefaults = .standard) -> Double { + resolvedVerticalPadding((defaults.object(forKey: verticalPaddingKey) as? NSNumber)?.doubleValue ?? defaultVerticalPadding) + } +} + struct OmnibarInlineCompletion: Equatable { let typedText: String let displayText: String @@ -249,6 +288,11 @@ struct BrowserPanelView: View { @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + @AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + private var browserProfilePopoverHorizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + @AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey) + private var browserProfilePopoverVerticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs @@ -337,6 +381,18 @@ struct BrowserPanelView: View { ) } + private var browserToolbarAccessorySpacing: CGFloat { + CGFloat(BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)) + } + + private var browserProfilePopoverHorizontalPadding: CGFloat { + CGFloat(BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw)) + } + + private var browserProfilePopoverVerticalPadding: CGFloat { + CGFloat(BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw)) + } + private var browserChromeBackground: Color { Color(nsColor: browserChromeStyle.backgroundColor) } @@ -475,6 +531,9 @@ struct BrowserPanelView: View { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, + BrowserToolbarAccessorySpacingDebugSettings.key: BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing, + BrowserProfilePopoverDebugSettings.horizontalPaddingKey: BrowserProfilePopoverDebugSettings.defaultHorizontalPadding, + BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding, BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) refreshBrowserChromeStyle() @@ -487,6 +546,18 @@ struct BrowserPanelView: View { if browserImportHintVariantRaw != resolvedHintVariant.rawValue { browserImportHintVariantRaw = resolvedHintVariant.rawValue } + let resolvedToolbarAccessorySpacing = BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw) + if browserToolbarAccessorySpacingRaw != resolvedToolbarAccessorySpacing { + browserToolbarAccessorySpacingRaw = resolvedToolbarAccessorySpacing + } + let resolvedProfilePopoverHorizontalPadding = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw) + if browserProfilePopoverHorizontalPaddingRaw != resolvedProfilePopoverHorizontalPadding { + browserProfilePopoverHorizontalPaddingRaw = resolvedProfilePopoverHorizontalPadding + } + let resolvedProfilePopoverVerticalPadding = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw) + if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding { + browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding + } panel.refreshAppearanceDrivenColors() panel.setBrowserThemeMode(browserThemeMode) applyPendingAddressBarFocusRequestIfNeeded() @@ -641,12 +712,14 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - if shouldShowToolbarImportHintChip { - browserImportHintToolbarChip + HStack(spacing: browserToolbarAccessorySpacing) { + if shouldShowToolbarImportHintChip { + browserImportHintToolbarChip + } + browserProfileButton + browserThemeModeButton + developerToolsButton } - browserProfileButton - browserThemeModeButton - developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) @@ -892,7 +965,8 @@ struct BrowserPanelView: View { .buttonStyle(.plain) } } - .padding(8) + .padding(.horizontal, browserProfilePopoverHorizontalPadding) + .padding(.vertical, browserProfilePopoverVerticalPadding) .frame(minWidth: 208) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 4140cbd6..343ca118 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -28,6 +28,7 @@ struct cmuxApp: App { @AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) private var toggleBrowserDeveloperToolsShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) @@ -39,6 +40,10 @@ struct cmuxApp: App { @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + private var browserToolbarAccessorySpacing: Int { + BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw) + } + init() { if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { Self.terminateForMissingLaunchTag() @@ -341,6 +346,15 @@ struct cmuxApp: App { BrowserImportHintDebugWindowController.shared.show() } + Button( + String( + localized: "debug.menu.browserProfilePopoverDebug", + defaultValue: "Browser Profile Popover Debug…" + ) + ) { + BrowserProfilePopoverDebugWindowController.shared.show() + } + Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -365,6 +379,29 @@ struct cmuxApp: App { } } + Menu( + String( + localized: "debug.menu.browserToolbarButtonSpacing", + defaultValue: "Browser Toolbar Button Spacing" + ) + ) { + ForEach(BrowserToolbarAccessorySpacingDebugSettings.supportedValues, id: \.self) { spacing in + Button { + browserToolbarAccessorySpacingRaw = spacing + } label: { + if browserToolbarAccessorySpacing == spacing { + Label { + Text(verbatim: "\(spacing)") + } icon: { + Image(systemName: "checkmark") + } + } else { + Text(verbatim: "\(spacing)") + } + } + } + } + Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints) Toggle( String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"), @@ -1065,6 +1102,7 @@ struct cmuxApp: App { private func openAllDebugWindows() { BrowserImportHintDebugWindowController.shared.show() + BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1698,6 +1736,14 @@ private struct DebugWindowControlsView: View { Button("Browser Import Hint Debug…") { BrowserImportHintDebugWindowController.shared.show() } + Button( + String( + localized: "debug.menu.browserProfilePopoverDebug", + defaultValue: "Browser Profile Popover Debug…" + ) + ) { + BrowserProfilePopoverDebugWindowController.shared.show() + } Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1712,6 +1758,7 @@ private struct DebugWindowControlsView: View { } Button("Open All Debug Windows") { BrowserImportHintDebugWindowController.shared.show() + BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1949,6 +1996,205 @@ private final class BrowserImportHintDebugWindowController: NSWindowController, } } +private final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BrowserProfilePopoverDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 340), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = String( + localized: "debug.windows.browserProfilePopover.title", + defaultValue: "Browser Profile Popover Debug" + ) + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.browserProfilePopoverDebug") + window.center() + window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BrowserProfilePopoverDebugView: View { + @AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + private var horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + @AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey) + private var verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding + + private var horizontalPaddingBinding: Binding { + Binding( + get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) }, + set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) } + ) + } + + private var verticalPaddingBinding: Binding { + Binding( + get: { BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw) }, + set: { verticalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding($0) } + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text( + String( + localized: "debug.browserProfilePopover.heading", + defaultValue: "Browser Profile Popover" + ) + ) + .font(.headline) + + Text( + String( + localized: "debug.browserProfilePopover.note", + defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + + GroupBox( + String( + localized: "debug.browserProfilePopover.group.padding", + defaultValue: "Padding" + ) + ) { + VStack(alignment: .leading, spacing: 8) { + sliderRow( + String( + localized: "debug.browserProfilePopover.label.horizontal", + defaultValue: "Horizontal" + ), + value: horizontalPaddingBinding, + range: BrowserProfilePopoverDebugSettings.horizontalPaddingRange + ) + sliderRow( + String( + localized: "debug.browserProfilePopover.label.vertical", + defaultValue: "Vertical" + ), + value: verticalPaddingBinding, + range: BrowserProfilePopoverDebugSettings.verticalPaddingRange + ) + } + .padding(.top, 2) + } + + GroupBox( + String( + localized: "debug.browserProfilePopover.group.preview", + defaultValue: "Preview" + ) + ) { + profilePopoverPreview + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button( + String( + localized: "debug.browserProfilePopover.reset", + defaultValue: "Reset" + ) + ) { + horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding + } + } + + Text( + String( + localized: "debug.browserProfilePopover.liveNote", + defaultValue: "Changes apply live to the browser profile popover." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var profilePopoverPreview: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 12, alignment: .center) + Text(String(localized: "browser.profile.default", defaultValue: "Default")) + .font(.system(size: 12)) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.12)) + ) + } + + Divider() + + Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) + .font(.system(size: 12)) + + Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) + .font(.system(size: 12)) + } + .padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw)) + .padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw)) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.primary.opacity(0.08)) + ) + ) + } + + private func sliderRow(_ label: String, value: Binding, range: ClosedRange) -> some View { + HStack(spacing: 8) { + Text(label) + Slider(value: value, in: range, step: 1) + Text(String(format: "%.0f", value.wrappedValue)) + .font(.caption) + .monospacedDigit() + .frame(width: 32, alignment: .trailing) + } + } +} + private struct BrowserImportHintDebugView: View { @AppStorage(BrowserImportHintSettings.variantKey) private var variantRaw = BrowserImportHintSettings.defaultVariant.rawValue @@ -3369,6 +3615,7 @@ struct SettingsView: View { @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @ObservedObject private var notificationStore = TerminalNotificationStore.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index e4d5f54f..58ccf28e 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -284,6 +284,39 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(lines.contains("Created cmux profiles: You, austin")) } + @MainActor + func testImportWizardCanBeConstructedForSettingsChoosePath() { + let destinationProfiles = [ + BrowserProfileDefinition( + id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, + displayName: "Default", + createdAt: .distantPast, + isBuiltInDefault: true + ) + ] + let browser = makeInstalledBrowserCandidate( + descriptorID: "google-chrome", + displayName: "Chrome", + profiles: [ + makeSourceProfile(displayName: "Default", path: "/tmp/browser-import-chrome-default", isDefault: true), + makeSourceProfile(displayName: "Profile 1", path: "/tmp/browser-import-chrome-profile-1", isDefault: false), + ] + ) + + let window = BrowserDataImportCoordinator.shared.debugMakeImportWizardWindow( + browsers: [browser], + destinationProfiles: destinationProfiles, + defaultDestinationProfileID: destinationProfiles[0].id + ) + defer { + window.orderOut(nil) + window.close() + } + + XCTAssertEqual(window.title, "Import Browser Data") + XCTAssertNotNil(window.contentView) + } + private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile { InstalledBrowserProfile( displayName: displayName, @@ -291,4 +324,32 @@ final class BrowserImportMappingTests: XCTestCase { isDefault: isDefault ) } + + private func makeInstalledBrowserCandidate( + descriptorID: String, + displayName: String, + profiles: [InstalledBrowserProfile] + ) -> InstalledBrowserCandidate { + let descriptor = try! XCTUnwrap(InstalledBrowserDetector.allBrowserDescriptors.first(where: { $0.id == descriptorID })) + return InstalledBrowserCandidate( + descriptor: BrowserImportBrowserDescriptor( + id: descriptor.id, + displayName: displayName, + family: descriptor.family, + tier: descriptor.tier, + bundleIdentifiers: descriptor.bundleIdentifiers, + appNames: descriptor.appNames, + dataRootRelativePaths: descriptor.dataRootRelativePaths, + dataArtifactRelativePaths: descriptor.dataArtifactRelativePaths, + supportsDataOnlyDetection: descriptor.supportsDataOnlyDetection + ), + resolvedFamily: descriptor.family, + homeDirectoryURL: URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), + appURL: nil, + dataRootURL: URL(fileURLWithPath: "/tmp/browser-import-\(descriptorID)", isDirectory: true), + profiles: profiles, + detectionSignals: ["test"], + detectionScore: 1 + ) + } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 6ce551a0..a422cedd 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1470,6 +1470,56 @@ final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { ) } + func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() { + let defaults = makeIsolatedDefaults() + defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key) + + XCTAssertEqual( + BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), + BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + ) + } + + func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() { + let defaults = makeIsolatedDefaults() + defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) + + XCTAssertEqual( + BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), + BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + ) + } + + func testBrowserProfilePopoverPaddingDefaultsWhenUnset() { + let defaults = makeIsolatedDefaults() + defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) + + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + ) + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultVerticalPadding + ) + } + + func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) + + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + ) + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultVerticalPadding + ) + } + func testCopyPayloadUsesPersistedValues() { let defaults = makeIsolatedDefaults() defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) From 93d2245a97c31268808ce394ee1d8cbf0b7bfbaa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:02:42 -0700 Subject: [PATCH 71/77] fix(web): exclude PostHog proxy from i18n middleware (#1626) * fix(web): exclude PostHog proxy path from i18n middleware The next-intl middleware added in cf75da8f intercepts /cmuxterm/* requests (the PostHog reverse proxy), causing analytics to break since March 12. Add cmuxterm to the negative lookahead so proxy requests bypass i18n. * refactor(web): rename middleware.ts to proxy.ts for Next.js 16 Next.js 16 renamed middleware.ts to proxy.ts. Migrate to the new convention since we're on Next.js 16.1.6. * feat(web): migrate PostHog to managed reverse proxy at r.cmux.dev Replace the Next.js rewrites-based proxy (/cmuxterm -> us.i.posthog.com) with PostHog's managed reverse proxy at r.cmux.dev. This removes the rewrites from next.config.ts entirely and eliminates the proxy.ts matcher conflict that caused the analytics regression. --------- Co-authored-by: Lawrence Chen --- web/app/[locale]/posthog.tsx | 2 +- web/next.config.ts | 12 ------------ web/{middleware.ts => proxy.ts} | 0 3 files changed, 1 insertion(+), 13 deletions(-) rename web/{middleware.ts => proxy.ts} (100%) diff --git a/web/app/[locale]/posthog.tsx b/web/app/[locale]/posthog.tsx index 33d46c48..8c924c3c 100644 --- a/web/app/[locale]/posthog.tsx +++ b/web/app/[locale]/posthog.tsx @@ -7,7 +7,7 @@ import { useEffect, Suspense } from "react"; if (typeof window !== "undefined") { posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", { - api_host: "/cmuxterm", + api_host: "https://r.cmux.dev", ui_host: "https://us.posthog.com", person_profiles: "identified_only", capture_pageview: false, diff --git a/web/next.config.ts b/web/next.config.ts index 1c25cf3d..3ab0db0d 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -15,18 +15,6 @@ const nextConfig: NextConfig = { }, ], }, - async rewrites() { - return [ - { - source: "/cmuxterm/static/:path*", - destination: "https://us-assets.i.posthog.com/static/:path*", - }, - { - source: "/cmuxterm/:path*", - destination: "https://us.i.posthog.com/:path*", - }, - ]; - }, }; export default withNextIntl(nextConfig); diff --git a/web/middleware.ts b/web/proxy.ts similarity index 100% rename from web/middleware.ts rename to web/proxy.ts From bb6dacf20d010d37528c07dc862da685eb8d8c7d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 15:06:12 -0700 Subject: [PATCH 72/77] Add regression coverage for app lookup open targets --- Sources/AppDelegate.swift | 4 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 40 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cb6a838d..0787d814 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -417,11 +417,13 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { let homeDirectoryPath: String let fileExistsAtPath: (String) -> Bool let isExecutableFileAtPath: (String) -> Bool + let applicationPathForName: (String) -> String? static let live = DetectionEnvironment( homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, - isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) } + isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, + applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } ) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 71a728d2..96b4083b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7895,12 +7895,14 @@ final class FinderServicePathResolverTests: XCTestCase { final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { private func environment( existingPaths: Set, - homeDirectoryPath: String = "/Users/tester" + homeDirectoryPath: String = "/Users/tester", + applicationPathsByName: [String: String] = [:] ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { TerminalDirectoryOpenTarget.DetectionEnvironment( homeDirectoryPath: homeDirectoryPath, fileExistsAtPath: { existingPaths.contains($0) }, - isExecutableFileAtPath: { existingPaths.contains($0) } + isExecutableFileAtPath: { existingPaths.contains($0) }, + applicationPathForName: { applicationPathsByName[$0] } ) } @@ -7939,9 +7941,10 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { XCTAssertFalse(availableTargets.contains(.vscode)) } - func testVSCodeRequiresCodeTunnelExecutable() { + func testVSCodeInlineRequiresCodeTunnelExecutable() { let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) - XCTAssertFalse(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) + XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) + XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.isAvailable(in: env)) } func testITerm2DetectsLegacyBundleName() { @@ -7954,6 +7957,35 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) } + func testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() { + let vscodePath = "/Volumes/Tools/Code.app" + let env = environment( + existingPaths: [ + vscodePath, + "\(vscodePath)/Contents/Resources/app/bin/code-tunnel", + ], + applicationPathsByName: [ + "Code": vscodePath, + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.vscodeInline)) + } + + func testTowerDetectedViaApplicationLookupOutsideApplications() { + let towerPath = "/Volumes/Setapp/Tower.app" + let env = environment( + existingPaths: [towerPath], + applicationPathsByName: [ + "Tower": towerPath, + ] + ) + + XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) + } + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) From a22bfa97099a2a7d9388e5eef12434ecaf176bf6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 15:09:58 -0700 Subject: [PATCH 73/77] Fix command palette open target availability --- Sources/AppDelegate.swift | 21 +++++++++++++++++++-- Sources/ContentView.swift | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0787d814..8be1eaf8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -435,8 +435,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) } - static let cachedLiveAvailableTargets: Set = availableTargets(in: .live) - var commandPaletteCommandId: String { "palette.terminalOpenDirectory.\(rawValue)" } @@ -526,6 +524,17 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { return path } + + // Fall back to LaunchServices so apps outside the standard bundle paths + // still appear in the command palette. + for applicationName in applicationSearchNames { + guard let resolvedPath = environment.applicationPathForName(applicationName), + environment.fileExistsAtPath(resolvedPath) else { + continue + } + return resolvedPath + } + return nil } @@ -545,6 +554,14 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return uniquePreservingOrder(expanded) } + private var applicationSearchNames: [String] { + uniquePreservingOrder( + applicationBundlePathCandidates.map { + URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent + } + ) + } + private var applicationBundlePathCandidates: [String] { switch self { case .androidStudio: diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8146541b..f62fc661 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -4803,7 +4803,7 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) if panelIsTerminal { - let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + let availableTargets = TerminalDirectoryOpenTarget.availableTargets() for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { snapshot.setBool( CommandPaletteContextKeys.terminalOpenTargetAvailable(target), From f6d4229ec703c07d5938c1abf663ae4849e72690 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 15:24:39 -0700 Subject: [PATCH 74/77] Show all open-in commands in the palette --- Sources/ContentView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f62fc661..3f9ad98c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5372,7 +5372,6 @@ struct ContentView: View { keywords: target.commandPaletteKeywords, when: { context in context.bool(CommandPaletteContextKeys.panelIsTerminal) - && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target)) } ) ) From 018510d4ef5e00ec73a4990a25280173c5b97107 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 16:18:55 -0700 Subject: [PATCH 75/77] Add failing command palette blink regression test --- cmuxUITests/SidebarHelpMenuUITests.swift | 104 +++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index b27cee62..dfd0e3b0 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -299,6 +299,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { private var socketPath = "" private let hiddenSurfaceToken = "cmux-command-palette-hidden-surface" private let visibleSurfaceToken = "cmux-command-palette-visible-surface" + private let noMatchWorkspaceQuery = "cmux-command-palette-no-match" override func setUp() { super.setUp() @@ -475,6 +476,69 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } + func testSwitcherEmptyStateDoesNotBlinkWhileRefiningNoMatchQuery() throws { + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + launchAndActivate(app) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 1 + }, + "Expected the main window to be visible" + ) + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + + let mainWindowId = try XCTUnwrap( + socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + try seedWorkspaceSwitcherCorpus(workspaceCount: 96) + + let searchField = app.textFields["CommandPaletteSearchField"] + app.typeKey("p", modifierFlags: [.command]) + XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field") + searchField.click() + + try debugTypeText(String(repeating: "z", count: 8)) + + let emptyLabel = app.staticTexts["No workspaces match your search."].firstMatch + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 5.0) { + guard emptyLabel.exists else { return false } + guard let snapshot = commandPaletteSnapshot(windowId: mainWindowId) else { return false } + return (snapshot["query"] as? String) == String(repeating: "z", count: 8) + && self.commandPaletteResultRows(from: snapshot).isEmpty + }, + "Expected the switcher to reach a visible no-results state before refining the query" + ) + + try debugTypeText("z") + + let emptyLabelDisappearedWhileRefining = sidebarHelpPollUntil(timeout: 0.5, pollInterval: 0.01) { + !emptyLabel.exists + } + XCTAssertFalse( + emptyLabelDisappearedWhileRefining, + "Expected refining an already-empty switcher query to keep the empty-state label visible" + ) + + let refinedSnapshot = try XCTUnwrap( + waitForCommandPaletteSnapshot( + windowId: mainWindowId, + query: String(repeating: "z", count: 9), + timeout: 5.0 + ) { snapshot in + self.commandPaletteResultRows(from: snapshot).isEmpty + } + ) + XCTAssertTrue( + commandPaletteResultRows(from: refinedSnapshot).isEmpty, + "Expected the refined no-match query to stay empty. snapshot=\(refinedSnapshot)" + ) + } + private func launchAndActivate(_ app: XCUIApplication) { app.launch() XCTAssertTrue( @@ -592,6 +656,46 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { return UUID(uuidString: value) != nil ? value : nil } + private func debugTypeText(_ text: String) throws { + let response = try XCTUnwrap( + socketJSON( + method: "debug.type", + params: ["text": text] + ), + "Expected a response from debug.type" + ) + XCTAssertEqual(response["ok"] as? Bool, true, "Expected debug.type to succeed. response=\(response)") + } + + private func seedWorkspaceSwitcherCorpus(workspaceCount: Int) throws { + guard workspaceCount > 1 else { return } + + for index in 1.. String? { ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command) } From 4162eccf662bc8333e4577e25702369eb436d32f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 16:29:35 -0700 Subject: [PATCH 76/77] Keep command palette empty state stable while typing --- Sources/ContentView.swift | 43 ++++++++++++++- .../CommandPaletteSearchEngineTests.swift | 53 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3f9ad98c..34809815 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1404,6 +1404,7 @@ struct ContentView: View { @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? @State private var commandPaletteResolvedSearchFingerprint: Int? + @State private var commandPaletteResolvedMatchingQuery = "" @State private var isCommandPaletteSearchPending = false @State private var commandPalettePendingActivation: CommandPalettePendingActivation? @State private var commandPaletteResultsRevision: UInt64 = 0 @@ -3257,7 +3258,7 @@ struct ContentView: View { // stale switcher rows cannot linger above command-mode results. VStack(spacing: 0) { if visibleResults.isEmpty { - if commandPaletteHasCurrentResolvedResults { + if commandPaletteShouldShowEmptyState { Text(commandPaletteEmptyStateText) .font(.system(size: 13, weight: .regular)) .foregroundStyle(.secondary) @@ -4106,6 +4107,27 @@ struct ContentView: View { !hasVisibleResultsForScope } + static func commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: Bool, + visibleResultsScopeMatches: Bool, + resolvedSearchScopeMatches: Bool, + resolvedSearchFingerprintMatches: Bool, + resolvedResultsAreEmpty: Bool, + currentMatchingQuery: String, + resolvedMatchingQuery: String + ) -> Bool { + guard isSearchPending, + visibleResultsScopeMatches, + resolvedSearchScopeMatches, + resolvedSearchFingerprintMatches, + resolvedResultsAreEmpty else { + return false + } + + return currentMatchingQuery == resolvedMatchingQuery + || currentMatchingQuery.hasPrefix(resolvedMatchingQuery) + } + private func scheduleCommandPaletteResultsRefresh( query: String? = nil, forceSearchCorpusRefresh: Bool = false @@ -4152,6 +4174,7 @@ struct ContentView: View { commandPaletteResolvedSearchRequestID = requestID commandPaletteResolvedSearchScope = scope commandPaletteResolvedSearchFingerprint = fingerprint + commandPaletteResolvedMatchingQuery = matchingQuery isCommandPaletteSearchPending = false setCommandPaletteVisibleResults( cachedCommandPaletteResults, @@ -4210,6 +4233,7 @@ struct ContentView: View { commandPaletteResolvedSearchRequestID = requestID commandPaletteResolvedSearchScope = scope commandPaletteResolvedSearchFingerprint = fingerprint + commandPaletteResolvedMatchingQuery = matchingQuery isCommandPaletteSearchPending = false setCommandPaletteVisibleResults( cachedCommandPaletteResults, @@ -6096,6 +6120,23 @@ struct ContentView: View { !isCommandPaletteSearchPending && commandPaletteResolvedSearchRequestID == commandPaletteSearchRequestID } + private var commandPaletteShouldShowEmptyState: Bool { + guard commandPaletteVisibleResults.isEmpty else { return false } + if commandPaletteHasCurrentResolvedResults { + return true + } + + return Self.commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: isCommandPaletteSearchPending, + visibleResultsScopeMatches: commandPaletteVisibleResultsScope == commandPaletteListScope, + resolvedSearchScopeMatches: commandPaletteResolvedSearchScope == commandPaletteListScope, + resolvedSearchFingerprintMatches: commandPaletteResolvedSearchFingerprint == commandPaletteVisibleResultsFingerprint, + resolvedResultsAreEmpty: cachedCommandPaletteResults.isEmpty, + currentMatchingQuery: commandPaletteQueryForMatching, + resolvedMatchingQuery: commandPaletteResolvedMatchingQuery + ) + } + private func runCommandPaletteResolvedActivation(_ activation: CommandPaletteResolvedActivation) { switch activation { case .command(let commandID): diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 6976ad1d..51cd4182 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -434,6 +434,59 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } + func testPendingEmptyStateIsPreservedWhenRefiningAResolvedNoMatchQuery() { + XCTAssertTrue( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true, + currentMatchingQuery: "zzzzzzzzz", + resolvedMatchingQuery: "zzzzzzzz" + ) + ) + } + + func testPendingEmptyStateIsNotPreservedWhenQueryDoesNotRefineResolvedNoMatch() { + XCTAssertFalse( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true, + currentMatchingQuery: "zzzza", + resolvedMatchingQuery: "zzzzb" + ) + ) + } + + func testPendingEmptyStateIsNotPreservedWhenResolvedResultsMayBeStale() { + XCTAssertFalse( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: false, + resolvedResultsAreEmpty: true, + currentMatchingQuery: "zzzzzzzzz", + resolvedMatchingQuery: "zzzzzzzz" + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: false, + currentMatchingQuery: "zzzzzzzzz", + resolvedMatchingQuery: "zzzzzzzz" + ) + ) + } + func testVisibleResultsResetWhenQueryChangesCommandPaletteScope() { XCTAssertTrue( ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition( From a42e6663c41626dacda0fbec585b9d0eed81ee1c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 17 Mar 2026 17:17:24 -0700 Subject: [PATCH 77/77] Fix layout follow-up spin loop (#1633) --- Sources/GhosttyTerminalView.swift | 4 + Sources/Workspace.swift | 179 ++++++++++++++++++++++-------- 2 files changed, 138 insertions(+), 45 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fa51c0be..2c241ffa 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7278,6 +7278,10 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.isVisibleInUI } + var debugPortalActive: Bool { + isActive + } + var debugPortalFrameInWindow: CGRect { guard window != nil else { return .zero } return convert(bounds, to: nil) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d8e75ea2..d40006d0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5199,6 +5199,8 @@ final class Workspace: Identifiable, ObservableObject { private var layoutFollowUpBrowserPanelId: UUID? private var layoutFollowUpBrowserExitFocusPanelId: UUID? private var layoutFollowUpNeedsGeometryPass = false + private var layoutFollowUpAttemptScheduled = false + private var layoutFollowUpStalledAttemptCount = 0 private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? @@ -7954,6 +7956,7 @@ final class Workspace: Identifiable, ObservableObject { layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId } layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry + layoutFollowUpStalledAttemptCount = 0 if layoutFollowUpTimeoutWorkItem == nil { installLayoutFollowUpObservers() @@ -7965,10 +7968,8 @@ final class Workspace: Identifiable, ObservableObject { private func installLayoutFollowUpObservers() { guard layoutFollowUpTimeoutWorkItem == nil else { return } - func enqueueAttempt() { - DispatchQueue.main.async { [weak self] in - self?.attemptEventDrivenLayoutFollowUp() - } + let enqueueAttempt: () -> Void = { [weak self] in + self?.scheduleLayoutFollowUpAttempt() } layoutFollowUpObservers.append(NotificationCenter.default.addObserver( @@ -8048,6 +8049,28 @@ final class Workspace: Identifiable, ObservableObject { layoutFollowUpBrowserPanelId = nil layoutFollowUpBrowserExitFocusPanelId = nil layoutFollowUpNeedsGeometryPass = false + layoutFollowUpAttemptScheduled = false + layoutFollowUpStalledAttemptCount = 0 + } + + private func scheduleLayoutFollowUpAttempt() { + guard layoutFollowUpTimeoutWorkItem != nil else { return } + guard !layoutFollowUpAttemptScheduled else { return } + + layoutFollowUpAttemptScheduled = true + let delay = layoutFollowUpBackoffDelay() + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.layoutFollowUpAttemptScheduled = false + self.attemptEventDrivenLayoutFollowUp() + } + } + + private func layoutFollowUpBackoffDelay() -> TimeInterval { + guard layoutFollowUpStalledAttemptCount > 0 else { return 0 } + let baseDelay: TimeInterval = 0.01 + let exponent = min(layoutFollowUpStalledAttemptCount - 1, 5) + return min(0.25, baseDelay * pow(2.0, Double(exponent))) } private func flushWorkspaceWindowLayouts() { @@ -8085,6 +8108,22 @@ final class Workspace: Identifiable, ObservableObject { return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) } + private func terminalFocusNeedsFollowUp() -> Bool { + guard let panelId = layoutFollowUpTerminalFocusPanelId, + let terminalPanel = terminalPanel(for: panelId) else { + return false + } + return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + } + + private func browserPanelNeedsFollowUp() -> Bool { + guard let panelId = layoutFollowUpBrowserPanelId, + let browserPanel = browserPanel(for: panelId) else { + return false + } + return !browserPortalReady(for: browserPanel) + } + private func attemptEventDrivenLayoutFollowUp() { guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } isAttemptingLayoutFollowUp = true @@ -8092,6 +8131,13 @@ final class Workspace: Identifiable, ObservableObject { flushWorkspaceWindowLayouts() + let geometryPendingBefore = layoutFollowUpNeedsGeometryPass + let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp() + let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp() + let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() + let browserPanelPendingBefore = browserPanelNeedsFollowUp() + let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil + if layoutFollowUpNeedsGeometryPass { layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() } @@ -8117,14 +8163,20 @@ final class Workspace: Identifiable, ObservableObject { if let browserPanelId = layoutFollowUpBrowserPanelId { if let browserPanel = browserPanel(for: browserPanelId) { - if browserPortalAnchorReady(for: browserPanel) { + let anchorReady = browserPortalAnchorReady(for: browserPanel) + let wasReady = browserPortalReady(for: browserPanel) + if anchorReady && !wasReady { BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) + } + let isReady = browserPortalReady(for: browserPanel) + if isReady, + (!wasReady || BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.containerHidden == true) { BrowserWindowPortalRegistry.refresh( webView: browserPanel.webView, reason: reason ) } - if browserPortalReady(for: browserPanel) { + if isReady { layoutFollowUpBrowserPanelId = nil } } else { @@ -8145,20 +8197,8 @@ final class Workspace: Identifiable, ObservableObject { } } - let terminalFocusPending: Bool = { - guard let panelId = layoutFollowUpTerminalFocusPanelId, - let terminalPanel = terminalPanel(for: panelId) else { - return false - } - return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() - }() - let browserPanelPending: Bool = { - guard let panelId = layoutFollowUpBrowserPanelId, - let browserPanel = browserPanel(for: panelId) else { - return false - } - return !browserPortalReady(for: browserPanel) - }() + let terminalFocusPending = terminalFocusNeedsFollowUp() + let browserPanelPending = browserPanelNeedsFollowUp() let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil let needsMoreWork = layoutFollowUpNeedsGeometryPass || @@ -8170,6 +8210,22 @@ final class Workspace: Identifiable, ObservableObject { if !needsMoreWork { clearLayoutFollowUp() + return + } + + let didMakeProgress = + (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) || + (terminalPortalPendingBefore && !terminalPortalPending) || + (browserVisibilityPendingBefore && !browserVisibilityPending) || + (terminalFocusPendingBefore && !terminalFocusPending) || + (browserPanelPendingBefore && !browserPanelPending) || + (browserExitPendingBefore && !browserExitPending) + + if didMakeProgress { + layoutFollowUpStalledAttemptCount = 0 + scheduleLayoutFollowUpAttempt() + } else { + layoutFollowUpStalledAttemptCount += 1 } } @@ -8243,19 +8299,30 @@ final class Workspace: Identifiable, ObservableObject { return visiblePanelIds } - private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() { + @discardableResult + private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + var didChange = false for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) - terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) - terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id) + if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible { + terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) + didChange = true + } + let shouldBeActive = shouldBeVisible && focusedPanelId == terminalPanel.id + if terminalPanel.hostedView.debugPortalActive != shouldBeActive { + terminalPanel.hostedView.setActive(shouldBeActive) + didChange = true + } TerminalWindowPortalRegistry.updateEntryVisibility( for: terminalPanel.hostedView, visibleInUI: shouldBeVisible ) } + + return didChange } private func terminalPortalVisibilityNeedsFollowUp() -> Bool { @@ -8278,43 +8345,65 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { + @discardableResult + private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + var didChange = false for panel in panels.values { guard let browserPanel = panel as? BrowserPanel else { continue } let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) + let anchorView = browserPanel.portalAnchorView + let snapshot = BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView) if shouldBeVisible { - BrowserWindowPortalRegistry.updateEntryVisibility( - for: browserPanel.webView, - visibleInUI: true, - zPriority: 2 - ) - let anchorView = browserPanel.portalAnchorView - let anchorReady = - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - if anchorReady { + if snapshot?.visibleInUI == false { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: true, + zPriority: 2 + ) + didChange = true + } + let anchorReady = browserPortalAnchorReady(for: browserPanel) + let portalReady = browserPortalReady(for: browserPanel) + if anchorReady && !portalReady { BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) + if browserPortalReady(for: browserPanel) { + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + didChange = true + } + } else if anchorReady && snapshot?.containerHidden == true { BrowserWindowPortalRegistry.refresh( webView: browserPanel.webView, reason: reason ) + didChange = true } } else { - BrowserWindowPortalRegistry.updateEntryVisibility( - for: browserPanel.webView, - visibleInUI: false, - zPriority: 0 - ) - BrowserWindowPortalRegistry.hide( - webView: browserPanel.webView, - source: reason - ) + let portalNeedsHide = + snapshot?.visibleInUI == true || + snapshot?.containerHidden == false + if portalNeedsHide { + if snapshot?.visibleInUI == true { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: false, + zPriority: 0 + ) + } + BrowserWindowPortalRegistry.hide( + webView: browserPanel.webView, + source: reason + ) + didChange = true + } } } + + return didChange } private func browserPortalVisibilityNeedsFollowUp() -> Bool {