import Foundation import Combine import WebKit import AppKit import Bonsplit 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 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" } } 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") } 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 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)) } 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]) } } 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 } 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: 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() 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 /// 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? /// Find-in-page state. Non-nil when the find bar is visible. @Published var searchState: BrowserSearchState? = nil { didSet { if let searchState { 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 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 var activePortalHostLease: PortalHostLease? 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). private var preferredDeveloperToolsVisible: Bool = false 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 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") } 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 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 current.hostId == hostId { activePortalHostLease = next return true } let currentUsable = Self.portalHostIsUsable(current) let nextUsable = Self.portalHostIsUsable(next) let shouldReplace = current.paneId != paneId.id || !currentUsable || (nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio)) if shouldReplace { #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))" ) #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 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() -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. config.websiteDataStore = .default() // 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.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) } init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.browserThemeMode = BrowserThemeSettings.mode() let webView = Self.makeWebView() self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() navDelegate.didFinish = { webView in BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) Task { @MainActor [weak self] in self?.refreshFavicon(from: webView) self?.applyBrowserThemeModeIfNeeded() // Keep find-in-page open through load completion and refresh matches for the new DOM. self?.restoreFindStateAfterNavigation(replaySearch: true) } } navDelegate.didFailNavigation = { [weak self] _, failedURL in Task { @MainActor in guard let self else { return } // Clear stale title/favicon from the previous page so the tab // shows the failed URL instead of the old page's branding. self.pageTitle = failedURL.isEmpty ? "" : failedURL self.faviconPNGData = nil self.lastFaviconURLString = nil // Keep find-in-page open and clear stale counters on failed loads. self.restoreFindStateAfterNavigation(replaySearch: false) } } navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } 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) } self.uiDelegate = browserUIDelegate bindWebView(webView) 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 } func triggerFlash() { 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) { // URL changes let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in self?.currentURL = webView.url } } webViewObservers.append(urlObserver) // Title changes let titleObserver = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in Task { @MainActor in // 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 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 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 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 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() 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() replacement.pageZoom = desiredZoom webView = replacement webViewInstanceID = UUID() shouldRenderWebView = wasRenderable bindWebView(replacement) 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) { return } window.makeFirstResponder(webView) } func unfocus() { 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() webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil navigationDelegate = nil uiDelegate = nil webViewObservers.removeAll() webViewCancellables.removeAll() faviconTask?.cancel() faviconTask = nil } 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 faviconTask = Task { @MainActor [weak self, weak webView] in guard let self, let webView 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.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.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 { BrowserHistoryStore.shared.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 let webView = webView Task { @MainActor in BrowserWindowPortalRegistry.detach(webView: webView) } webViewObservers.removeAll() webViewCancellables.removeAll() } } 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, 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() } @discardableResult func toggleDeveloperTools() -> Bool { #if DEBUG dlog( "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) #endif guard let inspector = webView.cmuxInspectorObject() else { return false } let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false let targetVisible = !visible let selector = NSSelectorFromString(targetVisible ? "show" : "close") guard inspector.responds(to: selector) else { return false } inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = targetVisible if targetVisible { let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false if visibleAfterToggle { cancelDeveloperToolsRestoreRetry() } else { developerToolsRestoreRetryAttempt = 0 scheduleDeveloperToolsRestoreRetry() } } else { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } #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 true } @discardableResult func showDeveloperTools() -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if !visible { let showSelector = NSSelectorFromString("show") guard inspector.responds(to: showSelector) else { return false } inspector.cmuxCallVoid(selector: showSelector) } preferredDeveloperToolsVisible = true if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { cancelDeveloperToolsRestoreRetry() } else { scheduleDeveloperToolsRestoreRetry() } return true } @discardableResult func showDeveloperToolsConsole() -> Bool { guard showDeveloperTools() else { return false } 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 visible { 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 let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return } let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach forceDeveloperToolsRefreshOnNextAttach = false let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visible { #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif cancelDeveloperToolsRestoreRetry() return } let selector = NSSelectorFromString("show") guard inspector.responds(to: selector) else { cancelDeveloperToolsRestoreRetry() 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 { inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { cancelDeveloperToolsRestoreRetry() } 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 { guard let inspector = webView.cmuxInspectorObject() else { return false } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visible { let selector = NSSelectorFromString("close") guard inspector.responds(to: selector) else { return false } inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = false forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() return true } /// 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 } @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() { if searchState == nil { searchState = BrowserSearchState() } postBrowserSearchFocusNotification() // 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() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.postBrowserSearchFocusNotification() } } private func postBrowserSearchFocusNotification() { 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() { 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() } 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 { 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 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 return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" } 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)) } } private 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 } } 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. private 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 } 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 or window.open() — open in a new tab. if navigationAction.targetFrame == nil, 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)? 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()) } /// Returning nil tells WebKit not to open a new window. /// createWebViewWith is only called when the page requests a new window /// (window.open(), target=_blank, etc.). Always open in a new tab. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { // createWebViewWith is only called when the page requests a new window, // so always treat as new-tab intent regardless of modifiers/button. #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" 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) " + "openInNewTab=1" ) #endif if let url = navigationAction.request.url { if browserShouldOpenURLExternally(url) { let opened = NSWorkspace.shared.open(url) if !opened { NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) } #if DEBUG dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") #endif return nil } 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) } } } }