cmux/Sources/Panels/BrowserPanel.swift
Lawrence Chen de6cfededa Fix inline completion showing "news." instead of "news.ycombinator.com"
Reject URLs whose host lacks a TLD (no internal dot) from autocompletion
candidates, history recording, and clean up existing bogus entries on load.
2026-02-15 21:13:31 -08:00

1459 lines
51 KiB
Swift

import Foundation
import Combine
import WebKit
import AppKit
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
case google
case duckduckgo
case bing
var id: String { rawValue }
var displayName: String {
switch self {
case .google: return "Google"
case .duckduckgo: return "DuckDuckGo"
case .bing: return "Bing"
}
}
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")
}
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 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
}
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:
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.
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
private static let sharedProcessPool = WKProcessPool()
let id: UUID
let panelType: PanelType = .browser
/// The workspace ID this panel belongs to
private(set) var workspaceId: UUID
/// The underlying web view
let webView: WKWebView
/// 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 let blankURLString = "about:blank"
/// Published URL being displayed
@Published private(set) var currentURL: URL?
/// 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 can go back state
@Published private(set) var canGoBack: Bool = false
/// Published can go forward state
@Published private(set) var canGoForward: Bool = false
/// 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
private var cancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?
private var webViewObservers: [NSKeyValueObservation] = []
// 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 lastFaviconURLString: String?
private let minPageZoom: CGFloat = 0.25
private let maxPageZoom: CGFloat = 5.0
private let pageZoomStep: CGFloat = 0.1
var displayTitle: String {
if !pageTitle.isEmpty {
return pageTitle
}
if let url = currentURL {
return url.host ?? url.absoluteString
}
return "Browser"
}
var displayIcon: String? {
"globe"
}
var isDirty: Bool {
false
}
init(workspaceId: UUID, initialURL: URL? = nil) {
self.id = UUID()
self.workspaceId = workspaceId
// Configure web view
let config = WKWebViewConfiguration()
config.processPool = BrowserPanel.sharedProcessPool
// 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
// Set up web view
let webView = CmuxWebView(frame: .zero, configuration: config)
webView.allowsBackForwardNavigationGestures = true
// Always present as Safari.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
self.webView = webView
// 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)
}
}
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
webView.navigationDelegate = navDelegate
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)
}
webView.uiDelegate = browserUIDelegate
self.uiDelegate = browserUIDelegate
// Observe web view properties
setupObservers()
// Navigate to initial URL if provided
if let url = initialURL {
navigate(to: url)
}
}
func updateWorkspaceId(_ newWorkspaceId: UUID) {
workspaceId = newWorkspaceId
}
func triggerFlash() {
focusFlashToken &+= 1
}
private func setupObservers() {
// 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
self?.canGoBack = webView.canGoBack
}
}
webViewObservers.append(backObserver)
// Can go forward
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoForward = webView.canGoForward
}
}
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)
}
// 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()
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 }
faviconTask = Task { @MainActor [weak self, weak webView] in
guard let self, let webView 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
}
}
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 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
}
}
@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 {
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) {
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
var request = URLRequest(url: url)
// Behave like a normal browser (respect HTTP caching). Reload is handled separately.
request.cachePolicy = .useProtocolCachePolicy
webView.load(request)
}
/// 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) {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
navigate(to: url)
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)
}
deinit {
webViewObservers.removeAll()
}
}
func resolveBrowserNavigableURL(_ input: String) -> URL? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard !trimmed.contains(" ") else { return nil }
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "http" || scheme == "https" {
return url
}
return nil
}
let lower = trimmed.lowercased()
if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") {
return URL(string: "http://\(trimmed)")
}
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 }
webView.goBack()
}
/// Go forward in history
func goForward() {
guard canGoForward else { return }
webView.goForward()
}
/// Open a link in a new browser surface in the same pane
func openLinkInNewTab(url: URL) {
guard let tabManager = AppDelegate.shared?.tabManager,
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }),
let paneId = workspace.paneId(forPanelId: id) else { return }
workspace.newBrowserSurface(inPane: paneId, url: url, focus: true)
}
/// Reload the current page
func reload() {
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
webView.reload()
}
/// Stop loading
func stopLoading() {
webView.stopLoading()
}
@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)
}
func suppressOmnibarAutofocus(for seconds: TimeInterval) {
suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds)
}
func suppressWebViewFocus(for seconds: TimeInterval) {
suppressWebViewFocusUntil = Date().addingTimeInterval(seconds)
}
func clearWebViewFocusSuppression() {
suppressWebViewFocusUntil = nil
}
func shouldSuppressOmnibarAutofocus() -> Bool {
if let until = suppressOmnibarAutofocusUntil {
return Date() < until
}
return false
}
func shouldSuppressWebViewFocus() -> Bool {
if suppressWebViewFocusForAddressBar {
return true
}
if let until = suppressWebViewFocusUntil {
return Date() < until
}
return false
}
func beginSuppressWebViewFocusForAddressBar() {
suppressWebViewFocusForAddressBar = true
}
func endSuppressWebViewFocusForAddressBar() {
suppressWebViewFocusForAddressBar = 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 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
}
}
// MARK: - Navigation Delegate
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
var openInNewTab: ((URL) -> Void)?
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
// Navigation started
}
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)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
NSLog("BrowserPanel provisional navigation failed: %@", error.localizedDescription)
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
// target=_blank or window.open() open in a new tab instead of a new window
if navigationAction.targetFrame == nil,
let url = navigationAction.request.url {
openInNewTab?(url)
decisionHandler(.cancel)
return
}
// Cmd+click on a regular link open in a new tab
if navigationAction.navigationType == .linkActivated,
navigationAction.modifierFlags.contains(.command),
let url = navigationAction.request.url {
openInNewTab?(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
// MARK: - UI Delegate
private class BrowserUIDelegate: NSObject, WKUIDelegate {
var openInNewTab: ((URL) -> Void)?
/// Handle cmd+click / target=_blank links. Returning nil tells WebKit not to open a new window;
/// instead we open the URL as a new surface in the same pane.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
openInNewTab?(url)
}
return nil
}
}