Remote loopback requests still route through the localtest.me alias for proxy transport, but the omnibar now canonicalizes the alias host back to localhost for display.
3532 lines
132 KiB
Swift
3532 lines
132 KiB
Swift
import Foundation
|
|
import Combine
|
|
import WebKit
|
|
import AppKit
|
|
import Bonsplit
|
|
import Network
|
|
|
|
struct BrowserProxyEndpoint: Equatable {
|
|
let host: String
|
|
let port: Int
|
|
}
|
|
|
|
struct BrowserRemoteWorkspaceStatus: Equatable {
|
|
let target: String
|
|
let connectionState: WorkspaceRemoteConnectionState
|
|
let heartbeatCount: Int
|
|
let lastHeartbeatAt: Date?
|
|
}
|
|
|
|
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
|
|
case google
|
|
case duckduckgo
|
|
case bing
|
|
case kagi
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .google: return "Google"
|
|
case .duckduckgo: return "DuckDuckGo"
|
|
case .bing: return "Bing"
|
|
case .kagi: return "Kagi"
|
|
}
|
|
}
|
|
|
|
func searchURL(query: String) -> URL? {
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
var components: URLComponents?
|
|
switch self {
|
|
case .google:
|
|
components = URLComponents(string: "https://www.google.com/search")
|
|
case .duckduckgo:
|
|
components = URLComponents(string: "https://duckduckgo.com/")
|
|
case .bing:
|
|
components = URLComponents(string: "https://www.bing.com/search")
|
|
case .kagi:
|
|
components = URLComponents(string: "https://kagi.com/search")
|
|
}
|
|
|
|
components?.queryItems = [
|
|
URLQueryItem(name: "q", value: trimmed),
|
|
]
|
|
return components?.url
|
|
}
|
|
}
|
|
|
|
enum BrowserSearchSettings {
|
|
static let searchEngineKey = "browserSearchEngine"
|
|
static let searchSuggestionsEnabledKey = "browserSearchSuggestionsEnabled"
|
|
static let defaultSearchEngine: BrowserSearchEngine = .google
|
|
static let defaultSearchSuggestionsEnabled: Bool = true
|
|
|
|
static func currentSearchEngine(defaults: UserDefaults = .standard) -> BrowserSearchEngine {
|
|
guard let raw = defaults.string(forKey: searchEngineKey),
|
|
let engine = BrowserSearchEngine(rawValue: raw) else {
|
|
return defaultSearchEngine
|
|
}
|
|
return engine
|
|
}
|
|
|
|
static func currentSearchSuggestionsEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
// Mirror @AppStorage behavior: bool(forKey:) returns false if key doesn't exist.
|
|
// Default to enabled unless user explicitly set a value.
|
|
if defaults.object(forKey: searchSuggestionsEnabledKey) == nil {
|
|
return defaultSearchSuggestionsEnabled
|
|
}
|
|
return defaults.bool(forKey: searchSuggestionsEnabledKey)
|
|
}
|
|
}
|
|
|
|
enum BrowserThemeMode: String, CaseIterable, Identifiable {
|
|
case system
|
|
case light
|
|
case dark
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .system:
|
|
return "System"
|
|
case .light:
|
|
return "Light"
|
|
case .dark:
|
|
return "Dark"
|
|
}
|
|
}
|
|
|
|
var iconName: String {
|
|
switch self {
|
|
case .system:
|
|
return "circle.lefthalf.filled"
|
|
case .light:
|
|
return "sun.max"
|
|
case .dark:
|
|
return "moon"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum BrowserThemeSettings {
|
|
static let modeKey = "browserThemeMode"
|
|
static let legacyForcedDarkModeEnabledKey = "browserForcedDarkModeEnabled"
|
|
static let defaultMode: BrowserThemeMode = .system
|
|
|
|
static func mode(for rawValue: String?) -> BrowserThemeMode {
|
|
guard let rawValue, let mode = BrowserThemeMode(rawValue: rawValue) else {
|
|
return defaultMode
|
|
}
|
|
return mode
|
|
}
|
|
|
|
static func mode(defaults: UserDefaults = .standard) -> BrowserThemeMode {
|
|
let resolvedMode = mode(for: defaults.string(forKey: modeKey))
|
|
if defaults.string(forKey: modeKey) != nil {
|
|
return resolvedMode
|
|
}
|
|
|
|
// Migrate the legacy bool toggle only when the new mode key is unset.
|
|
if defaults.object(forKey: legacyForcedDarkModeEnabledKey) != nil {
|
|
let migratedMode: BrowserThemeMode = defaults.bool(forKey: legacyForcedDarkModeEnabledKey) ? .dark : .system
|
|
defaults.set(migratedMode.rawValue, forKey: modeKey)
|
|
return migratedMode
|
|
}
|
|
|
|
return defaultMode
|
|
}
|
|
}
|
|
|
|
enum BrowserLinkOpenSettings {
|
|
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
|
|
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
|
|
|
|
static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser"
|
|
static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true
|
|
|
|
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
|
|
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
|
|
|
|
static let browserHostWhitelistKey = "browserHostWhitelist"
|
|
static let defaultBrowserHostWhitelist: String = ""
|
|
|
|
static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil {
|
|
return defaultOpenTerminalLinksInCmuxBrowser
|
|
}
|
|
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
|
|
}
|
|
|
|
static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil {
|
|
return defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
|
}
|
|
return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey)
|
|
}
|
|
|
|
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
|
|
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)
|
|
}
|
|
|
|
// Migrate existing behavior for users who only had the link-click toggle.
|
|
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil {
|
|
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
|
|
}
|
|
|
|
return defaultInterceptTerminalOpenCommandInCmuxBrowser
|
|
}
|
|
|
|
static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool {
|
|
interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)
|
|
}
|
|
|
|
static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] {
|
|
let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist
|
|
return raw
|
|
.components(separatedBy: .newlines)
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
|
|
/// Check whether a hostname matches the configured whitelist.
|
|
/// Empty whitelist means "allow all" (no filtering).
|
|
/// Supports exact match and wildcard prefix (`*.example.com`).
|
|
static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool {
|
|
let rawPatterns = hostWhitelist(defaults: defaults)
|
|
if rawPatterns.isEmpty { return true }
|
|
guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { return false }
|
|
for rawPattern in rawPatterns {
|
|
guard let pattern = normalizeWhitelistPattern(rawPattern) else { continue }
|
|
if hostMatchesPattern(normalizedHost, pattern: pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func normalizeWhitelistPattern(_ rawPattern: String) -> String? {
|
|
let trimmed = rawPattern
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
if trimmed.hasPrefix("*.") {
|
|
let suffixRaw = String(trimmed.dropFirst(2))
|
|
guard let suffix = BrowserInsecureHTTPSettings.normalizeHost(suffixRaw) else { return nil }
|
|
return "*.\(suffix)"
|
|
}
|
|
|
|
return BrowserInsecureHTTPSettings.normalizeHost(trimmed)
|
|
}
|
|
|
|
private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool {
|
|
if pattern.hasPrefix("*.") {
|
|
let suffix = String(pattern.dropFirst(2))
|
|
return host == suffix || host.hasSuffix(".\(suffix)")
|
|
}
|
|
return host == pattern
|
|
}
|
|
}
|
|
|
|
enum BrowserInsecureHTTPSettings {
|
|
static let allowlistKey = "browserInsecureHTTPAllowlist"
|
|
static let defaultAllowlistPatterns = [
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"0.0.0.0",
|
|
"*.localtest.me",
|
|
]
|
|
static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n")
|
|
|
|
static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] {
|
|
normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey))
|
|
}
|
|
|
|
static func normalizedAllowlistPatterns(rawValue: String?) -> [String] {
|
|
let source: String
|
|
if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
source = rawValue
|
|
} else {
|
|
source = defaultAllowlistText
|
|
}
|
|
let parsed = parsePatterns(from: source)
|
|
return parsed.isEmpty ? defaultAllowlistPatterns : parsed
|
|
}
|
|
|
|
static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool {
|
|
isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey))
|
|
}
|
|
|
|
static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool {
|
|
guard let normalizedHost = normalizeHost(host) else { return false }
|
|
return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in
|
|
hostMatchesPattern(normalizedHost, pattern: pattern)
|
|
}
|
|
}
|
|
|
|
static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) {
|
|
guard let normalizedHost = normalizeHost(host) else { return }
|
|
var patterns = normalizedAllowlistPatterns(defaults: defaults)
|
|
guard !patterns.contains(normalizedHost) else { return }
|
|
patterns.append(normalizedHost)
|
|
defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey)
|
|
}
|
|
|
|
static func normalizeHost(_ rawHost: String) -> String? {
|
|
var value = rawHost
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
guard !value.isEmpty else { return nil }
|
|
|
|
if let parsed = URL(string: value)?.host {
|
|
return trimHost(parsed)
|
|
}
|
|
|
|
if let schemeRange = value.range(of: "://") {
|
|
value = String(value[schemeRange.upperBound...])
|
|
}
|
|
|
|
if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) {
|
|
value = String(value[..<slash])
|
|
}
|
|
|
|
if value.hasPrefix("[") {
|
|
if let closing = value.firstIndex(of: "]") {
|
|
value = String(value[value.index(after: value.startIndex)..<closing])
|
|
} else {
|
|
value.removeFirst()
|
|
}
|
|
} else if let colon = value.lastIndex(of: ":"),
|
|
value[value.index(after: colon)...].allSatisfy(\.isNumber),
|
|
value.filter({ $0 == ":" }).count == 1 {
|
|
value = String(value[..<colon])
|
|
}
|
|
|
|
return trimHost(value)
|
|
}
|
|
|
|
private static func parsePatterns(from rawValue: String) -> [String] {
|
|
let separators = CharacterSet(charactersIn: ",;\n\r\t")
|
|
var out: [String] = []
|
|
var seen = Set<String>()
|
|
for token in rawValue.components(separatedBy: separators) {
|
|
guard let normalized = normalizePattern(token) else { continue }
|
|
guard seen.insert(normalized).inserted else { continue }
|
|
out.append(normalized)
|
|
}
|
|
return out
|
|
}
|
|
|
|
private static func normalizePattern(_ rawPattern: String) -> String? {
|
|
let trimmed = rawPattern
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
if trimmed.hasPrefix("*.") {
|
|
let suffixRaw = String(trimmed.dropFirst(2))
|
|
guard let suffix = normalizeHost(suffixRaw) else { return nil }
|
|
return "*.\(suffix)"
|
|
}
|
|
|
|
return normalizeHost(trimmed)
|
|
}
|
|
|
|
private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool {
|
|
if pattern.hasPrefix("*.") {
|
|
let suffix = String(pattern.dropFirst(2))
|
|
return host == suffix || host.hasSuffix(".\(suffix)")
|
|
}
|
|
return host == pattern
|
|
}
|
|
|
|
private static func trimHost(_ raw: String) -> String? {
|
|
let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
// Canonicalize IDN entries (e.g. bücher.example -> xn--bcher-kva.example)
|
|
// so user-entered allowlist patterns compare against URL.host consistently.
|
|
if let canonicalized = URL(string: "https://\(trimmed)")?.host {
|
|
return canonicalized
|
|
}
|
|
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
func browserShouldBlockInsecureHTTPURL(
|
|
_ url: URL,
|
|
defaults: UserDefaults = .standard
|
|
) -> Bool {
|
|
browserShouldBlockInsecureHTTPURL(
|
|
url,
|
|
rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
|
|
)
|
|
}
|
|
|
|
func browserShouldBlockInsecureHTTPURL(
|
|
_ url: URL,
|
|
rawAllowlist: String?
|
|
) -> Bool {
|
|
guard url.scheme?.lowercased() == "http" else { return false }
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true }
|
|
return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist)
|
|
}
|
|
|
|
func browserShouldConsumeOneTimeInsecureHTTPBypass(
|
|
_ url: URL,
|
|
bypassHostOnce: inout String?
|
|
) -> Bool {
|
|
guard let bypassHost = bypassHostOnce else { return false }
|
|
guard url.scheme?.lowercased() == "http",
|
|
let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
|
|
return false
|
|
}
|
|
guard host == bypassHost else { return false }
|
|
bypassHostOnce = nil
|
|
return true
|
|
}
|
|
|
|
func browserShouldPersistInsecureHTTPAllowlistSelection(
|
|
response: NSApplication.ModalResponse,
|
|
suppressionEnabled: Bool
|
|
) -> Bool {
|
|
guard suppressionEnabled else { return false }
|
|
return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn
|
|
}
|
|
|
|
func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
|
|
var preparedRequest = request
|
|
// Match browser behavior for ordinary loads while preserving method/body/headers.
|
|
preparedRequest.cachePolicy = .useProtocolCachePolicy
|
|
return preparedRequest
|
|
}
|
|
|
|
private let browserEmbeddedNavigationSchemes: Set<String> = [
|
|
"about",
|
|
"applewebdata",
|
|
"blob",
|
|
"data",
|
|
"http",
|
|
"https",
|
|
"javascript",
|
|
]
|
|
|
|
func browserShouldOpenURLExternally(_ url: URL) -> Bool {
|
|
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false }
|
|
return !browserEmbeddedNavigationSchemes.contains(scheme)
|
|
}
|
|
|
|
enum BrowserUserAgentSettings {
|
|
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
|
|
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
|
|
// fallback/old UIs or trigger bot checks.
|
|
static let safariUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15"
|
|
}
|
|
|
|
func normalizedBrowserHistoryNamespace(bundleIdentifier: String) -> String {
|
|
if bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") {
|
|
return "com.cmuxterm.app.debug"
|
|
}
|
|
if bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") {
|
|
return "com.cmuxterm.app.staging"
|
|
}
|
|
return bundleIdentifier
|
|
}
|
|
|
|
@MainActor
|
|
final class BrowserHistoryStore: ObservableObject {
|
|
static let shared = BrowserHistoryStore()
|
|
|
|
struct Entry: Codable, Identifiable, Hashable {
|
|
let id: UUID
|
|
var url: String
|
|
var title: String?
|
|
var lastVisited: Date
|
|
var visitCount: Int
|
|
var typedCount: Int
|
|
var lastTypedAt: Date?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case url
|
|
case title
|
|
case lastVisited
|
|
case visitCount
|
|
case typedCount
|
|
case lastTypedAt
|
|
}
|
|
|
|
init(
|
|
id: UUID,
|
|
url: String,
|
|
title: String?,
|
|
lastVisited: Date,
|
|
visitCount: Int,
|
|
typedCount: Int = 0,
|
|
lastTypedAt: Date? = nil
|
|
) {
|
|
self.id = id
|
|
self.url = url
|
|
self.title = title
|
|
self.lastVisited = lastVisited
|
|
self.visitCount = visitCount
|
|
self.typedCount = typedCount
|
|
self.lastTypedAt = lastTypedAt
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(UUID.self, forKey: .id)
|
|
url = try container.decode(String.self, forKey: .url)
|
|
title = try container.decodeIfPresent(String.self, forKey: .title)
|
|
lastVisited = try container.decode(Date.self, forKey: .lastVisited)
|
|
visitCount = try container.decode(Int.self, forKey: .visitCount)
|
|
typedCount = try container.decodeIfPresent(Int.self, forKey: .typedCount) ?? 0
|
|
lastTypedAt = try container.decodeIfPresent(Date.self, forKey: .lastTypedAt)
|
|
}
|
|
}
|
|
|
|
@Published private(set) var entries: [Entry] = []
|
|
|
|
private let fileURL: URL?
|
|
private var didLoad: Bool = false
|
|
private var saveTask: Task<Void, Never>?
|
|
private let maxEntries: Int = 5000
|
|
private let saveDebounceNanoseconds: UInt64 = 120_000_000
|
|
|
|
private struct SuggestionCandidate {
|
|
let entry: Entry
|
|
let urlLower: String
|
|
let urlSansSchemeLower: String
|
|
let hostLower: String
|
|
let pathAndQueryLower: String
|
|
let titleLower: String
|
|
}
|
|
|
|
private struct ScoredSuggestion {
|
|
let entry: Entry
|
|
let score: Double
|
|
}
|
|
|
|
init(fileURL: URL? = nil) {
|
|
// Avoid calling @MainActor-isolated static methods from default argument context.
|
|
self.fileURL = fileURL ?? BrowserHistoryStore.defaultHistoryFileURL()
|
|
}
|
|
|
|
func loadIfNeeded() {
|
|
guard !didLoad else { return }
|
|
didLoad = true
|
|
guard let fileURL else { return }
|
|
migrateLegacyTaggedHistoryFileIfNeeded(to: fileURL)
|
|
|
|
// Load synchronously on first access so the first omnibar query can use
|
|
// persisted history immediately (important for deterministic UI behavior).
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: fileURL)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
let decoded: [Entry]
|
|
do {
|
|
decoded = try JSONDecoder().decode([Entry].self, from: data)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
// Most-recent first.
|
|
entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited })
|
|
|
|
// Remove entries with invalid hosts (no TLD), e.g. "https://news."
|
|
let beforeCount = entries.count
|
|
entries.removeAll { entry in
|
|
guard let url = URL(string: entry.url),
|
|
let host = url.host?.lowercased() else { return false }
|
|
let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host
|
|
return !trimmed.contains(".")
|
|
}
|
|
if entries.count != beforeCount {
|
|
scheduleSave()
|
|
}
|
|
}
|
|
|
|
func recordVisit(url: URL?, title: String?) {
|
|
loadIfNeeded()
|
|
|
|
guard let url else { return }
|
|
guard let scheme = url.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https" else { return }
|
|
// Skip URLs whose host lacks a TLD (e.g. "https://news.").
|
|
if let host = url.host?.lowercased() {
|
|
let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host
|
|
if !trimmed.contains(".") { return }
|
|
}
|
|
|
|
let urlString = url.absoluteString
|
|
guard urlString != "about:blank" else { return }
|
|
let normalizedKey = normalizedHistoryKey(url: url)
|
|
|
|
if let idx = entries.firstIndex(where: {
|
|
if $0.url == urlString { return true }
|
|
return normalizedHistoryKey(urlString: $0.url) == normalizedKey
|
|
}) {
|
|
entries[idx].lastVisited = Date()
|
|
entries[idx].visitCount += 1
|
|
// Prefer non-empty titles, but don't clobber an existing title with empty/whitespace.
|
|
if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
entries[idx].title = title
|
|
}
|
|
} else {
|
|
entries.insert(Entry(
|
|
id: UUID(),
|
|
url: urlString,
|
|
title: title?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
lastVisited: Date(),
|
|
visitCount: 1
|
|
), at: 0)
|
|
}
|
|
|
|
// Keep most-recent first and bound size.
|
|
entries.sort(by: { $0.lastVisited > $1.lastVisited })
|
|
if entries.count > maxEntries {
|
|
entries.removeLast(entries.count - maxEntries)
|
|
}
|
|
|
|
scheduleSave()
|
|
}
|
|
|
|
func recordTypedNavigation(url: URL?) {
|
|
loadIfNeeded()
|
|
|
|
guard let url else { return }
|
|
guard let scheme = url.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https" else { return }
|
|
// Skip URLs whose host lacks a TLD (e.g. "https://news.").
|
|
if let host = url.host?.lowercased() {
|
|
let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host
|
|
if !trimmed.contains(".") { return }
|
|
}
|
|
|
|
let urlString = url.absoluteString
|
|
guard urlString != "about:blank" else { return }
|
|
|
|
let now = Date()
|
|
let normalizedKey = normalizedHistoryKey(url: url)
|
|
if let idx = entries.firstIndex(where: {
|
|
if $0.url == urlString { return true }
|
|
return normalizedHistoryKey(urlString: $0.url) == normalizedKey
|
|
}) {
|
|
entries[idx].typedCount += 1
|
|
entries[idx].lastTypedAt = now
|
|
entries[idx].lastVisited = now
|
|
} else {
|
|
entries.insert(Entry(
|
|
id: UUID(),
|
|
url: urlString,
|
|
title: nil,
|
|
lastVisited: now,
|
|
visitCount: 1,
|
|
typedCount: 1,
|
|
lastTypedAt: now
|
|
), at: 0)
|
|
}
|
|
|
|
entries.sort(by: { $0.lastVisited > $1.lastVisited })
|
|
if entries.count > maxEntries {
|
|
entries.removeLast(entries.count - maxEntries)
|
|
}
|
|
|
|
scheduleSave()
|
|
}
|
|
|
|
func suggestions(for input: String, limit: Int = 10) -> [Entry] {
|
|
loadIfNeeded()
|
|
guard limit > 0 else { return [] }
|
|
|
|
let q = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !q.isEmpty else { return [] }
|
|
let queryTokens = tokenizeSuggestionQuery(q)
|
|
let now = Date()
|
|
|
|
let matched = entries.compactMap { entry -> ScoredSuggestion? in
|
|
let candidate = makeSuggestionCandidate(entry: entry)
|
|
guard let score = suggestionScore(candidate: candidate, query: q, queryTokens: queryTokens, now: now) else {
|
|
return nil
|
|
}
|
|
return ScoredSuggestion(entry: entry, score: score)
|
|
}
|
|
.sorted { lhs, rhs in
|
|
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
|
if lhs.entry.lastVisited != rhs.entry.lastVisited { return lhs.entry.lastVisited > rhs.entry.lastVisited }
|
|
if lhs.entry.visitCount != rhs.entry.visitCount { return lhs.entry.visitCount > rhs.entry.visitCount }
|
|
return lhs.entry.url < rhs.entry.url
|
|
}
|
|
|
|
if matched.count <= limit { return matched.map(\.entry) }
|
|
return Array(matched.prefix(limit).map(\.entry))
|
|
}
|
|
|
|
func recentSuggestions(limit: Int = 10) -> [Entry] {
|
|
loadIfNeeded()
|
|
guard limit > 0 else { return [] }
|
|
|
|
let ranked = entries.sorted { lhs, rhs in
|
|
if lhs.typedCount != rhs.typedCount { return lhs.typedCount > rhs.typedCount }
|
|
let lhsTypedDate = lhs.lastTypedAt ?? .distantPast
|
|
let rhsTypedDate = rhs.lastTypedAt ?? .distantPast
|
|
if lhsTypedDate != rhsTypedDate { return lhsTypedDate > rhsTypedDate }
|
|
if lhs.lastVisited != rhs.lastVisited { return lhs.lastVisited > rhs.lastVisited }
|
|
if lhs.visitCount != rhs.visitCount { return lhs.visitCount > rhs.visitCount }
|
|
return lhs.url < rhs.url
|
|
}
|
|
|
|
if ranked.count <= limit { return ranked }
|
|
return Array(ranked.prefix(limit))
|
|
}
|
|
|
|
func clearHistory() {
|
|
loadIfNeeded()
|
|
saveTask?.cancel()
|
|
saveTask = nil
|
|
entries = []
|
|
guard let fileURL else { return }
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
|
|
@discardableResult
|
|
func removeHistoryEntry(urlString: String) -> Bool {
|
|
loadIfNeeded()
|
|
let normalized = normalizedHistoryKey(urlString: urlString)
|
|
let originalCount = entries.count
|
|
entries.removeAll { entry in
|
|
if entry.url == urlString { return true }
|
|
guard let normalized else { return false }
|
|
return normalizedHistoryKey(urlString: entry.url) == normalized
|
|
}
|
|
let didRemove = entries.count != originalCount
|
|
if didRemove {
|
|
scheduleSave()
|
|
}
|
|
return didRemove
|
|
}
|
|
|
|
func flushPendingSaves() {
|
|
loadIfNeeded()
|
|
saveTask?.cancel()
|
|
saveTask = nil
|
|
guard let fileURL else { return }
|
|
try? Self.persistSnapshot(entries, to: fileURL)
|
|
}
|
|
|
|
private func scheduleSave() {
|
|
guard let fileURL else { return }
|
|
|
|
saveTask?.cancel()
|
|
let snapshot = entries
|
|
let debounceNanoseconds = saveDebounceNanoseconds
|
|
|
|
saveTask = Task.detached(priority: .utility) {
|
|
do {
|
|
try await Task.sleep(nanoseconds: debounceNanoseconds) // debounce
|
|
} catch {
|
|
return
|
|
}
|
|
if Task.isCancelled { return }
|
|
|
|
do {
|
|
try Self.persistSnapshot(snapshot, to: fileURL)
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func migrateLegacyTaggedHistoryFileIfNeeded(to targetURL: URL) {
|
|
let fm = FileManager.default
|
|
guard !fm.fileExists(atPath: targetURL.path) else { return }
|
|
guard let legacyURL = Self.legacyTaggedHistoryFileURL(),
|
|
legacyURL != targetURL,
|
|
fm.fileExists(atPath: legacyURL.path) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let dir = targetURL.deletingLastPathComponent()
|
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
|
try fm.copyItem(at: legacyURL, to: targetURL)
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
private func makeSuggestionCandidate(entry: Entry) -> SuggestionCandidate {
|
|
let urlLower = entry.url.lowercased()
|
|
let urlSansSchemeLower = stripHTTPSSchemePrefix(urlLower)
|
|
let components = URLComponents(string: entry.url)
|
|
let hostLower = components?.host?.lowercased() ?? ""
|
|
let path = (components?.percentEncodedPath ?? components?.path ?? "").lowercased()
|
|
let query = (components?.percentEncodedQuery ?? components?.query ?? "").lowercased()
|
|
let pathAndQueryLower: String
|
|
if query.isEmpty {
|
|
pathAndQueryLower = path
|
|
} else {
|
|
pathAndQueryLower = "\(path)?\(query)"
|
|
}
|
|
let titleLower = (entry.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return SuggestionCandidate(
|
|
entry: entry,
|
|
urlLower: urlLower,
|
|
urlSansSchemeLower: urlSansSchemeLower,
|
|
hostLower: hostLower,
|
|
pathAndQueryLower: pathAndQueryLower,
|
|
titleLower: titleLower
|
|
)
|
|
}
|
|
|
|
private func suggestionScore(
|
|
candidate: SuggestionCandidate,
|
|
query: String,
|
|
queryTokens: [String],
|
|
now: Date
|
|
) -> Double? {
|
|
let queryIncludesScheme = query.hasPrefix("http://") || query.hasPrefix("https://")
|
|
let urlMatchValue = queryIncludesScheme ? candidate.urlLower : candidate.urlSansSchemeLower
|
|
let isSingleCharacterQuery = query.count == 1
|
|
if isSingleCharacterQuery {
|
|
let hasSingleCharStrongMatch =
|
|
candidate.hostLower.hasPrefix(query) ||
|
|
candidate.titleLower.hasPrefix(query) ||
|
|
urlMatchValue.hasPrefix(query)
|
|
guard hasSingleCharStrongMatch else { return nil }
|
|
}
|
|
|
|
let queryMatches =
|
|
urlMatchValue.contains(query) ||
|
|
candidate.hostLower.contains(query) ||
|
|
candidate.pathAndQueryLower.contains(query) ||
|
|
candidate.titleLower.contains(query)
|
|
|
|
let tokenMatches = !queryTokens.isEmpty && queryTokens.allSatisfy { token in
|
|
candidate.urlSansSchemeLower.contains(token) ||
|
|
candidate.hostLower.contains(token) ||
|
|
candidate.pathAndQueryLower.contains(token) ||
|
|
candidate.titleLower.contains(token)
|
|
}
|
|
|
|
guard queryMatches || tokenMatches else { return nil }
|
|
|
|
var score = 0.0
|
|
|
|
if urlMatchValue == query { score += 1200 }
|
|
if candidate.hostLower == query { score += 980 }
|
|
if candidate.hostLower.hasPrefix(query) { score += 680 }
|
|
if urlMatchValue.hasPrefix(query) { score += 560 }
|
|
if candidate.titleLower.hasPrefix(query) { score += 420 }
|
|
if candidate.pathAndQueryLower.hasPrefix(query) { score += 300 }
|
|
|
|
if candidate.hostLower.contains(query) { score += 210 }
|
|
if candidate.pathAndQueryLower.contains(query) { score += 165 }
|
|
if candidate.titleLower.contains(query) { score += 145 }
|
|
|
|
for token in queryTokens {
|
|
if candidate.hostLower == token { score += 260 }
|
|
else if candidate.hostLower.hasPrefix(token) { score += 170 }
|
|
else if candidate.hostLower.contains(token) { score += 110 }
|
|
|
|
if candidate.pathAndQueryLower.hasPrefix(token) { score += 80 }
|
|
else if candidate.pathAndQueryLower.contains(token) { score += 52 }
|
|
|
|
if candidate.titleLower.hasPrefix(token) { score += 74 }
|
|
else if candidate.titleLower.contains(token) { score += 48 }
|
|
}
|
|
|
|
// Blend recency and repeat visits so history feels closer to browser frecency.
|
|
let ageHours = max(0, now.timeIntervalSince(candidate.entry.lastVisited) / 3600)
|
|
let recencyScore = max(0, 110 - (ageHours / 3))
|
|
let frequencyScore = min(120, log1p(Double(max(1, candidate.entry.visitCount))) * 38)
|
|
let typedFrequencyScore = min(190, log1p(Double(max(0, candidate.entry.typedCount))) * 80)
|
|
let typedRecencyScore: Double
|
|
if let lastTypedAt = candidate.entry.lastTypedAt {
|
|
let typedAgeHours = max(0, now.timeIntervalSince(lastTypedAt) / 3600)
|
|
typedRecencyScore = max(0, 85 - (typedAgeHours / 4))
|
|
} else {
|
|
typedRecencyScore = 0
|
|
}
|
|
score += recencyScore + frequencyScore + typedFrequencyScore + typedRecencyScore
|
|
|
|
return score
|
|
}
|
|
|
|
private func stripHTTPSSchemePrefix(_ value: String) -> String {
|
|
if value.hasPrefix("https://") {
|
|
return String(value.dropFirst("https://".count))
|
|
}
|
|
if value.hasPrefix("http://") {
|
|
return String(value.dropFirst("http://".count))
|
|
}
|
|
return value
|
|
}
|
|
|
|
private func normalizedHistoryKey(url: URL) -> String? {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil }
|
|
return normalizedHistoryKey(components: &components)
|
|
}
|
|
|
|
private func normalizedHistoryKey(urlString: String) -> String? {
|
|
guard var components = URLComponents(string: urlString) else { return nil }
|
|
return normalizedHistoryKey(components: &components)
|
|
}
|
|
|
|
private func normalizedHistoryKey(components: inout URLComponents) -> String? {
|
|
guard let scheme = components.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https",
|
|
var host = components.host?.lowercased() else {
|
|
return nil
|
|
}
|
|
|
|
if host.hasPrefix("www.") {
|
|
host.removeFirst(4)
|
|
}
|
|
|
|
if (scheme == "http" && components.port == 80) ||
|
|
(scheme == "https" && components.port == 443) {
|
|
components.port = nil
|
|
}
|
|
|
|
let portPart: String
|
|
if let port = components.port {
|
|
portPart = ":\(port)"
|
|
} else {
|
|
portPart = ""
|
|
}
|
|
|
|
var path = components.percentEncodedPath
|
|
if path.isEmpty { path = "/" }
|
|
while path.count > 1, path.hasSuffix("/") {
|
|
path.removeLast()
|
|
}
|
|
|
|
let queryPart: String
|
|
if let query = components.percentEncodedQuery, !query.isEmpty {
|
|
queryPart = "?\(query.lowercased())"
|
|
} else {
|
|
queryPart = ""
|
|
}
|
|
|
|
return "\(scheme)://\(host)\(portPart)\(path)\(queryPart)"
|
|
}
|
|
|
|
private func tokenizeSuggestionQuery(_ query: String) -> [String] {
|
|
var tokens: [String] = []
|
|
var seen = Set<String>()
|
|
let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters).union(.symbols)
|
|
for raw in query.components(separatedBy: separators) {
|
|
let token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !token.isEmpty else { continue }
|
|
guard !seen.contains(token) else { continue }
|
|
seen.insert(token)
|
|
tokens.append(token)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
nonisolated private static func defaultHistoryFileURL() -> URL? {
|
|
let fm = FileManager.default
|
|
guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
return nil
|
|
}
|
|
let bundleId = Bundle.main.bundleIdentifier ?? "cmux"
|
|
let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId)
|
|
let dir = appSupport.appendingPathComponent(namespace, isDirectory: true)
|
|
return dir.appendingPathComponent("browser_history.json", isDirectory: false)
|
|
}
|
|
|
|
nonisolated private static func legacyTaggedHistoryFileURL() -> URL? {
|
|
guard let bundleId = Bundle.main.bundleIdentifier else { return nil }
|
|
let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId)
|
|
guard namespace != bundleId else { return nil }
|
|
let fm = FileManager.default
|
|
guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
return nil
|
|
}
|
|
let dir = appSupport.appendingPathComponent(bundleId, isDirectory: true)
|
|
return dir.appendingPathComponent("browser_history.json", isDirectory: false)
|
|
}
|
|
|
|
nonisolated private static func persistSnapshot(_ snapshot: [Entry], to fileURL: URL) throws {
|
|
let dir = fileURL.deletingLastPathComponent()
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.withoutEscapingSlashes]
|
|
let data = try encoder.encode(snapshot)
|
|
try data.write(to: fileURL, options: [.atomic])
|
|
}
|
|
}
|
|
|
|
actor BrowserSearchSuggestionService {
|
|
static let shared = BrowserSearchSuggestionService()
|
|
|
|
func suggestions(engine: BrowserSearchEngine, query: String) async -> [String] {
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return [] }
|
|
|
|
// Deterministic UI-test hook for validating remote suggestion rendering
|
|
// without relying on external network behavior.
|
|
let forced = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"]
|
|
?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON")
|
|
if let forced,
|
|
let data = forced.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] {
|
|
return parsed.compactMap { item in
|
|
guard let s = item as? String else { return nil }
|
|
let value = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
}
|
|
|
|
// Google's endpoint can intermittently throttle/block app-style traffic.
|
|
// Query fallbacks in parallel so we can show predictions quickly.
|
|
if engine == .google {
|
|
return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed)
|
|
}
|
|
|
|
return await fetchRemoteSuggestions(engine: engine, query: trimmed)
|
|
}
|
|
|
|
private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] {
|
|
await withTaskGroup(of: [String].self, returning: [String].self) { group in
|
|
group.addTask {
|
|
await self.fetchRemoteSuggestions(engine: .google, query: query)
|
|
}
|
|
group.addTask {
|
|
await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query)
|
|
}
|
|
group.addTask {
|
|
await self.fetchRemoteSuggestions(engine: .bing, query: query)
|
|
}
|
|
|
|
while let result = await group.next() {
|
|
if !result.isEmpty {
|
|
group.cancelAll()
|
|
return result
|
|
}
|
|
}
|
|
|
|
return []
|
|
}
|
|
}
|
|
|
|
private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] {
|
|
let url: URL?
|
|
switch engine {
|
|
case .google:
|
|
var c = URLComponents(string: "https://suggestqueries.google.com/complete/search")
|
|
c?.queryItems = [
|
|
URLQueryItem(name: "client", value: "firefox"),
|
|
URLQueryItem(name: "q", value: query),
|
|
]
|
|
url = c?.url
|
|
case .duckduckgo:
|
|
var c = URLComponents(string: "https://duckduckgo.com/ac/")
|
|
c?.queryItems = [
|
|
URLQueryItem(name: "q", value: query),
|
|
URLQueryItem(name: "type", value: "list"),
|
|
]
|
|
url = c?.url
|
|
case .bing:
|
|
var c = URLComponents(string: "https://www.bing.com/osjson.aspx")
|
|
c?.queryItems = [
|
|
URLQueryItem(name: "query", value: query),
|
|
]
|
|
url = c?.url
|
|
case .kagi:
|
|
var c = URLComponents(string: "https://kagi.com/api/autosuggest")
|
|
c?.queryItems = [
|
|
URLQueryItem(name: "q", value: query),
|
|
]
|
|
url = c?.url
|
|
}
|
|
|
|
guard let url else { return [] }
|
|
|
|
var req = URLRequest(url: url)
|
|
req.timeoutInterval = 0.65
|
|
req.cachePolicy = .returnCacheDataElseLoad
|
|
req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent")
|
|
req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await URLSession.shared.data(for: req)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
guard let http = response as? HTTPURLResponse,
|
|
(200..<300).contains(http.statusCode) else {
|
|
return []
|
|
}
|
|
|
|
switch engine {
|
|
case .google, .bing, .kagi:
|
|
return parseOSJSON(data: data)
|
|
case .duckduckgo:
|
|
return parseDuckDuckGo(data: data)
|
|
}
|
|
}
|
|
|
|
private func parseOSJSON(data: Data) -> [String] {
|
|
// Format: [query, [suggestions...], ...]
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
|
root.count >= 2,
|
|
let list = root[1] as? [Any] else {
|
|
return []
|
|
}
|
|
var out: [String] = []
|
|
out.reserveCapacity(list.count)
|
|
for item in list {
|
|
guard let s = item as? String else { continue }
|
|
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continue }
|
|
out.append(trimmed)
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func parseDuckDuckGo(data: Data) -> [String] {
|
|
// Format: [{phrase:"..."}, ...]
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
|
|
return []
|
|
}
|
|
var out: [String] = []
|
|
out.reserveCapacity(root.count)
|
|
for item in root {
|
|
guard let dict = item as? [String: Any],
|
|
let phrase = dict["phrase"] as? String else { continue }
|
|
let trimmed = phrase.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continue }
|
|
out.append(trimmed)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
/// BrowserPanel provides a WKWebView-based browser panel.
|
|
/// All browser panels share a WKProcessPool for cookie sharing.
|
|
private enum BrowserInsecureHTTPNavigationIntent {
|
|
case currentTab
|
|
case newTab
|
|
}
|
|
|
|
@MainActor
|
|
final class BrowserPanel: Panel, ObservableObject {
|
|
/// Shared process pool for cookie sharing across all browser panels
|
|
private static let sharedProcessPool = WKProcessPool()
|
|
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
|
|
private static let remoteLoopbackHosts: Set<String> = [
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"0.0.0.0",
|
|
]
|
|
|
|
static let telemetryHookBootstrapScriptSource = """
|
|
(() => {
|
|
if (window.__cmuxHooksInstalled) return true;
|
|
window.__cmuxHooksInstalled = true;
|
|
|
|
window.__cmuxConsoleLog = window.__cmuxConsoleLog || [];
|
|
const __pushConsole = (level, args) => {
|
|
try {
|
|
const text = Array.from(args || []).map((x) => {
|
|
if (typeof x === 'string') return x;
|
|
try { return JSON.stringify(x); } catch (_) { return String(x); }
|
|
}).join(' ');
|
|
window.__cmuxConsoleLog.push({ level, text, timestamp_ms: Date.now() });
|
|
if (window.__cmuxConsoleLog.length > 512) {
|
|
window.__cmuxConsoleLog.splice(0, window.__cmuxConsoleLog.length - 512);
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
|
|
const methods = ['log', 'info', 'warn', 'error', 'debug'];
|
|
for (const m of methods) {
|
|
const orig = (window.console && window.console[m]) ? window.console[m].bind(window.console) : null;
|
|
window.console[m] = function(...args) {
|
|
__pushConsole(m, args);
|
|
if (orig) return orig(...args);
|
|
};
|
|
}
|
|
|
|
window.__cmuxErrorLog = window.__cmuxErrorLog || [];
|
|
window.addEventListener('error', (ev) => {
|
|
try {
|
|
const message = String((ev && ev.message) || '');
|
|
const source = String((ev && ev.filename) || '');
|
|
const line = Number((ev && ev.lineno) || 0);
|
|
const col = Number((ev && ev.colno) || 0);
|
|
window.__cmuxErrorLog.push({ message, source, line, column: col, timestamp_ms: Date.now() });
|
|
if (window.__cmuxErrorLog.length > 512) {
|
|
window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512);
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
window.addEventListener('unhandledrejection', (ev) => {
|
|
try {
|
|
const reason = ev && ev.reason;
|
|
const message = typeof reason === 'string' ? reason : (reason && reason.message ? String(reason.message) : String(reason));
|
|
window.__cmuxErrorLog.push({ message, source: 'unhandledrejection', line: 0, column: 0, timestamp_ms: Date.now() });
|
|
if (window.__cmuxErrorLog.length > 512) {
|
|
window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512);
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
|
|
return true;
|
|
})()
|
|
"""
|
|
|
|
static let dialogTelemetryHookBootstrapScriptSource = """
|
|
(() => {
|
|
if (window.__cmuxDialogHooksInstalled) return true;
|
|
window.__cmuxDialogHooksInstalled = true;
|
|
|
|
window.__cmuxDialogQueue = window.__cmuxDialogQueue || [];
|
|
window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null };
|
|
const __pushDialog = (type, message, defaultText) => {
|
|
window.__cmuxDialogQueue.push({
|
|
type,
|
|
message: String(message || ''),
|
|
default_text: defaultText == null ? null : String(defaultText),
|
|
timestamp_ms: Date.now()
|
|
});
|
|
if (window.__cmuxDialogQueue.length > 128) {
|
|
window.__cmuxDialogQueue.splice(0, window.__cmuxDialogQueue.length - 128);
|
|
}
|
|
};
|
|
|
|
window.alert = function(message) {
|
|
__pushDialog('alert', message, null);
|
|
};
|
|
window.confirm = function(message) {
|
|
__pushDialog('confirm', message, null);
|
|
return !!window.__cmuxDialogDefaults.confirm;
|
|
};
|
|
window.prompt = function(message, defaultValue) {
|
|
__pushDialog('prompt', message, defaultValue == null ? null : defaultValue);
|
|
const v = window.__cmuxDialogDefaults.prompt;
|
|
if (v === null || v === undefined) {
|
|
return defaultValue == null ? '' : String(defaultValue);
|
|
}
|
|
return String(v);
|
|
};
|
|
|
|
return true;
|
|
})()
|
|
"""
|
|
|
|
private static func clampedGhosttyBackgroundOpacity(_ opacity: Double) -> CGFloat {
|
|
CGFloat(max(0.0, min(1.0, opacity)))
|
|
}
|
|
|
|
private static func isDarkAppearance(
|
|
appAppearance: NSAppearance? = NSApp?.effectiveAppearance
|
|
) -> Bool {
|
|
guard let appAppearance else { return false }
|
|
return appAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
|
}
|
|
|
|
private static func resolvedGhosttyBackgroundColor(from notification: Notification? = nil) -> NSColor {
|
|
let userInfo = notification?.userInfo
|
|
let baseColor = (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)
|
|
?? GhosttyApp.shared.defaultBackgroundColor
|
|
|
|
let opacity: Double
|
|
if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double {
|
|
opacity = value
|
|
} else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber {
|
|
opacity = value.doubleValue
|
|
} else {
|
|
opacity = GhosttyApp.shared.defaultBackgroundOpacity
|
|
}
|
|
|
|
return baseColor.withAlphaComponent(clampedGhosttyBackgroundOpacity(opacity))
|
|
}
|
|
|
|
private static func resolvedBrowserChromeBackgroundColor(
|
|
from notification: Notification? = nil,
|
|
appAppearance: NSAppearance? = NSApp?.effectiveAppearance
|
|
) -> NSColor {
|
|
if isDarkAppearance(appAppearance: appAppearance) {
|
|
return resolvedGhosttyBackgroundColor(from: notification)
|
|
}
|
|
return NSColor.windowBackgroundColor
|
|
}
|
|
|
|
let id: UUID
|
|
let panelType: PanelType = .browser
|
|
|
|
/// The workspace ID this panel belongs to
|
|
private(set) var workspaceId: UUID
|
|
|
|
/// The underlying web view
|
|
private(set) var webView: WKWebView
|
|
|
|
/// 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?
|
|
|
|
/// Whether the browser panel should render its WKWebView in the content area.
|
|
/// New browser tabs stay in an empty "new tab" state until first navigation.
|
|
@Published private(set) var shouldRenderWebView: Bool = false
|
|
|
|
/// True when the browser is showing the internal empty new-tab page (no WKWebView attached yet).
|
|
var isShowingNewTabPage: Bool {
|
|
!shouldRenderWebView
|
|
}
|
|
|
|
/// Published page title
|
|
@Published private(set) var pageTitle: String = ""
|
|
|
|
/// Published favicon (PNG data). When present, the tab bar can render it instead of a SF symbol.
|
|
@Published private(set) var faviconPNGData: Data?
|
|
|
|
/// Published loading state
|
|
@Published private(set) var isLoading: Bool = false
|
|
|
|
/// Snapshot of remote SSH connection status for this panel's workspace.
|
|
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
|
|
|
|
/// Published download state for browser downloads (navigation + context menu).
|
|
@Published private(set) var isDownloading: Bool = false
|
|
|
|
/// Published can go back state
|
|
@Published private(set) var canGoBack: Bool = false
|
|
|
|
/// Published can go forward state
|
|
@Published private(set) var canGoForward: Bool = false
|
|
|
|
private var nativeCanGoBack: Bool = false
|
|
private var nativeCanGoForward: Bool = false
|
|
private var usesRestoredSessionHistory: Bool = false
|
|
private var restoredBackHistoryStack: [URL] = []
|
|
private var restoredForwardHistoryStack: [URL] = []
|
|
private var restoredHistoryCurrentURL: URL?
|
|
|
|
/// Published estimated progress (0.0 - 1.0)
|
|
@Published private(set) var estimatedProgress: Double = 0.0
|
|
|
|
/// Increment to request a UI-only flash highlight (e.g. from a keyboard shortcut).
|
|
@Published private(set) var focusFlashToken: Int = 0
|
|
|
|
/// Sticky omnibar-focus intent. This survives view mount timing races and is
|
|
/// cleared only after BrowserPanelView acknowledges handling it.
|
|
@Published private(set) var pendingAddressBarFocusRequestId: UUID?
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var navigationDelegate: BrowserNavigationDelegate?
|
|
private var uiDelegate: BrowserUIDelegate?
|
|
private var downloadDelegate: BrowserDownloadDelegate?
|
|
private var webViewObservers: [NSKeyValueObservation] = []
|
|
private var activeDownloadCount: Int = 0
|
|
|
|
// Avoid flickering the loading indicator for very fast navigations.
|
|
private let minLoadingIndicatorDuration: TimeInterval = 0.35
|
|
private var loadingStartedAt: Date?
|
|
private var loadingEndWorkItem: DispatchWorkItem?
|
|
private var loadingGeneration: Int = 0
|
|
|
|
private var faviconTask: Task<Void, Never>?
|
|
private var faviconRefreshGeneration: Int = 0
|
|
private var lastFaviconURLString: String?
|
|
private let minPageZoom: CGFloat = 0.25
|
|
private let maxPageZoom: CGFloat = 5.0
|
|
private let pageZoomStep: CGFloat = 0.1
|
|
private var insecureHTTPBypassHostOnce: String?
|
|
private var insecureHTTPAlertFactory: () -> NSAlert
|
|
private var insecureHTTPAlertWindowProvider: () -> NSWindow?
|
|
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
|
private var preferredDeveloperToolsVisible: Bool = false
|
|
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
|
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
|
private var developerToolsRestoreRetryAttempt: Int = 0
|
|
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
|
|
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
|
private var remoteProxyEndpoint: BrowserProxyEndpoint?
|
|
private var browserThemeMode: BrowserThemeMode
|
|
|
|
var displayTitle: String {
|
|
if !pageTitle.isEmpty {
|
|
return pageTitle
|
|
}
|
|
if let url = currentURL {
|
|
return url.host ?? url.absoluteString
|
|
}
|
|
return "New tab"
|
|
}
|
|
|
|
var displayIcon: String? {
|
|
"globe"
|
|
}
|
|
|
|
var isDirty: Bool {
|
|
false
|
|
}
|
|
|
|
init(
|
|
workspaceId: UUID,
|
|
initialURL: URL? = nil,
|
|
bypassInsecureHTTPHostOnce: String? = nil,
|
|
proxyEndpoint: BrowserProxyEndpoint? = nil
|
|
) {
|
|
self.id = UUID()
|
|
self.workspaceId = workspaceId
|
|
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
|
|
self.remoteProxyEndpoint = proxyEndpoint
|
|
self.browserThemeMode = BrowserThemeSettings.mode()
|
|
|
|
let webView = Self.makeWebView(for: workspaceId)
|
|
self.webView = webView
|
|
self.insecureHTTPAlertFactory = { NSAlert() }
|
|
self.insecureHTTPAlertWindowProvider = { [weak webView] in
|
|
webView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
applyRemoteProxyConfigurationIfAvailable()
|
|
|
|
// Set up navigation delegate
|
|
let navDelegate = BrowserNavigationDelegate()
|
|
navDelegate.didFinish = { webView in
|
|
BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title)
|
|
Task { @MainActor [weak self] in
|
|
self?.refreshFavicon(from: webView)
|
|
self?.applyBrowserThemeModeIfNeeded()
|
|
}
|
|
}
|
|
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] request, intent in
|
|
self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false)
|
|
}
|
|
// Set up download delegate for navigation-based downloads.
|
|
// Downloads save to a temp file synchronously (no NSSavePanel during WebKit
|
|
// callbacks), then show NSSavePanel after the download completes.
|
|
let dlDelegate = BrowserDownloadDelegate()
|
|
dlDelegate.onDownloadStarted = { [weak self] _ in
|
|
self?.beginDownloadActivity()
|
|
}
|
|
dlDelegate.onDownloadReadyToSave = { [weak self] in
|
|
self?.endDownloadActivity()
|
|
}
|
|
dlDelegate.onDownloadFailed = { [weak self] _ in
|
|
self?.endDownloadActivity()
|
|
}
|
|
navDelegate.downloadDelegate = dlDelegate
|
|
self.downloadDelegate = dlDelegate
|
|
webView.onContextMenuDownloadStateChanged = { [weak self] downloading in
|
|
if downloading {
|
|
self?.beginDownloadActivity()
|
|
} else {
|
|
self?.endDownloadActivity()
|
|
}
|
|
}
|
|
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] request, intent in
|
|
self?.requestNavigation(request, intent: intent)
|
|
}
|
|
webView.uiDelegate = browserUIDelegate
|
|
self.uiDelegate = browserUIDelegate
|
|
|
|
// Observe web view properties
|
|
setupObservers()
|
|
applyBrowserThemeModeIfNeeded()
|
|
|
|
// Navigate to initial URL if provided
|
|
if let url = initialURL {
|
|
shouldRenderWebView = true
|
|
navigate(to: url)
|
|
}
|
|
}
|
|
|
|
private func beginDownloadActivity() {
|
|
let apply = {
|
|
self.activeDownloadCount += 1
|
|
self.isDownloading = self.activeDownloadCount > 0
|
|
}
|
|
if Thread.isMainThread {
|
|
apply()
|
|
} else {
|
|
DispatchQueue.main.async(execute: apply)
|
|
}
|
|
}
|
|
|
|
private func endDownloadActivity() {
|
|
let apply = {
|
|
self.activeDownloadCount = max(0, self.activeDownloadCount - 1)
|
|
self.isDownloading = self.activeDownloadCount > 0
|
|
}
|
|
if Thread.isMainThread {
|
|
apply()
|
|
} else {
|
|
DispatchQueue.main.async(execute: apply)
|
|
}
|
|
}
|
|
|
|
func updateWorkspaceId(_ newWorkspaceId: UUID) {
|
|
guard workspaceId != newWorkspaceId else { return }
|
|
workspaceId = newWorkspaceId
|
|
rebindWebViewDataStoreIfNeeded()
|
|
}
|
|
|
|
func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) {
|
|
guard remoteProxyEndpoint != endpoint else { return }
|
|
remoteProxyEndpoint = endpoint
|
|
applyRemoteProxyConfigurationIfAvailable()
|
|
}
|
|
|
|
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
|
|
guard remoteWorkspaceStatus != status else { return }
|
|
remoteWorkspaceStatus = status
|
|
}
|
|
|
|
private func applyRemoteProxyConfigurationIfAvailable() {
|
|
guard #available(macOS 14.0, *) else { return }
|
|
|
|
let store = webView.configuration.websiteDataStore
|
|
guard let endpoint = remoteProxyEndpoint else {
|
|
store.proxyConfigurations = []
|
|
return
|
|
}
|
|
|
|
let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !host.isEmpty,
|
|
endpoint.port > 0 && endpoint.port <= 65535,
|
|
let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else {
|
|
store.proxyConfigurations = []
|
|
return
|
|
}
|
|
|
|
let nwEndpoint = NWEndpoint.hostPort(
|
|
host: NWEndpoint.Host(host),
|
|
port: nwPort
|
|
)
|
|
// Prefer SOCKSv5; keep CONNECT configured as fallback.
|
|
let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint)
|
|
let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint)
|
|
store.proxyConfigurations = [socks, connect]
|
|
}
|
|
|
|
private static func makeWebView(for workspaceId: UUID) -> CmuxWebView {
|
|
let config = WKWebViewConfiguration()
|
|
config.processPool = BrowserPanel.sharedProcessPool
|
|
// Keep data-store scoping at workspace granularity so remote proxy settings
|
|
// do not leak into local workspaces.
|
|
if #available(macOS 14.0, *) {
|
|
config.websiteDataStore = WKWebsiteDataStore(forIdentifier: workspaceId)
|
|
} else {
|
|
config.websiteDataStore = .default()
|
|
}
|
|
|
|
// Enable developer extras (DevTools)
|
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
|
|
|
// Enable JavaScript
|
|
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
// Keep browser console/error/dialog telemetry active from document start on every navigation.
|
|
config.userContentController.addUserScript(
|
|
WKUserScript(
|
|
source: Self.telemetryHookBootstrapScriptSource,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: false
|
|
)
|
|
)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: config)
|
|
webView.allowsBackForwardNavigationGestures = true
|
|
|
|
// Required for Web Inspector support on recent WebKit SDKs.
|
|
if #available(macOS 13.3, *) {
|
|
webView.isInspectable = true
|
|
}
|
|
|
|
// Match the empty-page background to the terminal theme so newly-created browsers
|
|
// don't flash white before content loads.
|
|
webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor()
|
|
|
|
// Always present as Safari.
|
|
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
|
return webView
|
|
}
|
|
|
|
private func rebindWebViewDataStoreIfNeeded() {
|
|
guard #available(macOS 14.0, *) else { return }
|
|
|
|
let oldWebView = webView
|
|
let restoreURL = oldWebView.url ?? currentURL
|
|
let restorePageZoom = oldWebView.pageZoom
|
|
let shouldRestoreNavigation = shouldRenderWebView
|
|
&& restoreURL?.absoluteString != blankURLString
|
|
|
|
oldWebView.stopLoading()
|
|
oldWebView.navigationDelegate = nil
|
|
oldWebView.uiDelegate = nil
|
|
if let oldCmuxWebView = oldWebView as? CmuxWebView {
|
|
oldCmuxWebView.onContextMenuDownloadStateChanged = nil
|
|
}
|
|
BrowserWindowPortalRegistry.detach(webView: oldWebView)
|
|
oldWebView.removeFromSuperview()
|
|
|
|
webViewObservers.removeAll()
|
|
cancellables.removeAll()
|
|
|
|
let replacement = Self.makeWebView(for: workspaceId)
|
|
replacement.pageZoom = restorePageZoom
|
|
replacement.navigationDelegate = navigationDelegate
|
|
replacement.uiDelegate = uiDelegate
|
|
replacement.onContextMenuDownloadStateChanged = { [weak self] downloading in
|
|
if downloading {
|
|
self?.beginDownloadActivity()
|
|
} else {
|
|
self?.endDownloadActivity()
|
|
}
|
|
}
|
|
|
|
objectWillChange.send()
|
|
webView = replacement
|
|
insecureHTTPAlertWindowProvider = { [weak self] in
|
|
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
nativeCanGoBack = false
|
|
nativeCanGoForward = false
|
|
estimatedProgress = 0
|
|
refreshNavigationAvailability()
|
|
setupObservers()
|
|
applyRemoteProxyConfigurationIfAvailable()
|
|
|
|
if shouldRestoreNavigation, let restoreURL {
|
|
replacement.load(preparedNavigationRequest(URLRequest(url: restoreURL)))
|
|
}
|
|
}
|
|
|
|
private func rewriteLoopbackURLForRemoteProxyIfNeeded(_ url: URL) -> URL {
|
|
guard remoteProxyEndpoint != nil else { return url }
|
|
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return url }
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url }
|
|
guard Self.remoteLoopbackHosts.contains(host) else { return url }
|
|
|
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
components?.host = Self.remoteLoopbackProxyAliasHost
|
|
return components?.url ?? url
|
|
}
|
|
|
|
private func canonicalLoopbackURLForDisplayIfNeeded(_ url: URL) -> URL {
|
|
guard remoteProxyEndpoint != nil else { return url }
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url }
|
|
guard host == Self.remoteLoopbackProxyAliasHost else { return url }
|
|
|
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
components?.host = "localhost"
|
|
return components?.url ?? url
|
|
}
|
|
|
|
private func preparedNavigationRequest(_ request: URLRequest) -> URLRequest {
|
|
var prepared = browserPreparedNavigationRequest(request)
|
|
guard let url = prepared.url else { return prepared }
|
|
let rewrittenURL = rewriteLoopbackURLForRemoteProxyIfNeeded(url)
|
|
if rewrittenURL != url {
|
|
prepared.url = rewrittenURL
|
|
}
|
|
return prepared
|
|
}
|
|
|
|
func triggerFlash() {
|
|
focusFlashToken &+= 1
|
|
}
|
|
|
|
func sessionNavigationHistorySnapshot() -> (
|
|
backHistoryURLStrings: [String],
|
|
forwardHistoryURLStrings: [String]
|
|
) {
|
|
if usesRestoredSessionHistory {
|
|
let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) }
|
|
// `restoredForwardHistoryStack` stores nearest-forward entries at the end.
|
|
let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) }
|
|
return (back, forward)
|
|
}
|
|
|
|
let back = webView.backForwardList.backList.compactMap {
|
|
Self.serializableSessionHistoryURLString($0.url)
|
|
}
|
|
let forward = webView.backForwardList.forwardList.compactMap {
|
|
Self.serializableSessionHistoryURLString($0.url)
|
|
}
|
|
return (back, forward)
|
|
}
|
|
|
|
func restoreSessionNavigationHistory(
|
|
backHistoryURLStrings: [String],
|
|
forwardHistoryURLStrings: [String],
|
|
currentURLString: String?
|
|
) {
|
|
let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings)
|
|
let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings)
|
|
guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return }
|
|
|
|
usesRestoredSessionHistory = true
|
|
restoredBackHistoryStack = restoredBack
|
|
// Store nearest-forward entries at the end to make stack pop operations trivial.
|
|
restoredForwardHistoryStack = Array(restoredForward.reversed())
|
|
restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString)
|
|
refreshNavigationAvailability()
|
|
}
|
|
|
|
private func setupObservers() {
|
|
// URL changes
|
|
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
|
|
Task { @MainActor in
|
|
self?.currentURL = webView.url
|
|
}
|
|
}
|
|
webViewObservers.append(urlObserver)
|
|
|
|
// Title changes
|
|
let titleObserver = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in
|
|
Task { @MainActor in
|
|
// Keep showing the last non-empty title while the new navigation is loading.
|
|
// WebKit often clears title to nil/"" during reload/navigation, which causes
|
|
// a distracting tab-title flash (e.g. to host/URL). Only accept non-empty titles.
|
|
let trimmed = (webView.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
self?.pageTitle = trimmed
|
|
}
|
|
}
|
|
webViewObservers.append(titleObserver)
|
|
|
|
// Loading state
|
|
let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in
|
|
Task { @MainActor in
|
|
self?.handleWebViewLoadingChanged(webView.isLoading)
|
|
}
|
|
}
|
|
webViewObservers.append(loadingObserver)
|
|
|
|
// Can go back
|
|
let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.nativeCanGoBack = webView.canGoBack
|
|
self.refreshNavigationAvailability()
|
|
}
|
|
}
|
|
webViewObservers.append(backObserver)
|
|
|
|
// Can go forward
|
|
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.nativeCanGoForward = webView.canGoForward
|
|
self.refreshNavigationAvailability()
|
|
}
|
|
}
|
|
webViewObservers.append(forwardObserver)
|
|
|
|
// Progress
|
|
let progressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
|
|
Task { @MainActor in
|
|
self?.estimatedProgress = webView.estimatedProgress
|
|
}
|
|
}
|
|
webViewObservers.append(progressObserver)
|
|
|
|
NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)
|
|
.sink { [weak self] notification in
|
|
guard let self else { return }
|
|
self.webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor(from: notification)
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// 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()
|
|
cancellables.removeAll()
|
|
faviconTask?.cancel()
|
|
faviconTask = nil
|
|
}
|
|
|
|
private func refreshFavicon(from webView: WKWebView) {
|
|
faviconTask?.cancel()
|
|
faviconTask = nil
|
|
|
|
guard let pageURL = webView.url else { return }
|
|
guard let scheme = pageURL.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return }
|
|
faviconRefreshGeneration &+= 1
|
|
let refreshGeneration = faviconRefreshGeneration
|
|
|
|
faviconTask = Task { @MainActor [weak self, weak webView] in
|
|
guard let self, let webView else { return }
|
|
guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return }
|
|
|
|
// Try to discover the best icon URL from the document.
|
|
let js = """
|
|
(() => {
|
|
const links = Array.from(document.querySelectorAll(
|
|
'link[rel~=\"icon\"], link[rel=\"shortcut icon\"], link[rel=\"apple-touch-icon\"], link[rel=\"apple-touch-icon-precomposed\"]'
|
|
));
|
|
function score(link) {
|
|
const v = (link.sizes && link.sizes.value) ? link.sizes.value : '';
|
|
if (v === 'any') return 1000;
|
|
let max = 0;
|
|
for (const part of v.split(/\\s+/)) {
|
|
const m = part.match(/(\\d+)x(\\d+)/);
|
|
if (!m) continue;
|
|
const a = parseInt(m[1], 10);
|
|
const b = parseInt(m[2], 10);
|
|
if (Number.isFinite(a)) max = Math.max(max, a);
|
|
if (Number.isFinite(b)) max = Math.max(max, b);
|
|
}
|
|
return max;
|
|
}
|
|
links.sort((a, b) => score(b) - score(a));
|
|
return links[0]?.href || '';
|
|
})();
|
|
"""
|
|
|
|
var discoveredURL: URL?
|
|
if let href = try? await webView.evaluateJavaScript(js) as? String {
|
|
let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty, let u = URL(string: trimmed) {
|
|
discoveredURL = u
|
|
}
|
|
}
|
|
guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return }
|
|
|
|
let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL)
|
|
let iconURL = discoveredURL ?? fallbackURL
|
|
guard let iconURL else { return }
|
|
|
|
// Avoid repeated fetches.
|
|
let iconURLString = iconURL.absoluteString
|
|
if iconURLString == lastFaviconURLString, faviconPNGData != nil {
|
|
return
|
|
}
|
|
lastFaviconURLString = iconURLString
|
|
|
|
var req = URLRequest(url: iconURL)
|
|
req.timeoutInterval = 2.0
|
|
req.cachePolicy = .returnCacheDataElseLoad
|
|
req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent")
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await URLSession.shared.data(for: req)
|
|
} catch {
|
|
return
|
|
}
|
|
guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return }
|
|
|
|
guard let http = response as? HTTPURLResponse,
|
|
(200..<300).contains(http.statusCode) else {
|
|
return
|
|
}
|
|
|
|
// Use >= 2x the rendered point size so we don't upscale (blurry) on Retina.
|
|
guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { return }
|
|
// Only update if we got a real icon; keep the old one otherwise to avoid flashes.
|
|
faviconPNGData = png
|
|
}
|
|
}
|
|
|
|
private func isCurrentFaviconRefresh(generation: Int) -> Bool {
|
|
guard !Task.isCancelled else { return false }
|
|
return generation == faviconRefreshGeneration
|
|
}
|
|
|
|
@MainActor
|
|
private static func makeFaviconPNGData(from raw: Data, targetPx: Int) -> Data? {
|
|
guard let image = NSImage(data: raw) else { return nil }
|
|
|
|
let px = max(16, min(128, targetPx))
|
|
let size = NSSize(width: px, height: px)
|
|
guard let rep = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: px,
|
|
pixelsHigh: px,
|
|
bitsPerSample: 8,
|
|
samplesPerPixel: 4,
|
|
hasAlpha: true,
|
|
isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0,
|
|
bitsPerPixel: 0
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
|
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
|
ctx?.imageInterpolation = .high
|
|
ctx?.shouldAntialias = true
|
|
NSGraphicsContext.current = ctx
|
|
|
|
NSColor.clear.setFill()
|
|
NSRect(origin: .zero, size: size).fill()
|
|
|
|
// Aspect-fit into the target square.
|
|
let srcSize = image.size
|
|
let scale = min(size.width / max(1, srcSize.width), size.height / max(1, srcSize.height))
|
|
let drawSize = NSSize(width: srcSize.width * scale, height: srcSize.height * scale)
|
|
let drawOrigin = NSPoint(x: (size.width - drawSize.width) / 2.0, y: (size.height - drawSize.height) / 2.0)
|
|
// Align to integral pixels to avoid soft edges at small sizes.
|
|
let drawRect = NSRect(
|
|
x: round(drawOrigin.x),
|
|
y: round(drawOrigin.y),
|
|
width: round(drawSize.width),
|
|
height: round(drawSize.height)
|
|
)
|
|
|
|
image.draw(
|
|
in: drawRect,
|
|
from: NSRect(origin: .zero, size: srcSize),
|
|
operation: .sourceOver,
|
|
fraction: 1.0,
|
|
respectFlipped: true,
|
|
hints: [.interpolation: NSImageInterpolation.high]
|
|
)
|
|
|
|
return rep.representation(using: .png, properties: [:])
|
|
}
|
|
|
|
private func handleWebViewLoadingChanged(_ newValue: Bool) {
|
|
if newValue {
|
|
// Any new load invalidates older favicon fetches, even for same-URL reloads.
|
|
faviconRefreshGeneration &+= 1
|
|
faviconTask?.cancel()
|
|
faviconTask = nil
|
|
lastFaviconURLString = nil
|
|
// Clear the previous page's favicon so it never persists across navigations.
|
|
// The loading spinner covers this gap; didFinish will fetch the new favicon.
|
|
faviconPNGData = nil
|
|
loadingGeneration &+= 1
|
|
loadingEndWorkItem?.cancel()
|
|
loadingEndWorkItem = nil
|
|
loadingStartedAt = Date()
|
|
isLoading = true
|
|
return
|
|
}
|
|
|
|
let genAtEnd = loadingGeneration
|
|
let startedAt = loadingStartedAt ?? Date()
|
|
let elapsed = Date().timeIntervalSince(startedAt)
|
|
let remaining = max(0, minLoadingIndicatorDuration - elapsed)
|
|
|
|
loadingEndWorkItem?.cancel()
|
|
loadingEndWorkItem = nil
|
|
|
|
if remaining <= 0.0001 {
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
// If loading restarted, ignore this end.
|
|
guard self.loadingGeneration == genAtEnd else { return }
|
|
// If WebKit is still loading, ignore.
|
|
guard !self.webView.isLoading else { return }
|
|
self.isLoading = false
|
|
}
|
|
loadingEndWorkItem = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: work)
|
|
}
|
|
|
|
// MARK: - Navigation
|
|
|
|
/// Navigate to a URL
|
|
func navigate(to url: URL, recordTypedNavigation: Bool = false) {
|
|
let request = URLRequest(url: url)
|
|
if shouldBlockInsecureHTTPNavigation(to: url) {
|
|
presentInsecureHTTPAlert(for: request, intent: .currentTab, recordTypedNavigation: recordTypedNavigation)
|
|
return
|
|
}
|
|
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
|
|
}
|
|
|
|
private func navigateWithoutInsecureHTTPPrompt(
|
|
to url: URL,
|
|
recordTypedNavigation: Bool,
|
|
preserveRestoredSessionHistory: Bool = false
|
|
) {
|
|
let request = URLRequest(url: url)
|
|
navigateWithoutInsecureHTTPPrompt(
|
|
request: request,
|
|
recordTypedNavigation: recordTypedNavigation,
|
|
preserveRestoredSessionHistory: preserveRestoredSessionHistory
|
|
)
|
|
}
|
|
|
|
private func navigateWithoutInsecureHTTPPrompt(
|
|
request: URLRequest,
|
|
recordTypedNavigation: Bool,
|
|
preserveRestoredSessionHistory: Bool = false
|
|
) {
|
|
guard let url = request.url else { return }
|
|
if !preserveRestoredSessionHistory {
|
|
abandonRestoredSessionHistoryIfNeeded()
|
|
}
|
|
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
|
|
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
|
shouldRenderWebView = true
|
|
if recordTypedNavigation {
|
|
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
|
}
|
|
navigationDelegate?.lastAttemptedURL = url
|
|
webView.load(preparedNavigationRequest(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(_ request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent) {
|
|
guard let url = request.url else { return }
|
|
if shouldBlockInsecureHTTPNavigation(to: url) {
|
|
presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false)
|
|
return
|
|
}
|
|
switch intent {
|
|
case .currentTab:
|
|
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: false)
|
|
case .newTab:
|
|
openLinkInNewTab(url: url)
|
|
}
|
|
}
|
|
|
|
private func presentInsecureHTTPAlert(
|
|
for request: URLRequest,
|
|
intent: BrowserInsecureHTTPNavigationIntent,
|
|
recordTypedNavigation: Bool
|
|
) {
|
|
guard let url = request.url else { return }
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return }
|
|
|
|
let alert = insecureHTTPAlertFactory()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = "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 handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self, weak alert] response in
|
|
self?.handleInsecureHTTPAlertResponse(
|
|
response,
|
|
alert: alert,
|
|
host: host,
|
|
request: request,
|
|
url: url,
|
|
intent: intent,
|
|
recordTypedNavigation: recordTypedNavigation
|
|
)
|
|
}
|
|
|
|
if let alertWindow = insecureHTTPAlertWindowProvider() {
|
|
alert.beginSheetModal(for: alertWindow, completionHandler: handleResponse)
|
|
return
|
|
}
|
|
|
|
handleResponse(alert.runModal())
|
|
}
|
|
|
|
private func handleInsecureHTTPAlertResponse(
|
|
_ response: NSApplication.ModalResponse,
|
|
alert: NSAlert?,
|
|
host: String,
|
|
request: URLRequest,
|
|
url: URL,
|
|
intent: BrowserInsecureHTTPNavigationIntent,
|
|
recordTypedNavigation: Bool
|
|
) {
|
|
if browserShouldPersistInsecureHTTPAllowlistSelection(
|
|
response: response,
|
|
suppressionEnabled: alert?.suppressionButton?.state == .on
|
|
) {
|
|
BrowserInsecureHTTPSettings.addAllowedHost(host)
|
|
}
|
|
switch response {
|
|
case .alertFirstButtonReturn:
|
|
NSWorkspace.shared.open(url)
|
|
case .alertSecondButtonReturn:
|
|
switch intent {
|
|
case .currentTab:
|
|
insecureHTTPBypassHostOnce = host
|
|
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
|
|
case .newTab:
|
|
openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host)
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
developerToolsRestoreRetryWorkItem?.cancel()
|
|
developerToolsRestoreRetryWorkItem = nil
|
|
let webView = webView
|
|
Task { @MainActor in
|
|
BrowserWindowPortalRegistry.detach(webView: webView)
|
|
}
|
|
webViewObservers.removeAll()
|
|
cancellables.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 }
|
|
if usesRestoredSessionHistory {
|
|
guard let targetURL = restoredBackHistoryStack.popLast() else {
|
|
refreshNavigationAvailability()
|
|
return
|
|
}
|
|
if let current = resolvedCurrentSessionHistoryURL() {
|
|
restoredForwardHistoryStack.append(current)
|
|
}
|
|
restoredHistoryCurrentURL = targetURL
|
|
refreshNavigationAvailability()
|
|
navigateWithoutInsecureHTTPPrompt(
|
|
to: targetURL,
|
|
recordTypedNavigation: false,
|
|
preserveRestoredSessionHistory: true
|
|
)
|
|
return
|
|
}
|
|
|
|
webView.goBack()
|
|
}
|
|
|
|
/// Go forward in history
|
|
func goForward() {
|
|
guard canGoForward else { return }
|
|
if usesRestoredSessionHistory {
|
|
guard let targetURL = restoredForwardHistoryStack.popLast() else {
|
|
refreshNavigationAvailability()
|
|
return
|
|
}
|
|
if let current = resolvedCurrentSessionHistoryURL() {
|
|
restoredBackHistoryStack.append(current)
|
|
}
|
|
restoredHistoryCurrentURL = targetURL
|
|
refreshNavigationAvailability()
|
|
navigateWithoutInsecureHTTPPrompt(
|
|
to: targetURL,
|
|
recordTypedNavigation: false,
|
|
preserveRestoredSessionHistory: true
|
|
)
|
|
return
|
|
}
|
|
|
|
webView.goForward()
|
|
}
|
|
|
|
/// Open a link in a new browser surface in the same pane
|
|
func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
|
|
#if DEBUG
|
|
dlog(
|
|
"browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " +
|
|
"bypass=\(bypassInsecureHTTPHostOnce ?? "nil")"
|
|
)
|
|
#endif
|
|
guard let tabManager = AppDelegate.shared?.tabManager else {
|
|
#if DEBUG
|
|
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager")
|
|
#endif
|
|
return
|
|
}
|
|
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
|
|
#if DEBUG
|
|
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing")
|
|
#endif
|
|
return
|
|
}
|
|
guard let paneId = workspace.paneId(forPanelId: id) else {
|
|
#if DEBUG
|
|
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing")
|
|
#endif
|
|
return
|
|
}
|
|
workspace.newBrowserSurface(
|
|
inPane: paneId,
|
|
url: url,
|
|
focus: true,
|
|
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
|
)
|
|
#if DEBUG
|
|
dlog(
|
|
"browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
/// Reload the current page
|
|
func reload() {
|
|
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
|
webView.reload()
|
|
}
|
|
|
|
/// Stop loading
|
|
func stopLoading() {
|
|
webView.stopLoading()
|
|
}
|
|
|
|
@discardableResult
|
|
func toggleDeveloperTools() -> Bool {
|
|
#if DEBUG
|
|
dlog(
|
|
"browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " +
|
|
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
|
|
)
|
|
#endif
|
|
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
|
let isVisibleSelector = NSSelectorFromString("isVisible")
|
|
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
|
let targetVisible = !visible
|
|
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
|
|
guard inspector.responds(to: selector) else { return false }
|
|
inspector.cmuxCallVoid(selector: selector)
|
|
preferredDeveloperToolsVisible = targetVisible
|
|
if targetVisible {
|
|
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
|
if visibleAfterToggle {
|
|
cancelDeveloperToolsRestoreRetry()
|
|
} else {
|
|
developerToolsRestoreRetryAttempt = 0
|
|
scheduleDeveloperToolsRestoreRetry()
|
|
}
|
|
} else {
|
|
cancelDeveloperToolsRestoreRetry()
|
|
forceDeveloperToolsRefreshOnNextAttach = false
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " +
|
|
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
|
|
)
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
dlog(
|
|
"browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " +
|
|
"\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())"
|
|
)
|
|
}
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func showDeveloperTools() -> Bool {
|
|
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
|
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
|
if !visible {
|
|
let showSelector = NSSelectorFromString("show")
|
|
guard inspector.responds(to: showSelector) else { return false }
|
|
inspector.cmuxCallVoid(selector: showSelector)
|
|
}
|
|
preferredDeveloperToolsVisible = true
|
|
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
|
|
cancelDeveloperToolsRestoreRetry()
|
|
} else {
|
|
scheduleDeveloperToolsRestoreRetry()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func showDeveloperToolsConsole() -> Bool {
|
|
guard showDeveloperTools() else { return false }
|
|
guard let inspector = webView.cmuxInspectorObject() else { return true }
|
|
// WebKit private inspector API differs by OS; try known console selectors.
|
|
let consoleSelectors = [
|
|
"showConsole",
|
|
"showConsoleTab",
|
|
"showConsoleView",
|
|
]
|
|
for raw in consoleSelectors {
|
|
let selector = NSSelectorFromString(raw)
|
|
if inspector.responds(to: selector) {
|
|
inspector.cmuxCallVoid(selector: selector)
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Called before WKWebView detaches so manual inspector closes are respected.
|
|
func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) {
|
|
guard let inspector = webView.cmuxInspectorObject() else { return }
|
|
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
|
|
if visible {
|
|
preferredDeveloperToolsVisible = true
|
|
cancelDeveloperToolsRestoreRetry()
|
|
return
|
|
}
|
|
if preserveVisibleIntent && preferredDeveloperToolsVisible {
|
|
return
|
|
}
|
|
preferredDeveloperToolsVisible = false
|
|
cancelDeveloperToolsRestoreRetry()
|
|
}
|
|
|
|
/// Called after WKWebView reattaches to keep inspector stable across split/layout churn.
|
|
func restoreDeveloperToolsAfterAttachIfNeeded() {
|
|
guard preferredDeveloperToolsVisible else {
|
|
cancelDeveloperToolsRestoreRetry()
|
|
forceDeveloperToolsRefreshOnNextAttach = false
|
|
return
|
|
}
|
|
guard let inspector = webView.cmuxInspectorObject() else {
|
|
scheduleDeveloperToolsRestoreRetry()
|
|
return
|
|
}
|
|
|
|
let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach
|
|
forceDeveloperToolsRefreshOnNextAttach = false
|
|
|
|
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
|
if visible {
|
|
#if DEBUG
|
|
if shouldForceRefresh {
|
|
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
|
}
|
|
#endif
|
|
cancelDeveloperToolsRestoreRetry()
|
|
return
|
|
}
|
|
|
|
let selector = NSSelectorFromString("show")
|
|
guard inspector.responds(to: selector) else {
|
|
cancelDeveloperToolsRestoreRetry()
|
|
return
|
|
}
|
|
#if DEBUG
|
|
if shouldForceRefresh {
|
|
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
|
}
|
|
#endif
|
|
// WebKit inspector "show" can trigger transient first-responder churn while
|
|
// panel attachment is still stabilizing. Keep this auto-restore path from
|
|
// mutating first responder so AppKit doesn't walk tearing-down responder chains.
|
|
cmuxWithWindowFirstResponderBypass {
|
|
inspector.cmuxCallVoid(selector: selector)
|
|
}
|
|
preferredDeveloperToolsVisible = true
|
|
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
|
if visibleAfterShow {
|
|
cancelDeveloperToolsRestoreRetry()
|
|
} else {
|
|
scheduleDeveloperToolsRestoreRetry()
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func isDeveloperToolsVisible() -> Bool {
|
|
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
|
return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
|
}
|
|
|
|
@discardableResult
|
|
func hideDeveloperTools() -> Bool {
|
|
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
|
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
|
if visible {
|
|
let selector = NSSelectorFromString("close")
|
|
guard inspector.responds(to: selector) else { return false }
|
|
inspector.cmuxCallVoid(selector: selector)
|
|
}
|
|
preferredDeveloperToolsVisible = false
|
|
forceDeveloperToolsRefreshOnNextAttach = false
|
|
cancelDeveloperToolsRestoreRetry()
|
|
return true
|
|
}
|
|
|
|
/// During split/layout transitions SwiftUI can briefly mark the browser surface hidden
|
|
/// while its container is off-window. Avoid detaching in that transient phase if
|
|
/// DevTools is intended to remain open, because detach/reattach can blank inspector content.
|
|
func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool {
|
|
preferredDeveloperToolsVisible
|
|
}
|
|
|
|
func requestDeveloperToolsRefreshAfterNextAttach(reason: String) {
|
|
guard preferredDeveloperToolsVisible else { return }
|
|
forceDeveloperToolsRefreshOnNextAttach = true
|
|
#if DEBUG
|
|
dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())")
|
|
#endif
|
|
}
|
|
|
|
func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool {
|
|
forceDeveloperToolsRefreshOnNextAttach
|
|
}
|
|
|
|
@discardableResult
|
|
func zoomIn() -> Bool {
|
|
applyPageZoom(webView.pageZoom + pageZoomStep)
|
|
}
|
|
|
|
@discardableResult
|
|
func zoomOut() -> Bool {
|
|
applyPageZoom(webView.pageZoom - pageZoomStep)
|
|
}
|
|
|
|
@discardableResult
|
|
func resetZoom() -> Bool {
|
|
applyPageZoom(1.0)
|
|
}
|
|
|
|
/// Take a snapshot of the web view
|
|
func takeSnapshot(completion: @escaping (NSImage?) -> Void) {
|
|
let config = WKSnapshotConfiguration()
|
|
webView.takeSnapshot(with: config) { image, error in
|
|
if let error = error {
|
|
NSLog("BrowserPanel snapshot error: %@", error.localizedDescription)
|
|
completion(nil)
|
|
return
|
|
}
|
|
completion(image)
|
|
}
|
|
}
|
|
|
|
/// Execute JavaScript
|
|
func evaluateJavaScript(_ script: String) async throws -> Any? {
|
|
try await webView.evaluateJavaScript(script)
|
|
}
|
|
|
|
func setBrowserThemeMode(_ mode: BrowserThemeMode) {
|
|
browserThemeMode = mode
|
|
applyBrowserThemeModeIfNeeded()
|
|
}
|
|
|
|
func refreshAppearanceDrivenColors() {
|
|
webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor()
|
|
}
|
|
|
|
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() {
|
|
if !suppressWebViewFocusForAddressBar {
|
|
#if DEBUG
|
|
dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))")
|
|
#endif
|
|
}
|
|
suppressWebViewFocusForAddressBar = true
|
|
}
|
|
|
|
func endSuppressWebViewFocusForAddressBar() {
|
|
if suppressWebViewFocusForAddressBar {
|
|
#if DEBUG
|
|
dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))")
|
|
#endif
|
|
}
|
|
suppressWebViewFocusForAddressBar = false
|
|
}
|
|
|
|
@discardableResult
|
|
func requestAddressBarFocus() -> UUID {
|
|
beginSuppressWebViewFocusForAddressBar()
|
|
if let pendingAddressBarFocusRequestId {
|
|
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
|
|
.map(canonicalLoopbackURLForDisplayIfNeeded)?
|
|
.absoluteString
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!webViewURL.isEmpty,
|
|
webViewURL != blankURLString {
|
|
return webViewURL
|
|
}
|
|
|
|
if let current = currentURL
|
|
.map(canonicalLoopbackURLForDisplayIfNeeded)?
|
|
.absoluteString
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!current.isEmpty,
|
|
current != blankURLString {
|
|
return current
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolvedCurrentSessionHistoryURL() -> URL? {
|
|
if let webViewURL = webView.url,
|
|
Self.serializableSessionHistoryURLString(webViewURL) != nil {
|
|
return webViewURL
|
|
}
|
|
if let currentURL,
|
|
Self.serializableSessionHistoryURLString(currentURL) != nil {
|
|
return currentURL
|
|
}
|
|
return restoredHistoryCurrentURL
|
|
}
|
|
|
|
private func refreshNavigationAvailability() {
|
|
let resolvedCanGoBack: Bool
|
|
let resolvedCanGoForward: Bool
|
|
if usesRestoredSessionHistory {
|
|
resolvedCanGoBack = !restoredBackHistoryStack.isEmpty
|
|
resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty
|
|
} else {
|
|
resolvedCanGoBack = nativeCanGoBack
|
|
resolvedCanGoForward = nativeCanGoForward
|
|
}
|
|
|
|
if canGoBack != resolvedCanGoBack {
|
|
canGoBack = resolvedCanGoBack
|
|
}
|
|
if canGoForward != resolvedCanGoForward {
|
|
canGoForward = resolvedCanGoForward
|
|
}
|
|
}
|
|
|
|
private func abandonRestoredSessionHistoryIfNeeded() {
|
|
guard usesRestoredSessionHistory else { return }
|
|
usesRestoredSessionHistory = false
|
|
restoredBackHistoryStack.removeAll(keepingCapacity: false)
|
|
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
|
|
restoredHistoryCurrentURL = nil
|
|
refreshNavigationAvailability()
|
|
}
|
|
|
|
private static func serializableSessionHistoryURLString(_ url: URL?) -> String? {
|
|
guard let url else { return nil }
|
|
let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty, value != "about:blank" else { return nil }
|
|
return value
|
|
}
|
|
|
|
private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? {
|
|
guard let raw else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil }
|
|
return URL(string: trimmed)
|
|
}
|
|
|
|
private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] {
|
|
values.compactMap { sanitizedSessionHistoryURL($0) }
|
|
}
|
|
|
|
}
|
|
|
|
private extension BrowserPanel {
|
|
func applyBrowserThemeModeIfNeeded() {
|
|
switch browserThemeMode {
|
|
case .system:
|
|
webView.appearance = nil
|
|
case .light:
|
|
webView.appearance = NSAppearance(named: .aqua)
|
|
case .dark:
|
|
webView.appearance = NSAppearance(named: .darkAqua)
|
|
}
|
|
|
|
let script = makeBrowserThemeModeScript(mode: browserThemeMode)
|
|
webView.evaluateJavaScript(script) { _, error in
|
|
#if DEBUG
|
|
if let error {
|
|
dlog("browser.themeMode error=\(error.localizedDescription)")
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
func makeBrowserThemeModeScript(mode: BrowserThemeMode) -> String {
|
|
let colorSchemeLiteral: String
|
|
switch mode {
|
|
case .system:
|
|
colorSchemeLiteral = "null"
|
|
case .light:
|
|
colorSchemeLiteral = "'light'"
|
|
case .dark:
|
|
colorSchemeLiteral = "'dark'"
|
|
}
|
|
|
|
return """
|
|
(() => {
|
|
const metaId = 'cmux-browser-theme-mode-meta';
|
|
const colorScheme = \(colorSchemeLiteral);
|
|
const root = document.documentElement || document.body;
|
|
if (!root) return;
|
|
|
|
let meta = document.getElementById(metaId);
|
|
if (colorScheme) {
|
|
root.style.setProperty('color-scheme', colorScheme, 'important');
|
|
root.setAttribute('data-cmux-browser-theme', colorScheme);
|
|
if (!meta) {
|
|
meta = document.createElement('meta');
|
|
meta.id = metaId;
|
|
meta.name = 'color-scheme';
|
|
(document.head || root).appendChild(meta);
|
|
}
|
|
meta.setAttribute('content', colorScheme);
|
|
} else {
|
|
root.style.removeProperty('color-scheme');
|
|
root.removeAttribute('data-cmux-browser-theme');
|
|
if (meta) {
|
|
meta.remove();
|
|
}
|
|
}
|
|
})();
|
|
"""
|
|
}
|
|
|
|
func scheduleDeveloperToolsRestoreRetry() {
|
|
guard preferredDeveloperToolsVisible else { return }
|
|
guard developerToolsRestoreRetryWorkItem == nil else { return }
|
|
guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return }
|
|
|
|
developerToolsRestoreRetryAttempt += 1
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
self.developerToolsRestoreRetryWorkItem = nil
|
|
self.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
}
|
|
developerToolsRestoreRetryWorkItem = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work)
|
|
}
|
|
|
|
func cancelDeveloperToolsRestoreRetry() {
|
|
developerToolsRestoreRetryWorkItem?.cancel()
|
|
developerToolsRestoreRetryWorkItem = nil
|
|
developerToolsRestoreRetryAttempt = 0
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension BrowserPanel {
|
|
func configureInsecureHTTPAlertHooksForTesting(
|
|
alertFactory: @escaping () -> NSAlert,
|
|
windowProvider: @escaping () -> NSWindow?
|
|
) {
|
|
insecureHTTPAlertFactory = alertFactory
|
|
insecureHTTPAlertWindowProvider = windowProvider
|
|
}
|
|
|
|
func resetInsecureHTTPAlertHooksForTesting() {
|
|
insecureHTTPAlertFactory = { NSAlert() }
|
|
insecureHTTPAlertWindowProvider = { [weak weakWebView = self.webView] in
|
|
weakWebView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
}
|
|
|
|
func presentInsecureHTTPAlertForTesting(
|
|
url: URL,
|
|
recordTypedNavigation: Bool = false
|
|
) {
|
|
presentInsecureHTTPAlert(
|
|
for: URLRequest(url: url),
|
|
intent: .currentTab,
|
|
recordTypedNavigation: recordTypedNavigation
|
|
)
|
|
}
|
|
|
|
private static func debugRectDescription(_ rect: NSRect) -> String {
|
|
String(
|
|
format: "%.1f,%.1f %.1fx%.1f",
|
|
rect.origin.x,
|
|
rect.origin.y,
|
|
rect.size.width,
|
|
rect.size.height
|
|
)
|
|
}
|
|
|
|
private static func debugObjectToken(_ object: AnyObject?) -> String {
|
|
guard let object else { return "nil" }
|
|
return String(describing: Unmanaged.passUnretained(object).toOpaque())
|
|
}
|
|
|
|
private static func debugInspectorSubviewCount(in root: NSView) -> Int {
|
|
var stack: [NSView] = [root]
|
|
var count = 0
|
|
while let current = stack.popLast() {
|
|
for subview in current.subviews {
|
|
if String(describing: type(of: subview)).contains("WKInspector") {
|
|
count += 1
|
|
}
|
|
stack.append(subview)
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func debugDeveloperToolsStateSummary() -> String {
|
|
let preferred = preferredDeveloperToolsVisible ? 1 : 0
|
|
let visible = isDeveloperToolsVisible() ? 1 : 0
|
|
let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1
|
|
let attached = webView.superview == nil ? 0 : 1
|
|
let inWindow = webView.window == nil ? 0 : 1
|
|
let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0
|
|
return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)"
|
|
}
|
|
|
|
func debugDeveloperToolsGeometrySummary() -> String {
|
|
let container = webView.superview
|
|
let containerBounds = container?.bounds ?? .zero
|
|
let webFrame = webView.frame
|
|
let inspectorInsets = max(0, containerBounds.height - webFrame.height)
|
|
let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY)
|
|
let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow)
|
|
let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0
|
|
let containerType = container.map { String(describing: type(of: $0)) } ?? "nil"
|
|
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private extension BrowserPanel {
|
|
@discardableResult
|
|
func applyPageZoom(_ candidate: CGFloat) -> Bool {
|
|
let clamped = max(minPageZoom, min(maxPageZoom, candidate))
|
|
if abs(webView.pageZoom - clamped) < 0.0001 {
|
|
return false
|
|
}
|
|
webView.pageZoom = clamped
|
|
return true
|
|
}
|
|
|
|
static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
|
|
var r = start
|
|
var hops = 0
|
|
while let cur = r, hops < 64 {
|
|
if cur === target { return true }
|
|
r = cur.nextResponder
|
|
hops += 1
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
private extension WKWebView {
|
|
func cmuxInspectorObject() -> NSObject? {
|
|
let selector = NSSelectorFromString("_inspector")
|
|
guard responds(to: selector),
|
|
let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else {
|
|
return nil
|
|
}
|
|
return inspector
|
|
}
|
|
}
|
|
|
|
private extension NSObject {
|
|
func cmuxCallBool(selector: Selector) -> Bool? {
|
|
guard responds(to: selector) else { return nil }
|
|
typealias Fn = @convention(c) (AnyObject, Selector) -> Bool
|
|
let fn = unsafeBitCast(method(for: selector), to: Fn.self)
|
|
return fn(self, selector)
|
|
}
|
|
|
|
func cmuxCallVoid(selector: Selector) {
|
|
guard responds(to: selector) else { return }
|
|
typealias Fn = @convention(c) (AnyObject, Selector) -> Void
|
|
let fn = unsafeBitCast(method(for: selector), to: Fn.self)
|
|
fn(self, selector)
|
|
}
|
|
}
|
|
|
|
// MARK: - Download Delegate
|
|
|
|
/// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI
|
|
/// during WebKit callbacks), then showing NSSavePanel after the download finishes.
|
|
private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate {
|
|
private struct DownloadState {
|
|
let tempURL: URL
|
|
let suggestedFilename: String
|
|
}
|
|
|
|
/// Tracks active downloads keyed by WKDownload identity.
|
|
private var activeDownloads: [ObjectIdentifier: DownloadState] = [:]
|
|
private let activeDownloadsLock = NSLock()
|
|
var onDownloadStarted: ((String) -> Void)?
|
|
var onDownloadReadyToSave: (() -> Void)?
|
|
var onDownloadFailed: ((Error) -> Void)?
|
|
|
|
private static let tempDir: URL = {
|
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}()
|
|
|
|
private static func sanitizedFilename(_ raw: String, fallbackURL: URL?) -> String {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let candidate = (trimmed as NSString).lastPathComponent
|
|
let fromURL = fallbackURL?.lastPathComponent ?? ""
|
|
let base = candidate.isEmpty ? fromURL : candidate
|
|
let replaced = base.replacingOccurrences(of: ":", with: "-")
|
|
let safe = replaced.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return safe.isEmpty ? "download" : safe
|
|
}
|
|
|
|
private func storeState(_ state: DownloadState, for download: WKDownload) {
|
|
activeDownloadsLock.lock()
|
|
activeDownloads[ObjectIdentifier(download)] = state
|
|
activeDownloadsLock.unlock()
|
|
}
|
|
|
|
private func removeState(for download: WKDownload) -> DownloadState? {
|
|
activeDownloadsLock.lock()
|
|
let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download))
|
|
activeDownloadsLock.unlock()
|
|
return state
|
|
}
|
|
|
|
private func notifyOnMain(_ action: @escaping () -> Void) {
|
|
if Thread.isMainThread {
|
|
action()
|
|
} else {
|
|
DispatchQueue.main.async(execute: action)
|
|
}
|
|
}
|
|
|
|
func download(
|
|
_ download: WKDownload,
|
|
decideDestinationUsing response: URLResponse,
|
|
suggestedFilename: String,
|
|
completionHandler: @escaping (URL?) -> Void
|
|
) {
|
|
// Save to a temp file — return synchronously so WebKit is never blocked.
|
|
let safeFilename = Self.sanitizedFilename(suggestedFilename, fallbackURL: response.url)
|
|
let tempFilename = "\(UUID().uuidString)-\(safeFilename)"
|
|
let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false)
|
|
try? FileManager.default.removeItem(at: destURL)
|
|
storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download)
|
|
notifyOnMain { [weak self] in
|
|
self?.onDownloadStarted?(safeFilename)
|
|
}
|
|
#if DEBUG
|
|
dlog("download.decideDestination file=\(safeFilename)")
|
|
#endif
|
|
NSLog("BrowserPanel download: temp path=%@", destURL.path)
|
|
completionHandler(destURL)
|
|
}
|
|
|
|
func downloadDidFinish(_ download: WKDownload) {
|
|
guard let info = removeState(for: download) else {
|
|
#if DEBUG
|
|
dlog("download.finished missing-state")
|
|
#endif
|
|
return
|
|
}
|
|
#if DEBUG
|
|
dlog("download.finished file=\(info.suggestedFilename)")
|
|
#endif
|
|
NSLog("BrowserPanel download finished: %@", info.suggestedFilename)
|
|
|
|
// Show NSSavePanel on the next runloop iteration (safe context).
|
|
DispatchQueue.main.async {
|
|
self.onDownloadReadyToSave?()
|
|
let savePanel = NSSavePanel()
|
|
savePanel.nameFieldStringValue = info.suggestedFilename
|
|
savePanel.canCreateDirectories = true
|
|
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
|
|
|
savePanel.begin { result in
|
|
guard result == .OK, let destURL = savePanel.url else {
|
|
try? FileManager.default.removeItem(at: info.tempURL)
|
|
return
|
|
}
|
|
do {
|
|
try? FileManager.default.removeItem(at: destURL)
|
|
try FileManager.default.moveItem(at: info.tempURL, to: destURL)
|
|
NSLog("BrowserPanel download saved: %@", destURL.path)
|
|
} catch {
|
|
NSLog("BrowserPanel download move failed: %@", error.localizedDescription)
|
|
try? FileManager.default.removeItem(at: info.tempURL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
|
if let info = removeState(for: download) {
|
|
try? FileManager.default.removeItem(at: info.tempURL)
|
|
}
|
|
notifyOnMain { [weak self] in
|
|
self?.onDownloadFailed?(error)
|
|
}
|
|
#if DEBUG
|
|
dlog("download.failed error=\(error.localizedDescription)")
|
|
#endif
|
|
NSLog("BrowserPanel download failed: %@", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation Delegate
|
|
|
|
func browserNavigationShouldOpenInNewTab(
|
|
navigationType: WKNavigationType,
|
|
modifierFlags: NSEvent.ModifierFlags,
|
|
buttonNumber: Int,
|
|
hasRecentMiddleClickIntent: Bool = false,
|
|
currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type,
|
|
currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber
|
|
) -> Bool {
|
|
guard navigationType == .linkActivated || navigationType == .other else {
|
|
return false
|
|
}
|
|
|
|
if modifierFlags.contains(.command) {
|
|
return true
|
|
}
|
|
if buttonNumber == 2 {
|
|
return true
|
|
}
|
|
// In some WebKit paths, middle-click arrives as buttonNumber=4.
|
|
// Recover intent when we just observed a local middle-click.
|
|
if buttonNumber == 4, hasRecentMiddleClickIntent {
|
|
return true
|
|
}
|
|
|
|
// WebKit can omit buttonNumber for middle-click link activations.
|
|
if let currentEventType,
|
|
(currentEventType == .otherMouseDown || currentEventType == .otherMouseUp),
|
|
currentEventButtonNumber == 2 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
|
var didFinish: ((WKWebView) -> Void)?
|
|
var didFailNavigation: ((WKWebView, String) -> Void)?
|
|
var openInNewTab: ((URL) -> Void)?
|
|
var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
|
|
var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)?
|
|
/// Direct reference to the download delegate — must be set synchronously in didBecome callbacks.
|
|
var downloadDelegate: WKDownloadDelegate?
|
|
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
|
|
/// when a provisional navigation fails (e.g. connection refused on localhost:3000).
|
|
var lastAttemptedURL: URL?
|
|
|
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
|
lastAttemptedURL = webView.url
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
didFinish?(webView)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
NSLog("BrowserPanel navigation failed: %@", error.localizedDescription)
|
|
// Treat committed-navigation failures the same as provisional ones so
|
|
// stale favicon/title state from the prior page gets cleared.
|
|
let failedURL = webView.url?.absoluteString ?? ""
|
|
didFailNavigation?(webView, failedURL)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
let nsError = error as NSError
|
|
NSLog("BrowserPanel provisional navigation failed: %@", error.localizedDescription)
|
|
|
|
// Cancelled navigations (e.g. rapid typing) are not real errors.
|
|
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
|
|
return
|
|
}
|
|
|
|
// "Frame load interrupted" (WebKitErrorDomain code 102) fires when a
|
|
// navigation response is converted into a download via .download policy.
|
|
// This is expected and should not show an error page.
|
|
if nsError.domain == "WebKitErrorDomain", nsError.code == 102 {
|
|
return
|
|
}
|
|
|
|
let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String
|
|
?? lastAttemptedURL?.absoluteString
|
|
?? ""
|
|
didFailNavigation?(webView, failedURL)
|
|
loadErrorPage(in: webView, failedURL: failedURL, error: nsError)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, 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
|
|
) {
|
|
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
|
|
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
|
|
navigationType: navigationAction.navigationType,
|
|
modifierFlags: navigationAction.modifierFlags,
|
|
buttonNumber: navigationAction.buttonNumber,
|
|
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
|
|
)
|
|
#if DEBUG
|
|
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
|
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
|
|
let navType = String(describing: navigationAction.navigationType)
|
|
dlog(
|
|
"browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " +
|
|
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
|
|
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
|
|
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
|
|
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
|
|
)
|
|
#endif
|
|
|
|
if let url = navigationAction.request.url,
|
|
navigationAction.targetFrame?.isMainFrame != false,
|
|
shouldBlockInsecureHTTPNavigation?(url) == true {
|
|
let intent: BrowserInsecureHTTPNavigationIntent
|
|
if shouldOpenInNewTab {
|
|
intent = .newTab
|
|
} else {
|
|
intent = .currentTab
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " +
|
|
"url=\(url.absoluteString)"
|
|
)
|
|
#endif
|
|
handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent)
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
|
|
// WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
|
|
// Hand these off to macOS so the owning app can handle them.
|
|
if let url = navigationAction.request.url,
|
|
navigationAction.targetFrame?.isMainFrame != false,
|
|
browserShouldOpenURLExternally(url) {
|
|
let opened = NSWorkspace.shared.open(url)
|
|
if !opened {
|
|
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
|
|
}
|
|
#if DEBUG
|
|
dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
|
|
#endif
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
|
|
// Cmd+click and middle-click on regular links should always open in a new tab.
|
|
if shouldOpenInNewTab,
|
|
let url = navigationAction.request.url {
|
|
#if DEBUG
|
|
dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)")
|
|
#endif
|
|
openInNewTab?(url)
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
|
|
// target=_blank or window.open() without explicit new-tab intent — navigate in-place.
|
|
if navigationAction.targetFrame == nil,
|
|
navigationAction.request.url != nil {
|
|
#if DEBUG
|
|
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
|
|
dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)")
|
|
#endif
|
|
webView.load(navigationAction.request)
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
|
|
#if DEBUG
|
|
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
|
|
dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)")
|
|
#endif
|
|
decisionHandler(.allow)
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
decidePolicyFor navigationResponse: WKNavigationResponse,
|
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
|
) {
|
|
if !navigationResponse.isForMainFrame {
|
|
decisionHandler(.allow)
|
|
return
|
|
}
|
|
|
|
let mime = navigationResponse.response.mimeType ?? "unknown"
|
|
let canShow = navigationResponse.canShowMIMEType
|
|
let responseURL = navigationResponse.response.url?.absoluteString ?? "nil"
|
|
|
|
// Only classify HTTP(S) top-level responses as downloads.
|
|
if let scheme = navigationResponse.response.url?.scheme?.lowercased(),
|
|
scheme != "http", scheme != "https" {
|
|
decisionHandler(.allow)
|
|
return
|
|
}
|
|
|
|
NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d",
|
|
responseURL, mime, canShow ? 1 : 0,
|
|
navigationResponse.isForMainFrame ? 1 : 0)
|
|
|
|
// Check if this response should be treated as a download.
|
|
// Criteria: explicit Content-Disposition: attachment, or a MIME type
|
|
// that WebKit cannot render inline.
|
|
if let response = navigationResponse.response as? HTTPURLResponse {
|
|
let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? ""
|
|
if contentDisposition.lowercased().hasPrefix("attachment") {
|
|
NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL)
|
|
#if DEBUG
|
|
dlog("download.policy=download reason=content-disposition mime=\(mime)")
|
|
#endif
|
|
decisionHandler(.download)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !canShow {
|
|
NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL)
|
|
#if DEBUG
|
|
dlog("download.policy=download reason=cannotShowMIME mime=\(mime)")
|
|
#endif
|
|
decisionHandler(.download)
|
|
return
|
|
}
|
|
|
|
decisionHandler(.allow)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
|
#if DEBUG
|
|
dlog("download.didBecome source=navigationAction")
|
|
#endif
|
|
NSLog("BrowserPanel download didBecome from navigationAction")
|
|
download.delegate = downloadDelegate
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
|
#if DEBUG
|
|
dlog("download.didBecome source=navigationResponse")
|
|
#endif
|
|
NSLog("BrowserPanel download didBecome from navigationResponse")
|
|
download.delegate = downloadDelegate
|
|
}
|
|
}
|
|
|
|
// MARK: - UI Delegate
|
|
|
|
private class BrowserUIDelegate: NSObject, WKUIDelegate {
|
|
var openInNewTab: ((URL) -> Void)?
|
|
var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)?
|
|
|
|
private func javaScriptDialogTitle(for webView: WKWebView) -> String {
|
|
if let absolute = webView.url?.absoluteString, !absolute.isEmpty {
|
|
return "The page at \(absolute) says:"
|
|
}
|
|
return "This page says:"
|
|
}
|
|
|
|
private func presentDialog(
|
|
_ alert: NSAlert,
|
|
for webView: WKWebView,
|
|
completion: @escaping (NSApplication.ModalResponse) -> Void
|
|
) {
|
|
if let window = webView.window {
|
|
alert.beginSheetModal(for: window, completionHandler: completion)
|
|
return
|
|
}
|
|
completion(alert.runModal())
|
|
}
|
|
|
|
/// Returning nil tells WebKit not to open a new window.
|
|
/// Cmd+click and middle-click open in a new tab; regular target=_blank navigates in-place.
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
createWebViewWith configuration: WKWebViewConfiguration,
|
|
for navigationAction: WKNavigationAction,
|
|
windowFeatures: WKWindowFeatures
|
|
) -> WKWebView? {
|
|
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
|
|
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
|
|
navigationType: navigationAction.navigationType,
|
|
modifierFlags: navigationAction.modifierFlags,
|
|
buttonNumber: navigationAction.buttonNumber,
|
|
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
|
|
)
|
|
#if DEBUG
|
|
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
|
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
|
|
let navType = String(describing: navigationAction.navigationType)
|
|
dlog(
|
|
"browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " +
|
|
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
|
|
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
|
|
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
|
|
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
|
|
)
|
|
#endif
|
|
if let url = navigationAction.request.url {
|
|
if browserShouldOpenURLExternally(url) {
|
|
let opened = NSWorkspace.shared.open(url)
|
|
if !opened {
|
|
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
|
|
}
|
|
#if DEBUG
|
|
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
|
|
#endif
|
|
return nil
|
|
}
|
|
if let requestNavigation {
|
|
let intent: BrowserInsecureHTTPNavigationIntent =
|
|
shouldOpenInNewTab ? .newTab : .currentTab
|
|
#if DEBUG
|
|
dlog(
|
|
"browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " +
|
|
"url=\(url.absoluteString)"
|
|
)
|
|
#endif
|
|
requestNavigation(navigationAction.request, intent)
|
|
} else if shouldOpenInNewTab {
|
|
#if DEBUG
|
|
dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)")
|
|
#endif
|
|
openInNewTab?(url)
|
|
} else {
|
|
#if DEBUG
|
|
dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)")
|
|
#endif
|
|
webView.load(navigationAction.request)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Handle <input type="file"> elements by presenting the native file picker.
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
runOpenPanelWith parameters: WKOpenPanelParameters,
|
|
initiatedByFrame frame: WKFrameInfo,
|
|
completionHandler: @escaping ([URL]?) -> Void
|
|
) {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
|
panel.canChooseDirectories = parameters.allowsDirectories
|
|
panel.canChooseFiles = true
|
|
panel.begin { result in
|
|
completionHandler(result == .OK ? panel.urls : nil)
|
|
}
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
runJavaScriptAlertPanelWithMessage message: String,
|
|
initiatedByFrame frame: WKFrameInfo,
|
|
completionHandler: @escaping () -> Void
|
|
) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .informational
|
|
alert.messageText = javaScriptDialogTitle(for: webView)
|
|
alert.informativeText = message
|
|
alert.addButton(withTitle: "OK")
|
|
presentDialog(alert, for: webView) { _ in completionHandler() }
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
runJavaScriptConfirmPanelWithMessage message: String,
|
|
initiatedByFrame frame: WKFrameInfo,
|
|
completionHandler: @escaping (Bool) -> Void
|
|
) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .informational
|
|
alert.messageText = javaScriptDialogTitle(for: webView)
|
|
alert.informativeText = message
|
|
alert.addButton(withTitle: "OK")
|
|
alert.addButton(withTitle: "Cancel")
|
|
presentDialog(alert, for: webView) { response in
|
|
completionHandler(response == .alertFirstButtonReturn)
|
|
}
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
runJavaScriptTextInputPanelWithPrompt prompt: String,
|
|
defaultText: String?,
|
|
initiatedByFrame frame: WKFrameInfo,
|
|
completionHandler: @escaping (String?) -> Void
|
|
) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .informational
|
|
alert.messageText = javaScriptDialogTitle(for: webView)
|
|
alert.informativeText = prompt
|
|
alert.addButton(withTitle: "OK")
|
|
alert.addButton(withTitle: "Cancel")
|
|
|
|
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
|
|
field.stringValue = defaultText ?? ""
|
|
alert.accessoryView = field
|
|
|
|
presentDialog(alert, for: webView) { response in
|
|
if response == .alertFirstButtonReturn {
|
|
completionHandler(field.stringValue)
|
|
} else {
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|