cmux/Sources/Panels/BrowserPanel.swift
2026-03-10 00:41:00 -07:00

4397 lines
165 KiB
Swift

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..<target.endIndex, in: target)
if regex.firstMatch(in: target, options: [], range: range) != nil {
return true
}
} else if target.range(of: value, options: [.caseInsensitive]) != nil {
return true
}
}
return false
}
/// Check whether a hostname matches the configured whitelist.
/// Empty whitelist means "allow all" (no filtering).
/// Supports exact match and wildcard prefix (`*.example.com`).
static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> 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[..<slash])
}
if value.hasPrefix("[") {
if let closing = value.firstIndex(of: "]") {
value = String(value[value.index(after: value.startIndex)..<closing])
} else {
value.removeFirst()
}
} else if let colon = value.lastIndex(of: ":"),
value[value.index(after: colon)...].allSatisfy(\.isNumber),
value.filter({ $0 == ":" }).count == 1 {
value = String(value[..<colon])
}
return trimHost(value)
}
private static func parsePatterns(from rawValue: String) -> [String] {
let separators = CharacterSet(charactersIn: ",;\n\r\t")
var out: [String] = []
var seen = Set<String>()
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<String> = [
"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<Void, Never>?
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<String>()
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<String, Never> 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 struct PortalHostLock {
let hostId: ObjectIdentifier
let paneId: UUID
}
private var activePortalHostLease: PortalHostLease?
private var pendingDistinctPortalHostReplacementPaneId: UUID?
private var lockedPortalHost: PortalHostLock?
private var webViewCancellables = Set<AnyCancellable>()
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<Void, Never>?
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 preparePortalHostReplacementForNextDistinctClaim(
inPane paneId: PaneID,
reason: String
) {
pendingDistinctPortalHostReplacementPaneId = paneId.id
if lockedPortalHost?.paneId == paneId.id {
lockedPortalHost = nil
}
#if DEBUG
dlog(
"browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))"
)
#endif
}
func claimPortalHost(
hostId: ObjectIdentifier,
paneId: PaneID,
inWindow: Bool,
bounds: CGRect,
reason: String
) -> Bool {
let next = PortalHostLease(
hostId: hostId,
paneId: paneId.id,
inWindow: inWindow,
area: Self.portalHostArea(for: bounds)
)
if let current = activePortalHostLease {
if let lock = lockedPortalHost,
(lock.hostId != current.hostId || lock.paneId != current.paneId) {
lockedPortalHost = nil
}
if current.hostId == hostId {
activePortalHostLease = next
return true
}
let currentUsable = Self.portalHostIsUsable(current)
let nextUsable = Self.portalHostIsUsable(next)
let isSamePaneReplacement = current.paneId == paneId.id
let shouldForceDistinctReplacement =
isSamePaneReplacement &&
pendingDistinctPortalHostReplacementPaneId == paneId.id &&
inWindow
if shouldForceDistinctReplacement {
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " +
"forced=1"
)
#endif
activePortalHostLease = next
pendingDistinctPortalHostReplacementPaneId = nil
lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id)
return true
}
let lockBlocksSamePaneReplacement =
isSamePaneReplacement &&
currentUsable &&
lockedPortalHost?.hostId == current.hostId &&
lockedPortalHost?.paneId == current.paneId
let shouldReplace =
current.paneId != paneId.id ||
!currentUsable ||
(
!lockBlocksSamePaneReplacement &&
nextUsable &&
next.area > (current.area * Self.portalHostReplacementAreaGainRatio)
)
if shouldReplace {
if lockedPortalHost?.hostId == current.hostId &&
lockedPortalHost?.paneId == current.paneId {
lockedPortalHost = nil
}
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area))"
)
#endif
activePortalHostLease = next
return true
}
#if DEBUG
dlog(
"browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " +
"locked=\(lockBlocksSamePaneReplacement ? 1 : 0)"
)
#endif
return false
}
activePortalHostLease = next
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=nil"
)
#endif
return true
}
@discardableResult
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool {
guard let current = activePortalHostLease, current.hostId == hostId else { return false }
activePortalHostLease = nil
if lockedPortalHost?.hostId == hostId {
lockedPortalHost = nil
}
#if DEBUG
dlog(
"browser.portal.host.release panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " +
"inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))"
)
#endif
return true
}
var displayIcon: String? {
"globe"
}
var isDirty: Bool {
false
}
private static func makeWebView() -> 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
let escapedTitle = escapeHTML(title)
let escapedMessage = escapeHTML(message)
let escapedURL = escapeHTML(failedURL)
let escapedReloadLabel = escapeHTML(String(localized: "browser.error.reload", defaultValue: "Reload"))
let html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 80vh; margin: 0; padding: 20px;
background: #1a1a1a; color: #e0e0e0;
}
.container { text-align: center; max-width: 420px; }
h1 { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
p { font-size: 13px; color: #999; line-height: 1.5; }
.url { font-size: 12px; color: #666; word-break: break-all; margin-top: 16px; }
button {
margin-top: 20px; padding: 6px 20px;
background: #333; color: #e0e0e0; border: 1px solid #555;
border-radius: 6px; font-size: 13px; cursor: pointer;
}
button:hover { background: #444; }
@media (prefers-color-scheme: light) {
body { background: #fafafa; color: #222; }
p { color: #666; }
.url { color: #999; }
button { background: #eee; color: #222; border-color: #ccc; }
button:hover { background: #ddd; }
}
</style>
</head>
<body>
<div class="container">
<h1>\(escapedTitle)</h1>
<p>\(escapedMessage)</p>
<div class="url">\(escapedURL)</div>
<button onclick="location.reload()">\(escapedReloadLabel)</button>
</div>
</body>
</html>
"""
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 <input type="file"> 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)
}
}
}
}