import Foundation import Combine import WebKit import AppKit import Bonsplit import SQLite3 import CryptoKit #if canImport(CommonCrypto) import CommonCrypto #endif #if canImport(Security) import Security #endif fileprivate 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 } enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) } static func color(backgroundColor: NSColor, opacity: Double) -> NSColor { backgroundColor.withAlphaComponent(clampedOpacity(opacity)) } static func color( from notification: Notification?, fallbackColor: NSColor, fallbackOpacity: Double ) -> NSColor { let userInfo = notification?.userInfo let backgroundColor = (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor) ?? fallbackColor let opacity: Double if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double { opacity = value } else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber { opacity = value.doubleValue } else { opacity = fallbackOpacity } return color(backgroundColor: backgroundColor, opacity: opacity) } static func color(from notification: Notification?) -> NSColor { color( from: notification, fallbackColor: GhosttyApp.shared.defaultBackgroundColor, fallbackOpacity: GhosttyApp.shared.defaultBackgroundOpacity ) } static func currentColor() -> NSColor { color( backgroundColor: GhosttyApp.shared.defaultBackgroundColor, opacity: GhosttyApp.shared.defaultBackgroundOpacity ) } } enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google case duckduckgo case bing case kagi case startpage var id: String { rawValue } var displayName: String { switch self { case .google: return "Google" case .duckduckgo: return "DuckDuckGo" case .bing: return "Bing" case .kagi: return "Kagi" case .startpage: return "Startpage" } } func searchURL(query: String) -> URL? { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } var components: URLComponents? switch self { case .google: components = URLComponents(string: "https://www.google.com/search") case .duckduckgo: components = URLComponents(string: "https://duckduckgo.com/") case .bing: components = URLComponents(string: "https://www.bing.com/search") case .kagi: components = URLComponents(string: "https://kagi.com/search") case .startpage: components = URLComponents(string: "https://www.startpage.com/do/dsearch") } components?.queryItems = [ URLQueryItem(name: "q", value: trimmed), ] return components?.url } } enum BrowserSearchSettings { static let searchEngineKey = "browserSearchEngine" static let searchSuggestionsEnabledKey = "browserSearchSuggestionsEnabled" static let defaultSearchEngine: BrowserSearchEngine = .google static let defaultSearchSuggestionsEnabled: Bool = true static func currentSearchEngine(defaults: UserDefaults = .standard) -> BrowserSearchEngine { guard let raw = defaults.string(forKey: searchEngineKey), let engine = BrowserSearchEngine(rawValue: raw) else { return defaultSearchEngine } return engine } static func currentSearchSuggestionsEnabled(defaults: UserDefaults = .standard) -> Bool { // Mirror @AppStorage behavior: bool(forKey:) returns false if key doesn't exist. // Default to enabled unless user explicitly set a value. if defaults.object(forKey: searchSuggestionsEnabledKey) == nil { return defaultSearchSuggestionsEnabled } return defaults.bool(forKey: searchSuggestionsEnabledKey) } } enum BrowserThemeMode: String, CaseIterable, Identifiable { case system case light case dark var id: String { rawValue } var displayName: String { switch self { case .system: return String(localized: "theme.system", defaultValue: "System") case .light: return String(localized: "theme.light", defaultValue: "Light") case .dark: return String(localized: "theme.dark", defaultValue: "Dark") } } var iconName: String { switch self { case .system: return "circle.lefthalf.filled" case .light: return "sun.max" case .dark: return "moon" } } } enum BrowserThemeSettings { static let modeKey = "browserThemeMode" static let legacyForcedDarkModeEnabledKey = "browserForcedDarkModeEnabled" static let defaultMode: BrowserThemeMode = .system static func mode(for rawValue: String?) -> BrowserThemeMode { guard let rawValue, let mode = BrowserThemeMode(rawValue: rawValue) else { return defaultMode } return mode } static func mode(defaults: UserDefaults = .standard) -> BrowserThemeMode { let resolvedMode = mode(for: defaults.string(forKey: modeKey)) if defaults.string(forKey: modeKey) != nil { return resolvedMode } // Migrate the legacy bool toggle only when the new mode key is unset. if defaults.object(forKey: legacyForcedDarkModeEnabledKey) != nil { let migratedMode: BrowserThemeMode = defaults.bool(forKey: legacyForcedDarkModeEnabledKey) ? .dark : .system defaults.set(migratedMode.rawValue, forKey: modeKey) return migratedMode } return defaultMode } } 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 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 static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser" static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" static let defaultBrowserExternalOpenPatterns: String = "" static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil { return defaultOpenTerminalLinksInCmuxBrowser } return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil { return defaultOpenSidebarPullRequestLinksInCmuxBrowser } return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) } static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) } // Migrate existing behavior for users who only had the link-click toggle. if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } return defaultInterceptTerminalOpenCommandInCmuxBrowser } static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool { interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults) } static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist return raw .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } static func externalOpenPatterns(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserExternalOpenPatternsKey) ?? defaultBrowserExternalOpenPatterns return raw .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty && !$0.hasPrefix("#") } } static func shouldOpenExternally(_ url: URL, defaults: UserDefaults = .standard) -> Bool { shouldOpenExternally(url.absoluteString, defaults: defaults) } static func shouldOpenExternally(_ rawURL: String, defaults: UserDefaults = .standard) -> Bool { let target = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !target.isEmpty else { return false } for rawPattern in externalOpenPatterns(defaults: defaults) { guard let (isRegex, value) = parseExternalPattern(rawPattern) else { continue } if isRegex { guard let regex = try? NSRegularExpression(pattern: value, options: [.caseInsensitive]) else { continue } let range = NSRange(target.startIndex.. Bool { let rawPatterns = hostWhitelist(defaults: defaults) if rawPatterns.isEmpty { return true } guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { return false } for rawPattern in rawPatterns { guard let pattern = normalizeWhitelistPattern(rawPattern) else { continue } if hostMatchesPattern(normalizedHost, pattern: pattern) { return true } } return false } private static func normalizeWhitelistPattern(_ rawPattern: String) -> String? { let trimmed = rawPattern .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard !trimmed.isEmpty else { return nil } if trimmed.hasPrefix("*.") { let suffixRaw = String(trimmed.dropFirst(2)) guard let suffix = BrowserInsecureHTTPSettings.normalizeHost(suffixRaw) else { return nil } return "*.\(suffix)" } return BrowserInsecureHTTPSettings.normalizeHost(trimmed) } private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool { if pattern.hasPrefix("*.") { let suffix = String(pattern.dropFirst(2)) return host == suffix || host.hasSuffix(".\(suffix)") } return host == pattern } private static func parseExternalPattern(_ rawPattern: String) -> (isRegex: Bool, value: String)? { let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if trimmed.lowercased().hasPrefix("re:") { let regexPattern = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) guard !regexPattern.isEmpty else { return nil } return (isRegex: true, value: regexPattern) } return (isRegex: false, value: trimmed) } } enum BrowserInsecureHTTPSettings { static let allowlistKey = "browserInsecureHTTPAllowlist" static let defaultAllowlistPatterns = [ "localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me", ] static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n") static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] { normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey)) } static func normalizedAllowlistPatterns(rawValue: String?) -> [String] { let source: String if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source = rawValue } else { source = defaultAllowlistText } let parsed = parsePatterns(from: source) return parsed.isEmpty ? defaultAllowlistPatterns : parsed } static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool { isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey)) } static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool { guard let normalizedHost = normalizeHost(host) else { return false } return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in hostMatchesPattern(normalizedHost, pattern: pattern) } } static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) { guard let normalizedHost = normalizeHost(host) else { return } var patterns = normalizedAllowlistPatterns(defaults: defaults) guard !patterns.contains(normalizedHost) else { return } patterns.append(normalizedHost) defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey) } static func normalizeHost(_ rawHost: String) -> String? { var value = rawHost .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard !value.isEmpty else { return nil } if let parsed = URL(string: value)?.host { return trimHost(parsed) } if let schemeRange = value.range(of: "://") { value = String(value[schemeRange.upperBound...]) } if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) { value = String(value[.. [String] { let separators = CharacterSet(charactersIn: ",;\n\r\t") var out: [String] = [] var seen = Set() for token in rawValue.components(separatedBy: separators) { guard let normalized = normalizePattern(token) else { continue } guard seen.insert(normalized).inserted else { continue } out.append(normalized) } return out } private static func normalizePattern(_ rawPattern: String) -> String? { let trimmed = rawPattern .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard !trimmed.isEmpty else { return nil } if trimmed.hasPrefix("*.") { let suffixRaw = String(trimmed.dropFirst(2)) guard let suffix = normalizeHost(suffixRaw) else { return nil } return "*.\(suffix)" } return normalizeHost(trimmed) } private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool { if pattern.hasPrefix("*.") { let suffix = String(pattern.dropFirst(2)) return host == suffix || host.hasSuffix(".\(suffix)") } return host == pattern } private static func trimHost(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: ".")) guard !trimmed.isEmpty else { return nil } // Canonicalize IDN entries (e.g. bücher.example -> xn--bcher-kva.example) // so user-entered allowlist patterns compare against URL.host consistently. if let canonicalized = URL(string: "https://\(trimmed)")?.host { return canonicalized } return trimmed } } func browserShouldBlockInsecureHTTPURL( _ url: URL, defaults: UserDefaults = .standard ) -> Bool { browserShouldBlockInsecureHTTPURL( url, rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) ) } func browserShouldBlockInsecureHTTPURL( _ url: URL, rawAllowlist: String? ) -> Bool { guard url.scheme?.lowercased() == "http" else { return false } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) } func browserShouldConsumeOneTimeInsecureHTTPBypass( _ url: URL, bypassHostOnce: inout String? ) -> Bool { guard let bypassHost = bypassHostOnce else { return false } guard url.scheme?.lowercased() == "http", let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return false } guard host == bypassHost else { return false } bypassHostOnce = nil return true } func browserShouldPersistInsecureHTTPAllowlistSelection( response: NSApplication.ModalResponse, suppressionEnabled: Bool ) -> Bool { guard suppressionEnabled else { return false } return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn } func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { var preparedRequest = request // Match browser behavior for ordinary loads while preserving method/body/headers. preparedRequest.cachePolicy = .useProtocolCachePolicy return preparedRequest } func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? { guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil } let path = fileURL.path var isDirectory: ObjCBool = false if fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue { return fileURL } let parent = fileURL.deletingLastPathComponent() guard !parent.path.isEmpty, parent.path.hasPrefix("/") else { return nil } return parent } @discardableResult func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavigation? { guard let url = request.url else { return nil } if url.isFileURL { guard let readAccessURL = browserReadAccessURL(forLocalFileURL: url) else { return nil } return webView.loadFileURL(url, allowingReadAccessTo: readAccessURL) } return webView.load(browserPreparedNavigationRequest(request)) } private let browserEmbeddedNavigationSchemes: Set = [ "about", "applewebdata", "blob", "data", "file", "http", "https", "javascript", ] func browserShouldOpenURLExternally(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false } return !browserEmbeddedNavigationSchemes.contains(scheme) } enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve // fallback/old UIs or trigger bot checks. static let safariUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15" } func normalizedBrowserHistoryNamespace(bundleIdentifier: String) -> String { if bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") { return "com.cmuxterm.app.debug" } if bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") { return "com.cmuxterm.app.staging" } return bundleIdentifier } @MainActor final class BrowserHistoryStore: ObservableObject { static let shared = BrowserHistoryStore() struct Entry: Codable, Identifiable, Hashable { let id: UUID var url: String var title: String? var lastVisited: Date var visitCount: Int var typedCount: Int var lastTypedAt: Date? private enum CodingKeys: String, CodingKey { case id case url case title case lastVisited case visitCount case typedCount case lastTypedAt } init( id: UUID, url: String, title: String?, lastVisited: Date, visitCount: Int, typedCount: Int = 0, lastTypedAt: Date? = nil ) { self.id = id self.url = url self.title = title self.lastVisited = lastVisited self.visitCount = visitCount self.typedCount = typedCount self.lastTypedAt = lastTypedAt } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) url = try container.decode(String.self, forKey: .url) title = try container.decodeIfPresent(String.self, forKey: .title) lastVisited = try container.decode(Date.self, forKey: .lastVisited) visitCount = try container.decode(Int.self, forKey: .visitCount) typedCount = try container.decodeIfPresent(Int.self, forKey: .typedCount) ?? 0 lastTypedAt = try container.decodeIfPresent(Date.self, forKey: .lastTypedAt) } } @Published private(set) var entries: [Entry] = [] private let fileURL: URL? private var didLoad: Bool = false private var saveTask: Task? private let maxEntries: Int = 5000 private let saveDebounceNanoseconds: UInt64 = 120_000_000 private struct SuggestionCandidate { let entry: Entry let urlLower: String let urlSansSchemeLower: String let hostLower: String let pathAndQueryLower: String let titleLower: String } private struct ScoredSuggestion { let entry: Entry let score: Double } init(fileURL: URL? = nil) { // Avoid calling @MainActor-isolated static methods from default argument context. self.fileURL = fileURL ?? BrowserHistoryStore.defaultHistoryFileURL() } func loadIfNeeded() { guard !didLoad else { return } didLoad = true guard let fileURL else { return } migrateLegacyTaggedHistoryFileIfNeeded(to: fileURL) // Load synchronously on first access so the first omnibar query can use // persisted history immediately (important for deterministic UI behavior). let data: Data do { data = try Data(contentsOf: fileURL) } catch { return } let decoded: [Entry] do { decoded = try JSONDecoder().decode([Entry].self, from: data) } catch { return } // Most-recent first. entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited }) // Remove entries with invalid hosts (no TLD), e.g. "https://news." let beforeCount = entries.count entries.removeAll { entry in guard let url = URL(string: entry.url), let host = url.host?.lowercased() else { return false } let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host return !trimmed.contains(".") } if entries.count != beforeCount { scheduleSave() } } func recordVisit(url: URL?, title: String?) { loadIfNeeded() guard let url else { return } guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } // Skip URLs whose host lacks a TLD (e.g. "https://news."). if let host = url.host?.lowercased() { let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host if !trimmed.contains(".") { return } } let urlString = url.absoluteString guard urlString != "about:blank" else { return } let normalizedKey = normalizedHistoryKey(url: url) if let idx = entries.firstIndex(where: { if $0.url == urlString { return true } return normalizedHistoryKey(urlString: $0.url) == normalizedKey }) { entries[idx].lastVisited = Date() entries[idx].visitCount += 1 // Prefer non-empty titles, but don't clobber an existing title with empty/whitespace. if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { entries[idx].title = title } } else { entries.insert(Entry( id: UUID(), url: urlString, title: title?.trimmingCharacters(in: .whitespacesAndNewlines), lastVisited: Date(), visitCount: 1 ), at: 0) } // Keep most-recent first and bound size. entries.sort(by: { $0.lastVisited > $1.lastVisited }) if entries.count > maxEntries { entries.removeLast(entries.count - maxEntries) } scheduleSave() } func recordTypedNavigation(url: URL?) { loadIfNeeded() guard let url else { return } guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } // Skip URLs whose host lacks a TLD (e.g. "https://news."). if let host = url.host?.lowercased() { let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host if !trimmed.contains(".") { return } } let urlString = url.absoluteString guard urlString != "about:blank" else { return } let now = Date() let normalizedKey = normalizedHistoryKey(url: url) if let idx = entries.firstIndex(where: { if $0.url == urlString { return true } return normalizedHistoryKey(urlString: $0.url) == normalizedKey }) { entries[idx].typedCount += 1 entries[idx].lastTypedAt = now entries[idx].lastVisited = now } else { entries.insert(Entry( id: UUID(), url: urlString, title: nil, lastVisited: now, visitCount: 1, typedCount: 1, lastTypedAt: now ), at: 0) } entries.sort(by: { $0.lastVisited > $1.lastVisited }) if entries.count > maxEntries { entries.removeLast(entries.count - maxEntries) } scheduleSave() } func suggestions(for input: String, limit: Int = 10) -> [Entry] { loadIfNeeded() guard limit > 0 else { return [] } let q = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !q.isEmpty else { return [] } let queryTokens = tokenizeSuggestionQuery(q) let now = Date() let matched = entries.compactMap { entry -> ScoredSuggestion? in let candidate = makeSuggestionCandidate(entry: entry) guard let score = suggestionScore(candidate: candidate, query: q, queryTokens: queryTokens, now: now) else { return nil } return ScoredSuggestion(entry: entry, score: score) } .sorted { lhs, rhs in if lhs.score != rhs.score { return lhs.score > rhs.score } if lhs.entry.lastVisited != rhs.entry.lastVisited { return lhs.entry.lastVisited > rhs.entry.lastVisited } if lhs.entry.visitCount != rhs.entry.visitCount { return lhs.entry.visitCount > rhs.entry.visitCount } return lhs.entry.url < rhs.entry.url } if matched.count <= limit { return matched.map(\.entry) } return Array(matched.prefix(limit).map(\.entry)) } func recentSuggestions(limit: Int = 10) -> [Entry] { loadIfNeeded() guard limit > 0 else { return [] } let ranked = entries.sorted { lhs, rhs in if lhs.typedCount != rhs.typedCount { return lhs.typedCount > rhs.typedCount } let lhsTypedDate = lhs.lastTypedAt ?? .distantPast let rhsTypedDate = rhs.lastTypedAt ?? .distantPast if lhsTypedDate != rhsTypedDate { return lhsTypedDate > rhsTypedDate } if lhs.lastVisited != rhs.lastVisited { return lhs.lastVisited > rhs.lastVisited } if lhs.visitCount != rhs.visitCount { return lhs.visitCount > rhs.visitCount } return lhs.url < rhs.url } if ranked.count <= limit { return ranked } 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() saveTask = nil entries = [] guard let fileURL else { return } try? FileManager.default.removeItem(at: fileURL) } @discardableResult func removeHistoryEntry(urlString: String) -> Bool { loadIfNeeded() let normalized = normalizedHistoryKey(urlString: urlString) let originalCount = entries.count entries.removeAll { entry in if entry.url == urlString { return true } guard let normalized else { return false } return normalizedHistoryKey(urlString: entry.url) == normalized } let didRemove = entries.count != originalCount if didRemove { scheduleSave() } return didRemove } func flushPendingSaves() { loadIfNeeded() saveTask?.cancel() saveTask = nil guard let fileURL else { return } try? Self.persistSnapshot(entries, to: fileURL) } private func scheduleSave() { guard let fileURL else { return } saveTask?.cancel() let snapshot = entries let debounceNanoseconds = saveDebounceNanoseconds saveTask = Task.detached(priority: .utility) { do { try await Task.sleep(nanoseconds: debounceNanoseconds) // debounce } catch { return } if Task.isCancelled { return } do { try Self.persistSnapshot(snapshot, to: fileURL) } catch { return } } } private func migrateLegacyTaggedHistoryFileIfNeeded(to targetURL: URL) { let fm = FileManager.default guard !fm.fileExists(atPath: targetURL.path) else { return } guard let legacyURL = Self.legacyTaggedHistoryFileURL(), legacyURL != targetURL, fm.fileExists(atPath: legacyURL.path) else { return } do { let dir = targetURL.deletingLastPathComponent() try fm.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) try fm.copyItem(at: legacyURL, to: targetURL) } catch { return } } private func makeSuggestionCandidate(entry: Entry) -> SuggestionCandidate { let urlLower = entry.url.lowercased() let urlSansSchemeLower = stripHTTPSSchemePrefix(urlLower) let components = URLComponents(string: entry.url) let hostLower = components?.host?.lowercased() ?? "" let path = (components?.percentEncodedPath ?? components?.path ?? "").lowercased() let query = (components?.percentEncodedQuery ?? components?.query ?? "").lowercased() let pathAndQueryLower: String if query.isEmpty { pathAndQueryLower = path } else { pathAndQueryLower = "\(path)?\(query)" } let titleLower = (entry.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return SuggestionCandidate( entry: entry, urlLower: urlLower, urlSansSchemeLower: urlSansSchemeLower, hostLower: hostLower, pathAndQueryLower: pathAndQueryLower, titleLower: titleLower ) } private func suggestionScore( candidate: SuggestionCandidate, query: String, queryTokens: [String], now: Date ) -> Double? { let queryIncludesScheme = query.hasPrefix("http://") || query.hasPrefix("https://") let urlMatchValue = queryIncludesScheme ? candidate.urlLower : candidate.urlSansSchemeLower let isSingleCharacterQuery = query.count == 1 if isSingleCharacterQuery { let hasSingleCharStrongMatch = candidate.hostLower.hasPrefix(query) || candidate.titleLower.hasPrefix(query) || urlMatchValue.hasPrefix(query) guard hasSingleCharStrongMatch else { return nil } } let queryMatches = urlMatchValue.contains(query) || candidate.hostLower.contains(query) || candidate.pathAndQueryLower.contains(query) || candidate.titleLower.contains(query) let tokenMatches = !queryTokens.isEmpty && queryTokens.allSatisfy { token in candidate.urlSansSchemeLower.contains(token) || candidate.hostLower.contains(token) || candidate.pathAndQueryLower.contains(token) || candidate.titleLower.contains(token) } guard queryMatches || tokenMatches else { return nil } var score = 0.0 if urlMatchValue == query { score += 1200 } if candidate.hostLower == query { score += 980 } if candidate.hostLower.hasPrefix(query) { score += 680 } if urlMatchValue.hasPrefix(query) { score += 560 } if candidate.titleLower.hasPrefix(query) { score += 420 } if candidate.pathAndQueryLower.hasPrefix(query) { score += 300 } if candidate.hostLower.contains(query) { score += 210 } if candidate.pathAndQueryLower.contains(query) { score += 165 } if candidate.titleLower.contains(query) { score += 145 } for token in queryTokens { if candidate.hostLower == token { score += 260 } else if candidate.hostLower.hasPrefix(token) { score += 170 } else if candidate.hostLower.contains(token) { score += 110 } if candidate.pathAndQueryLower.hasPrefix(token) { score += 80 } else if candidate.pathAndQueryLower.contains(token) { score += 52 } if candidate.titleLower.hasPrefix(token) { score += 74 } else if candidate.titleLower.contains(token) { score += 48 } } // Blend recency and repeat visits so history feels closer to browser frecency. let ageHours = max(0, now.timeIntervalSince(candidate.entry.lastVisited) / 3600) let recencyScore = max(0, 110 - (ageHours / 3)) let frequencyScore = min(120, log1p(Double(max(1, candidate.entry.visitCount))) * 38) let typedFrequencyScore = min(190, log1p(Double(max(0, candidate.entry.typedCount))) * 80) let typedRecencyScore: Double if let lastTypedAt = candidate.entry.lastTypedAt { let typedAgeHours = max(0, now.timeIntervalSince(lastTypedAt) / 3600) typedRecencyScore = max(0, 85 - (typedAgeHours / 4)) } else { typedRecencyScore = 0 } score += recencyScore + frequencyScore + typedFrequencyScore + typedRecencyScore return score } private func stripHTTPSSchemePrefix(_ value: String) -> String { if value.hasPrefix("https://") { return String(value.dropFirst("https://".count)) } if value.hasPrefix("http://") { return String(value.dropFirst("http://".count)) } return value } private func normalizedHistoryKey(url: URL) -> String? { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } return normalizedHistoryKey(components: &components) } private func normalizedHistoryKey(urlString: String) -> String? { guard var components = URLComponents(string: urlString) else { return nil } return normalizedHistoryKey(components: &components) } private func normalizedHistoryKey(components: inout URLComponents) -> String? { guard let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https", var host = components.host?.lowercased() else { return nil } if host.hasPrefix("www.") { host.removeFirst(4) } if (scheme == "http" && components.port == 80) || (scheme == "https" && components.port == 443) { components.port = nil } let portPart: String if let port = components.port { portPart = ":\(port)" } else { portPart = "" } var path = components.percentEncodedPath if path.isEmpty { path = "/" } while path.count > 1, path.hasSuffix("/") { path.removeLast() } let queryPart: String if let query = components.percentEncodedQuery, !query.isEmpty { queryPart = "?\(query.lowercased())" } else { queryPart = "" } return "\(scheme)://\(host)\(portPart)\(path)\(queryPart)" } private func tokenizeSuggestionQuery(_ query: String) -> [String] { var tokens: [String] = [] var seen = Set() let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters).union(.symbols) for raw in query.components(separatedBy: separators) { let token = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { continue } guard !seen.contains(token) else { continue } seen.insert(token) tokens.append(token) } return tokens } nonisolated private static func defaultHistoryFileURL() -> URL? { 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 = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) let dir = appSupport.appendingPathComponent(namespace, isDirectory: true) return dir.appendingPathComponent("browser_history.json", isDirectory: false) } nonisolated private static func legacyTaggedHistoryFileURL() -> URL? { guard let bundleId = Bundle.main.bundleIdentifier else { return nil } let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) guard namespace != bundleId else { return nil } let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let dir = appSupport.appendingPathComponent(bundleId, isDirectory: true) return dir.appendingPathComponent("browser_history.json", isDirectory: false) } nonisolated private static func persistSnapshot(_ snapshot: [Entry], to fileURL: URL) throws { let dir = fileURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) let encoder = JSONEncoder() encoder.outputFormatting = [.withoutEscapingSlashes] 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 { static let shared = BrowserSearchSuggestionService() func suggestions(engine: BrowserSearchEngine, query: String) async -> [String] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } // Deterministic UI-test hook for validating remote suggestion rendering // without relying on external network behavior. let forced = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] ?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON") if let forced, let data = forced.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] { return parsed.compactMap { item in guard let s = item as? String else { return nil } let value = s.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } // Google's endpoint can intermittently throttle/block app-style traffic. // Query fallbacks in parallel so we can show predictions quickly. if engine == .google { return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed) } return await fetchRemoteSuggestions(engine: engine, query: trimmed) } private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] { await withTaskGroup(of: [String].self, returning: [String].self) { group in group.addTask { await self.fetchRemoteSuggestions(engine: .google, query: query) } group.addTask { await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query) } group.addTask { await self.fetchRemoteSuggestions(engine: .bing, query: query) } while let result = await group.next() { if !result.isEmpty { group.cancelAll() return result } } return [] } } private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] { let url: URL? switch engine { case .google: var c = URLComponents(string: "https://suggestqueries.google.com/complete/search") c?.queryItems = [ URLQueryItem(name: "client", value: "firefox"), URLQueryItem(name: "q", value: query), ] url = c?.url case .duckduckgo: var c = URLComponents(string: "https://duckduckgo.com/ac/") c?.queryItems = [ URLQueryItem(name: "q", value: query), URLQueryItem(name: "type", value: "list"), ] url = c?.url case .bing: var c = URLComponents(string: "https://www.bing.com/osjson.aspx") c?.queryItems = [ URLQueryItem(name: "query", value: query), ] url = c?.url case .kagi: var c = URLComponents(string: "https://kagi.com/api/autosuggest") c?.queryItems = [ URLQueryItem(name: "q", value: query), ] url = c?.url case .startpage: var c = URLComponents(string: "https://www.startpage.com/osuggestions") c?.queryItems = [ URLQueryItem(name: "q", value: query), ] url = c?.url } guard let url else { return [] } var req = URLRequest(url: url) req.timeoutInterval = 0.65 req.cachePolicy = .returnCacheDataElseLoad req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") let data: Data let response: URLResponse do { (data, response) = try await URLSession.shared.data(for: req) } catch { return [] } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return [] } switch engine { case .google, .bing, .kagi, .startpage: return parseOSJSON(data: data) case .duckduckgo: return parseDuckDuckGo(data: data) } } private func parseOSJSON(data: Data) -> [String] { // Format: [query, [suggestions...], ...] guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any], root.count >= 2, let list = root[1] as? [Any] else { return [] } var out: [String] = [] out.reserveCapacity(list.count) for item in list { guard let s = item as? String else { continue } let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } out.append(trimmed) } return out } private func parseDuckDuckGo(data: Data) -> [String] { // Format: [{phrase:"..."}, ...] guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any] else { return [] } var out: [String] = [] out.reserveCapacity(root.count) for item in root { guard let dict = item as? [String: Any], let phrase = dict["phrase"] as? String else { continue } let trimmed = phrase.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } out.append(trimmed) } return out } } /// BrowserPanel provides a WKWebView-based browser panel. /// All browser panels share a WKProcessPool for cookie sharing. private enum BrowserInsecureHTTPNavigationIntent { case currentTab case newTab } /// Observable state for browser find-in-page. Mirrors `TerminalSurface.SearchState`. @MainActor final class BrowserSearchState: ObservableObject { @Published var needle: String @Published var selected: UInt? @Published var total: UInt? init(needle: String = "") { self.needle = needle } } final class BrowserPortalAnchorView: NSView { override var acceptsFirstResponder: Bool { false } override var isOpaque: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { nil } } @MainActor 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; window.__cmuxHooksInstalled = true; window.__cmuxConsoleLog = window.__cmuxConsoleLog || []; const __pushConsole = (level, args) => { try { const text = Array.from(args || []).map((x) => { if (typeof x === 'string') return x; try { return JSON.stringify(x); } catch (_) { return String(x); } }).join(' '); window.__cmuxConsoleLog.push({ level, text, timestamp_ms: Date.now() }); if (window.__cmuxConsoleLog.length > 512) { window.__cmuxConsoleLog.splice(0, window.__cmuxConsoleLog.length - 512); } } catch (_) {} }; const methods = ['log', 'info', 'warn', 'error', 'debug']; for (const m of methods) { const orig = (window.console && window.console[m]) ? window.console[m].bind(window.console) : null; window.console[m] = function(...args) { __pushConsole(m, args); if (orig) return orig(...args); }; } window.__cmuxErrorLog = window.__cmuxErrorLog || []; window.addEventListener('error', (ev) => { try { const message = String((ev && ev.message) || ''); const source = String((ev && ev.filename) || ''); const line = Number((ev && ev.lineno) || 0); const col = Number((ev && ev.colno) || 0); window.__cmuxErrorLog.push({ message, source, line, column: col, timestamp_ms: Date.now() }); if (window.__cmuxErrorLog.length > 512) { window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512); } } catch (_) {} }); window.addEventListener('unhandledrejection', (ev) => { try { const reason = ev && ev.reason; const message = typeof reason === 'string' ? reason : (reason && reason.message ? String(reason.message) : String(reason)); window.__cmuxErrorLog.push({ message, source: 'unhandledrejection', line: 0, column: 0, timestamp_ms: Date.now() }); if (window.__cmuxErrorLog.length > 512) { window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512); } } catch (_) {} }); return true; })() """ static let dialogTelemetryHookBootstrapScriptSource = """ (() => { if (window.__cmuxDialogHooksInstalled) return true; window.__cmuxDialogHooksInstalled = true; window.__cmuxDialogQueue = window.__cmuxDialogQueue || []; window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null }; const __pushDialog = (type, message, defaultText) => { window.__cmuxDialogQueue.push({ type, message: String(message || ''), default_text: defaultText == null ? null : String(defaultText), timestamp_ms: Date.now() }); if (window.__cmuxDialogQueue.length > 128) { window.__cmuxDialogQueue.splice(0, window.__cmuxDialogQueue.length - 128); } }; window.alert = function(message) { __pushDialog('alert', message, null); }; window.confirm = function(message) { __pushDialog('confirm', message, null); return !!window.__cmuxDialogDefaults.confirm; }; window.prompt = function(message, defaultValue) { __pushDialog('prompt', message, defaultValue == null ? null : defaultValue); const v = window.__cmuxDialogDefaults.prompt; if (v === null || v === undefined) { return defaultValue == null ? '' : String(defaultValue); } return String(v); }; return true; })() """ private static func clampedGhosttyBackgroundOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) } private static func isDarkAppearance( appAppearance: NSAppearance? = NSApp?.effectiveAppearance ) -> Bool { guard let appAppearance else { return false } return appAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua } private static func resolvedGhosttyBackgroundColor(from notification: Notification? = nil) -> NSColor { let userInfo = notification?.userInfo let baseColor = (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor) ?? GhosttyApp.shared.defaultBackgroundColor let opacity: Double if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double { opacity = value } else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber { opacity = value.doubleValue } else { opacity = GhosttyApp.shared.defaultBackgroundOpacity } return baseColor.withAlphaComponent(clampedGhosttyBackgroundOpacity(opacity)) } private static func resolvedBrowserChromeBackgroundColor( from notification: Notification? = nil, appAppearance: NSAppearance? = NSApp?.effectiveAppearance ) -> NSColor { if isDarkAppearance(appAppearance: appAppearance) { return resolvedGhosttyBackgroundColor(from: notification) } return NSColor.windowBackgroundColor } let id: UUID let panelType: PanelType = .browser /// 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 /// Monotonic identity for the current WKWebView instance. /// Incremented whenever we replace the underlying WKWebView after a process crash. @Published private(set) var webViewInstanceID: UUID = UUID() /// Prevent the omnibar from auto-focusing for a short window after explicit programmatic focus. /// This avoids races where SwiftUI focus state steals first responder back from WebKit. private var suppressOmnibarAutofocusUntil: Date? /// Prevent forcing web-view focus when another UI path requested omnibar focus. /// Used to keep omnibar text-field focus from being immediately stolen by panel focus. private var suppressWebViewFocusUntil: Date? private var suppressWebViewFocusForAddressBar: Bool = false private var addressBarFocusRestoreGeneration: UInt64 = 0 private let blankURLString = "about:blank" private static let addressBarFocusCaptureScript = """ (() => { try { const syncState = (state) => { window.__cmuxAddressBarFocusState = state; try { if (window.top && window.top !== window) { window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); } else if (window.top) { window.top.__cmuxAddressBarFocusState = state; } } catch (_) {} }; const active = document.activeElement; if (!active) { syncState(null); return "cleared:none"; } const tag = (active.tagName || "").toLowerCase(); const type = (active.type || "").toLowerCase(); const isEditable = !!active.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden"); if (!isEditable) { syncState(null); return "cleared:noneditable"; } let id = active.getAttribute("data-cmux-addressbar-focus-id"); if (!id) { id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); active.setAttribute("data-cmux-addressbar-focus-id", id); } const state = { id, selectionStart: null, selectionEnd: null }; if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") { state.selectionStart = active.selectionStart; state.selectionEnd = active.selectionEnd; } syncState(state); return "captured:" + id; } catch (_) { return "error"; } })(); """ private static let addressBarFocusTrackingBootstrapScript = """ (() => { try { if (window.__cmuxAddressBarFocusTrackerInstalled) return true; window.__cmuxAddressBarFocusTrackerInstalled = true; const syncState = (state) => { window.__cmuxAddressBarFocusState = state; try { if (window.top && window.top !== window) { window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); } else if (window.top) { window.top.__cmuxAddressBarFocusState = state; } } catch (_) {} }; if (window.top === window && !window.__cmuxAddressBarFocusMessageBridgeInstalled) { window.__cmuxAddressBarFocusMessageBridgeInstalled = true; window.addEventListener("message", (ev) => { try { const data = ev ? ev.data : null; if (!data || !Object.prototype.hasOwnProperty.call(data, "cmuxAddressBarFocusState")) return; window.__cmuxAddressBarFocusState = data.cmuxAddressBarFocusState || null; } catch (_) {} }, true); } const isEditable = (el) => { if (!el) return false; const tag = (el.tagName || "").toLowerCase(); const type = (el.type || "").toLowerCase(); return !!el.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden"); }; const ensureFocusId = (el) => { let id = el.getAttribute("data-cmux-addressbar-focus-id"); if (!id) { id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); el.setAttribute("data-cmux-addressbar-focus-id", id); } return id; }; const snapshot = (el) => { if (!isEditable(el)) { syncState(null); return; } const state = { id: ensureFocusId(el), selectionStart: null, selectionEnd: null }; if (typeof el.selectionStart === "number" && typeof el.selectionEnd === "number") { state.selectionStart = el.selectionStart; state.selectionEnd = el.selectionEnd; } syncState(state); }; document.addEventListener("focusin", (ev) => { snapshot(ev && ev.target ? ev.target : document.activeElement); }, true); document.addEventListener("selectionchange", () => { snapshot(document.activeElement); }, true); document.addEventListener("input", () => { snapshot(document.activeElement); }, true); document.addEventListener("mousedown", (ev) => { const target = ev && ev.target ? ev.target : null; if (!isEditable(target)) { syncState(null); } }, true); window.addEventListener("beforeunload", () => { syncState(null); }, true); snapshot(document.activeElement); return true; } catch (_) { return false; } })(); """ private static let addressBarFocusRestoreScript = """ (() => { try { const readState = () => { let state = window.__cmuxAddressBarFocusState; try { if ((!state || typeof state.id !== "string" || !state.id) && window.top && window.top.__cmuxAddressBarFocusState) { state = window.top.__cmuxAddressBarFocusState; } } catch (_) {} return state; }; const clearState = () => { window.__cmuxAddressBarFocusState = null; try { if (window.top && window.top !== window) { window.top.postMessage({ cmuxAddressBarFocusState: null }, "*"); } else if (window.top) { window.top.__cmuxAddressBarFocusState = null; } } catch (_) {} }; const state = readState(); if (!state || typeof state.id !== "string" || !state.id) { return "no_state"; } const selector = '[data-cmux-addressbar-focus-id="' + state.id + '"]'; const findTarget = (doc) => { if (!doc) return null; const direct = doc.querySelector(selector); if (direct && direct.isConnected) return direct; const frames = doc.querySelectorAll("iframe,frame"); for (let i = 0; i < frames.length; i += 1) { const frame = frames[i]; try { const childDoc = frame.contentDocument; if (!childDoc) continue; const nested = findTarget(childDoc); if (nested) return nested; } catch (_) {} } return null; }; const target = findTarget(document); if (!target) { clearState(); return "missing_target"; } try { target.focus({ preventScroll: true }); } catch (_) { try { target.focus(); } catch (_) {} } let focused = false; try { focused = target === target.ownerDocument.activeElement || (typeof target.matches === "function" && target.matches(":focus")); } catch (_) {} if (!focused) { return "not_focused"; } if ( typeof state.selectionStart === "number" && typeof state.selectionEnd === "number" && typeof target.setSelectionRange === "function" ) { try { target.setSelectionRange(state.selectionStart, state.selectionEnd); } catch (_) {} } clearState(); return "restored"; } catch (_) { return "error"; } })(); """ /// Published URL being displayed @Published private(set) var currentURL: URL? /// Whether the browser panel should render its WKWebView in the content area. /// New browser tabs stay in an empty "new tab" state until first navigation. @Published private(set) var shouldRenderWebView: Bool = false /// True when the browser is showing the internal empty new-tab page (no WKWebView attached yet). var isShowingNewTabPage: Bool { !shouldRenderWebView } /// Published page title @Published private(set) var pageTitle: String = "" /// Published favicon (PNG data). When present, the tab bar can render it instead of a SF symbol. @Published private(set) var faviconPNGData: Data? /// Published loading state @Published private(set) var isLoading: Bool = false /// Published download state for browser downloads (navigation + context menu). @Published private(set) var isDownloading: Bool = false /// Published can go back state @Published private(set) var canGoBack: Bool = false /// Published can go forward state @Published private(set) var canGoForward: Bool = false private var nativeCanGoBack: Bool = false private var nativeCanGoForward: Bool = false private var usesRestoredSessionHistory: Bool = false private var restoredBackHistoryStack: [URL] = [] private var restoredForwardHistoryStack: [URL] = [] private var restoredHistoryCurrentURL: URL? /// Published estimated progress (0.0 - 1.0) @Published private(set) var estimatedProgress: Double = 0.0 /// Increment to request a UI-only flash highlight (e.g. from a keyboard shortcut). @Published private(set) var focusFlashToken: Int = 0 /// Sticky omnibar-focus intent. This survives view mount timing races and is /// cleared only after BrowserPanelView acknowledges handling it. @Published private(set) var pendingAddressBarFocusRequestId: UUID? /// Semantic in-panel focus target used by split switching and transient overlays. private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView /// Incremented whenever async browser find focus ownership changes. @Published private(set) var searchFocusRequestGeneration: UInt64 = 0 /// Find-in-page state. Non-nil when the find bar is visible. @Published var searchState: BrowserSearchState? = nil { didSet { if let searchState { preferredFocusIntent = .findField NSLog("Find: browser search state created panel=%@", id.uuidString) searchNeedleCancellable = searchState.$needle .removeDuplicates() .map { needle -> AnyPublisher in if needle.isEmpty || needle.count >= 3 { return Just(needle).eraseToAnyPublisher() } return Just(needle) .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) .eraseToAnyPublisher() } .switchToLatest() .sink { [weak self] needle in guard let self else { return } NSLog("Find: browser needle updated panel=%@ needle=%@", self.id.uuidString, needle) self.executeFindSearch(needle) } } else if oldValue != nil { searchNeedleCancellable = nil if preferredFocusIntent == .findField { preferredFocusIntent = .webView } invalidateSearchFocusRequests(reason: "searchStateCleared") NSLog("Find: browser search state cleared panel=%@", id.uuidString) executeFindClear() } } } private var searchNeedleCancellable: AnyCancellable? let portalAnchorView = BrowserPortalAnchorView(frame: .zero) private struct PortalHostLease { let hostId: ObjectIdentifier let paneId: UUID let inWindow: Bool let area: CGFloat } private struct PortalHostLock { let hostId: ObjectIdentifier let paneId: UUID } private enum DeveloperToolsPresentation { case unknown case attached case detached } private var activePortalHostLease: PortalHostLease? private var pendingDistinctPortalHostReplacementPaneId: UUID? private var lockedPortalHost: PortalHostLock? private var webViewCancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? private var downloadDelegate: BrowserDownloadDelegate? private var webViewObservers: [NSKeyValueObservation] = [] private var activeDownloadCount: Int = 0 // Avoid flickering the loading indicator for very fast navigations. private let minLoadingIndicatorDuration: TimeInterval = 0.35 private var loadingStartedAt: Date? private var loadingEndWorkItem: DispatchWorkItem? private var loadingGeneration: Int = 0 private var faviconTask: Task? private var faviconRefreshGeneration: Int = 0 private var lastFaviconURLString: String? private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 private var insecureHTTPBypassHostOnce: String? private var insecureHTTPAlertFactory: () -> NSAlert private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). @Published private(set) var preferredDeveloperToolsVisible: Bool = false private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown private var forceDeveloperToolsRefreshOnNextAttach: Bool = false private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsTransitionTargetVisible: Bool? private var pendingDeveloperToolsTransitionTargetVisible: Bool? private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol? private var preferredAttachedDeveloperToolsWidth: CGFloat? private var preferredAttachedDeveloperToolsWidthFraction: CGFloat? private var browserThemeMode: BrowserThemeMode var displayTitle: String { if !pageTitle.isEmpty { return pageTitle } if let url = currentURL { return url.host ?? url.absoluteString } 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 private static func portalHostArea(for bounds: CGRect) -> CGFloat { max(0, bounds.width) * max(0, bounds.height) } private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool { lease.inWindow && lease.area > portalHostAreaThreshold } func preparePortalHostReplacementForNextDistinctClaim( inPane paneId: PaneID, reason: String ) { pendingDistinctPortalHostReplacementPaneId = paneId.id if lockedPortalHost?.paneId == paneId.id { lockedPortalHost = nil } #if DEBUG dlog( "browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))" ) #endif } func claimPortalHost( hostId: ObjectIdentifier, paneId: PaneID, inWindow: Bool, bounds: CGRect, reason: String ) -> Bool { let next = PortalHostLease( hostId: hostId, paneId: paneId.id, inWindow: inWindow, area: Self.portalHostArea(for: bounds) ) if let current = activePortalHostLease { if let lock = lockedPortalHost, (lock.hostId != current.hostId || lock.paneId != current.paneId) { lockedPortalHost = nil } if current.hostId == hostId { activePortalHostLease = next return true } let currentUsable = Self.portalHostIsUsable(current) let nextUsable = Self.portalHostIsUsable(next) let isSamePaneReplacement = current.paneId == paneId.id let shouldForceDistinctReplacement = isSamePaneReplacement && pendingDistinctPortalHostReplacementPaneId == paneId.id && inWindow if shouldForceDistinctReplacement { #if DEBUG dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + "replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " + "forced=1" ) #endif activePortalHostLease = next pendingDistinctPortalHostReplacementPaneId = nil lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) return true } let lockBlocksSamePaneReplacement = isSamePaneReplacement && currentUsable && lockedPortalHost?.hostId == current.hostId && lockedPortalHost?.paneId == current.paneId let shouldReplace = current.paneId != paneId.id || !currentUsable || ( !lockBlocksSamePaneReplacement && nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio) ) if shouldReplace { if lockedPortalHost?.hostId == current.hostId && lockedPortalHost?.paneId == current.paneId { lockedPortalHost = nil } #if DEBUG dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + "replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area))" ) #endif activePortalHostLease = next return true } #if DEBUG dlog( "browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " + "ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " + "locked=\(lockBlocksSamePaneReplacement ? 1 : 0)" ) #endif return false } activePortalHostLease = next #if DEBUG dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "replacingHost=nil" ) #endif return true } @discardableResult func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool { guard let current = activePortalHostLease, current.hostId == hostId else { return false } activePortalHostLease = nil if lockedPortalHost?.hostId == hostId { lockedPortalHost = nil } #if DEBUG dlog( "browser.portal.host.release panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + "inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))" ) #endif return true } var displayIcon: String? { "globe" } var isDirty: Bool { false } private static func makeWebView(profileID: UUID) -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] config.websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: profileID) // Enable developer extras (DevTools) config.preferences.setValue(true, forKey: "developerExtrasEnabled") // Enable JavaScript config.defaultWebpagePreferences.allowsContentJavaScript = true // Keep browser console/error/dialog telemetry active from document start on every navigation. config.userContentController.addUserScript( WKUserScript( source: Self.telemetryHookBootstrapScriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: false ) ) // Track the last editable focused element continuously so omnibar exit can // restore page input focus even if capture runs after first-responder handoff. config.userContentController.addUserScript( WKUserScript( source: Self.addressBarFocusTrackingBootstrapScript, injectionTime: .atDocumentStart, forMainFrameOnly: false ) ) let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true if #available(macOS 13.3, *) { webView.isInspectable = true } // Match the empty-page background to the terminal theme so newly-created browsers // don't flash white before content loads. webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() // Always present as Safari. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent return webView } private func bindWebView(_ webView: CmuxWebView) { webView.onContextMenuDownloadStateChanged = { [weak self] downloading in if downloading { self?.beginDownloadActivity() } else { self?.endDownloadActivity() } } 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 } return instanceID == webViewInstanceID } 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(profileID: resolvedProfileID) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false } navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) } navDelegate.didTerminateWebContentProcess = { [weak self] webView in self?.replaceWebViewAfterContentProcessTermination(for: webView) } // Set up download delegate for navigation-based downloads. // 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.onDownloadReadyToSave = { [weak self] in self?.endDownloadActivity() } dlDelegate.onDownloadFailed = { [weak self] _ in self?.endDownloadActivity() } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate self.navigationDelegate = navDelegate // Set up UI delegate (handles cmd+click, target=_blank, and context menu) let browserUIDelegate = BrowserUIDelegate() browserUIDelegate.openInNewTab = { [weak self] url in guard let self else { return } self.openLinkInNewTab(url: url) } 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) installDetachedDeveloperToolsWindowCloseObserver() applyBrowserThemeModeIfNeeded() insecureHTTPAlertWindowProvider = { [weak self] in self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } // Navigate to initial URL if provided if let url = initialURL { shouldRenderWebView = true navigate(to: url) } } private func beginDownloadActivity() { let apply = { self.activeDownloadCount += 1 self.isDownloading = self.activeDownloadCount > 0 } if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } } private func endDownloadActivity() { let apply = { self.activeDownloadCount = max(0, self.activeDownloadCount - 1) self.isDownloading = self.activeDownloadCount > 0 } if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } } func updateWorkspaceId(_ newWorkspaceId: UUID) { 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() { guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 } func sessionNavigationHistorySnapshot() -> ( backHistoryURLStrings: [String], forwardHistoryURLStrings: [String] ) { if usesRestoredSessionHistory { let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } // `restoredForwardHistoryStack` stores nearest-forward entries at the end. let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } return (back, forward) } let back = webView.backForwardList.backList.compactMap { Self.serializableSessionHistoryURLString($0.url) } let forward = webView.backForwardList.forwardList.compactMap { Self.serializableSessionHistoryURLString($0.url) } return (back, forward) } func restoreSessionNavigationHistory( backHistoryURLStrings: [String], forwardHistoryURLStrings: [String], currentURLString: String? ) { let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings) let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings) guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return } usesRestoredSessionHistory = true restoredBackHistoryStack = restoredBack // Store nearest-forward entries at the end to make stack pop operations trivial. restoredForwardHistoryStack = Array(restoredForward.reversed()) restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString) refreshNavigationAvailability() } private func setupObservers(for webView: WKWebView) { let observedWebViewInstanceID = webViewInstanceID // URL changes 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 } } webViewObservers.append(urlObserver) // Title changes let titleObserver = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } // Keep showing the last non-empty title while the new navigation is loading. // WebKit often clears title to nil/"" during reload/navigation, which causes // a distracting tab-title flash (e.g. to host/URL). Only accept non-empty titles. let trimmed = (webView.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } self.pageTitle = trimmed } } webViewObservers.append(titleObserver) // Loading state let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } self.handleWebViewLoadingChanged(webView.isLoading) } } webViewObservers.append(loadingObserver) // Can go back let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } self.nativeCanGoBack = webView.canGoBack self.refreshNavigationAvailability() } } webViewObservers.append(backObserver) // Can go forward let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } self.nativeCanGoForward = webView.canGoForward self.refreshNavigationAvailability() } } webViewObservers.append(forwardObserver) // Progress let progressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } self.estimatedProgress = webView.estimatedProgress } } webViewObservers.append(progressObserver) NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange) .sink { [weak self] notification in guard let self else { return } self.webView.underPageBackgroundColor = GhosttyBackgroundTheme.color(from: notification) } .store(in: &webViewCancellables) } private func replaceWebViewAfterContentProcessTermination(for terminatedWebView: WKWebView) { guard terminatedWebView === webView else { return } let wasRenderable = shouldRenderWebView let restoreURL = terminatedWebView.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 restoreDevTools = preferredDeveloperToolsVisible #if DEBUG dlog( "browser.webview.replace.begin panel=\(id.uuidString.prefix(5)) " + "renderable=\(wasRenderable ? 1 : 0) restoreURL=\(restoreURLString ?? "nil") " + "restoreHistoryBack=\(history.backHistoryURLStrings.count) " + "restoreHistoryForward=\(history.forwardHistoryURLStrings.count)" ) #endif webViewObservers.removeAll() webViewCancellables.removeAll() 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 } let replacement = Self.makeWebView(profileID: profileID) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement 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 restoreDevTools { requestDeveloperToolsRefreshAfterNextAttach(reason: "webcontent_process_terminated") } #if DEBUG dlog( "browser.webview.replace.end panel=\(id.uuidString.prefix(5)) " + "instance=\(webViewInstanceID.uuidString.prefix(6)) " + "restoreURL=\(restoreURLString ?? "nil") shouldRestore=\(shouldRestoreURL ? 1 : 0)" ) #endif } #if DEBUG func debugSimulateWebContentProcessTermination() { replaceWebViewAfterContentProcessTermination(for: webView) } #endif // MARK: - Panel Protocol func focus() { if shouldSuppressWebViewFocus() { return } guard let window = webView.window, !webView.isHiddenOrHasHiddenAncestor else { return } // If nothing meaningful is loaded yet, prefer letting the omnibar take focus. if !webView.isLoading { let urlString = webView.url?.absoluteString ?? currentURL?.absoluteString if urlString == nil || urlString == "about:blank" { return } } if Self.responderChainContains(window.firstResponder, target: webView) { noteWebViewFocused() return } if window.makeFirstResponder(webView) { noteWebViewFocused() } } func unfocus() { invalidateSearchFocusRequests(reason: "panelUnfocus") guard let window = webView.window else { return } if Self.responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) } } func close() { // 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 navigationDelegate = nil uiDelegate = nil webViewObservers.removeAll() webViewCancellables.removeAll() faviconTask?.cancel() 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 guard let pageURL = webView.url else { return } guard let scheme = pageURL.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } faviconRefreshGeneration &+= 1 let refreshGeneration = faviconRefreshGeneration let refreshWebViewInstanceID = webViewInstanceID faviconTask = Task { @MainActor [weak self, weak webView] in guard let self, let webView else { return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } // Try to discover the best icon URL from the document. let js = """ (() => { const links = Array.from(document.querySelectorAll( 'link[rel~=\"icon\"], link[rel=\"shortcut icon\"], link[rel=\"apple-touch-icon\"], link[rel=\"apple-touch-icon-precomposed\"]' )); function score(link) { const v = (link.sizes && link.sizes.value) ? link.sizes.value : ''; if (v === 'any') return 1000; let max = 0; for (const part of v.split(/\\s+/)) { const m = part.match(/(\\d+)x(\\d+)/); if (!m) continue; const a = parseInt(m[1], 10); const b = parseInt(m[2], 10); if (Number.isFinite(a)) max = Math.max(max, a); if (Number.isFinite(b)) max = Math.max(max, b); } return max; } links.sort((a, b) => score(b) - score(a)); return links[0]?.href || ''; })(); """ var discoveredURL: URL? if let href = try? await webView.evaluateJavaScript(js) as? String { let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, let u = URL(string: trimmed) { discoveredURL = u } } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) let iconURL = discoveredURL ?? fallbackURL guard let iconURL else { return } // Avoid repeated fetches. let iconURLString = iconURL.absoluteString if iconURLString == lastFaviconURLString, faviconPNGData != nil { return } lastFaviconURLString = iconURLString var req = URLRequest(url: iconURL) req.timeoutInterval = 2.0 req.cachePolicy = .returnCacheDataElseLoad req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") let data: Data let response: URLResponse do { (data, response) = try await URLSession.shared.data(for: req) } catch { return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return } // 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 } // Only update if we got a real icon; keep the old one otherwise to avoid flashes. faviconPNGData = png } } private func isCurrentFaviconRefresh(generation: Int) -> Bool { guard !Task.isCancelled else { return false } return generation == faviconRefreshGeneration } @MainActor private static func makeFaviconPNGData(from raw: Data, targetPx: Int) -> Data? { guard let image = NSImage(data: raw) else { return nil } let px = max(16, min(128, targetPx)) let size = NSSize(width: px, height: px) guard let rep = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 ) else { return nil } NSGraphicsContext.saveGraphicsState() defer { NSGraphicsContext.restoreGraphicsState() } let ctx = NSGraphicsContext(bitmapImageRep: rep) ctx?.imageInterpolation = .high ctx?.shouldAntialias = true NSGraphicsContext.current = ctx NSColor.clear.setFill() NSRect(origin: .zero, size: size).fill() // Aspect-fit into the target square. let srcSize = image.size let scale = min(size.width / max(1, srcSize.width), size.height / max(1, srcSize.height)) let drawSize = NSSize(width: srcSize.width * scale, height: srcSize.height * scale) let drawOrigin = NSPoint(x: (size.width - drawSize.width) / 2.0, y: (size.height - drawSize.height) / 2.0) // Align to integral pixels to avoid soft edges at small sizes. let drawRect = NSRect( x: round(drawOrigin.x), y: round(drawOrigin.y), width: round(drawSize.width), height: round(drawSize.height) ) image.draw( in: drawRect, from: NSRect(origin: .zero, size: srcSize), operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [.interpolation: NSImageInterpolation.high] ) return rep.representation(using: .png, properties: [:]) } private func handleWebViewLoadingChanged(_ newValue: Bool) { if newValue { // Any new load invalidates older favicon fetches, even for same-URL reloads. faviconRefreshGeneration &+= 1 faviconTask?.cancel() faviconTask = nil lastFaviconURLString = nil // Clear the previous page's favicon so it never persists across navigations. // The loading spinner covers this gap; didFinish will fetch the new favicon. faviconPNGData = nil loadingGeneration &+= 1 loadingEndWorkItem?.cancel() loadingEndWorkItem = nil loadingStartedAt = Date() isLoading = true return } let genAtEnd = loadingGeneration let startedAt = loadingStartedAt ?? Date() let elapsed = Date().timeIntervalSince(startedAt) let remaining = max(0, minLoadingIndicatorDuration - elapsed) loadingEndWorkItem?.cancel() loadingEndWorkItem = nil if remaining <= 0.0001 { isLoading = false return } let work = DispatchWorkItem { [weak self] in guard let self else { return } // If loading restarted, ignore this end. guard self.loadingGeneration == genAtEnd else { return } // If WebKit is still loading, ignore. guard !self.webView.isLoading else { return } self.isLoading = false } loadingEndWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: work) } // MARK: - Navigation /// Navigate to a URL func navigate(to url: URL, recordTypedNavigation: Bool = false) { let request = URLRequest(url: url) if shouldBlockInsecureHTTPNavigation(to: url) { presentInsecureHTTPAlert(for: request, intent: .currentTab, recordTypedNavigation: recordTypedNavigation) return } navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) } private func navigateWithoutInsecureHTTPPrompt( to url: URL, recordTypedNavigation: Bool, preserveRestoredSessionHistory: Bool = false ) { let request = URLRequest(url: url) navigateWithoutInsecureHTTPPrompt( request: request, recordTypedNavigation: recordTypedNavigation, preserveRestoredSessionHistory: preserveRestoredSessionHistory ) } private func navigateWithoutInsecureHTTPPrompt( request: URLRequest, recordTypedNavigation: Bool, preserveRestoredSessionHistory: Bool = false ) { guard let url = request.url else { return } if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true if recordTypedNavigation { historyStore.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url browserLoadRequest(request, in: webView) } /// Navigate with smart URL/search detection /// - If input looks like a URL, navigate to it /// - Otherwise, perform a web search func navigateSmart(_ input: String) { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } if let url = resolveNavigableURL(from: trimmed) { navigate(to: url, recordTypedNavigation: true) return } let engine = BrowserSearchSettings.currentSearchEngine() guard let searchURL = engine.searchURL(query: trimmed) else { return } navigate(to: searchURL) } func resolveNavigableURL(from input: String) -> URL? { resolveBrowserNavigableURL(input) } private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) { return false } return browserShouldBlockInsecureHTTPURL(url) } private func requestNavigation(_ request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent) { guard let url = request.url else { return } if shouldBlockInsecureHTTPNavigation(to: url) { presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) return } switch intent { case .currentTab: navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: false) case .newTab: openLinkInNewTab(url: url) } } private func presentInsecureHTTPAlert( for request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent, recordTypedNavigation: Bool ) { guard let url = request.url else { return } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } let alert = insecureHTTPAlertFactory() 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 self, weak alert] response in self?.handleInsecureHTTPAlertResponse( response, alert: alert, host: host, request: request, url: url, intent: intent, recordTypedNavigation: recordTypedNavigation ) } if let alertWindow = insecureHTTPAlertWindowProvider() { alert.beginSheetModal(for: alertWindow, completionHandler: handleResponse) return } handleResponse(alert.runModal()) } private func handleInsecureHTTPAlertResponse( _ response: NSApplication.ModalResponse, alert: NSAlert?, host: String, request: URLRequest, url: URL, intent: BrowserInsecureHTTPNavigationIntent, recordTypedNavigation: Bool ) { if browserShouldPersistInsecureHTTPAllowlistSelection( response: response, suppressionEnabled: alert?.suppressionButton?.state == .on ) { BrowserInsecureHTTPSettings.addAllowedHost(host) } switch response { case .alertFirstButtonReturn: NSWorkspace.shared.open(url) case .alertSecondButtonReturn: switch intent { case .currentTab: insecureHTTPBypassHostOnce = host navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) case .newTab: openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) } default: return } } deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil developerToolsTransitionSettleWorkItem?.cancel() developerToolsTransitionSettleWorkItem = nil if let detachedDeveloperToolsWindowCloseObserver { NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver) } webViewObservers.removeAll() webViewCancellables.removeAll() let webView = webView Task { @MainActor in BrowserWindowPortalRegistry.detach(webView: webView) } } } extension BrowserPanel { private var needsWorkspaceContextReset: Bool { shouldRenderWebView || currentURL != nil || !pageTitle.isEmpty || faviconPNGData != nil || searchState != nil || nativeCanGoBack || nativeCanGoForward || restoredHistoryCurrentURL != nil || !restoredBackHistoryStack.isEmpty || !restoredForwardHistoryStack.isEmpty || estimatedProgress > 0 || isLoading || isDownloading || activeDownloadCount != 0 || preferredDeveloperToolsVisible || webView.superview != nil } func resetForWorkspaceContextChange(reason: String) { guard needsWorkspaceContextReset else { #if DEBUG dlog( "browser.contextReset.skip panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0)" ) #endif return } #if DEBUG dlog( "browser.contextReset.begin panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0) " + "url=\(preferredURLStringForOmnibar() ?? "nil")" ) #endif _ = hideDeveloperTools() cancelDeveloperToolsRestoreRetry() preferredDeveloperToolsVisible = false preferredDeveloperToolsPresentation = .unknown forceDeveloperToolsRefreshOnNextAttach = false developerToolsDetachedOpenGraceDeadline = nil developerToolsRestoreRetryAttempt = 0 preferredAttachedDeveloperToolsWidth = nil preferredAttachedDeveloperToolsWidthFraction = nil loadingEndWorkItem?.cancel() loadingEndWorkItem = nil faviconTask?.cancel() faviconTask = nil faviconRefreshGeneration &+= 1 loadingGeneration &+= 1 activeDownloadCount = 0 isDownloading = false isLoading = false estimatedProgress = 0 nativeCanGoBack = false nativeCanGoForward = false navigationDelegate?.lastAttemptedURL = nil abandonRestoredSessionHistoryIfNeeded() pendingAddressBarFocusRequestId = nil preferredFocusIntent = .addressBar suppressOmnibarAutofocusUntil = nil suppressWebViewFocusUntil = nil endSuppressWebViewFocusForAddressBar() invalidateAddressBarPageFocusRestoreAttempts() invalidateSearchFocusRequests(reason: "contextReset") searchState = nil pageTitle = "" currentURL = nil faviconPNGData = nil lastFaviconURLString = nil activePortalHostLease = nil pendingDistinctPortalHostReplacementPaneId = nil lockedPortalHost = nil let oldWebView = webView webViewObservers.removeAll() webViewCancellables.removeAll() 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(profileID: profileID) webViewInstanceID = UUID() webView = replacement shouldRenderWebView = false bindWebView(replacement) applyBrowserThemeModeIfNeeded() refreshNavigationAvailability() #if DEBUG dlog( "browser.contextReset.end panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) instance=\(webViewInstanceID.uuidString.prefix(6))" ) #endif } } func resolveBrowserNavigableURL(_ input: String) -> URL? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard !trimmed.contains(" ") else { return nil } // Check localhost/loopback before generic URL parsing because // URL(string: "localhost:3777") treats "localhost" as a scheme. let lower = trimmed.lowercased() if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") { return URL(string: "http://\(trimmed)") } if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { if scheme == "http" || scheme == "https" { return url } if scheme == "file", url.isFileURL, url.path.hasPrefix("/") { return url } return nil } if trimmed.contains(":") || trimmed.contains("/") { return URL(string: "https://\(trimmed)") } if trimmed.contains(".") { return URL(string: "https://\(trimmed)") } return nil } extension BrowserPanel { /// Go back in history func goBack() { guard canGoBack else { return } if usesRestoredSessionHistory { guard let targetURL = restoredBackHistoryStack.popLast() else { refreshNavigationAvailability() return } if let current = resolvedCurrentSessionHistoryURL() { restoredForwardHistoryStack.append(current) } restoredHistoryCurrentURL = targetURL refreshNavigationAvailability() navigateWithoutInsecureHTTPPrompt( to: targetURL, recordTypedNavigation: false, preserveRestoredSessionHistory: true ) return } webView.goBack() } /// Go forward in history func goForward() { guard canGoForward else { return } if usesRestoredSessionHistory { guard let targetURL = restoredForwardHistoryStack.popLast() else { refreshNavigationAvailability() return } if let current = resolvedCurrentSessionHistoryURL() { restoredBackHistoryStack.append(current) } restoredHistoryCurrentURL = targetURL refreshNavigationAvailability() navigateWithoutInsecureHTTPPrompt( to: targetURL, recordTypedNavigation: false, preserveRestoredSessionHistory: true ) return } webView.goForward() } /// Open a link in a new browser surface in the same pane func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { #if DEBUG dlog( "browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " + "workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " + "bypass=\(bypassInsecureHTTPHostOnce ?? "nil")" ) #endif guard let app = AppDelegate.shared else { #if DEBUG dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingAppDelegate") #endif return } guard let workspace = app.workspaceContainingPanel( panelId: id, preferredWorkspaceId: workspaceId )?.workspace else { #if DEBUG dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing") #endif return } guard let paneId = workspace.paneId(forPanelId: id) else { #if DEBUG dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing") #endif return } workspace.newBrowserSurface( inPane: paneId, url: url, focus: true, preferredProfileID: profileID, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) #if DEBUG dlog( "browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " + "workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))" ) #endif } /// Reload the current page func reload() { webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent webView.reload() } /// Stop loading func stopLoading() { webView.stopLoading() } private static func windowContainsInspectorViews(_ root: NSView) -> Bool { if String(describing: type(of: root)).contains("WKInspector") { return true } for subview in root.subviews where windowContainsInspectorViews(subview) { return true } return false } private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool { guard window.title.hasPrefix("Web Inspector") else { return false } guard let contentView = window.contentView else { return false } return windowContainsInspectorViews(contentView) } private func detachedDeveloperToolsWindows() -> [NSWindow] { let mainWindow = webView.window return NSApp.windows.filter { candidate in if let mainWindow, candidate === mainWindow { return false } return Self.isDetachedInspectorWindow(candidate) } } private func hasAttachedDeveloperToolsLayout() -> Bool { guard let container = webView.superview else { return false } return Self.visibleDescendants(in: container) .contains { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) } } private func setPreferredDeveloperToolsPresentation(_ next: DeveloperToolsPresentation) { guard preferredDeveloperToolsPresentation != next else { return } preferredDeveloperToolsPresentation = next DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } } private func syncDeveloperToolsPresentationPreferenceFromUI() { if !detachedDeveloperToolsWindows().isEmpty { setPreferredDeveloperToolsPresentation(.detached) } else if hasAttachedDeveloperToolsLayout() { setPreferredDeveloperToolsPresentation(.attached) developerToolsDetachedOpenGraceDeadline = nil } } private func installDetachedDeveloperToolsWindowCloseObserver() { guard detachedDeveloperToolsWindowCloseObserver == nil else { return } detachedDeveloperToolsWindowCloseObserver = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: nil, queue: .main ) { [weak self] notification in guard let self, let window = notification.object as? NSWindow else { return } let isDetachedInspectorWindow = MainActor.assumeIsolated { Self.isDetachedInspectorWindow(window) } guard isDetachedInspectorWindow else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } guard self.preferredDeveloperToolsPresentation == .detached else { return } guard self.preferredDeveloperToolsVisible else { return } guard !self.isDeveloperToolsVisible() else { return } self.developerToolsDetachedOpenGraceDeadline = nil self.preferredDeveloperToolsVisible = false self.cancelDeveloperToolsRestoreRetry() #if DEBUG dlog( "browser.devtools detachedClose.manual panel=\(self.id.uuidString.prefix(5)) " + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" ) #endif } } } private func shouldDismissDetachedDeveloperToolsWindows() -> Bool { preferredDeveloperToolsPresentation == .attached } private func dismissDetachedDeveloperToolsWindowsIfNeeded() { guard shouldDismissDetachedDeveloperToolsWindows() else { return } guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(), let mainWindow = webView.window else { return } for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) { #if DEBUG dlog( "browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " + "title=\(window.title) frame=\(NSStringFromRect(window.frame))" ) #endif window.close() } } private func scheduleDetachedDeveloperToolsWindowDismissal() { guard shouldDismissDetachedDeveloperToolsWindows() else { return } for delay in [0.0, 0.15] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.dismissDetachedDeveloperToolsWindowsIfNeeded() } } } private func prepareDeveloperToolsForRevealIfNeeded(_ inspector: NSObject) { guard preferredDeveloperToolsPresentation == .unknown else { return } let attachSelector = NSSelectorFromString("attach") guard inspector.responds(to: attachSelector) else { return } inspector.cmuxCallVoid(selector: attachSelector) } @discardableResult private func revealDeveloperTools(_ inspector: NSObject) -> Bool { let isVisibleSelector = NSSelectorFromString("isVisible") if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false { developerToolsDetachedOpenGraceDeadline = nil return true } prepareDeveloperToolsForRevealIfNeeded(inspector) let showSelector = NSSelectorFromString("show") guard inspector.responds(to: showSelector) else { return false } inspector.cmuxCallVoid(selector: showSelector) let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false if preferredDeveloperToolsPresentation == .detached { developerToolsDetachedOpenGraceDeadline = visibleAfterShow ? nil : Date().addingTimeInterval(developerToolsDetachedOpenGracePeriod) } else { developerToolsDetachedOpenGraceDeadline = nil } return visibleAfterShow } @discardableResult private func concealDeveloperTools(_ inspector: NSObject) -> Bool { let isVisibleSelector = NSSelectorFromString("isVisible") guard inspector.cmuxCallBool(selector: isVisibleSelector) ?? false else { return true } var invokedSelector = false for rawSelector in ["hide", "close"] { let selector = NSSelectorFromString(rawSelector) guard inspector.responds(to: selector) else { continue } invokedSelector = true inspector.cmuxCallVoid(selector: selector) if !(inspector.cmuxCallBool(selector: isVisibleSelector) ?? false) { return true } } guard invokedSelector else { return false } return !(inspector.cmuxCallBool(selector: isVisibleSelector) ?? false) } private var isDeveloperToolsTransitionInFlight: Bool { developerToolsTransitionSettleWorkItem != nil } private func effectiveDeveloperToolsVisibilityIntent() -> Bool { if let pendingDeveloperToolsTransitionTargetVisible { return pendingDeveloperToolsTransitionTargetVisible } if let developerToolsTransitionTargetVisible { return developerToolsTransitionTargetVisible } return isDeveloperToolsVisible() } private func scheduleDeveloperToolsTransitionSettle(source: String) { developerToolsTransitionSettleWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.developerToolsTransitionSettleWorkItem = nil self?.finishDeveloperToolsTransition(source: source) } developerToolsTransitionSettleWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsTransitionSettleDelay, execute: workItem) } private func finishDeveloperToolsTransition(source: String) { let pendingTargetVisible = pendingDeveloperToolsTransitionTargetVisible pendingDeveloperToolsTransitionTargetVisible = nil developerToolsTransitionTargetVisible = nil guard let pendingTargetVisible else { return } guard pendingTargetVisible != isDeveloperToolsVisible() else { return } _ = performDeveloperToolsVisibilityTransition(to: pendingTargetVisible, source: "\(source).queued") } @discardableResult private func enqueueDeveloperToolsVisibilityTransition( to targetVisible: Bool, source: String ) -> Bool { if isDeveloperToolsTransitionInFlight { pendingDeveloperToolsTransitionTargetVisible = targetVisible preferredDeveloperToolsVisible = targetVisible if !targetVisible { developerToolsDetachedOpenGraceDeadline = nil forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() } #if DEBUG dlog( "browser.devtools transition.queue panel=\(id.uuidString.prefix(5)) " + "source=\(source) target=\(targetVisible ? 1 : 0) \(debugDeveloperToolsStateSummary())" ) #endif return true } return performDeveloperToolsVisibilityTransition(to: targetVisible, source: source) } @discardableResult private func performDeveloperToolsVisibilityTransition( to targetVisible: Bool, source: String ) -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false preferredDeveloperToolsVisible = targetVisible developerToolsTransitionTargetVisible = targetVisible if targetVisible { if !visible { _ = revealDeveloperTools(inspector) } else { developerToolsDetachedOpenGraceDeadline = nil } } else { if visible { syncDeveloperToolsPresentationPreferenceFromUI() guard concealDeveloperTools(inspector) else { developerToolsTransitionTargetVisible = nil return false } } developerToolsDetachedOpenGraceDeadline = nil } if targetVisible { let visibleAfterTransition = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false if visibleAfterTransition { syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() } else { developerToolsRestoreRetryAttempt = 0 scheduleDeveloperToolsRestoreRetry() } } else { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } if visible != targetVisible { scheduleDeveloperToolsTransitionSettle(source: source) } else { developerToolsTransitionTargetVisible = nil } return true } @discardableResult func toggleDeveloperTools() -> Bool { #if DEBUG dlog( "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) #endif let targetVisible = !effectiveDeveloperToolsVisibilityIntent() let handled = enqueueDeveloperToolsVisibilityTransition(to: targetVisible, source: "toggle") #if DEBUG dlog( "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) DispatchQueue.main.async { [weak self] in guard let self else { return } dlog( "browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" ) } #endif return handled } @discardableResult func showDeveloperTools() -> Bool { return enqueueDeveloperToolsVisibilityTransition(to: true, source: "show") } @discardableResult func showDeveloperToolsConsole() -> Bool { guard showDeveloperTools() else { return false } guard !isDeveloperToolsTransitionInFlight else { return true } guard let inspector = webView.cmuxInspectorObject() else { return true } // WebKit private inspector API differs by OS; try known console selectors. let consoleSelectors = [ "showConsole", "showConsoleTab", "showConsoleView", ] for raw in consoleSelectors { let selector = NSSelectorFromString(raw) if inspector.responds(to: selector) { inspector.cmuxCallVoid(selector: selector) break } } return true } /// Called before WKWebView detaches so manual inspector closes are respected. func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { guard let inspector = webView.cmuxInspectorObject() else { return } guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } if isDeveloperToolsTransitionInFlight { let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible preferredDeveloperToolsVisible = targetVisible if targetVisible, visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() } else if !targetVisible { developerToolsDetachedOpenGraceDeadline = nil forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() } return } if visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() preferredDeveloperToolsVisible = true cancelDeveloperToolsRestoreRetry() return } if preserveVisibleIntent && preferredDeveloperToolsVisible { return } preferredDeveloperToolsVisible = false cancelDeveloperToolsRestoreRetry() } /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. func restoreDeveloperToolsAfterAttachIfNeeded() { guard preferredDeveloperToolsVisible else { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false return } guard !isDeveloperToolsTransitionInFlight else { return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return } let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach forceDeveloperToolsRefreshOnNextAttach = false let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif cancelDeveloperToolsRestoreRetry() return } let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling { preferredDeveloperToolsVisible = false developerToolsDetachedOpenGraceDeadline = nil cancelDeveloperToolsRestoreRetry() #if DEBUG dlog( "browser.devtools detachedClose.consume panel=\(id.uuidString.prefix(5)) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) #endif return } #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif // WebKit inspector show can trigger transient first-responder churn while // panel attachment is still stabilizing. Keep this auto-restore path from // mutating first responder so AppKit doesn't walk tearing-down responder chains. cmuxWithWindowFirstResponderBypass { _ = revealDeveloperTools(inspector) } preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() } else { scheduleDeveloperToolsRestoreRetry() } } @discardableResult func isDeveloperToolsVisible() -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false } @discardableResult func hideDeveloperTools() -> Bool { return enqueueDeveloperToolsVisibilityTransition(to: false, source: "hide") } /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden /// while its container is off-window. Avoid detaching in that transient phase if /// DevTools is intended to remain open, because detach/reattach can blank inspector content. func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool { preferredDeveloperToolsVisible && !hasSideDockedDeveloperToolsLayout() } func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { guard preferredDeveloperToolsVisible else { return } forceDeveloperToolsRefreshOnNextAttach = true #if DEBUG dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") #endif } func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool { forceDeveloperToolsRefreshOnNextAttach } func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool { preferredDeveloperToolsVisible && ( forceDeveloperToolsRefreshOnNextAttach || developerToolsRestoreRetryWorkItem != nil || webView.superview == nil || webView.window == nil ) } func shouldUseLocalInlineDeveloperToolsHosting() -> Bool { guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return false } if preferredDeveloperToolsPresentation == .detached { return false } return detachedDeveloperToolsWindows().isEmpty } func recordPreferredAttachedDeveloperToolsWidth(_ width: CGFloat, containerBounds: NSRect) { let normalizedWidth = max(0, width) preferredAttachedDeveloperToolsWidth = normalizedWidth guard containerBounds.width > 0 else { preferredAttachedDeveloperToolsWidthFraction = nil return } preferredAttachedDeveloperToolsWidthFraction = normalizedWidth / containerBounds.width } func preferredAttachedDeveloperToolsWidthState() -> (width: CGFloat?, widthFraction: CGFloat?) { (preferredAttachedDeveloperToolsWidth, preferredAttachedDeveloperToolsWidthFraction) } @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) } @discardableResult func zoomOut() -> Bool { applyPageZoom(webView.pageZoom - pageZoomStep) } @discardableResult func resetZoom() -> Bool { applyPageZoom(1.0) } /// Take a snapshot of the web view func takeSnapshot(completion: @escaping (NSImage?) -> Void) { let config = WKSnapshotConfiguration() webView.takeSnapshot(with: config) { image, error in if let error = error { NSLog("BrowserPanel snapshot error: %@", error.localizedDescription) completion(nil) return } completion(image) } } /// Execute JavaScript func evaluateJavaScript(_ script: String) async throws -> Any? { try await webView.evaluateJavaScript(script) } // MARK: - Find in Page func startFind() { preferredFocusIntent = .findField let created = searchState == nil if created { searchState = BrowserSearchState() } let generation = beginSearchFocusRequest(reason: "startFind") #if DEBUG let window = webView.window dlog( "browser.find.start panel=\(id.uuidString.prefix(5)) " + "created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " + "generation=\(generation) " + "window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " + "firstResponder=\(String(describing: window?.firstResponder))" ) #endif postBrowserSearchFocusNotification(reason: "immediate", generation: generation) // Focus notification can race with portal overlay mount. Re-post on the // next runloop and shortly after so the find field can claim first responder. DispatchQueue.main.async { [weak self] in self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation) } } private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) { guard canApplySearchFocusRequest(generation) else { #if DEBUG dlog( "browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) generation=\(generation)" ) #endif return } #if DEBUG let window = webView.window dlog( "browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " + "generation=\(generation) " + "reason=\(reason) window=\(window?.windowNumber ?? -1) " + "firstResponder=\(String(describing: window?.firstResponder))" ) #endif NotificationCenter.default.post(name: .browserSearchFocus, object: id) } func findNext() { Task { @MainActor [weak self] in guard let self else { return } let result = try? await self.webView.evaluateJavaScript(BrowserFindJavaScript.nextScript()) self.parseFindResult(result) } } func findPrevious() { Task { @MainActor [weak self] in guard let self else { return } let result = try? await self.webView.evaluateJavaScript(BrowserFindJavaScript.previousScript()) self.parseFindResult(result) } } func hideFind() { invalidateSearchFocusRequests(reason: "hideFind") searchState = nil } private func restoreFindStateAfterNavigation(replaySearch: Bool) { guard let state = searchState else { return } state.total = nil state.selected = nil if replaySearch, !state.needle.isEmpty { executeFindSearch(state.needle) } postBrowserSearchFocusNotification( reason: "restoreAfterNavigation", generation: searchFocusRequestGeneration ) } private func executeFindSearch(_ needle: String) { guard !needle.isEmpty else { executeFindClear() searchState?.selected = nil searchState?.total = nil return } Task { @MainActor [weak self] in guard let self else { return } let js = BrowserFindJavaScript.searchScript(query: needle) do { let result = try await self.webView.evaluateJavaScript(js) self.parseFindResult(result) } catch { NSLog("Find: browser JS search error: %@", error.localizedDescription) } } } private func executeFindClear() { Task { @MainActor [weak self] in guard let self else { return } do { _ = try await self.webView.evaluateJavaScript(BrowserFindJavaScript.clearScript()) } catch { NSLog("Find: browser JS clear error: %@", error.localizedDescription) } } } private func parseFindResult(_ result: Any?) { guard let jsonString = result as? String, let data = jsonString.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let total = json["total"] as? Int, let current = json["current"] as? Int, total >= 0, current >= 0 else { return } searchState?.total = UInt(total) searchState?.selected = total > 0 ? UInt(current) : nil } func setBrowserThemeMode(_ mode: BrowserThemeMode) { browserThemeMode = mode applyBrowserThemeModeIfNeeded() } func refreshAppearanceDrivenColors() { webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() } func suppressOmnibarAutofocus(for seconds: TimeInterval) { suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds) #if DEBUG dlog( "browser.focus.omnibarAutofocus.suppress panel=\(id.uuidString.prefix(5)) " + "seconds=\(String(format: "%.2f", seconds))" ) #endif } func suppressWebViewFocus(for seconds: TimeInterval) { suppressWebViewFocusUntil = Date().addingTimeInterval(seconds) #if DEBUG dlog( "browser.focus.webView.suppress panel=\(id.uuidString.prefix(5)) " + "seconds=\(String(format: "%.2f", seconds))" ) #endif } func clearWebViewFocusSuppression() { suppressWebViewFocusUntil = nil #if DEBUG dlog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))") #endif } func shouldSuppressOmnibarAutofocus() -> Bool { if let until = suppressOmnibarAutofocusUntil { return Date() < until } return false } func shouldSuppressWebViewFocus() -> Bool { if suppressWebViewFocusForAddressBar { return true } if searchState != nil { return true } if let until = suppressWebViewFocusUntil { return Date() < until } return false } func beginSuppressWebViewFocusForAddressBar() { let enteringAddressBar = !suppressWebViewFocusForAddressBar if enteringAddressBar { #if DEBUG dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") #endif invalidateAddressBarPageFocusRestoreAttempts() } suppressWebViewFocusForAddressBar = true if enteringAddressBar { captureAddressBarPageFocusIfNeeded() } } func endSuppressWebViewFocusForAddressBar() { if suppressWebViewFocusForAddressBar { #if DEBUG dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") #endif } suppressWebViewFocusForAddressBar = false } @discardableResult func requestAddressBarFocus() -> UUID { preferredFocusIntent = .addressBar invalidateSearchFocusRequests(reason: "requestAddressBarFocus") beginSuppressWebViewFocusForAddressBar() if let pendingAddressBarFocusRequestId { #if DEBUG dlog( "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + "request=\(pendingAddressBarFocusRequestId.uuidString.prefix(8)) result=reuse_pending" ) #endif return pendingAddressBarFocusRequestId } let requestId = UUID() pendingAddressBarFocusRequestId = requestId #if DEBUG dlog( "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) result=new" ) #endif return requestId } func noteWebViewFocused() { guard searchState == nil else { return } guard preferredFocusIntent != .webView else { return } preferredFocusIntent = .webView invalidateSearchFocusRequests(reason: "webViewFocused") } func noteAddressBarFocused() { guard preferredFocusIntent != .addressBar else { return } preferredFocusIntent = .addressBar invalidateSearchFocusRequests(reason: "addressBarFocused") } func noteFindFieldFocused() { guard preferredFocusIntent != .findField else { return } preferredFocusIntent = .findField } func canApplySearchFocusRequest(_ generation: UInt64) -> Bool { generation != 0 && generation == searchFocusRequestGeneration && searchState != nil && preferredFocusIntent == .findField } func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { return .browser(.addressBar) } if searchState != nil && preferredFocusIntent == .findField { return .browser(.findField) } if let window, Self.responderChainContains(window.firstResponder, target: webView) { return .browser(.webView) } return .browser(preferredFocusIntent) } func preferredFocusIntentForActivation() -> PanelFocusIntent { if pendingAddressBarFocusRequestId != nil { return .browser(.addressBar) } if searchState != nil && preferredFocusIntent == .findField { return .browser(.findField) } return .browser(preferredFocusIntent) } func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { guard case .browser(let target) = intent else { return } switch target { case .webView: preferredFocusIntent = .webView invalidateSearchFocusRequests(reason: "prepareWebView") endSuppressWebViewFocusForAddressBar() case .addressBar: preferredFocusIntent = .addressBar invalidateSearchFocusRequests(reason: "prepareAddressBar") beginSuppressWebViewFocusForAddressBar() case .findField: preferredFocusIntent = .findField } #if DEBUG dlog( "browser.focus.prepare panel=\(id.uuidString.prefix(5)) " + "target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)" ) #endif } @discardableResult func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { guard case .browser(let target) = intent else { return false } switch target { case .webView: noteWebViewFocused() focus() return true case .addressBar: let requestId = requestAddressBarFocus() NotificationCenter.default.post(name: .browserFocusAddressBar, object: id) #if DEBUG dlog( "browser.focus.restore panel=\(id.uuidString.prefix(5)) " + "target=addressBar request=\(requestId.uuidString.prefix(8))" ) #endif return true case .findField: startFind() return true } } func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { return .browser(.addressBar) } if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id { return .browser(.findField) } if Self.responderChainContains(responder, target: webView) { return .browser(.webView) } return nil } @discardableResult func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { guard case .browser(let target) = intent else { return false } switch target { case .findField: invalidateSearchFocusRequests(reason: "yieldFindField") let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window) #if DEBUG if yielded { dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind") } #endif return yielded case .addressBar: guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false } let yielded = window.makeFirstResponder(nil) #if DEBUG if yielded { dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar") } #endif return yielded case .webView: guard Self.responderChainContains(window.firstResponder, target: webView) else { return false } return window.makeFirstResponder(nil) } } @discardableResult private func beginSearchFocusRequest(reason: String) -> UInt64 { searchFocusRequestGeneration &+= 1 #if DEBUG dlog( "browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " + "generation=\(searchFocusRequestGeneration) reason=\(reason)" ) #endif return searchFocusRequestGeneration } private func invalidateSearchFocusRequests(reason: String) { searchFocusRequestGeneration &+= 1 #if DEBUG dlog( "browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " + "generation=\(searchFocusRequestGeneration) reason=\(reason)" ) #endif } func acknowledgeAddressBarFocusRequest(_ requestId: UUID) { guard pendingAddressBarFocusRequestId == requestId else { #if DEBUG dlog( "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) result=ignored " + "pending=\(pendingAddressBarFocusRequestId?.uuidString.prefix(8) ?? "nil")" ) #endif return } pendingAddressBarFocusRequestId = nil #if DEBUG dlog( "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) result=cleared" ) #endif } private func captureAddressBarPageFocusIfNeeded() { webView.evaluateJavaScript(Self.addressBarFocusCaptureScript) { [weak self] result, error in #if DEBUG guard let self else { return } if let error { dlog( "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + "result=error message=\(error.localizedDescription)" ) return } let resultValue = (result as? String) ?? "unknown" dlog( "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + "result=\(resultValue)" ) #else _ = self _ = result _ = error #endif } } private enum AddressBarPageFocusRestoreStatus: String { case restored case noState = "no_state" case missingTarget = "missing_target" case notFocused = "not_focused" case error } private static func addressBarPageFocusRestoreStatus( from result: Any?, error: Error? ) -> AddressBarPageFocusRestoreStatus { if error != nil { return .error } guard let raw = result as? String else { return .error } return AddressBarPageFocusRestoreStatus(rawValue: raw) ?? .error } func invalidateAddressBarPageFocusRestoreAttempts() { addressBarFocusRestoreGeneration &+= 1 #if DEBUG dlog( "browser.focus.addressBar.restore.invalidate panel=\(id.uuidString.prefix(5)) " + "generation=\(addressBarFocusRestoreGeneration)" ) #endif } func restoreAddressBarPageFocusIfNeeded(completion: @escaping (Bool) -> Void) { addressBarFocusRestoreGeneration &+= 1 let generation = addressBarFocusRestoreGeneration let delays: [TimeInterval] = [0.0, 0.03, 0.09, 0.2] restoreAddressBarPageFocusAttemptIfNeeded( attempt: 0, delays: delays, generation: generation, completion: completion ) } private func restoreAddressBarPageFocusAttemptIfNeeded( attempt: Int, delays: [TimeInterval], generation: UInt64, completion: @escaping (Bool) -> Void ) { guard generation == addressBarFocusRestoreGeneration else { completion(false) return } webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in guard let self else { completion(false) return } guard generation == self.addressBarFocusRestoreGeneration else { completion(false) return } let status = Self.addressBarPageFocusRestoreStatus(from: result, error: error) let canRetry = (status == .notFocused || status == .error) let hasNextAttempt = attempt + 1 < delays.count #if DEBUG if let error { dlog( "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + "attempt=\(attempt) status=\(status.rawValue) " + "message=\(error.localizedDescription)" ) } else { dlog( "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + "attempt=\(attempt) status=\(status.rawValue)" ) } #endif if status == .restored { completion(true) return } if canRetry && hasNextAttempt { let delay = delays[attempt + 1] DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { completion(false) return } guard generation == self.addressBarFocusRestoreGeneration else { completion(false) return } self.restoreAddressBarPageFocusAttemptIfNeeded( attempt: attempt + 1, delays: delays, generation: generation, completion: completion ) } return } completion(false) } } /// 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 .trimmingCharacters(in: .whitespacesAndNewlines), !webViewURL.isEmpty, webViewURL != blankURLString { return webViewURL } if let current = currentURL?.absoluteString .trimmingCharacters(in: .whitespacesAndNewlines), !current.isEmpty, current != blankURLString { return current } return nil } private func resolvedCurrentSessionHistoryURL() -> URL? { if let webViewURL = webView.url, Self.serializableSessionHistoryURLString(webViewURL) != nil { return webViewURL } if let currentURL, Self.serializableSessionHistoryURLString(currentURL) != nil { return currentURL } return restoredHistoryCurrentURL } private func refreshNavigationAvailability() { let resolvedCanGoBack: Bool let resolvedCanGoForward: Bool if usesRestoredSessionHistory { resolvedCanGoBack = !restoredBackHistoryStack.isEmpty resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty } else { resolvedCanGoBack = nativeCanGoBack resolvedCanGoForward = nativeCanGoForward } if canGoBack != resolvedCanGoBack { canGoBack = resolvedCanGoBack } if canGoForward != resolvedCanGoForward { canGoForward = resolvedCanGoForward } } private func abandonRestoredSessionHistoryIfNeeded() { guard usesRestoredSessionHistory else { return } usesRestoredSessionHistory = false restoredBackHistoryStack.removeAll(keepingCapacity: false) restoredForwardHistoryStack.removeAll(keepingCapacity: false) restoredHistoryCurrentURL = nil refreshNavigationAvailability() } private static func serializableSessionHistoryURLString(_ url: URL?) -> String? { guard let url else { return nil } let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty, value != "about:blank" else { return nil } return value } private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil } return URL(string: trimmed) } private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] { values.compactMap { sanitizedSessionHistoryURL($0) } } } private extension BrowserPanel { func applyBrowserThemeModeIfNeeded() { switch browserThemeMode { case .system: webView.appearance = nil case .light: webView.appearance = NSAppearance(named: .aqua) case .dark: webView.appearance = NSAppearance(named: .darkAqua) } let script = makeBrowserThemeModeScript(mode: browserThemeMode) webView.evaluateJavaScript(script) { _, error in #if DEBUG if let error { dlog("browser.themeMode error=\(error.localizedDescription)") } #endif } } func makeBrowserThemeModeScript(mode: BrowserThemeMode) -> String { let colorSchemeLiteral: String switch mode { case .system: colorSchemeLiteral = "null" case .light: colorSchemeLiteral = "'light'" case .dark: colorSchemeLiteral = "'dark'" } return """ (() => { const metaId = 'cmux-browser-theme-mode-meta'; const colorScheme = \(colorSchemeLiteral); const root = document.documentElement || document.body; if (!root) return; let meta = document.getElementById(metaId); if (colorScheme) { root.style.setProperty('color-scheme', colorScheme, 'important'); root.setAttribute('data-cmux-browser-theme', colorScheme); if (!meta) { meta = document.createElement('meta'); meta.id = metaId; meta.name = 'color-scheme'; (document.head || root).appendChild(meta); } meta.setAttribute('content', colorScheme); } else { root.style.removeProperty('color-scheme'); root.removeAttribute('data-cmux-browser-theme'); if (meta) { meta.remove(); } } })(); """ } func scheduleDeveloperToolsRestoreRetry() { guard preferredDeveloperToolsVisible else { return } guard developerToolsRestoreRetryWorkItem == nil else { return } guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return } developerToolsRestoreRetryAttempt += 1 let work = DispatchWorkItem { [weak self] in guard let self else { return } self.developerToolsRestoreRetryWorkItem = nil self.restoreDeveloperToolsAfterAttachIfNeeded() } developerToolsRestoreRetryWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work) } func cancelDeveloperToolsRestoreRetry() { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil developerToolsRestoreRetryAttempt = 0 } } #if DEBUG extension BrowserPanel { func configureInsecureHTTPAlertHooksForTesting( alertFactory: @escaping () -> NSAlert, windowProvider: @escaping () -> NSWindow? ) { insecureHTTPAlertFactory = alertFactory insecureHTTPAlertWindowProvider = windowProvider } func resetInsecureHTTPAlertHooksForTesting() { insecureHTTPAlertFactory = { NSAlert() } insecureHTTPAlertWindowProvider = { [weak self] in self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } } func presentInsecureHTTPAlertForTesting( url: URL, recordTypedNavigation: Bool = false ) { presentInsecureHTTPAlert( for: URLRequest(url: url), intent: .currentTab, recordTypedNavigation: recordTypedNavigation ) } private static func debugRectDescription(_ rect: NSRect) -> String { String( format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height ) } private static func debugObjectToken(_ object: AnyObject?) -> String { guard let object else { return "nil" } return String(describing: Unmanaged.passUnretained(object).toOpaque()) } private static func debugInspectorSubviewCount(in root: NSView) -> Int { var stack: [NSView] = [root] var count = 0 while let current = stack.popLast() { for subview in current.subviews { if String(describing: type(of: subview)).contains("WKInspector") { count += 1 } stack.append(subview) } } return count } func debugDeveloperToolsStateSummary() -> String { let preferred = preferredDeveloperToolsVisible ? 1 : 0 let visible = isDeveloperToolsVisible() ? 1 : 0 let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 let transitionTarget = developerToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" let pendingTarget = pendingDeveloperToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)" } func debugDeveloperToolsGeometrySummary() -> String { let container = webView.superview let containerBounds = container?.bounds ?? .zero let webFrame = webView.frame let inspectorInsets = max(0, containerBounds.height - webFrame.height) let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY) let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow) let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0 let containerType = container.map { String(describing: type(of: $0)) } ?? "nil" 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)" } } #endif private extension BrowserPanel { @discardableResult func applyPageZoom(_ candidate: CGFloat) -> Bool { let clamped = max(minPageZoom, min(maxPageZoom, candidate)) if abs(webView.pageZoom - clamped) < 0.0001 { return false } webView.pageZoom = clamped return true } static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { var r = start var hops = 0 while let cur = r, hops < 64 { if cur === target { return true } r = cur.nextResponder hops += 1 } return false } func hasSideDockedDeveloperToolsLayout() -> Bool { guard let container = webView.superview else { return false } return Self.visibleDescendants(in: container) .filter { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) } .contains { inspectorCandidate in hasSideDockedInspectorSibling(startingAt: inspectorCandidate, root: container) } } func hasSideDockedInspectorSibling(startingAt inspectorLeaf: NSView, root: NSView) -> Bool { var current: NSView? = inspectorLeaf while let inspectorView = current, inspectorView !== root { guard let containerView = inspectorView.superview else { break } let hasSideDockedSibling = containerView.subviews.contains { candidate in guard Self.isVisibleSideDockSiblingCandidate(candidate) else { return false } guard candidate !== inspectorView else { return false } let horizontallyAdjacent = candidate.frame.maxX <= inspectorView.frame.minX + 1 || candidate.frame.minX >= inspectorView.frame.maxX - 1 guard horizontallyAdjacent else { return false } return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 } if hasSideDockedSibling { return true } current = containerView } return false } static func visibleDescendants(in root: NSView) -> [NSView] { var descendants: [NSView] = [] var stack = Array(root.subviews.reversed()) while let view = stack.popLast() { descendants.append(view) stack.append(contentsOf: view.subviews.reversed()) } return descendants } static func isInspectorView(_ view: NSView) -> Bool { String(describing: type(of: view)).contains("WKInspector") } static func isVisibleSideDockInspectorCandidate(_ view: NSView) -> Bool { !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1 } static func isVisibleSideDockSiblingCandidate(_ view: NSView) -> Bool { !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1 } static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) } } extension WKWebView { func cmuxInspectorObject() -> NSObject? { let selector = NSSelectorFromString("_inspector") guard responds(to: selector), let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { return nil } return inspector } func cmuxInspectorFrontendWebView() -> WKWebView? { guard let inspector = cmuxInspectorObject() else { return nil } let selector = NSSelectorFromString("inspectorWebView") guard inspector.responds(to: selector), let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else { return nil } return inspectorWebView } } private extension NSObject { func cmuxCallBool(selector: Selector) -> Bool? { guard responds(to: selector) else { return nil } typealias Fn = @convention(c) (AnyObject, Selector) -> Bool let fn = unsafeBitCast(method(for: selector), to: Fn.self) return fn(self, selector) } func cmuxCallVoid(selector: Selector) { guard responds(to: selector) else { return } typealias Fn = @convention(c) (AnyObject, Selector) -> Void let fn = unsafeBitCast(method(for: selector), to: Fn.self) fn(self, selector) } } // MARK: - Download Delegate /// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI /// during WebKit callbacks), then showing NSSavePanel after the download finishes. class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private struct DownloadState { let tempURL: URL let suggestedFilename: String } /// Tracks active downloads keyed by WKDownload identity. private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] private let activeDownloadsLock = NSLock() var onDownloadStarted: ((String) -> Void)? var onDownloadReadyToSave: (() -> Void)? var onDownloadFailed: ((Error) -> Void)? private static let tempDir: URL = { let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir }() private static func sanitizedFilename(_ raw: String, fallbackURL: URL?) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let candidate = (trimmed as NSString).lastPathComponent let fromURL = fallbackURL?.lastPathComponent ?? "" let base = candidate.isEmpty ? fromURL : candidate let replaced = base.replacingOccurrences(of: ":", with: "-") let safe = replaced.trimmingCharacters(in: .whitespacesAndNewlines) return safe.isEmpty ? "download" : safe } private func storeState(_ state: DownloadState, for download: WKDownload) { activeDownloadsLock.lock() activeDownloads[ObjectIdentifier(download)] = state activeDownloadsLock.unlock() } private func removeState(for download: WKDownload) -> DownloadState? { activeDownloadsLock.lock() let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) activeDownloadsLock.unlock() return state } private func notifyOnMain(_ action: @escaping () -> Void) { if Thread.isMainThread { action() } else { DispatchQueue.main.async(execute: action) } } func download( _ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void ) { // Save to a temp file — return synchronously so WebKit is never blocked. let safeFilename = Self.sanitizedFilename(suggestedFilename, fallbackURL: response.url) let tempFilename = "\(UUID().uuidString)-\(safeFilename)" let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) try? FileManager.default.removeItem(at: destURL) storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download) notifyOnMain { [weak self] in self?.onDownloadStarted?(safeFilename) } #if DEBUG dlog("download.decideDestination file=\(safeFilename)") #endif NSLog("BrowserPanel download: temp path=%@", destURL.path) completionHandler(destURL) } func downloadDidFinish(_ download: WKDownload) { guard let info = removeState(for: download) else { #if DEBUG dlog("download.finished missing-state") #endif return } #if DEBUG dlog("download.finished file=\(info.suggestedFilename)") #endif NSLog("BrowserPanel download finished: %@", info.suggestedFilename) // Show NSSavePanel on the next runloop iteration (safe context). DispatchQueue.main.async { self.onDownloadReadyToSave?() let savePanel = NSSavePanel() savePanel.nameFieldStringValue = info.suggestedFilename savePanel.canCreateDirectories = true savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first savePanel.begin { result in guard result == .OK, let destURL = savePanel.url else { try? FileManager.default.removeItem(at: info.tempURL) return } do { try? FileManager.default.removeItem(at: destURL) try FileManager.default.moveItem(at: info.tempURL, to: destURL) NSLog("BrowserPanel download saved: %@", destURL.path) } catch { NSLog("BrowserPanel download move failed: %@", error.localizedDescription) try? FileManager.default.removeItem(at: info.tempURL) } } } } func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { if let info = removeState(for: download) { try? FileManager.default.removeItem(at: info.tempURL) } notifyOnMain { [weak self] in self?.onDownloadFailed?(error) } #if DEBUG dlog("download.failed error=\(error.localizedDescription)") #endif NSLog("BrowserPanel download failed: %@", error.localizedDescription) } } // MARK: - Navigation Delegate func browserNavigationShouldOpenInNewTab( navigationType: WKNavigationType, modifierFlags: NSEvent.ModifierFlags, buttonNumber: Int, hasRecentMiddleClickIntent: Bool = false, currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber ) -> Bool { guard navigationType == .linkActivated || navigationType == .other else { return false } if modifierFlags.contains(.command) { return true } if buttonNumber == 2 { return true } // In some WebKit paths, middle-click arrives as buttonNumber=4. // Recover intent when we just observed a local middle-click. if buttonNumber == 4, hasRecentMiddleClickIntent { return true } // WebKit can omit buttonNumber for middle-click link activations. if let currentEventType, (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), currentEventButtonNumber == 2 { return true } 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 } 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)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? /// Direct reference to the download delegate — must be set synchronously in didBecome callbacks. var downloadDelegate: WKDownloadDelegate? /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL /// when a provisional navigation fails (e.g. connection refused on localhost:3000). var lastAttemptedURL: URL? func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastAttemptedURL = webView.url } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { didFinish?(webView) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) // Treat committed-navigation failures the same as provisional ones so // stale favicon/title state from the prior page gets cleared. let failedURL = webView.url?.absoluteString ?? "" didFailNavigation?(webView, failedURL) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { let nsError = error as NSError NSLog("BrowserPanel provisional navigation failed: %@", error.localizedDescription) // Cancelled navigations (e.g. rapid typing) are not real errors. if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } // "Frame load interrupted" (WebKitErrorDomain code 102) fires when a // navigation response is converted into a download via .download policy. // This is expected and should not show an error page. if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { return } let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String ?? lastAttemptedURL?.absoluteString ?? "" didFailNavigation?(webView, failedURL) loadErrorPage(in: webView, failedURL: failedURL, error: nsError) } func webView( _ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { // WKWebView rejects all authentication challenges by default when this // delegate method is not implemented (.rejectProtectionSpace). This // breaks TLS client-certificate flows such as Microsoft Entra ID // Conditional Access, which verifies device compliance via a client // certificate stored in the system keychain by MDM enrollment. // // By returning .performDefaultHandling the system's standard URL-loading // behaviour takes over: the keychain is searched for matching client // identities, MDM-installed root CAs are trusted, and any configured SSO // extensions (e.g. Microsoft Enterprise SSO) can intercept the challenge. completionHandler(.performDefaultHandling, nil) } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { #if DEBUG dlog("browser.webcontent.terminated panel=\(String(describing: self))") #endif didTerminateWebContentProcess?(webView) } private func loadErrorPage(in webView: WKWebView, failedURL: String, error: NSError) { let title: String let message: String switch (error.domain, error.code) { case (NSURLErrorDomain, NSURLErrorCannotConnectToHost), (NSURLErrorDomain, NSURLErrorCannotFindHost), (NSURLErrorDomain, NSURLErrorTimedOut): title = String(localized: "browser.error.cantReach.title", defaultValue: "Can\u{2019}t reach this page") if failedURL.isEmpty { message = String(localized: "browser.error.cantReach.messageSite", defaultValue: "The site refused to connect. Check that a server is running on this address.") } else { message = String(localized: "browser.error.cantReach.messageURL", defaultValue: "\(failedURL) refused to connect. Check that a server is running on this address.") } case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet), (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): title = String(localized: "browser.error.noInternet", defaultValue: "No internet connection") message = String(localized: "browser.error.checkNetwork", defaultValue: "Check your network connection and try again.") case (NSURLErrorDomain, NSURLErrorSecureConnectionFailed), (NSURLErrorDomain, NSURLErrorServerCertificateUntrusted), (NSURLErrorDomain, NSURLErrorServerCertificateHasUnknownRoot), (NSURLErrorDomain, NSURLErrorServerCertificateHasBadDate), (NSURLErrorDomain, NSURLErrorServerCertificateNotYetValid): title = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") message = String(localized: "browser.error.invalidCertificate", defaultValue: "The certificate for this site is invalid.") default: title = String(localized: "browser.error.cantOpen.title", defaultValue: "Can\u{2019}t open this page") message = error.localizedDescription } let escapeHTML: (String) -> String = { value in value .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } let escapedTitle = escapeHTML(title) let escapedMessage = escapeHTML(message) let escapedURL = escapeHTML(failedURL) let escapedReloadLabel = escapeHTML(String(localized: "browser.error.reload", defaultValue: "Reload")) let html = """

\(escapedTitle)

\(escapedMessage)

\(escapedURL)
""" webView.loadHTMLString(html, baseURL: URL(string: failedURL)) } func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( navigationType: navigationAction.navigationType, modifierFlags: navigationAction.modifierFlags, buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" let navType = String(describing: navigationAction.navigationType) dlog( "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" ) #endif if let url = navigationAction.request.url, navigationAction.targetFrame?.isMainFrame != false, shouldBlockInsecureHTTPNavigation?(url) == true { let intent: BrowserInsecureHTTPNavigationIntent if shouldOpenInNewTab || navigationAction.targetFrame == nil { intent = .newTab } else { intent = .currentTab } #if DEBUG dlog( "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + "url=\(url.absoluteString)" ) #endif handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) decisionHandler(.cancel) return } // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). // Hand these off to macOS so the owning app can handle them. if let url = navigationAction.request.url, navigationAction.targetFrame?.isMainFrame != false, 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=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") #endif decisionHandler(.cancel) return } // Cmd+click and middle-click on regular links should always open in a new tab. if shouldOpenInNewTab, let url = navigationAction.request.url { #if DEBUG dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") #endif openInNewTab?(url) decisionHandler(.cancel) return } // 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)") #endif openInNewTab?(url) decisionHandler(.cancel) return } #if DEBUG let targetURL = navigationAction.request.url?.absoluteString ?? "nil" dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") #endif decisionHandler(.allow) } func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void ) { if !navigationResponse.isForMainFrame { decisionHandler(.allow) return } let mime = navigationResponse.response.mimeType ?? "unknown" let canShow = navigationResponse.canShowMIMEType let responseURL = navigationResponse.response.url?.absoluteString ?? "nil" // Only classify HTTP(S) top-level responses as downloads. if let scheme = navigationResponse.response.url?.scheme?.lowercased(), scheme != "http", scheme != "https" { decisionHandler(.allow) return } NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d", responseURL, mime, canShow ? 1 : 0, navigationResponse.isForMainFrame ? 1 : 0) // Check if this response should be treated as a download. // Criteria: explicit Content-Disposition: attachment, or a MIME type // that WebKit cannot render inline. if let response = navigationResponse.response as? HTTPURLResponse { let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" if contentDisposition.lowercased().hasPrefix("attachment") { NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL) #if DEBUG dlog("download.policy=download reason=content-disposition mime=\(mime)") #endif decisionHandler(.download) return } } if !canShow { NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL) #if DEBUG dlog("download.policy=download reason=cannotShowMIME mime=\(mime)") #endif decisionHandler(.download) return } decisionHandler(.allow) } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG dlog("download.didBecome source=navigationAction") #endif NSLog("BrowserPanel download didBecome from navigationAction") download.delegate = downloadDelegate } func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { #if DEBUG dlog("download.didBecome source=navigationResponse") #endif NSLog("BrowserPanel download didBecome from navigationResponse") download.delegate = downloadDelegate } } // MARK: - UI Delegate 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 { 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()) } /// 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? { #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" let navType = String(describing: navigationAction.navigationType) dlog( "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + "eventType=\(currentEventType) eventButton=\(currentEventButton)" ) #endif // 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 dlog( "browser.nav.createWebView.action kind=requestNavigation intent=newTab " + "url=\(url.absoluteString)" ) #endif requestNavigation(navigationAction.request, intent) } else { #if DEBUG dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") #endif openInNewTab?(url) } } return nil } /// Handle elements by presenting the native file picker. 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) } 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) } } } } // MARK: - Browser Data Import enum BrowserImportScope: String, CaseIterable, Identifiable { case cookiesOnly case historyOnly case cookiesAndHistory case everything var id: String { rawValue } var displayName: String { switch self { case .cookiesOnly: return String(localized: "browser.import.scope.cookiesOnly", defaultValue: "Cookies only") case .historyOnly: return String(localized: "browser.import.scope.historyOnly", defaultValue: "History only") case .cookiesAndHistory: return String(localized: "browser.import.scope.cookiesAndHistory", defaultValue: "Cookies + history") case .everything: return String(localized: "browser.import.scope.everything", defaultValue: "Everything") } } var includesCookies: Bool { switch self { case .cookiesOnly, .cookiesAndHistory, .everything: return true case .historyOnly: return false } } var includesHistory: Bool { switch self { case .cookiesOnly: return false case .historyOnly, .cookiesAndHistory, .everything: return true } } static func fromSelection( includeCookies: Bool, includeHistory: Bool, includeAdditionalData: Bool ) -> BrowserImportScope? { if includeAdditionalData { return .everything } guard includeCookies || includeHistory else { return nil } if includeCookies && includeHistory { return .cookiesAndHistory } if includeCookies { return .cookiesOnly } return .historyOnly } } enum BrowserImportEngineFamily: String, Hashable { case chromium case firefox 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 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 resolvedFamily: BrowserImportEngineFamily let homeDirectoryURL: URL let appURL: URL? let dataRootURL: URL? let profiles: [InstalledBrowserProfile] let detectionSignals: [String] let detectionScore: Int var id: String { descriptor.id } var displayName: String { descriptor.displayName } var family: BrowserImportEngineFamily { resolvedFamily } var profileURLs: [URL] { profiles.map(\.rootURL) } } 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: .chromium, tier: 3, bundleIdentifiers: ["net.imput.helium", "com.jadenGeller.Helium", "com.jaden.geller.helium"], appNames: ["Helium.app"], dataRootRelativePaths: [ "Library/Application Support/net.imput.helium", "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, appBundleIdentifier: appDetection.bundleIdentifier, fileManager: fileManager ) if appDetection.url == nil, !descriptor.supportsDataOnlyDetection { return nil } let hasData = dataDetection.dataRootURL != nil || !dataDetection.profiles.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.profiles.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.profiles.isEmpty { signals.append("profiles:\(dataDetection.profiles.count)") } if !dataDetection.artifactHits.isEmpty { signals.append(contentsOf: dataDetection.artifactHits.map { "artifact:\($0)" }) } return InstalledBrowserCandidate( descriptor: descriptor, resolvedFamily: dataDetection.family, homeDirectoryURL: homeDirectoryURL, appURL: appDetection.url, dataRootURL: dataDetection.dataRootURL, profiles: dataDetection.profiles, 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 String( localized: "browser.import.detected.none", defaultValue: "No supported browsers detected." ) } let names = browsers.map(\.displayName) if names.count <= limit { return String( format: String( localized: "browser.import.detected.all", defaultValue: "Detected: %@." ), names.joined(separator: ", ") ) } 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.other", defaultValue: "Detected: %@, +%ld more." ), shown, remaining ) } private static func detectApplication( descriptor: BrowserImportBrowserDescriptor, appSearchDirectories: [URL], bundleLookup: BundleLookup, fileManager: FileManager ) -> (url: URL?, signals: [String], bundleIdentifier: String?) { for knownBundleIdentifier in descriptor.bundleIdentifiers { if let appURL = bundleLookup(knownBundleIdentifier) { return (appURL, ["bundle:\(knownBundleIdentifier)"], bundleIdentifier(for: appURL) ?? knownBundleIdentifier) } } 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)"], bundleIdentifier(for: appURL)) } } } return (nil, [], nil) } private static func detectData( descriptor: BrowserImportBrowserDescriptor, homeDirectoryURL: URL, appBundleIdentifier: String?, fileManager: FileManager ) -> (dataRootURL: URL?, family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile], artifactHits: [String]) { var bestRootURL: URL? var bestFamily = descriptor.family var bestProfiles: [InstalledBrowserProfile] = [] var bestArtifacts: [String] = [] let candidateRootPaths = candidateDataRootRelativePaths( descriptor: descriptor, appBundleIdentifier: appBundleIdentifier ) for relativePath in candidateRootPaths { let rootURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: true) guard fileManager.fileExists(atPath: rootURL.path) else { continue } let detectedProfiles = detectProfiles( descriptor: descriptor, rootURL: rootURL, homeDirectoryURL: homeDirectoryURL, fileManager: fileManager ) 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 bestFamily = detectedProfiles.family bestProfiles = detectedProfiles.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 = candidateRootPaths.first { let rootURL = homeDirectoryURL.appendingPathComponent(rootPath, isDirectory: true) if fileManager.fileExists(atPath: rootURL.path) { bestRootURL = rootURL } } } if bestProfiles.isEmpty, let bestRootURL { bestProfiles = [ InstalledBrowserProfile( displayName: String(localized: "browser.profile.default", defaultValue: "Default"), rootURL: bestRootURL, isDefault: true ) ] } return ( dataRootURL: bestRootURL, family: bestFamily, profiles: sortProfiles(dedupedProfiles(bestProfiles)), artifactHits: bestArtifacts ) } 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() 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 ) -> [InstalledBrowserProfile] { let nameMap = chromiumProfileNameMap(rootURL: rootURL) var profiles: [InstalledBrowserProfile] = [] if looksLikeChromiumProfile(rootURL: rootURL, fileManager: fileManager) { profiles.append( InstalledBrowserProfile( displayName: chromiumProfileDisplayName( directoryName: rootURL.lastPathComponent, nameMap: nameMap, isDefault: true ), rootURL: rootURL, isDefault: true ) ) } 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 ") || nameMap[name] != nil if isLikelyProfile && looksLikeChromiumProfile(rootURL: child, fileManager: fileManager) { profiles.append( InstalledBrowserProfile( displayName: chromiumProfileDisplayName( directoryName: name, nameMap: nameMap, isDefault: name == "Default" ), rootURL: child, isDefault: name == "Default" ) ) } } return sortProfiles(dedupedProfiles(profiles)) } private static func firefoxProfiles( rootURL: URL, fileManager: FileManager ) -> [InstalledBrowserProfile] { 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) { let directoryName = child.lastPathComponent profiles.append( InstalledBrowserProfile( displayName: directoryName, rootURL: child, isDefault: directoryName.localizedCaseInsensitiveContains("default") ) ) } } } return sortProfiles(dedupedProfiles(profiles)) } private static func firefoxProfilesFromINI( rootURL: URL, fileManager: FileManager ) -> [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] = [:] 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 webKitProfiles( descriptor: BrowserImportBrowserDescriptor, rootURL: URL, homeDirectoryURL: URL, fileManager: FileManager ) -> [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 } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { result[directoryName] = trimmedName } } 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] { [ 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 dedupedProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { var seen = Set() 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 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 } } 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( 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 { 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() 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() 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 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, plan: RealizedBrowserImportExecutionPlan, scope: BrowserImportScope, domainFilters: [String] ) async -> BrowserImportOutcome { var outcomeEntries: [BrowserImportOutcomeEntry] = [] var warnings: [String] = [] var seenWarnings = Set() 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) } } if scope == .everything { 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, warnings: warnings ) } 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, sourceProfiles: sourceProfiles, destinationProfileID: destinationProfileID, domainFilters: domainFilters ) case .chromium: 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: [ String( localized: "browser.import.warning.safariCookiesUnsupported", defaultValue: "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." ) ] ) } return CookieImportResult( importedCount: 0, skippedCount: 0, warnings: [ String( format: String( localized: "browser.import.warning.cookieImportUnsupported", defaultValue: "%@ cookie import is not implemented yet." ), browser.displayName ) ] ) } } 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, sourceProfiles: sourceProfiles, destinationProfileID: destinationProfileID, domainFilters: domainFilters ) case .chromium: return await importChromiumHistory( from: browser, sourceProfiles: sourceProfiles, destinationProfileID: destinationProfileID, domainFilters: domainFilters ) case .webkit: 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 = sourceProfiles.map { $0.rootURL.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( 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, 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 = sourceProfiles.map { $0.rootURL.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 encryptedValue = sqliteColumnData(statement, index: 6) guard !name.isEmpty else { return } guard domainMatches(host: host, filters: domainFilters) else { 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] = [ .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( 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, 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) } 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 = sourceProfiles.map { $0.rootURL.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) ?? .distantPast rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { 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, 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 = sourceProfiles.map { $0.rootURL.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) ?? .distantPast rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) } } catch { 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, 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 = sourceProfiles.map { $0.rootURL.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: [ String( format: String( localized: "browser.import.warning.noHistoryDatabase", defaultValue: "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( String( format: String( localized: "browser.import.warning.browserHistoryReadFailed", defaultValue: "Failed reading %@ history at %@: %@" ), browser.displayName, databaseURL.lastPathComponent, error.localizedDescription ) ) } } let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) return HistoryImportResult(importedCount: importedCount, warnings: warnings) } 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 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) ) } let historyStore = BrowserProfileStore.shared.historyStore(for: destinationProfileID) return historyStore.mergeImportedEntries(entries) } } private static func setCookiesInStore(_ cookies: [HTTPCookie], destinationProfileID: UUID) async -> Int { guard !cookies.isEmpty else { return 0 } let store = await MainActor.run { BrowserProfileStore.shared.websiteDataStore(for: destinationProfileID).httpCookieStore } var importedCount = 0 for cookie in cookies { 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 { 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 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) } } #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() private var importInProgress = false private init() {} 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]?, 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 = 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, 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: 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 ) await MainActor.run { self.hideProgressWindow(progressWindow) self.presentOutcome(outcome) self.importInProgress = false } } } private func promptForSelection( browsers: [InstalledBrowserCandidate], destinationProfiles: [BrowserProfileDefinition]?, defaultDestinationProfileID: UUID? ) -> ImportSelection? { guard !browsers.isEmpty else { return nil } 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] = [:] 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 var sourceProfilesScrollHeightConstraint: NSLayoutConstraint? 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 additionalDataNoteLabel = NSTextField(wrappingLabelWithString: "") 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) private let cancelButton = NSButton(title: "", target: nil, action: nil) private let primaryButton = NSButton(title: "", target: nil, action: nil) 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: 292), styleMask: [.titled, .closable], backing: .buffered, defer: false ) super.init() setupUI() configureInitialState() } func runModal() -> ImportSelection? { panel.center() panel.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) 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() { switch step { case .source: return case .sourceProfiles: step = .source case .dataTypes: step = .sourceProfiles } validationLabel.isHidden = true updateStepUI() } @objc private func handleCancel() { finishModal(with: .cancel) } @objc 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() 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: includeAdditionalData ) else { validationLabel.stringValue = String( localized: "browser.import.validation.scope", defaultValue: "Select Cookies, History, or both before starting import." ) validationLabel.isHidden = false return } let selectedBrowser = selectedBrowser() let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) selection = ImportSelection( browser: selectedBrowser, executionPlan: currentExecutionPlan(), scope: scope, domainFilters: domainFilters ) finishModal(with: .OK) } } @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() updatePanelSize() } @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 } @objc private func handleImportOptionChanged(_ sender: NSButton) { validationLabel.isHidden = true updateAdditionalDataNoteVisibility() updatePanelSize() } private func setupUI() { 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: 292)) contentView.translatesAutoresizingMaskIntoConstraints = false panel.contentView = contentView let titleLabel = NSTextField( labelWithString: String( localized: "browser.import.title", defaultValue: "Import Browser Data" ) ) titleLabel.font = NSFont.systemFont(ofSize: 22, weight: .semibold) stepLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) 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 = 3 validationLabel.translatesAutoresizingMaskIntoConstraints = false 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) 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, sourceProfilesContainer, dataTypesContainer, validationLabel, ]) contentStack.orientation = .vertical 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: 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: 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), ]) } private func setupSourceContainer() { for browser in browsers { sourcePopup.addItem(withTitle: browser.displayName) } sourcePopup.selectItem(at: 0) sourcePopup.target = self sourcePopup.action = #selector(handleSourceChanged) let sourceLabel = NSTextField( labelWithString: String(localized: "browser.import.source", defaultValue: "Source") ) sourceLabel.alignment = .right 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: 11) detectedLabel.textColor = .secondaryLabelColor detectedLabel.maximumNumberOfLines = 2 detectedLabel.preferredMaxLayoutWidth = 500 sourceContainer.orientation = .vertical sourceContainer.spacing = 8 sourceContainer.alignment = .leading sourceContainer.addArrangedSubview(sourceRow) sourceContainer.addArrangedSubview(detectedLabel) } private func setupSourceProfilesContainer() { let sourceProfilesTitle = NSTextField( labelWithString: String( localized: "browser.import.sourceProfiles", defaultValue: "Source Profiles" ) ) 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: 12) sourceProfilesEmptyLabel.textColor = .secondaryLabelColor sourceProfilesEmptyLabel.maximumNumberOfLines = 0 sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 500 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 sourceProfilesScrollHeightConstraint = sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 76) sourceProfilesScrollHeightConstraint?.isActive = true sourceProfilesScrollView.widthAnchor.constraint(equalTo: sourceProfilesContainer.widthAnchor).isActive = true 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 = 8 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 additionalDataCheckbox.state = .off cookiesCheckbox.title = String( localized: "browser.import.cookies", defaultValue: "Cookies (site sign-ins)" ) historyCheckbox.title = String( localized: "browser.import.history", defaultValue: "History (visited pages)" ) additionalDataCheckbox.title = String( 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") 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(_:)) destinationModeContainer.orientation = .vertical destinationModeContainer.spacing = 6 destinationModeContainer.alignment = .leading destinationModeContainer.addArrangedSubview(separateProfilesRadio) destinationModeContainer.addArrangedSubview(mergeProfilesRadio) mergeDestinationPopup.target = self mergeDestinationPopup.action = #selector(handleMergeDestinationChanged(_:)) mergeDestinationPopup.setContentHuggingPriority(.defaultLow, for: .horizontal) mergeDestinationPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) separateDestinationRows.orientation = .vertical separateDestinationRows.spacing = 6 separateDestinationRows.alignment = .leading mergeDestinationRow.orientation = .horizontal mergeDestinationRow.spacing = 6 mergeDestinationRow.alignment = .centerY destinationHelpLabel.font = NSFont.systemFont(ofSize: 11) destinationHelpLabel.textColor = .secondaryLabelColor 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( localized: "browser.import.destination.cmux", defaultValue: "cmux destination" ) ) 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 = 72 let domainRow = NSStackView(views: [domainLabel, domainField]) domainRow.orientation = .horizontal domainRow.spacing = 8 domainRow.alignment = .centerY domainRow.distribution = .fill additionalDataNoteLabel.stringValue = String( localized: "browser.import.additionalData.note", defaultValue: "Bookmarks, settings, and extensions import are not available yet." ) additionalDataNoteLabel.font = NSFont.systemFont(ofSize: 11) additionalDataNoteLabel.textColor = .secondaryLabelColor additionalDataNoteLabel.maximumNumberOfLines = 2 additionalDataNoteLabel.preferredMaxLayoutWidth = 500 additionalDataNoteLabel.isHidden = true dataTypesContainer.orientation = .vertical dataTypesContainer.spacing = 6 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(additionalDataCheckbox) dataTypesContainer.addArrangedSubview(additionalDataNoteLabel) dataTypesContainer.addArrangedSubview(domainRow) } private func configureInitialState() { step = .source refreshSourceProfilesList() updateAdditionalDataNoteVisibility() updateStepUI() } private func updateStepUI() { switch step { case .source: stepLabel.stringValue = String( localized: "browser.import.step.source", defaultValue: "Step 1 of 3" ) sourceContainer.isHidden = false sourceProfilesContainer.isHidden = true dataTypesContainer.isHidden = true backButton.isHidden = true primaryButton.isEnabled = true primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") case .sourceProfiles: stepLabel.stringValue = String( localized: "browser.import.step.sourceProfiles", defaultValue: "Step 2 of 3" ) 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( localized: "browser.import.step.dataTypes", defaultValue: "Step 3 of 3" ) sourceContainer.isHidden = true sourceProfilesContainer.isHidden = true dataTypesContainer.isHidden = false backButton.isHidden = false primaryButton.isEnabled = true primaryButton.title = String( localized: "browser.import.start", defaultValue: "Start Import" ) } updatePanelSize() } private func selectedBrowser() -> InstalledBrowserCandidate { let selectedIndex = max(0, min(sourcePopup.indexOfSelectedItem, browsers.count - 1)) 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) updateSourceProfilesPresentation(for: browser) 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) } updateSourceProfilesPresentation(for: browser) } private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { 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 { 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." ) 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 = "" destinationHelpLabel.isHidden = true } } 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 = 110 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) } popup.setContentHuggingPriority(.defaultLow, for: .horizontal) popup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let row = NSStackView(views: [sourceLabel, popup]) row.orientation = .horizontal row.spacing = 6 row.alignment = .centerY row.distribution = .fill 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 = 110 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 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 if NSApp.modalWindow == panel { NSApp.stopModal(withCode: response) } panel.orderOut(nil) } } 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: 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 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) { let lines = BrowserImportOutcomeFormatter.lines(for: outcome) let alert = NSAlert() alert.alertStyle = .informational alert.messageText = String( localized: "browser.import.complete.title", defaultValue: "Browser data import complete" ) alert.informativeText = lines.joined(separator: "\n") alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) alert.runModal() } }