cmux/Sources/Panels/BrowserPanel.swift
Lawrence Chen 484b66c8ac
Fix terminal keys swallowed after opening browser (#45)
* Fix terminal keys (arrows, Ctrl+N/P) swallowed after opening browser

After a browser panel is shown, SwiftUI's internal focus system activates
and its _NSHostingView starts consuming arrow keys and other non-Command
key events via performKeyEquivalent, preventing them from reaching the
terminal's keyDown handler.

Fix: In the NSWindow performKeyEquivalent swizzle, when GhosttyNSView is
the first responder and the event has no Command modifier, route directly
to the terminal's performKeyEquivalent — bypassing SwiftUI's view hierarchy
walk entirely.

Also clear stale browserAddressBarFocusedPanelId when a terminal surface
has focus, preventing Cmd+N from being eaten by omnibar selection logic
after focus transitions away from a browser.

Adds DEBUG-only keyboard event ring buffer (KeyDebugLog) that dumps to
/tmp/cmux-key-debug.log for diagnosing future key routing issues.

* Fix split focus and Cmd+Shift+N swallowed after opening browser

Split focus: capture the source terminal's hostedView before bonsplit
mutates focusedPaneId, so focusPanel moves focus FROM the old pane
instead of from the new pane to itself. Also retry ensureFocus when the
new terminal's view has no window yet (matching the existing retry
pattern for isVisibleInUI).

Cmd+Shift+N: after WKWebView has been in the responder chain, SwiftUI's
internal focus system can intercept Command-key events in the content
view hierarchy (returning true) without firing the CommandGroup action
closure. Fix by dispatching Command-key events directly to NSApp.mainMenu
when the terminal is first responder, bypassing the broken SwiftUI path.
Also add Cmd+Shift+N to handleCustomShortcut so it's customizable and
doesn't depend on SwiftUI menu dispatch at all.

* Unified debug event log: merge key/mouse/focus into /tmp/cmux-debug.log

- Delete KeyDebugLog, MouseDebugLog, klog(), mlog() from AppDelegate
- Replace all klog/mlog calls with dlog() (provided by bonsplit)
- Remove debugLogCallback wiring from Workspace
- Add focus change logging: focus.panel, focus.firstResponder,
  split.created, focus.moveFocus
- Add import Bonsplit where needed for dlog access
- Fix stale drag state on cancelled tab drags (bonsplit submodule)

* Fix split focus stolen by re-entrant becomeFirstResponder during reparenting

During programmatic splits (Cmd+D / Cmd+Shift+D), SwiftUI reparents the old
terminal view, which fires becomeFirstResponder → onFocus → focusPanel for the
OLD panel, stealing focus from the newly created pane.

Add programmaticFocusTargetPanelId guard to suppress re-entrant focusPanel
calls for non-target panels during split creation.

Also document the unified debug event log in CLAUDE.md.

* Clear stale title/favicon when browser navigation fails

When a page fails to load (e.g. connection refused), the tab was still
showing the previous page's title and favicon. Now didFailProvisionalNavigation
resets pageTitle to the failed URL and clears faviconPNGData.

* Fix Cmd+N swallowed by browser omnibar and improve split focus suppression

- Only Ctrl+N/P trigger omnibar navigation, not Cmd+N/P (Cmd+N should
  always create new workspace regardless of address bar focus)
- Move split focus suppression from workspace-level guard to source:
  suppress becomeFirstResponder side-effects (onFocus + ghostty_surface_set_focus)
  directly on the old GhosttyNSView during reparenting, preventing both
  model-level and libghostty-level focus divergence
- Remove programmaticFocusTargetPanelId from Workspace.focusPanel

* Fix omnibar hang, WebView white flash, drag-over-browser, and idle CPU spin

- Omnibar: first click selects all without entering NSTextView tracking loop;
  subsequent clicks have 3s synthetic mouseUp safety net to prevent hang
- WebView: set underPageBackgroundColor to match window so new browsers don't
  flash white before content loads
- Drag/drop: register custom UTType (com.splittabbar.tabtransfer) in Info.plist
  so WKWebView doesn't intercept tab drags; override registerForDraggedTypes
  on CmuxWebView as belt-and-suspenders
- CPU: fix infinite makeFirstResponder loop in controlTextDidEndEditing by
  checking both the text field and its field editor (the actual first responder)
2026-02-17 03:21:08 -08:00

1574 lines
56 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
// Match the empty-page background to the window so newly-created browsers
// don't flash white before content loads.
webView.underPageBackgroundColor = .windowBackgroundColor
// 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.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
}
}
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
navigationDelegate?.lastAttemptedURL = url
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 }
// 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
}
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 }
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 didFailNavigation: ((WKWebView, String) -> Void)?
var openInNewTab: ((URL) -> Void)?
/// 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)
}
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
}
let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String
?? lastAttemptedURL?.absoluteString
?? ""
didFailNavigation?(webView, failedURL)
loadErrorPage(in: webView, failedURL: failedURL, error: nsError)
}
func webView(_ webView: WKWebView, webContentProcessDidTerminate: WKWebView) {
NSLog("BrowserPanel web content process terminated, reloading")
webView.reload()
}
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 = "Can\u{2019}t reach this page"
message = "\(failedURL.isEmpty ? "The site" : failedURL) refused to connect. Check that a server is running on this address."
case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet),
(NSURLErrorDomain, NSURLErrorNetworkConnectionLost):
title = "No internet connection"
message = "Check your network connection and try again."
case (NSURLErrorDomain, NSURLErrorSecureConnectionFailed),
(NSURLErrorDomain, NSURLErrorServerCertificateUntrusted),
(NSURLErrorDomain, NSURLErrorServerCertificateHasUnknownRoot),
(NSURLErrorDomain, NSURLErrorServerCertificateHasBadDate),
(NSURLErrorDomain, NSURLErrorServerCertificateNotYetValid):
title = "Connection isn\u{2019}t secure"
message = "The certificate for this site is invalid."
default:
title = "Can\u{2019}t open this page"
message = error.localizedDescription
}
let escapedURL = failedURL
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
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>\(title)</h1>
<p>\(message)</p>
<div class="url">\(escapedURL)</div>
<button onclick="location.reload()">Reload</button>
</div>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: URL(string: failedURL))
}
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
}
}