1885 lines
68 KiB
Swift
1885 lines
68 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 BrowserLinkOpenSettings {
|
|
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
|
|
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
|
|
|
|
static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil {
|
|
return defaultOpenTerminalLinksInCmuxBrowser
|
|
}
|
|
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
|
|
}
|
|
}
|
|
|
|
enum BrowserInsecureHTTPSettings {
|
|
static let allowlistKey = "browserInsecureHTTPAllowlist"
|
|
static let defaultAllowlistPatterns = [
|
|
"127.0.0.1",
|
|
"localhost",
|
|
"*.localtest.me",
|
|
]
|
|
static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n")
|
|
|
|
static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] {
|
|
normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey))
|
|
}
|
|
|
|
static func normalizedAllowlistPatterns(rawValue: String?) -> [String] {
|
|
let source: String
|
|
if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
source = rawValue
|
|
} else {
|
|
source = defaultAllowlistText
|
|
}
|
|
let parsed = parsePatterns(from: source)
|
|
return parsed.isEmpty ? defaultAllowlistPatterns : parsed
|
|
}
|
|
|
|
static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool {
|
|
isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey))
|
|
}
|
|
|
|
static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool {
|
|
guard let normalizedHost = normalizeHost(host) else { return false }
|
|
return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in
|
|
hostMatchesPattern(normalizedHost, pattern: pattern)
|
|
}
|
|
}
|
|
|
|
static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) {
|
|
guard let normalizedHost = normalizeHost(host) else { return }
|
|
var patterns = normalizedAllowlistPatterns(defaults: defaults)
|
|
guard !patterns.contains(normalizedHost) else { return }
|
|
patterns.append(normalizedHost)
|
|
defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey)
|
|
}
|
|
|
|
static func normalizeHost(_ rawHost: String) -> String? {
|
|
var value = rawHost
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
guard !value.isEmpty else { return nil }
|
|
|
|
if let parsed = URL(string: value)?.host {
|
|
return trimHost(parsed)
|
|
}
|
|
|
|
if let schemeRange = value.range(of: "://") {
|
|
value = String(value[schemeRange.upperBound...])
|
|
}
|
|
|
|
if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) {
|
|
value = String(value[..<slash])
|
|
}
|
|
|
|
if value.hasPrefix("[") {
|
|
if let closing = value.firstIndex(of: "]") {
|
|
value = String(value[value.index(after: value.startIndex)..<closing])
|
|
} else {
|
|
value.removeFirst()
|
|
}
|
|
} else if let colon = value.lastIndex(of: ":"),
|
|
value[value.index(after: colon)...].allSatisfy(\.isNumber),
|
|
value.filter({ $0 == ":" }).count == 1 {
|
|
value = String(value[..<colon])
|
|
}
|
|
|
|
return trimHost(value)
|
|
}
|
|
|
|
private static func parsePatterns(from rawValue: String) -> [String] {
|
|
let separators = CharacterSet(charactersIn: ",;\n\r\t")
|
|
var out: [String] = []
|
|
var seen = Set<String>()
|
|
for token in rawValue.components(separatedBy: separators) {
|
|
guard let normalized = normalizePattern(token) else { continue }
|
|
guard seen.insert(normalized).inserted else { continue }
|
|
out.append(normalized)
|
|
}
|
|
return out
|
|
}
|
|
|
|
private static func normalizePattern(_ rawPattern: String) -> String? {
|
|
let trimmed = rawPattern
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
if trimmed.hasPrefix("*.") {
|
|
let suffixRaw = String(trimmed.dropFirst(2))
|
|
guard let suffix = normalizeHost(suffixRaw) else { return nil }
|
|
return "*.\(suffix)"
|
|
}
|
|
|
|
return normalizeHost(trimmed)
|
|
}
|
|
|
|
private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool {
|
|
if pattern.hasPrefix("*.") {
|
|
let suffix = String(pattern.dropFirst(2))
|
|
return host == suffix || host.hasSuffix(".\(suffix)")
|
|
}
|
|
return host == pattern
|
|
}
|
|
|
|
private static func trimHost(_ raw: String) -> String? {
|
|
let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
func browserShouldBlockInsecureHTTPURL(
|
|
_ url: URL,
|
|
defaults: UserDefaults = .standard
|
|
) -> Bool {
|
|
browserShouldBlockInsecureHTTPURL(
|
|
url,
|
|
rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
|
|
)
|
|
}
|
|
|
|
func browserShouldBlockInsecureHTTPURL(
|
|
_ url: URL,
|
|
rawAllowlist: String?
|
|
) -> Bool {
|
|
guard url.scheme?.lowercased() == "http" else { return false }
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true }
|
|
return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist)
|
|
}
|
|
|
|
func browserShouldConsumeOneTimeInsecureHTTPBypass(
|
|
_ url: URL,
|
|
bypassHostOnce: inout String?
|
|
) -> Bool {
|
|
guard let bypassHost = bypassHostOnce else { return false }
|
|
guard url.scheme?.lowercased() == "http",
|
|
let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
|
|
return false
|
|
}
|
|
guard host == bypassHost else { return false }
|
|
bypassHostOnce = nil
|
|
return true
|
|
}
|
|
|
|
func browserShouldPersistInsecureHTTPAllowlistSelection(
|
|
response: NSApplication.ModalResponse,
|
|
suppressionEnabled: Bool
|
|
) -> Bool {
|
|
guard suppressionEnabled else { return false }
|
|
return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn
|
|
}
|
|
|
|
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.
|
|
private enum BrowserInsecureHTTPNavigationIntent {
|
|
case currentTab
|
|
case newTab
|
|
}
|
|
|
|
@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
|
|
|
|
/// Sticky omnibar-focus intent. This survives view mount timing races and is
|
|
/// cleared only after BrowserPanelView acknowledges handling it.
|
|
@Published private(set) var pendingAddressBarFocusRequestId: UUID?
|
|
|
|
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
|
|
private var insecureHTTPBypassHostOnce: String?
|
|
|
|
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, bypassInsecureHTTPHostOnce: String? = nil) {
|
|
self.id = UUID()
|
|
self.workspaceId = workspaceId
|
|
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
|
|
|
|
// 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)
|
|
}
|
|
navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in
|
|
self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false
|
|
}
|
|
navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in
|
|
self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
|
|
}
|
|
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)
|
|
}
|
|
browserUIDelegate.requestNavigation = { [weak self] url, intent in
|
|
self?.requestNavigation(url, intent: intent)
|
|
}
|
|
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, recordTypedNavigation: Bool = false) {
|
|
if shouldBlockInsecureHTTPNavigation(to: url) {
|
|
presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation)
|
|
return
|
|
}
|
|
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
|
|
}
|
|
|
|
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
|
|
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
|
|
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
|
if recordTypedNavigation {
|
|
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
|
}
|
|
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) {
|
|
navigate(to: url, recordTypedNavigation: true)
|
|
return
|
|
}
|
|
|
|
let engine = BrowserSearchSettings.currentSearchEngine()
|
|
guard let searchURL = engine.searchURL(query: trimmed) else { return }
|
|
navigate(to: searchURL)
|
|
}
|
|
|
|
func resolveNavigableURL(from input: String) -> URL? {
|
|
resolveBrowserNavigableURL(input)
|
|
}
|
|
|
|
private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool {
|
|
if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) {
|
|
return false
|
|
}
|
|
return browserShouldBlockInsecureHTTPURL(url)
|
|
}
|
|
|
|
private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) {
|
|
if shouldBlockInsecureHTTPNavigation(to: url) {
|
|
presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
|
|
return
|
|
}
|
|
switch intent {
|
|
case .currentTab:
|
|
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false)
|
|
case .newTab:
|
|
openLinkInNewTab(url: url)
|
|
}
|
|
}
|
|
|
|
private func presentInsecureHTTPAlert(
|
|
for url: URL,
|
|
intent: BrowserInsecureHTTPNavigationIntent,
|
|
recordTypedNavigation: Bool
|
|
) {
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return }
|
|
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = "Connection isn't secure"
|
|
alert.informativeText = """
|
|
\(host) uses plain HTTP, so traffic can be read or modified on the network.
|
|
|
|
Open this URL in your default browser, or proceed in cmux.
|
|
"""
|
|
alert.addButton(withTitle: "Open in Default Browser")
|
|
alert.addButton(withTitle: "Proceed in cmux")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.showsSuppressionButton = true
|
|
alert.suppressionButton?.title = "Always allow this host in cmux"
|
|
|
|
let response = alert.runModal()
|
|
if browserShouldPersistInsecureHTTPAllowlistSelection(
|
|
response: response,
|
|
suppressionEnabled: alert.suppressionButton?.state == .on
|
|
) {
|
|
BrowserInsecureHTTPSettings.addAllowedHost(host)
|
|
}
|
|
switch response {
|
|
case .alertFirstButtonReturn:
|
|
NSWorkspace.shared.open(url)
|
|
case .alertSecondButtonReturn:
|
|
switch intent {
|
|
case .currentTab:
|
|
insecureHTTPBypassHostOnce = host
|
|
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
|
|
case .newTab:
|
|
openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host)
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
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, bypassInsecureHTTPHostOnce: String? = nil) {
|
|
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,
|
|
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
|
)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
@discardableResult
|
|
func requestAddressBarFocus() -> UUID {
|
|
beginSuppressWebViewFocusForAddressBar()
|
|
if let pendingAddressBarFocusRequestId {
|
|
return pendingAddressBarFocusRequestId
|
|
}
|
|
let requestId = UUID()
|
|
pendingAddressBarFocusRequestId = requestId
|
|
return requestId
|
|
}
|
|
|
|
func acknowledgeAddressBarFocusRequest(_ requestId: UUID) {
|
|
guard pendingAddressBarFocusRequestId == requestId else { return }
|
|
pendingAddressBarFocusRequestId = nil
|
|
}
|
|
|
|
/// 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)?
|
|
var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
|
|
var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> 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
|
|
) {
|
|
if let url = navigationAction.request.url,
|
|
navigationAction.targetFrame?.isMainFrame != false,
|
|
shouldBlockInsecureHTTPNavigation?(url) == true {
|
|
let intent: BrowserInsecureHTTPNavigationIntent
|
|
if navigationAction.navigationType == .linkActivated,
|
|
navigationAction.modifierFlags.contains(.command) {
|
|
intent = .newTab
|
|
} else {
|
|
intent = .currentTab
|
|
}
|
|
handleBlockedInsecureHTTPNavigation?(url, intent)
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
|
|
// target=_blank or window.open() — navigate in the current webview
|
|
if navigationAction.targetFrame == nil,
|
|
let url = navigationAction.request.url {
|
|
webView.load(URLRequest(url: 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)?
|
|
var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
|
|
|
|
/// Returning nil tells WebKit not to open a new window.
|
|
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place.
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
createWebViewWith configuration: WKWebViewConfiguration,
|
|
for navigationAction: WKNavigationAction,
|
|
windowFeatures: WKWindowFeatures
|
|
) -> WKWebView? {
|
|
if let url = navigationAction.request.url {
|
|
if let requestNavigation {
|
|
let intent: BrowserInsecureHTTPNavigationIntent =
|
|
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
|
|
requestNavigation(url, intent)
|
|
} else if navigationAction.modifierFlags.contains(.command) {
|
|
openInNewTab?(url)
|
|
} else {
|
|
webView.load(URLRequest(url: url))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|