import Foundation import Combine import WebKit import AppKit import Bonsplit import Network import CFNetwork 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 } struct BrowserProxyEndpoint: Equatable { let host: String let port: Int } struct BrowserRemoteWorkspaceStatus: Equatable { let target: String let connectionState: WorkspaceRemoteConnectionState let heartbeatCount: Int let lastHeartbeatAt: Date? } enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { 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 = .toolbarChip 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 { private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" private static let remoteLoopbackHosts: Set = [ "localhost", "127.0.0.1", "::1", "0.0.0.0", ] /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() /// 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 private var websiteDataStore: WKWebsiteDataStore /// 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() } } } @Published private(set) var isElementFullscreenActive: Bool = false 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 var remoteProxyEndpoint: BrowserProxyEndpoint? @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? private var usesRemoteWorkspaceProxy: Bool private struct PendingRemoteNavigation { let request: URLRequest let recordTypedNavigation: Bool let preserveRestoredSessionHistory: Bool } private var pendingRemoteNavigation: PendingRemoteNavigation? private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsTransitionTargetVisible: Bool? private var pendingDeveloperToolsTransitionTargetVisible: Bool? private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? private var developerToolsVisibilityLossCheckWorkItem: DispatchWorkItem? private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 private let developerToolsAttachedManualCloseDetectionDelay: TimeInterval = 0.35 private var developerToolsLastAttachedHostAt: Date? private var developerToolsLastKnownVisibleAt: Date? 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, websiteDataStore: WKWebsiteDataStore? = nil ) -> CmuxWebView { let config = WKWebViewConfiguration() configureWebViewConfiguration( config, websiteDataStore: websiteDataStore ?? BrowserProfileStore.shared.websiteDataStore(for: profileID) ) 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 } static func configureWebViewConfiguration( _ configuration: WKWebViewConfiguration, websiteDataStore: WKWebsiteDataStore, processPool: WKProcessPool = BrowserPanel.sharedProcessPool ) { configuration.processPool = processPool configuration.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. configuration.websiteDataStore = websiteDataStore // Enable developer extras (DevTools) configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") configuration.preferences.isElementFullscreenEnabled = true // Enable JavaScript configuration.defaultWebpagePreferences.allowsContentJavaScript = true // Keep browser console/error/dialog telemetry active from document start on every navigation. // Main frame only — injecting into cross-origin iframes causes CAPTCHA providers // (reCAPTCHA, hCaptcha, Cloudflare Turnstile) to detect the overridden console.* // methods and __cmux* globals as environment tampering, failing the challenge. configuration.userContentController.addUserScript( WKUserScript( source: Self.telemetryHookBootstrapScriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true ) ) // Track the last editable focused element continuously so omnibar exit can // restore page input focus even if capture runs after first-responder handoff. // Main frame only — same CAPTCHA interference concern as telemetry hooks. configuration.userContentController.addUserScript( WKUserScript( source: Self.addressBarFocusTrackingBootstrapScript, injectionTime: .atDocumentStart, forMainFrameOnly: true ) ) } 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 } self.realignRestoredSessionHistoryToLiveCurrentIfPossible() 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, proxyEndpoint: BrowserProxyEndpoint? = nil, isRemoteWorkspace: Bool = false, remoteWebsiteDataStoreIdentifier: UUID? = 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.remoteProxyEndpoint = proxyEndpoint self.usesRemoteWorkspaceProxy = isRemoteWorkspace self.browserThemeMode = BrowserThemeSettings.mode() self.websiteDataStore = isRemoteWorkspace ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId) : BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) let webView = Self.makeWebView( profileID: resolvedProfileID, websiteDataStore: websiteDataStore ) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } applyRemoteProxyConfigurationIfAvailable() 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] filename in guard let self else { return } self.beginDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, userInfo: [ "surfaceId": self.id, "workspaceId": self.workspaceId, "event": [ "type": "started", "filename": filename ] ] ) } dlDelegate.onDownloadReadyToSave = { [weak self] in guard let self else { return } self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, userInfo: [ "surfaceId": self.id, "workspaceId": self.workspaceId, "event": [ "type": "ready_to_save" ] ] ) } dlDelegate.onDownloadFailed = { [weak self] error in guard let self else { return } self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, userInfo: [ "surfaceId": self.id, "workspaceId": self.workspaceId, "event": [ "type": "failed", "error": error.localizedDescription ] ] ) } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate 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) } } func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { guard remoteProxyEndpoint != endpoint else { return } remoteProxyEndpoint = endpoint applyRemoteProxyConfigurationIfAvailable() resumePendingRemoteNavigationIfNeeded() } func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { guard remoteWorkspaceStatus != status else { return } remoteWorkspaceStatus = status } private func applyRemoteProxyConfigurationIfAvailable() { guard #available(macOS 14.0, *) else { return } let store = webView.configuration.websiteDataStore guard let endpoint = remoteProxyEndpoint else { store.proxyConfigurations = [] return } let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, endpoint.port > 0 && endpoint.port <= 65535, let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { store.proxyConfigurations = [] return } let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) store.proxyConfigurations = [socks, connect] } private func beginDownloadActivity() { let apply = { self.activeDownloadCount += 1 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 } func reattachToWorkspace( _ newWorkspaceId: UUID, isRemoteWorkspace: Bool, remoteWebsiteDataStoreIdentifier: UUID? = nil, proxyEndpoint: BrowserProxyEndpoint?, remoteStatus: BrowserRemoteWorkspaceStatus? ) { workspaceId = newWorkspaceId usesRemoteWorkspaceProxy = isRemoteWorkspace let targetStore = isRemoteWorkspace ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? newWorkspaceId) : BrowserProfileStore.shared.websiteDataStore(for: profileID) let needsStoreSwap = webView.configuration.websiteDataStore !== targetStore websiteDataStore = targetStore remoteProxyEndpoint = proxyEndpoint remoteWorkspaceStatus = remoteStatus if needsStoreSwap { replaceWebViewPreservingState( from: webView, websiteDataStore: targetStore, reason: "workspace_reattach" ) } applyRemoteProxyConfigurationIfAvailable() resumePendingRemoteNavigationIfNeeded() } @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) if !usesRemoteWorkspaceProxy { websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) } let replacement = Self.makeWebView( profileID: resolvedProfileID, websiteDataStore: websiteDataStore ) 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(reason: WorkspaceAttentionFlashReason) { _ = reason guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 } func sessionNavigationHistorySnapshot() -> ( backHistoryURLStrings: [String], forwardHistoryURLStrings: [String] ) { realignRestoredSessionHistoryToLiveCurrentIfPossible() let nativeBack = webView.backForwardList.backList.compactMap { Self.serializableSessionHistoryURLString($0.url) } let nativeForward = webView.backForwardList.forwardList.compactMap { Self.serializableSessionHistoryURLString($0.url) } if usesRestoredSessionHistory { let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } // `restoredForwardHistoryStack` stores nearest-forward entries at the end. let restoredForward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } if isLiveSessionHistoryAlignedWithRestoredCurrent { return ( back, restoredForward.isEmpty ? nativeForward : restoredForward ) } return (back + nativeBack, nativeForward) } return (nativeBack, nativeForward) } private func resolvedLiveSessionHistoryURL() -> URL? { if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url), Self.serializableSessionHistoryURLString(webViewURL) != nil { return webViewURL } if let currentURL, Self.serializableSessionHistoryURLString(currentURL) != nil { return currentURL } return nil } private var isLiveSessionHistoryAlignedWithRestoredCurrent: Bool { let liveCurrent = Self.serializableSessionHistoryURLString(resolvedLiveSessionHistoryURL()) let restoredCurrent = Self.serializableSessionHistoryURLString(restoredHistoryCurrentURL) guard let liveCurrent, let restoredCurrent else { return true } return liveCurrent == restoredCurrent } private func realignRestoredSessionHistoryToLiveCurrentIfPossible() { guard usesRestoredSessionHistory else { return } guard let liveCurrent = resolvedLiveSessionHistoryURL(), let liveCurrentString = Self.serializableSessionHistoryURLString(liveCurrent) else { return } guard Self.serializableSessionHistoryURLString(restoredHistoryCurrentURL) != liveCurrentString else { return } let restoredBack = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } let restoredForward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } let restoredCurrent = Self.serializableSessionHistoryURLString(restoredHistoryCurrentURL) if let backIndex = restoredBack.lastIndex(of: liveCurrentString) { let newBack = Array(restoredBack[..