cmux/Sources/Panels/BrowserPanel.swift
Lawrence Chen 50f0dd334d
Fix frozen terminals after split churn (#12)
* Fix blank terminal after split operations and add visual tests

## Blank Terminal Fix
- Add `needsRefreshAfterWindowChange` flag in GhosttyTerminalView
- Force terminal refresh when view is added to window, even if size unchanged
- Add `ghostty_surface_refresh()` call in attachToView for same-view reattachment
- Add debug logging for surface attachment lifecycle (DEBUG builds only)

## Bonsplit Migration
- Add bonsplit as local Swift package (vendor/bonsplit submodule)
- Replace custom SplitTree with BonsplitController
- Add Panel protocol with TerminalPanel and BrowserPanel implementations
- Add SidebarTab as main tab container with BonsplitController
- Remove old Splits/ directory (SplitTree, SplitView, TerminalSplitTreeView)

## Visual Screenshot Tests
- Add test_visual_screenshots.py for automated visual regression testing
- Uses in-app screenshot API (CGWindowListCreateImage) - no screen recording needed
- Generates HTML report with before/after comparisons
- Tests: splits, browser panels, focus switching, close operations, rapid cycles
- Includes annotation fields for easy feedback

## Browser Shortcut (⌘⇧B)
- Add keyboard shortcut to open browser panel in current pane
- Add openBrowser() method to TabManager
- Add shortcut configuration in KeyboardShortcutSettings

## Screenshot Command
- Add 'screenshot' command to TerminalController for in-app window capture
- Returns OK with screenshot ID and path

## Other
- Add tests/visual_output/ and tests/visual_report.html to .gitignore

* Add browser title subscription and set tab height to 30px

- Subscribe to BrowserPanel.$pageTitle changes to update bonsplit tabs
- Update tab titles in real-time as page navigation occurs
- Clean up subscriptions when panels are removed
- Set bonsplit tab bar and tab height to 30px (in submodule)

* Fix socket API regressions in list_surfaces, list_bonsplit_tabs, focus_pane

- list_surfaces: Remove [terminal]/[browser] suffix to keep UUID-only format
  that clients and tests expect for parsing
- list_bonsplit_tabs --pane: Properly look up pane by UUID instead of
  creating a new PaneID (requires bonsplit PaneID.id to be public)
- focus_pane: Accept both UUID strings and integer indices as documented

* Fix browser panel stability and keyboard shortcuts

- Prevent WKWebView focus lifecycle crashes during split/view reshuffles
- Match bracket shortcuts via keyCode (Cmd+Shift+[ / ], Cmd+Ctrl+[ / ])
- Support Ghostty config goto_split:* keybinds when WebView is focused
- Add focus_webview/is_webview_focused socket commands and regression tests
- Rename SidebarTab to Workspace and update docs

* Make ctrl+enter keybind test skippable

Skip when the Ghostty keybind isn't configured or when osascript can't send keystrokes (no Accessibility permission), so VM runs stay green.

* Auto-focus browser omnibar when blank

When a browser surface is focused but no URL is loaded yet, focus the address bar instead of the WKWebView.

* Stabilize socket surface indexing

* Focus browser omnibar escape; add webview keybind UI tests

- Escape in omnibar now returns focus to WKWebView\n- Add UI tests for Cmd+Ctrl+H pane navigation with WebKit focused (including Ghostty config)\n- Avoid flaky element screenshots in UpdatePillUITests on the UTM VM

* Fix browser drag-to-split blanks and socket parsing

* Fix webview-focused shortcuts and stabilize browser splits

- Match ctrl/shift shortcuts by keyCode where needed (Ctrl+H, bracket keys)
- Load Ghostty goto_split triggers reliably and refresh on config load
- Add debug socket helpers: set_shortcut + simulate_shortcut for tests
- Convert browser goto_split/keybind tests to socket-based injection (no osascript)
- Bump bonsplit for drag-to-split fixes

* Fix split layout collapse and harden socket pane APIs

* Stabilize OSC 99 notification test timing

* Fix terminal focus routing after split reparent

* Support simulate_shortcut enter for focus routing test

* Stabilize terminal focus routing test

* Fix frozen new terminal tabs after many splits

* Fix frozen new terminal tabs after splits

* Fix terminal freeze on launch/new tabs

* Update ghostty submodule

* Fix terminal focus/render stalls after split churn

* Fix nested split collapsing existing pane

* Fix nested split collapse + stabilize new-surface focus

* Update bonsplit submodule

* Fix SIGINT test flake

* Remove bonsplit tab-switch crossfade

* Remove PROJECTS.md

* Remove bonsplit tab selection animation

* Ignore generated test reports

* Middle click closes tab

* Revert unintended .gitignore change

* Fix build after main merge

* Revert "Fix build after main merge"

This reverts commit 16bf9816d0856b5385d52f886aa5eb50f3c9d9a4.

* Revert "Merge remote-tracking branch 'origin/main' into fix/blank-terminal-and-visual-tests"

This reverts commit 7c20fb53fd71fea7a19a3673f2dd73e5f0c783c4, reversing
changes made to 0aff107d787bc9d8bbc28220090b4ca7af72e040.

* Remove tab close fade animation

* Use terminal.fill icon

* Make terminal tab icon smaller

* Match browser globe tab icon size

* Bonsplit: tab min width 48 and tighter close button

* Bonsplit: smaller tab title font

* Show unread notification badge in bonsplit tabs and improve UI polish

Sync unread notification state to bonsplit tab badges (blue dot).
Improve EmptyPanelView with Terminal/Browser buttons and shortcut hints.
Add tooltips to close tab button and search overlay buttons.

* Fix reload.sh single-instance safety check on macOS

Replace GNU-only `ps -o etimes=` with portable `ps -o etime=` and
parse the dd-hh:mm:ss format manually for macOS compatibility.

* Centralize keyboard shortcut definitions into Action enum

Replace per-shortcut boilerplate with a single Action enum that holds
the label, defaults key, and default binding for each shortcut. All
call sites now use shortcut(for:). Settings UI is data-driven via
ForEach(Action.allCases). Titlebar tooltips update dynamically when
shortcuts are changed. Remove duplicate .keyboardShortcut() modifiers
from menu items that are already handled by the event monitor.

* Fix WKWebView consuming app menu shortcuts and close panel confirmation

Add CmuxWebView subclass that routes key equivalents through the main
menu before WebKit, so Cmd+N/Cmd+W/tab switching work when a browser
pane is focused. Fix Cmd+W close-panel path: bypass Bonsplit delegate
gating after the user confirms the running-process dialog by tracking
forceCloseTabIds. Add unit tests (CmuxWebViewKeyEquivalentTests) and
UI test scaffolding (MenuKeyEquivalentRoutingUITests) with a new
cmux-unit Xcode scheme.

* Update CLAUDE.md and PROJECTS.md with recent changes

CLAUDE.md: enforce --tag for reload commands, add cleanup safety rules.
PROJECTS.md: log notification badge, reload.sh fix, Cmd+W fix, WebView
key equiv fix, and centralized shortcuts work.

* Keep selection index stable on close

* Add concepts page documenting terminology hierarchy

New docs page explaining Window > Workspace > Pane > Surface > Panel
hierarchy with aligned ASCII diagram. Updated tabs.mdx and splits.mdx
to use consistent terminology (workspace instead of tab, surface
instead of panel) and corrected outdated CLI command references.

* Update bonsplit submodule

* WIP: improve split close stability and UI regressions

* Close terminal panel on child exit; hide terminal dirty dot

* Fix split close/focus regressions and stabilize UI tests

* Add unread Dock/Cmd+Tab badge with settings toggle

* Fix browser-surface shortcuts and Cmd+L browser opening

* Snapshot current workspace state before regression fixes

* Update bonsplit submodule snapshot

* Stabilize split-close regression capture and sidebar resize assertions

* Change default Show Notifications shortcut from Cmd+Shift+I to Cmd+I

* Fix update check readiness race, enable release update logging, and improve checking spinner

* Restore terminal file drop, fix browser omnibar click focus, and add panel workspace ID mutation for surface moves

* Add Cmd+digit workspace hints, titlebar shortcut pills, sidebar drag-reorder, and workspace placement settings

* Add v2 browser automation API, surface move/reorder commands, and short-handle ref system to TerminalController

* Add CLI browser command surface, --id-format flag, and move/reorder commands

* Extend test clients with move/reorder APIs, ref-handle support, and increased timeouts

* Harden test runner scripts with deterministic builds, retry logic, and robust socket readiness

* Stabilize existing test suites with focus-wait helpers, increased timeouts, and API shape updates

* Add terminal file drop e2e regression test

* Add v2 browser API, CLI ref resolution, and surface move/reorder test suites

* Add unit tests for shortcut hints, workspace reorder, drop planner, and update UI test stabilization

* Add cmux-debug-windows skill with snapshot script and agent config

* Update project docs: mark browser parity and move/reorder phases complete, add parallel agent workflow guidelines

* Update bonsplit submodule: re-entrant setPosition guard, tab shortcut hints, and moveTab/reorderTab API

* Add browser agent UX improvements: snapshot refs, placement reuse, diagnostics, and skill docs

- Upgrade browser.snapshot to emit accessibility tree text with element refs (eN)
- Add right-sibling pane reuse policy for browser.open_split placement
- Add rich not_found diagnostics with retry logic for selector actions
- Support --snapshot-after for post-action verification on mutating commands
- Allow browser fill with empty text for clearing inputs
- Default CLI --id-format to refs-first (UUIDs opt-in via --id-format uuids|both)
- Format legacy new-pane/new-surface output with short surface refs
- Add skills/cmuxterm-browser/ and skills/cmuxterm/ end-user skill docs
- Add regression tests for placement policy, snapshot refs, diagnostics, and ID defaults

* Update bonsplit submodule: keep raster favicons in color when inactive
2026-02-13 16:45:31 -08:00

871 lines
29 KiB
Swift

import Foundation
import Combine
import WebKit
import AppKit
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
case google
case duckduckgo
case bing
var id: String { rawValue }
var displayName: String {
switch self {
case .google: return "Google"
case .duckduckgo: return "DuckDuckGo"
case .bing: return "Bing"
}
}
func searchURL(query: String) -> URL? {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var components: URLComponents?
switch self {
case .google:
components = URLComponents(string: "https://www.google.com/search")
case .duckduckgo:
components = URLComponents(string: "https://duckduckgo.com/")
case .bing:
components = URLComponents(string: "https://www.bing.com/search")
}
components?.queryItems = [
URLQueryItem(name: "q", value: trimmed),
]
return components?.url
}
}
enum BrowserSearchSettings {
static let searchEngineKey = "browserSearchEngine"
static let searchSuggestionsEnabledKey = "browserSearchSuggestionsEnabled"
static let defaultSearchEngine: BrowserSearchEngine = .google
static let defaultSearchSuggestionsEnabled: Bool = true
static func currentSearchEngine(defaults: UserDefaults = .standard) -> BrowserSearchEngine {
guard let raw = defaults.string(forKey: searchEngineKey),
let engine = BrowserSearchEngine(rawValue: raw) else {
return defaultSearchEngine
}
return engine
}
static func currentSearchSuggestionsEnabled(defaults: UserDefaults = .standard) -> Bool {
// Mirror @AppStorage behavior: bool(forKey:) returns false if key doesn't exist.
// Default to enabled unless user explicitly set a value.
if defaults.object(forKey: searchSuggestionsEnabledKey) == nil {
return defaultSearchSuggestionsEnabled
}
return defaults.bool(forKey: searchSuggestionsEnabledKey)
}
}
enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
// fallback/old UIs or trigger bot checks.
static let safariUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15"
}
@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
}
@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
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 }
Task.detached(priority: .utility) {
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
}
await MainActor.run {
// Most-recent first
self.entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited })
}
}
}
func recordVisit(url: URL?, title: String?) {
loadIfNeeded()
guard let url else { return }
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else { return }
let urlString = url.absoluteString
guard urlString != "about:blank" else { return }
if let idx = entries.firstIndex(where: { $0.url == urlString }) {
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 suggestions(for input: String, limit: Int = 10) -> [Entry] {
loadIfNeeded()
let q = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !q.isEmpty else { return [] }
func haystackMatches(_ s: String) -> Bool {
s.lowercased().contains(q)
}
// Basic matching: contains in URL or title.
// Sort by visit recency first; break ties by visit count.
let matched = entries.filter { e in
if haystackMatches(e.url) { return true }
if let t = e.title, haystackMatches(t) { return true }
return false
}
.sorted { a, b in
if a.lastVisited != b.lastVisited { return a.lastVisited > b.lastVisited }
return a.visitCount > b.visitCount
}
if matched.count <= limit { return matched }
return Array(matched.prefix(limit))
}
private func scheduleSave() {
guard let fileURL else { return }
saveTask?.cancel()
let snapshot = entries
saveTask = Task.detached(priority: .utility) {
do {
try await Task.sleep(nanoseconds: 500_000_000) // debounce
} catch {
return
}
let dir = fileURL.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
} catch {
return
}
let data: Data
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
data = try encoder.encode(snapshot)
} catch {
return
}
do {
try data.write(to: fileURL, options: [.atomic])
} catch {
return
}
}
}
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 dir = appSupport.appendingPathComponent(bundleId, isDirectory: true)
return dir.appendingPathComponent("browser_history.json", isDirectory: false)
}
}
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 [] }
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: trimmed),
]
url = c?.url
case .duckduckgo:
var c = URLComponents(string: "https://duckduckgo.com/ac/")
c?.queryItems = [
URLQueryItem(name: "q", value: trimmed),
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: trimmed),
]
url = c?.url
}
guard let url else { return [] }
var req = URLRequest(url: url)
req.timeoutInterval = 1.5
req.cachePolicy = .returnCacheDataElseLoad
req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent")
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch {
return []
}
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
return []
}
switch engine {
case .google, .bing:
return parseOSJSON(data: data)
case .duckduckgo:
return parseDuckDuckGo(data: data)
}
}
private func parseOSJSON(data: Data) -> [String] {
// Format: [query, [suggestions...], ...]
guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any],
root.count >= 2,
let list = root[1] as? [Any] else {
return []
}
var out: [String] = []
out.reserveCapacity(list.count)
for item in list {
guard let s = item as? String else { continue }
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
out.append(trimmed)
}
return out
}
private func parseDuckDuckGo(data: Data) -> [String] {
// Format: [{phrase:"..."}, ...]
guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
return []
}
var out: [String] = []
out.reserveCapacity(root.count)
for item in root {
guard let dict = item as? [String: Any],
let phrase = dict["phrase"] as? String else { continue }
let trimmed = phrase.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
out.append(trimmed)
}
return out
}
}
/// BrowserPanel provides a WKWebView-based browser panel.
/// All browser panels share a WKProcessPool for cookie sharing.
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
private static let sharedProcessPool = WKProcessPool()
let id: UUID
let panelType: PanelType = .browser
/// The workspace ID this panel belongs to
private(set) var workspaceId: UUID
/// The underlying web view
let webView: WKWebView
/// Prevent the omnibar from auto-focusing for a short window after explicit programmatic focus.
/// This avoids races where SwiftUI focus state steals first responder back from WebKit.
private var suppressOmnibarAutofocusUntil: Date?
/// Published URL being displayed
@Published private(set) var currentURL: URL?
/// Published page title
@Published private(set) var pageTitle: String = ""
/// Published favicon (PNG data). When present, the tab bar can render it instead of a SF symbol.
@Published private(set) var faviconPNGData: Data?
/// Published loading state
@Published private(set) var isLoading: Bool = false
/// Published can go back state
@Published private(set) var canGoBack: Bool = false
/// Published can go forward state
@Published private(set) var canGoForward: Bool = false
/// Published estimated progress (0.0 - 1.0)
@Published private(set) var estimatedProgress: Double = 0.0
/// Increment to request a UI-only flash highlight (e.g. from a keyboard shortcut).
@Published private(set) var focusFlashToken: Int = 0
private var cancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var webViewObservers: [NSKeyValueObservation] = []
// Avoid flickering the loading indicator for very fast navigations.
private let minLoadingIndicatorDuration: TimeInterval = 0.35
private var loadingStartedAt: Date?
private var loadingEndWorkItem: DispatchWorkItem?
private var loadingGeneration: Int = 0
private var faviconTask: Task<Void, Never>?
private var lastFaviconURLString: String?
var displayTitle: String {
if !pageTitle.isEmpty {
return pageTitle
}
if let url = currentURL {
return url.host ?? url.absoluteString
}
return "Browser"
}
var displayIcon: String? {
"globe"
}
var isDirty: Bool {
false
}
init(workspaceId: UUID, initialURL: URL? = nil) {
self.id = UUID()
self.workspaceId = workspaceId
// Configure web view
let config = WKWebViewConfiguration()
config.processPool = BrowserPanel.sharedProcessPool
// Ensure browser cookies/storage persist across navigations and launches.
// This reduces repeated consent/bot-challenge flows on sites like Google.
config.websiteDataStore = .default()
// Enable developer extras (DevTools)
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
// Enable JavaScript
config.defaultWebpagePreferences.allowsContentJavaScript = true
// Set up web view
let webView = CmuxWebView(frame: .zero, configuration: config)
webView.allowsBackForwardNavigationGestures = true
// Always present as Safari.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
self.webView = webView
// Set up navigation delegate
let navDelegate = BrowserNavigationDelegate()
navDelegate.didFinish = { webView in
BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title)
Task { @MainActor [weak self] in
self?.refreshFavicon(from: webView)
}
}
webView.navigationDelegate = navDelegate
self.navigationDelegate = navDelegate
// Observe web view properties
setupObservers()
// Navigate to initial URL if provided
if let url = initialURL {
navigate(to: url)
}
}
func updateWorkspaceId(_ newWorkspaceId: UUID) {
workspaceId = newWorkspaceId
}
func triggerFlash() {
focusFlashToken &+= 1
}
private func setupObservers() {
// URL changes
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.currentURL = webView.url
}
}
webViewObservers.append(urlObserver)
// Title changes
let titleObserver = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
// Keep showing the last non-empty title while the new navigation is loading.
// WebKit often clears title to nil/"" during reload/navigation, which causes
// a distracting tab-title flash (e.g. to host/URL). Only accept non-empty titles.
let trimmed = (webView.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self?.pageTitle = trimmed
}
}
webViewObservers.append(titleObserver)
// Loading state
let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.handleWebViewLoadingChanged(webView.isLoading)
}
}
webViewObservers.append(loadingObserver)
// Can go back
let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoBack = webView.canGoBack
}
}
webViewObservers.append(backObserver)
// Can go forward
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoForward = webView.canGoForward
}
}
webViewObservers.append(forwardObserver)
// Progress
let progressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.estimatedProgress = webView.estimatedProgress
}
}
webViewObservers.append(progressObserver)
}
// MARK: - Panel Protocol
func focus() {
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
webViewObservers.removeAll()
faviconTask?.cancel()
faviconTask = nil
}
private func refreshFavicon(from webView: WKWebView) {
faviconTask?.cancel()
faviconTask = nil
guard let pageURL = webView.url else { return }
guard let scheme = pageURL.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return }
faviconTask = Task { @MainActor [weak self, weak webView] in
guard let self, let webView else { return }
// Try to discover the best icon URL from the document.
let js = """
(() => {
const links = Array.from(document.querySelectorAll(
'link[rel~=\"icon\"], link[rel=\"shortcut icon\"], link[rel=\"apple-touch-icon\"], link[rel=\"apple-touch-icon-precomposed\"]'
));
function score(link) {
const v = (link.sizes && link.sizes.value) ? link.sizes.value : '';
if (v === 'any') return 1000;
let max = 0;
for (const part of v.split(/\\s+/)) {
const m = part.match(/(\\d+)x(\\d+)/);
if (!m) continue;
const a = parseInt(m[1], 10);
const b = parseInt(m[2], 10);
if (Number.isFinite(a)) max = Math.max(max, a);
if (Number.isFinite(b)) max = Math.max(max, b);
}
return max;
}
links.sort((a, b) => score(b) - score(a));
return links[0]?.href || '';
})();
"""
var discoveredURL: URL?
if let href = try? await webView.evaluateJavaScript(js) as? String {
let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, let u = URL(string: trimmed) {
discoveredURL = u
}
}
let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL)
let iconURL = discoveredURL ?? fallbackURL
guard let iconURL else { return }
// Avoid repeated fetches.
let iconURLString = iconURL.absoluteString
if iconURLString == lastFaviconURLString, faviconPNGData != nil {
return
}
lastFaviconURLString = iconURLString
var req = URLRequest(url: iconURL)
req.timeoutInterval = 2.0
req.cachePolicy = .returnCacheDataElseLoad
req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent")
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch {
return
}
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
return
}
// Use >= 2x the rendered point size so we don't upscale (blurry) on Retina.
guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { return }
// Only update if we got a real icon; keep the old one otherwise to avoid flashes.
faviconPNGData = png
}
}
@MainActor
private static func makeFaviconPNGData(from raw: Data, targetPx: Int) -> Data? {
guard let image = NSImage(data: raw) else { return nil }
let px = max(16, min(128, targetPx))
let size = NSSize(width: px, height: px)
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: px,
pixelsHigh: px,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
return nil
}
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let ctx = NSGraphicsContext(bitmapImageRep: rep)
ctx?.imageInterpolation = .high
ctx?.shouldAntialias = true
NSGraphicsContext.current = ctx
NSColor.clear.setFill()
NSRect(origin: .zero, size: size).fill()
// Aspect-fit into the target square.
let srcSize = image.size
let scale = min(size.width / max(1, srcSize.width), size.height / max(1, srcSize.height))
let drawSize = NSSize(width: srcSize.width * scale, height: srcSize.height * scale)
let drawOrigin = NSPoint(x: (size.width - drawSize.width) / 2.0, y: (size.height - drawSize.height) / 2.0)
// Align to integral pixels to avoid soft edges at small sizes.
let drawRect = NSRect(
x: round(drawOrigin.x),
y: round(drawOrigin.y),
width: round(drawSize.width),
height: round(drawSize.height)
)
image.draw(
in: drawRect,
from: NSRect(origin: .zero, size: srcSize),
operation: .sourceOver,
fraction: 1.0,
respectFlipped: true,
hints: [.interpolation: NSImageInterpolation.high]
)
return rep.representation(using: .png, properties: [:])
}
private func handleWebViewLoadingChanged(_ newValue: Bool) {
if newValue {
loadingGeneration &+= 1
loadingEndWorkItem?.cancel()
loadingEndWorkItem = nil
loadingStartedAt = Date()
isLoading = true
return
}
let genAtEnd = loadingGeneration
let startedAt = loadingStartedAt ?? Date()
let elapsed = Date().timeIntervalSince(startedAt)
let remaining = max(0, minLoadingIndicatorDuration - elapsed)
loadingEndWorkItem?.cancel()
loadingEndWorkItem = nil
if remaining <= 0.0001 {
isLoading = false
return
}
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
// If loading restarted, ignore this end.
guard self.loadingGeneration == genAtEnd else { return }
// If WebKit is still loading, ignore.
guard !self.webView.isLoading else { return }
self.isLoading = false
}
loadingEndWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: work)
}
// MARK: - Navigation
/// Navigate to a URL
func navigate(to url: URL) {
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
var request = URLRequest(url: url)
// Behave like a normal browser (respect HTTP caching). Reload is handled separately.
request.cachePolicy = .useProtocolCachePolicy
webView.load(request)
}
/// Navigate with smart URL/search detection
/// - If input looks like a URL, navigate to it
/// - Otherwise, perform a web search
func navigateSmart(_ input: String) {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if let url = parseSmartInput(trimmed) {
navigate(to: url)
}
}
private func parseSmartInput(_ input: String) -> URL? {
// Check if it's already a valid URL with scheme
if let url = URL(string: input), url.scheme != nil {
return url
}
// Check for localhost (prefer http:// since https is often not configured)
if input.hasPrefix("localhost") || input.hasPrefix("127.0.0.1") {
if let url = URL(string: "http://\(input)") {
return url
}
}
// Check if it looks like a domain (contains a dot and no spaces)
if input.contains(".") && !input.contains(" ") {
// Try adding https://
if let url = URL(string: "https://\(input)") {
return url
}
}
// Treat as a search query
let engine = BrowserSearchSettings.currentSearchEngine()
return engine.searchURL(query: input)
}
/// Go back in history
func goBack() {
guard canGoBack else { return }
webView.goBack()
}
/// Go forward in history
func goForward() {
guard canGoForward else { return }
webView.goForward()
}
/// Reload the current page
func reload() {
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
webView.reload()
}
/// Stop loading
func stopLoading() {
webView.stopLoading()
}
/// Take a snapshot of the web view
func takeSnapshot(completion: @escaping (NSImage?) -> Void) {
let config = WKSnapshotConfiguration()
webView.takeSnapshot(with: config) { image, error in
if let error = error {
NSLog("BrowserPanel snapshot error: %@", error.localizedDescription)
completion(nil)
return
}
completion(image)
}
}
/// Execute JavaScript
func evaluateJavaScript(_ script: String) async throws -> Any? {
try await webView.evaluateJavaScript(script)
}
func suppressOmnibarAutofocus(for seconds: TimeInterval) {
suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds)
}
func shouldSuppressOmnibarAutofocus() -> Bool {
if let until = suppressOmnibarAutofocusUntil {
return Date() < until
}
return false
}
deinit {
webViewObservers.removeAll()
}
}
private extension BrowserPanel {
static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
while let cur = r, hops < 64 {
if cur === target { return true }
r = cur.nextResponder
hops += 1
}
return false
}
}
// MARK: - Navigation Delegate
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
// Navigation started
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
didFinish?(webView)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
NSLog("BrowserPanel navigation failed: %@", error.localizedDescription)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
NSLog("BrowserPanel provisional navigation failed: %@", error.localizedDescription)
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
// Allow all navigation for now
decisionHandler(.allow)
}
}