* 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)
1574 lines
56 KiB
Swift
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: "&")
|
|
.replacingOccurrences(of: "<", with: "<")
|
|
.replacingOccurrences(of: ">", with: ">")
|
|
.replacingOccurrences(of: "\"", with: """)
|
|
|
|
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
|
|
}
|
|
}
|