Merge pull request #117 from manaflow-ai/fix/browser-devtools-shortcuts-pr

Fix browser devtools persistence and add Safari-style shortcuts
This commit is contained in:
Lawrence Chen 2026-02-20 17:49:10 -08:00 committed by GitHub
commit 9649a90163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2712 additions and 293 deletions

View file

@ -15,6 +15,7 @@
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; };
A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
@ -137,6 +138,7 @@
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; };
A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; };
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
@ -313,6 +315,7 @@
A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */,
A5001531 /* TerminalWindowPortal.swift */,
A5001533 /* BrowserWindowPortal.swift */,
A5001019 /* TerminalController.swift */,
A5001541 /* PortScanner.swift */,
A5001225 /* SocketControlSettings.swift */,
@ -541,6 +544,7 @@
A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */,
A5001532 /* TerminalWindowPortal.swift in Sources */,
A5001534 /* BrowserWindowPortal.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5001540 /* PortScanner.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,

View file

@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
### Browser
Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`.
| Shortcut | Action |
|----------|--------|
| ⌘ ⇧ L | Open browser in split |
@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
| ⌘ [ | Back |
| ⌘ ] | Forward |
| ⌘ R | Reload page |
| ⌥ ⌘ I | Open Developer Tools |
| ⌥ ⌘ I | Toggle Developer Tools (Safari default) |
| ⌥ ⌘ C | Show JavaScript Console (Safari default) |
### Notifications

View file

@ -1451,6 +1451,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
if let probeKind = self.developerToolsShortcutProbeKind(event: event) {
self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event)
}
#endif
if self.handleCustomShortcut(event: event) {
#if DEBUG
@ -1929,6 +1932,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
// Safari defaults:
// - Option+Command+I => Show/Toggle Web Inspector
// - Option+Command+C => Show JavaScript Console
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event)
#endif
let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle)
DispatchQueue.main.async { [weak self] in
self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle)
}
#endif
if !didHandle { NSSound.beep() }
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event)
#endif
let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle)
DispatchQueue.main.async { [weak self] in
self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle)
}
#endif
if !didHandle { NSSound.beep() }
return true
}
// Focus browser address bar: Cmd+L
if flags == [.command] && chars == "l" {
if let focusedPanel = tabManager?.focusedBrowserPanel {
@ -2076,10 +2112,162 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
}
#if DEBUG
private func developerToolsShortcutProbeKind(event: NSEvent) -> String? {
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
return "toggle.configured"
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
return "console.configured"
}
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags == [.command, .option] {
if chars == "i" || event.keyCode == 34 {
return "toggle.literal"
}
if chars == "c" || event.keyCode == 8 {
return "console.literal"
}
}
return nil
}
private func logDeveloperToolsShortcutSnapshot(
phase: String,
event: NSEvent? = nil,
didHandle: Bool? = nil
) {
let keyWindow = NSApp.keyWindow
let firstResponder = keyWindow?.firstResponder
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let eventDescription = event.map(NSWindow.keyDescription) ?? "none"
if let browser = tabManager?.focusedBrowserPanel {
var line =
"browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " +
"\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " +
"keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
if let didHandle {
line += " handled=\(didHandle ? 1 : 0)"
}
dlog(line)
return
}
var line =
"browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " +
"fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
if let didHandle {
line += " handled=\(didHandle ? 1 : 0)"
}
dlog(line)
}
#endif
private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) {
guard let browser = tabManager?.focusedBrowserPanel else { return }
guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return }
guard let keyWindow = NSApp.keyWindow else { return }
guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return }
let beforeResponder = keyWindow.firstResponder
let movedToWebView = keyWindow.makeFirstResponder(browser.webView)
let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil)
#if DEBUG
let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil"
let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let afterResponder = keyWindow.firstResponder
let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil"
let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
dlog(
"split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " +
"before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " +
"moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())"
)
#endif
}
@discardableResult
func performSplitShortcut(direction: SplitDirection) -> Bool {
let directionLabel: String
switch direction {
case .left: directionLabel = "left"
case .right: directionLabel = "right"
case .up: directionLabel = "up"
case .down: directionLabel = "down"
}
#if DEBUG
let keyWindow = NSApp.keyWindow
let firstResponder = keyWindow?.firstResponder
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let firstResponderWindow: Int = {
if let v = firstResponder as? NSView {
return v.window?.windowNumber ?? -1
}
if let w = firstResponder as? NSWindow {
return w.windowNumber
}
return -1
}()
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
if let browser = tabManager?.focusedBrowserPanel {
let webWindow = browser.webView.window?.windowNumber ?? -1
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
} else {
dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)")
}
#endif
prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel)
tabManager?.createSplit(direction: direction)
#if DEBUG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
let keyWindow = NSApp.keyWindow
let firstResponder = keyWindow?.firstResponder
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let firstResponderWindow: Int = {
if let v = firstResponder as? NSView {
return v.window?.windowNumber ?? -1
}
if let w = firstResponder as? NSWindow {
return w.windowNumber
}
return -1
}()
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
if let browser = self?.tabManager?.focusedBrowserPanel {
let webWindow = browser.webView.window?.windowNumber ?? -1
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
} else {
dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)")
}
}
recordGotoSplitSplitIfNeeded(direction: direction)
#endif
return true

View file

@ -0,0 +1,867 @@
import AppKit
import ObjectiveC
import WebKit
#if DEBUG
import Bonsplit
#endif
private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
guard let view else { return "nil" }
let ptr = Unmanaged.passUnretained(view).toOpaque()
return String(describing: ptr)
}
private func browserPortalDebugFrame(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
#endif
final class WindowBrowserHostView: NSView {
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
let hitView = super.hitTest(point)
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)
guard let rootView = window.contentView else { return false }
return Self.containsSplitDivider(at: windowPoint, in: rootView)
}
private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool {
guard !view.isHidden else { return false }
if let splitView = view as? NSSplitView {
let pointInSplit = splitView.convert(windowPoint, from: nil)
if splitView.bounds.contains(pointInSplit) {
let expansion: CGFloat = 5
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1, second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(
x: x,
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
guard first.height > 1, second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(
x: 0,
y: y,
width: splitView.bounds.width,
height: thickness
)
}
let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion)
if expanded.contains(pointInSplit) {
return true
}
}
}
}
for subview in view.subviews.reversed() {
if containsSplitDivider(at: windowPoint, in: subview) {
return true
}
}
return false
}
}
final class WindowBrowserSlotView: NSView {
override var isOpaque: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.masksToBounds = true
translatesAutoresizingMaskIntoConstraints = true
autoresizingMask = []
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
}
@MainActor
final class WindowBrowserPortal: NSObject {
private weak var window: NSWindow?
private let hostView = WindowBrowserHostView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private struct Entry {
weak var webView: WKWebView?
weak var containerView: WindowBrowserSlotView?
weak var anchorView: NSView?
var visibleInUI: Bool
var zPriority: Int
}
private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:]
private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = []
_ = ensureInstalled()
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
guard let (container, reference) = installationTarget(for: window) else { return false }
if hostView.superview !== container ||
installedContainerView !== container ||
installedReferenceView !== reference {
hostView.removeFromSuperview()
container.addSubview(hostView, positioned: .above, relativeTo: reference)
installedContainerView = container
installedReferenceView = reference
} else if !Self.isView(hostView, above: reference, in: container) {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
synchronizeHostFrameToReference()
return true
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " +
"frame=\(browserPortalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let contentView = window.contentView else { return nil }
if contentView.className == "NSGlassEffectView",
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
return (contentView, foreground)
}
guard let themeFrame = contentView.superview else { return nil }
return (themeFrame, contentView)
}
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
frame.maxX > bounds.maxX + epsilon ||
frame.maxY > bounds.maxY + epsilon
}
#if DEBUG
private static func inspectorSubviewCount(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
}
#endif
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
return false
}
return viewIndex > referenceIndex
}
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
if let existing = entry.containerView {
return existing
}
let created = WindowBrowserSlotView(frame: .zero)
#if DEBUG
dlog(
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(created))"
)
#endif
return created
}
private func moveWebKitRelatedSubviewsIfNeeded(
from sourceSuperview: NSView,
to containerView: WindowBrowserSlotView,
primaryWebView: WKWebView,
reason: String
) {
guard sourceSuperview !== containerView else { return }
// When Web Inspector is docked, WebKit can inject companion WK* subviews
// next to the primary WKWebView. Move those with the web view so inspector
// UI state does not get orphaned in the old host during split churn.
let relatedSubviews = sourceSuperview.subviews.filter { view in
if view === primaryWebView { return true }
return String(describing: type(of: view)).contains("WK")
}
guard !relatedSubviews.isEmpty else { return }
#if DEBUG
dlog(
"browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " +
"container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " +
"sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " +
"sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " +
"sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))"
)
#endif
for view in relatedSubviews {
let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
let className = String(describing: type(of: view))
view.removeFromSuperview()
containerView.addSubview(view, positioned: .above, relativeTo: nil)
let convertedFrame = containerView.convert(frameInWindow, from: nil)
view.frame = convertedFrame
#if DEBUG
dlog(
"browser.portal.reparent.batch.item reason=\(reason) class=\(className) " +
"view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " +
"converted=\(browserPortalDebugFrame(convertedFrame))"
)
#endif
}
}
func detachWebView(withId webViewId: ObjectIdentifier) {
guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return }
if let anchor = entry.anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
#if DEBUG
let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0
let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1
dlog(
"browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " +
"container=\(browserPortalDebugToken(entry.containerView)) " +
"anchor=\(browserPortalDebugToken(entry.anchorView)) " +
"hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)"
)
#endif
entry.webView?.removeFromSuperview()
entry.containerView?.removeFromSuperview()
}
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
let webViewId = ObjectIdentifier(webView)
let anchorId = ObjectIdentifier(anchorView)
let previousEntry = entriesByWebViewId[webViewId]
let containerView = ensureContainerView(
for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0),
webView: webView
)
if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId {
#if DEBUG
let previousToken = entriesByWebViewId[previousWebViewId]
.map { browserPortalDebugToken($0.webView) }
?? String(describing: previousWebViewId)
dlog(
"browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " +
"oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))"
)
#endif
detachWebView(withId: previousWebViewId)
}
if let oldEntry = entriesByWebViewId[webViewId],
let oldAnchor = oldEntry.anchorView,
oldAnchor !== anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
}
webViewByAnchorId[anchorId] = webViewId
entriesByWebViewId[webViewId] = Entry(
webView: webView,
containerView: containerView,
anchorView: anchorView,
visibleInUI: visibleInUI,
zPriority: zPriority
)
let didChangeAnchor: Bool = {
guard let previousAnchor = previousEntry?.anchorView else { return true }
return previousAnchor !== anchorView
}()
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
#if DEBUG
if previousEntry == nil ||
didChangeAnchor ||
becameVisible ||
priorityIncreased ||
webView.superview !== containerView ||
containerView.superview !== hostView {
dlog(
"browser.portal.bind web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) " +
"anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " +
"visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " +
"z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)"
)
}
#endif
if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
"reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
if let sourceSuperview = webView.superview {
moveWebKitRelatedSubviewsIfNeeded(
from: sourceSuperview,
to: containerView,
primaryWebView: webView,
reason: "bind.attachContainer"
)
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
if containerView.superview !== hostView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
"reason=attach super=\(browserPortalDebugToken(containerView.superview))"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
} else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " +
"didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " +
"priorityIncreased=\(priorityIncreased ? 1 : 0)"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
}
synchronizeWebView(withId: webViewId, source: "bind")
pruneDeadEntries()
}
func synchronizeWebViewForAnchor(_ anchorView: NSView) {
pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView)
let primaryWebViewId = webViewByAnchorId[anchorId]
if let primaryWebViewId {
synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary")
}
synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary")
scheduleDeferredFullSynchronizeAll()
}
private func scheduleDeferredFullSynchronizeAll() {
guard !hasDeferredFullSyncScheduled else { return }
hasDeferredFullSyncScheduled = true
#if DEBUG
dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)")
#endif
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasDeferredFullSyncScheduled = false
#if DEBUG
dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)")
#endif
self.synchronizeAllWebViews(excluding: nil, source: "deferredTick")
}
}
private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) {
guard ensureInstalled() else { return }
pruneDeadEntries()
let webViewIds = Array(entriesByWebViewId.keys)
for webViewId in webViewIds {
if webViewId == webViewIdToSkip { continue }
synchronizeWebView(withId: webViewId, source: source)
}
}
private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) {
guard ensureInstalled() else { return }
guard let entry = entriesByWebViewId[webViewId] else { return }
guard let webView = entry.webView else {
entriesByWebViewId.removeValue(forKey: webViewId)
return
}
guard let containerView = entry.containerView else {
entriesByWebViewId.removeValue(forKey: webViewId)
if let anchor = entry.anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
return
}
guard let anchorView = entry.anchorView, let window else {
#if DEBUG
if !containerView.isHidden {
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow"
)
}
#endif
containerView.isHidden = true
return
}
guard anchorView.window === window else {
#if DEBUG
if !containerView.isHidden {
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=1 " +
"reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))"
)
}
#endif
containerView.isHidden = true
return
}
if containerView.superview !== hostView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
"reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
}
if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
"reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
if let sourceSuperview = webView.superview {
moveWebKitRelatedSubviewsIfNeeded(
from: sourceSuperview,
to: containerView,
primaryWebView: webView,
reason: "sync.attachContainer"
)
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
_ = synchronizeHostFrameToReference()
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) " +
"reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " +
"anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
containerView.isHidden = true
scheduleDeferredFullSynchronizeAll()
return
}
let oldFrame = containerView.frame
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1
let outsideHostBounds = !hasVisibleIntersection
let shouldHide =
!entry.visibleInUI ||
anchorHidden ||
tinyFrame ||
!hasFiniteFrame ||
outsideHostBounds
#if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny {
dlog(
"browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
)
} else if restoredFromTiny {
dlog(
"browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
)
}
#endif
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin()
CATransaction.setDisableActions(true)
containerView.frame = targetFrame
CATransaction.commit()
}
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) {
let oldContainerBounds = containerView.bounds
CATransaction.begin()
CATransaction.setDisableActions(true)
containerView.bounds = expectedContainerBounds
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " +
"target=\(browserPortalDebugFrame(expectedContainerBounds))"
)
#endif
}
let containerBounds = containerView.bounds
let preNormalizeWebFrame = webView.frame
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
#if DEBUG
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
#endif
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
let oldWebFrame = preNormalizeWebFrame
CATransaction.begin()
CATransaction.setDisableActions(true)
webView.frame = containerBounds
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " +
"new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " +
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
"inspectorSubviews=\(inspectorSubviews) " +
"source=\(source)"
)
#endif
}
if containerView.isHidden != shouldHide {
#if DEBUG
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
#endif
containerView.isHidden = shouldHide
}
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
"container=\(browserPortalDebugToken(containerView)) " +
"anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
"webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " +
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
"inspectorSubviews=\(inspectorSubviews)"
)
#endif
}
private func pruneDeadEntries() {
let currentWindow = window
let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in
guard entry.webView != nil else { return webViewId }
guard let container = entry.containerView else { return webViewId }
guard let anchor = entry.anchorView else { return webViewId }
if container.superview == nil || !container.isDescendant(of: hostView) {
return webViewId
}
if anchor.window !== currentWindow || anchor.superview == nil {
return webViewId
}
if let reference = installedReferenceView,
!anchor.isDescendant(of: reference) {
return webViewId
}
return nil
}
for webViewId in deadWebViewIds {
detachWebView(withId: webViewId)
}
let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in
entry.anchorView.map { ObjectIdentifier($0) }
})
webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func webViewIds() -> Set<ObjectIdentifier> {
Set(entriesByWebViewId.keys)
}
func tearDown() {
for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId)
}
hostView.removeFromSuperview()
installedContainerView = nil
installedReferenceView = nil
}
#if DEBUG
func debugEntryCount() -> Int {
entriesByWebViewId.count
}
func debugHostedSubviewCount() -> Int {
hostView.subviews.count
}
#endif
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
for subview in hostView.subviews.reversed() {
guard let container = subview as? WindowBrowserSlotView else { continue }
guard !container.isHidden else { continue }
guard container.frame.contains(point) else { continue }
guard let webView = entriesByWebViewId
.first(where: { _, entry in entry.containerView === container })?
.value
.webView else { continue }
return webView
}
return nil
}
}
@MainActor
enum BrowserWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window)
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak window] _ in
MainActor.assumeIsolated {
if let window {
removePortal(for: window)
} else {
removePortal(windowId: windowId, window: nil)
}
}
}
objc_setAssociatedObject(
window,
&cmuxWindowBrowserPortalCloseObserverKey,
observer,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private static func removePortal(for window: NSWindow) {
removePortal(windowId: ObjectIdentifier(window), window: window)
}
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
portal.tearDown()
}
webViewToWindowId = webViewToWindowId.filter { $0.value != windowId }
guard let window else { return }
if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) {
NotificationCenter.default.removeObserver(observer)
}
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set<ObjectIdentifier>) {
webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in
mappedWindowId != windowId || validWebViewIds.contains(webViewId)
}
}
private static func portal(for window: NSWindow) -> WindowBrowserPortal {
if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
installWindowCloseObserverIfNeeded(for: window)
return existing
}
let portal = WindowBrowserPortal(window: window)
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
portalsByWindowId[ObjectIdentifier(window)] = portal
installWindowCloseObserverIfNeeded(for: window)
return portal
}
static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let webViewId = ObjectIdentifier(webView)
let nextPortal = portal(for: window)
if let oldWindowId = webViewToWindowId[webViewId],
oldWindowId != windowId {
portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId)
}
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
webViewToWindowId[webViewId] = windowId
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
}
static func synchronizeForAnchor(_ anchorView: NSView) {
guard let window = anchorView.window else { return }
let portal = portal(for: window)
portal.synchronizeWebViewForAnchor(anchorView)
}
static func detach(webView: WKWebView) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
#endif
}

View file

@ -31,6 +31,8 @@ enum KeyboardShortcutSettings {
// Panels
case openBrowser
case toggleBrowserDeveloperTools
case showBrowserJavaScriptConsole
var id: String { rawValue }
@ -56,6 +58,8 @@ enum KeyboardShortcutSettings {
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .openBrowser: return "Open Browser"
case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools"
case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console"
}
}
@ -81,6 +85,8 @@ enum KeyboardShortcutSettings {
case .prevSurface: return "shortcut.prevSurface"
case .newSurface: return "shortcut.newSurface"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
}
}
@ -126,6 +132,12 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:
// Safari default: Show Web Inspector.
return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false)
case .showBrowserJavaScriptConsole:
// Safari default: Show JavaScript Console.
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
}
}
@ -194,6 +206,8 @@ enum KeyboardShortcutSettings {
static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) }
static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) }
static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) }
static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) }
}
/// A keyboard shortcut that can be stored in UserDefaults

View file

@ -2,6 +2,7 @@ import Foundation
import Combine
import WebKit
import AppKit
import Bonsplit
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
case google
@ -1001,6 +1002,13 @@ final class BrowserPanel: Panel, ObservableObject {
private let maxPageZoom: CGFloat = 5.0
private let pageZoomStep: CGFloat = 0.1
private var insecureHTTPBypassHostOnce: String?
// 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
var displayTitle: String {
if !pageTitle.isEmpty {
@ -1042,6 +1050,11 @@ final class BrowserPanel: Panel, ObservableObject {
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 window so newly-created browsers
// don't flash white before content loads.
webView.underPageBackgroundColor = .windowBackgroundColor
@ -1489,6 +1502,12 @@ final class BrowserPanel: Panel, ObservableObject {
}
deinit {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
let webView = webView
Task { @MainActor in
BrowserWindowPortalRegistry.detach(webView: webView)
}
webViewObservers.removeAll()
}
}
@ -1561,6 +1580,183 @@ extension BrowserPanel {
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 visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? 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 {
developerToolsRestoreRetryAttempt = 0
} 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
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)
@ -1669,6 +1865,84 @@ extension BrowserPanel {
}
private extension BrowserPanel {
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 {
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 {
@ -1692,6 +1966,33 @@ private extension BrowserPanel {
}
}
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: - Navigation Delegate
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {

View file

@ -3,6 +3,113 @@ import SwiftUI
import WebKit
import AppKit
enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable {
case wrenchAndScrewdriver = "wrench.and.screwdriver"
case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill"
case curlyBracesSquare = "curlybraces.square"
case curlyBraces = "curlybraces"
case terminalFill = "terminal.fill"
case terminal = "terminal"
case hammer = "hammer"
case hammerCircle = "hammer.circle"
case ladybug = "ladybug"
case ladybugFill = "ladybug.fill"
case scope = "scope"
case codeChevrons = "chevron.left.slash.chevron.right"
case gearshape = "gearshape"
case gearshapeFill = "gearshape.fill"
case globe = "globe"
case globeAmericas = "globe.americas.fill"
var id: String { rawValue }
var title: String {
switch self {
case .wrenchAndScrewdriver: return "Wrench + Screwdriver"
case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)"
case .curlyBracesSquare: return "Curly Braces"
case .curlyBraces: return "Curly Braces (Plain)"
case .terminalFill: return "Terminal (Fill)"
case .terminal: return "Terminal"
case .hammer: return "Hammer"
case .hammerCircle: return "Hammer Circle"
case .ladybug: return "Bug"
case .ladybugFill: return "Bug (Fill)"
case .scope: return "Scope"
case .codeChevrons: return "Code Chevrons"
case .gearshape: return "Gear"
case .gearshapeFill: return "Gear (Fill)"
case .globe: return "Globe"
case .globeAmericas: return "Globe Americas (Fill)"
}
}
}
enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
case bonsplitInactive
case bonsplitActive
case accent
case tertiary
var id: String { rawValue }
var title: String {
switch self {
case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)"
case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)"
case .accent: return "Accent"
case .tertiary: return "Tertiary"
}
}
var color: Color {
switch self {
case .bonsplitInactive:
// Matches Bonsplit tab icon tint for inactive tabs.
return Color(nsColor: .secondaryLabelColor)
case .bonsplitActive:
// Matches Bonsplit tab icon tint for active tabs.
return Color(nsColor: .labelColor)
case .accent:
return .accentColor
case .tertiary:
return Color(nsColor: .tertiaryLabelColor)
}
}
}
enum BrowserDevToolsButtonDebugSettings {
static let iconNameKey = "browserDevToolsIconName"
static let iconColorKey = "browserDevToolsIconColor"
static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver
static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive
static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption {
guard let raw = defaults.string(forKey: iconNameKey),
let option = BrowserDevToolsIconOption(rawValue: raw) else {
return defaultIcon
}
return option
}
static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption {
guard let raw = defaults.string(forKey: iconColorKey),
let option = BrowserDevToolsIconColorOption(rawValue: raw) else {
return defaultColor
}
return option
}
static func copyPayload(defaults: UserDefaults = .standard) -> String {
let icon = iconOption(defaults: defaults)
let color = colorOption(defaults: defaults)
return """
browserDevToolsIconName=\(icon.rawValue)
browserDevToolsIconColor=\(color.rawValue)
"""
}
}
struct OmnibarInlineCompletion: Equatable {
let typedText: String
let displayText: String
@ -20,11 +127,14 @@ struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@State private var omnibarState = OmnibarState()
@State private var addressBarFocused: Bool = false
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@State private var suggestionTask: Task<Void, Never>?
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@ -38,6 +148,8 @@ struct BrowserPanelView: View {
@State private var omnibarPillFrame: CGRect = .zero
@State private var lastHandledAddressBarFocusRequestId: UUID?
private let omnibarPillCornerRadius: CGFloat = 12
private let addressBarButtonSize: CGFloat = 22
private let devToolsButtonIconSize: CGFloat = 11
private var searchEngine: BrowserSearchEngine {
BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine
@ -63,6 +175,14 @@ struct BrowserPanelView: View {
return searchSuggestionsEnabled
}
private var devToolsIconOption: BrowserDevToolsIconOption {
BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
}
private var devToolsColorOption: BrowserDevToolsIconColorOption {
BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
var body: some View {
VStack(spacing: 0) {
addressBar
@ -210,6 +330,8 @@ struct BrowserPanelView: View {
omnibarField
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
developerToolsButton
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
@ -219,8 +341,6 @@ struct BrowserPanelView: View {
}
private var addressBarButtonBar: some View {
let navButtonSize: CGFloat = 22
return HStack(spacing: 0) {
Button(action: {
#if DEBUG
@ -230,10 +350,10 @@ struct BrowserPanelView: View {
}) {
Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.disabled(!panel.canGoBack)
.opacity(panel.canGoBack ? 1.0 : 0.4)
.help("Go Back")
@ -246,10 +366,10 @@ struct BrowserPanelView: View {
}) {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.disabled(!panel.canGoForward)
.opacity(panel.canGoForward ? 1.0 : 0.4)
.help("Go Forward")
@ -269,14 +389,29 @@ struct BrowserPanelView: View {
}) {
Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.help(panel.isLoading ? "Stop" : "Reload")
}
}
private var developerToolsButton: some View {
Button(action: {
openDevTools()
}) {
Image(systemName: devToolsIconOption.rawValue)
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.help("Toggle Developer Tools")
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var omnibarField: some View {
let showSecureBadge = panel.currentURL?.scheme == "https"
@ -370,7 +505,8 @@ struct BrowserPanelView: View {
panel: panel,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused
isPanelFocused: isFocused,
portalZPriority: portalPriority
)
// Keep the representable identity stable across bonsplit structural updates.
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
@ -384,12 +520,6 @@ struct BrowserPanelView: View {
}
})
.zIndex(0)
.contextMenu {
Button("Open Developer Tools") {
openDevTools()
}
.keyboardShortcut("i", modifiers: [.command, .option])
}
}
private func triggerFocusFlashAnimation() {
@ -453,10 +583,11 @@ struct BrowserPanelView: View {
}
private func openDevTools() {
// WKWebView with developerExtrasEnabled allows right-click > Inspect Element
// We can also trigger via JavaScript
Task {
try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')")
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
#endif
if !panel.toggleDeveloperTools() {
NSSound.beep()
}
}
@ -2446,15 +2577,88 @@ struct WebViewRepresentable: NSViewRepresentable {
let shouldAttachWebView: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
let portalZPriority: Int
final class Coordinator {
weak var panel: BrowserPanel?
weak var webView: WKWebView?
var constraints: [NSLayoutConstraint] = []
var attachRetryWorkItem: DispatchWorkItem?
var attachRetryCount: Int = 0
var attachGeneration: Int = 0
var usesWindowPortal: Bool = false
var desiredPortalVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastPortalHostId: ObjectIdentifier?
}
private final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onDidMoveToWindow?()
onGeometryChanged?()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
onGeometryChanged?()
}
override func layout() {
super.layout()
onGeometryChanged?()
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
onGeometryChanged?()
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
onGeometryChanged?()
}
}
#if DEBUG
private static func logDevToolsState(
_ panel: BrowserPanel,
event: String,
generation: Int,
retryCount: Int,
details: String? = nil
) {
var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())"
if let details, !details.isEmpty {
line += " \(details)"
}
dlog(line)
}
private static func objectID(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func responderDescription(_ responder: NSResponder?) -> String {
guard let responder else { return "nil" }
return "\(type(of: responder))@\(objectID(responder))"
}
private static func rectDescription(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
private static func attachContext(webView: WKWebView, host: NSView) -> String {
let hostWindow = host.window?.windowNumber ?? -1
let webWindow = webView.window?.windowNumber ?? -1
let firstResponder = (webView.window ?? host.window)?.firstResponder
return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))"
}
#endif
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
@ -2466,22 +2670,141 @@ struct WebViewRepresentable: NSViewRepresentable {
return false
}
private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
}
private static func firstResponderResignState(
_ responder: NSResponder?,
webView: WKWebView
) -> (needsResign: Bool, flags: String) {
let inWebViewChain = responderChainContains(responder, target: webView)
let inspectorResponder = isLikelyInspectorResponder(responder)
let needsResign = inWebViewChain || inspectorResponder
return (
needsResign: needsResign,
flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)"
)
}
func makeCoordinator() -> Coordinator {
Coordinator()
let coordinator = Coordinator()
coordinator.panel = panel
return coordinator
}
func makeNSView(context: Context) -> NSView {
let container = NSView()
let container = HostContainerView()
container.wantsLayer = true
return container
}
private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) {
private static func clearPortalCallbacks(for host: NSView) {
guard let host = host as? HostContainerView else { return }
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
guard let host = nsView as? HostContainerView else { return }
let coordinator = context.coordinator
let previousVisible = coordinator.desiredPortalVisibleInUI
let previousZPriority = coordinator.desiredPortalZPriority
coordinator.desiredPortalVisibleInUI = shouldAttachWebView
coordinator.desiredPortalZPriority = portalZPriority
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
guard let host, let webView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard host.window != nil else { return }
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = ObjectIdentifier(host)
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
}
if !shouldAttachWebView {
// In portal mode we no longer detach/re-attach to preserve DevTools state.
// Sync the inspector preference directly so manual closes are respected.
panel.syncDeveloperToolsPreferenceFromInspector()
}
if host.window != nil {
let hostId = ObjectIdentifier(host)
let shouldBindNow =
coordinator.lastPortalHostId != hostId ||
webView.superview == nil ||
previousVisible != shouldAttachWebView ||
previousZPriority != portalZPriority
if shouldBindNow {
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
}
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "portal.update",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: host)
)
#endif
}
private static func attachWebView(_ webView: WKWebView, to host: NSView) {
// WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder
// while being detached/reparented during bonsplit/SwiftUI structural updates.
if let window = webView.window,
responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
if let window = webView.window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
window.makeFirstResponder(nil)
}
}
// The target host can already be in-window while the source host is tearing down.
// Re-check against the target window too (it can differ during split churn).
if let window = host.window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
window.makeFirstResponder(nil)
}
}
// Detach from any previous host (bonsplit/SwiftUI may rearrange views).
@ -2489,15 +2812,11 @@ struct WebViewRepresentable: NSViewRepresentable {
host.subviews.forEach { $0.removeFromSuperview() }
host.addSubview(webView)
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints = [
webView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
webView.topAnchor.constraint(equalTo: host.topAnchor),
webView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
]
NSLayoutConstraint.activate(coordinator.constraints)
// Work around WebKit bug 272474 where Inspect Element can render blank/flicker
// when WKWebView is edge-pinned using Auto Layout constraints.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = host.bounds
// Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out.
webView.needsLayout = true
@ -2506,7 +2825,13 @@ struct WebViewRepresentable: NSViewRepresentable {
webView.displayIfNeeded()
}
private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) {
private static func scheduleAttachRetry(
_ webView: WKWebView,
panel: BrowserPanel,
to host: NSView,
coordinator: Coordinator,
generation: Int
) {
// Don't schedule multiple overlapping retries.
guard coordinator.attachRetryWorkItem == nil else { return }
@ -2525,18 +2850,54 @@ struct WebViewRepresentable: NSViewRepresentable {
// is in a window during bonsplit tree updates; moving the webview too early can be flaky.
guard host.window != nil else {
coordinator.attachRetryCount += 1
#if DEBUG
if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 {
logDevToolsState(
panel,
event: "retry.waitingForWindow",
generation: generation,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: host)
)
}
#endif
// Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation)
scheduleAttachRetry(
webView,
panel: panel,
to: host,
coordinator: coordinator,
generation: generation
)
}
}
return
}
coordinator.attachRetryCount = 0
attachWebView(webView, to: host, coordinator: coordinator)
#if DEBUG
logDevToolsState(
panel,
event: "retry.attach.begin",
generation: generation,
retryCount: 0,
details: attachContext(webView: webView, host: host)
)
#endif
attachWebView(webView, to: host)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
logDevToolsState(
panel,
event: "retry.attached",
generation: generation,
retryCount: 0,
details: attachContext(webView: webView, host: host)
)
#endif
}
coordinator.attachRetryWorkItem = work
@ -2545,30 +2906,106 @@ struct WebViewRepresentable: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView
context.coordinator.panel = panel
context.coordinator.webView = webView
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
if shouldUseWindowPortal {
context.coordinator.usesWindowPortal = true
Self.clearPortalCallbacks(for: nsView)
updateUsingWindowPortal(nsView, context: context, webView: webView)
Self.applyFocus(
panel: panel,
webView: webView,
nsView: nsView,
shouldFocusWebView: shouldFocusWebView,
isPanelFocused: isPanelFocused
)
return
}
if context.coordinator.usesWindowPortal {
BrowserWindowPortalRegistry.detach(webView: webView)
context.coordinator.usesWindowPortal = false
context.coordinator.lastPortalHostId = nil
}
Self.clearPortalCallbacks(for: nsView)
// Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left
// in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce
// WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane.
if !shouldAttachWebView {
// Split/layout churn can briefly create an off-window phase while DevTools is open.
// Detaching here can blank inspector content even when visibility preference stays true.
if nsView.window == nil,
webView.superview != nil,
panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.skipped.offWindowDevTools",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
return
}
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.beforeSync",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.afterSync",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
context.coordinator.attachRetryWorkItem?.cancel()
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachRetryCount = 0
context.coordinator.attachGeneration += 1
// Resign focus if WebKit currently owns first responder.
if let window = webView.window,
Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
if let window = webView.window ?? nsView.window {
let state = Self.firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.resignFirstResponder",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags
)
#endif
window.makeFirstResponder(nil)
}
}
NSLayoutConstraint.deactivate(context.coordinator.constraints)
context.coordinator.constraints.removeAll()
if webView.superview != nil {
webView.removeFromSuperview()
}
nsView.subviews.forEach { $0.removeFromSuperview() }
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.done",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
return
}
@ -2578,17 +3015,83 @@ struct WebViewRepresentable: NSViewRepresentable {
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachGeneration += 1
if let window = webView.window ?? nsView.window {
let state = Self.firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.reparent.resignFirstResponder.begin",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags
)
#endif
let resigned = window.makeFirstResponder(nil)
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.reparent.resignFirstResponder.end",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)"
)
#endif
}
}
if nsView.window == nil {
// Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI
// can create containers that are never inserted into the window.
if panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow")
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.defer.requestRefresh",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
}
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.defer.offWindow",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
Self.scheduleAttachRetry(
webView,
panel: panel,
to: nsView,
coordinator: context.coordinator,
generation: context.coordinator.attachGeneration
)
} else {
Self.attachWebView(webView, to: nsView, coordinator: context.coordinator)
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.immediate.begin",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
Self.attachWebView(webView, to: nsView)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.immediate",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
}
} else {
// Already attached; no need for any pending retry.
@ -2596,25 +3099,59 @@ struct WebViewRepresentable: NSViewRepresentable {
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachRetryCount = 0
context.coordinator.attachGeneration += 1
let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
if hadPendingRefresh {
Self.logDevToolsState(
panel,
event: "attach.alreadyAttached.consumePendingRefresh",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
}
Self.logDevToolsState(
panel,
event: "attach.alreadyAttached",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
}
Self.applyFocus(
panel: panel,
webView: webView,
nsView: nsView,
shouldFocusWebView: shouldFocusWebView,
isPanelFocused: isPanelFocused
)
}
private static func applyFocus(
panel: BrowserPanel,
webView: WKWebView,
nsView: NSView,
shouldFocusWebView: Bool,
isPanelFocused: Bool
) {
// Focus handling. Avoid fighting the address bar when it is focused.
guard let window = nsView.window else { return }
if shouldFocusWebView {
if panel.shouldSuppressWebViewFocus() {
return
}
if Self.responderChainContains(window.firstResponder, target: webView) {
if responderChainContains(window.firstResponder, target: webView) {
return
}
window.makeFirstResponder(webView)
} else {
} else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
// Only force-resign WebView focus when this panel itself is not focused.
// If the panel is focused but the omnibar-focus state is briefly stale, aggressively
// clearing first responder here can undo programmatic webview focus (socket tests).
if !isPanelFocused && Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
}
window.makeFirstResponder(nil)
}
}
@ -2623,20 +3160,85 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.attachRetryWorkItem = nil
coordinator.attachRetryCount = 0
coordinator.attachGeneration += 1
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints.removeAll()
clearPortalCallbacks(for: nsView)
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
if coordinator.usesWindowPortal {
coordinator.usesWindowPortal = false
coordinator.lastPortalHostId = nil
// During split/layout churn we keep the WKWebView portal-hosted so DevTools
// does not lose state. BrowserPanel deinit explicitly detaches on real teardown.
if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
#if DEBUG
logDevToolsState(
panel,
event: "dismantle.portal.keepAttached",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView)
)
#endif
return
}
BrowserWindowPortalRegistry.detach(webView: webView)
return
}
// If we're being torn down while the WKWebView (or one of its subviews) is first responder,
// resign it before detaching.
let window = webView.window ?? nsView.window
if let window, responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
if let window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
if let panel {
logDevToolsState(
panel,
event: "dismantle.resignFirstResponder",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView) + " " + state.flags
)
}
#endif
window.makeFirstResponder(nil)
}
}
// During split/layout churn, SwiftUI may tear down a host view while a new one is still
// coming online. When DevTools is intended open, avoid eagerly detaching here.
if let panel,
panel.shouldPreserveWebViewAttachmentDuringTransientHide(),
webView.superview === nsView {
#if DEBUG
logDevToolsState(
panel,
event: "dismantle.skipDetach.devTools",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView)
)
#endif
return
}
if webView.superview === nsView {
webView.removeFromSuperview()
#if DEBUG
if let panel {
logDevToolsState(
panel,
event: "dismantle.detached",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView)
)
}
#endif
}
}
}

View file

@ -37,6 +37,7 @@ struct PanelContentView: View {
panel: browserPanel,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,
onRequestPanelFocus: onRequestPanelFocus
)
}

View file

@ -841,6 +841,16 @@ class TabManager: ObservableObject {
focusedBrowserPanel?.resetZoom() ?? false
}
@discardableResult
func toggleDeveloperToolsFocusedBrowser() -> Bool {
focusedBrowserPanel?.toggleDeveloperTools() ?? false
}
@discardableResult
func showJavaScriptConsoleFocusedBrowser() -> Bool {
focusedBrowserPanel?.showDeveloperToolsConsole() ?? false
}
/// Backwards compatibility: returns the focused surface ID
func focusedSurfaceId(for tabId: UUID) -> UUID? {
focusedPanelId(for: tabId)

View file

@ -15,6 +15,10 @@ struct cmuxApp: App {
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
private var showBrowserJavaScriptConsoleShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@ -428,6 +432,20 @@ struct cmuxApp: App {
}
.keyboardShortcut("r", modifiers: .command)
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
if !manager.toggleDeveloperToolsFocusedBrowser() {
NSSound.beep()
}
}
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
if !manager.showJavaScriptConsoleFocusedBrowser() {
NSSound.beep()
}
}
Button("Zoom In") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
}
@ -555,6 +573,20 @@ struct cmuxApp: App {
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
}
private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut {
decodeShortcut(
from: toggleBrowserDeveloperToolsShortcutData,
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
)
}
private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut {
decodeShortcut(
from: showBrowserJavaScriptConsoleShortcutData,
fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
)
}
private var splitBrowserRightMenuShortcut: StoredShortcut {
decodeShortcut(
from: splitBrowserRightShortcutData,
@ -1163,6 +1195,7 @@ private enum DebugWindowConfigSnapshot {
"""
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
return """
# Sidebar Debug
@ -1173,6 +1206,9 @@ private enum DebugWindowConfigSnapshot {
# Menu Bar Extra Debug
\(menuBarPayload)
# Browser DevTools Button
\(browserDevToolsPayload)
"""
}
@ -1239,6 +1275,16 @@ private struct DebugWindowControlsView: View {
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
private var selectedDevToolsIconOption: BrowserDevToolsIconOption {
BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
}
private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption {
BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
var body: some View {
ScrollView {
@ -1322,12 +1368,58 @@ private struct DebugWindowControlsView: View {
.padding(.top, 2)
}
GroupBox("Browser DevTools Button") {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Icon")
Picker("Icon", selection: $browserDevToolsIconNameRaw) {
ForEach(BrowserDevToolsIconOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
Spacer()
}
HStack(spacing: 8) {
Text("Color")
Picker("Color", selection: $browserDevToolsIconColorRaw) {
ForEach(BrowserDevToolsIconColorOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
Spacer()
}
HStack(spacing: 8) {
Text("Preview")
Spacer()
Image(systemName: selectedDevToolsIconOption.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(selectedDevToolsColorOption.color)
}
HStack(spacing: 12) {
Button("Reset Button") {
resetBrowserDevToolsButton()
}
Button("Copy Button Config") {
copyBrowserDevToolsButtonConfig()
}
}
}
.padding(.top, 2)
}
GroupBox("Copy") {
VStack(alignment: .leading, spacing: 8) {
Button("Copy All Debug Config") {
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
}
Text("Copies sidebar, background, and menu bar debug settings as one payload.")
Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.")
.font(.caption)
.foregroundColor(.secondary)
}
@ -1388,6 +1480,18 @@ private struct DebugWindowControlsView: View {
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
private func resetBrowserDevToolsButton() {
browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
}
private func copyBrowserDevToolsButtonConfig() {
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private final class AboutWindowController: NSWindowController, NSWindowDelegate {

View file

@ -1,6 +1,7 @@
import XCTest
import AppKit
import WebKit
import ObjectiveC.runtime
#if canImport(cmux_DEV)
@testable import cmux_DEV
@ -8,6 +9,49 @@ import WebKit
@testable import cmux
#endif
private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
private var cmuxUnitTestInspectorOverrideInstalled = false
private extension CmuxWebView {
@objc func cmuxUnitTestInspector() -> NSObject? {
objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
}
}
private extension WKWebView {
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
objc_setAssociatedObject(
self,
&cmuxUnitTestInspectorAssociationKey,
inspector,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
private func installCmuxUnitTestInspectorOverride() {
guard !cmuxUnitTestInspectorOverrideInstalled else { return }
guard let replacementMethod = class_getInstanceMethod(
CmuxWebView.self,
#selector(CmuxWebView.cmuxUnitTestInspector)
) else {
fatalError("Unable to locate test inspector replacement method")
}
let added = class_addMethod(
CmuxWebView.self,
NSSelectorFromString("_inspector"),
method_getImplementation(replacementMethod),
method_getTypeEncoding(replacementMethod)
)
guard added else {
fatalError("Unable to install CmuxWebView _inspector test override")
}
cmuxUnitTestInspectorOverrideInstalled = true
}
final class CmuxWebViewKeyEquivalentTests: XCTestCase {
private final class ActionSpy: NSObject {
private(set) var invoked: Bool = false
@ -53,18 +97,6 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
XCTAssertTrue(spy.invoked)
}
func testCmdReturnBypassesMenuRoutingWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "\r", modifiers: [.command])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return
XCTAssertNotNil(event)
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
XCTAssertFalse(spy.invoked)
}
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
let mainMenu = NSMenu()
@ -100,6 +132,258 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
private func makeIsolatedDefaults() -> UserDefaults {
let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
fatalError("Failed to create defaults suite")
}
defaults.removePersistentDomain(forName: suiteName)
addTeardownBlock {
defaults.removePersistentDomain(forName: suiteName)
}
return defaults
}
func testIconCatalogIncludesExpandedChoices() {
XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
}
func testIconOptionFallsBackToDefaultForUnknownRawValue() {
let defaults = makeIsolatedDefaults()
defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
XCTAssertEqual(
BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
BrowserDevToolsButtonDebugSettings.defaultIcon
)
}
func testColorOptionFallsBackToDefaultForUnknownRawValue() {
let defaults = makeIsolatedDefaults()
defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
XCTAssertEqual(
BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
BrowserDevToolsButtonDebugSettings.defaultColor
)
}
func testCopyPayloadUsesPersistedValues() {
let defaults = makeIsolatedDefaults()
defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
}
}
final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
func testSafariDefaultShortcutForToggleDeveloperTools() {
let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
XCTAssertEqual(shortcut.key, "i")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.option)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.control)
}
func testSafariDefaultShortcutForShowJavaScriptConsole() {
let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
XCTAssertEqual(shortcut.key, "c")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.option)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.control)
}
}
@MainActor
final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
let panel = BrowserPanel(workspaceId: UUID())
let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
XCTAssertEqual(developerExtras, true)
if #available(macOS 13.3, *) {
XCTAssertTrue(panel.webView.isInspectable)
}
}
}
@MainActor
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class FakeInspector: NSObject {
private(set) var showCount = 0
private(set) var closeCount = 0
private var visible = false
@objc func isVisible() -> Bool {
visible
}
@objc func show() {
showCount += 1
visible = true
}
@objc func close() {
closeCount += 1
visible = false
}
}
override class func setUp() {
super.setUp()
installCmuxUnitTestInspectorOverride()
}
private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) {
let panel = BrowserPanel(workspaceId: UUID())
let inspector = FakeInspector()
panel.webView.cmuxSetUnitTestInspector(inspector)
return (panel, inspector)
}
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
// Simulate WebKit closing inspector during detach/reattach churn.
inspector.close()
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.closeCount, 1)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate user closing inspector before detach.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
}
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate a transient close caused by view detach, not user intent.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
XCTAssertEqual(inspector.closeCount, 0)
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.closeCount, 0)
XCTAssertEqual(inspector.showCount, 1)
// The force-refresh request should be one-shot.
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertEqual(inspector.closeCount, 0)
XCTAssertEqual(inspector.showCount, 1)
}
func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
}
func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
let (panel, _) = makePanelWithInspector()
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
XCTAssertTrue(panel.hideDeveloperTools())
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
}
func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
let representable = WebViewRepresentable(
panel: panel,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
host.addSubview(panel.webView)
WebViewRepresentable.dismantleNSView(host, coordinator: coordinator)
XCTAssertTrue(panel.webView.superview === host)
}
func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() {
let (panel, _) = makePanelWithInspector()
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
let representable = WebViewRepresentable(
panel: panel,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
host.addSubview(panel.webView)
WebViewRepresentable.dismantleNSView(host, coordinator: coordinator)
XCTAssertNil(panel.webView.superview)
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {
func testCommandNineMapsToLastWorkspaceIndex() {
XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0)
@ -204,20 +488,6 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase {
}
}
final class BrowserOmnibarReturnSubmitPolicyTests: XCTestCase {
func testReturnSubmitAllowsPlainAndShiftOnly() {
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
}
func testReturnSubmitRejectsCommandControlAndOption() {
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command]))
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.control]))
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.option]))
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .shift]))
}
}
final class SidebarCommandHintPolicyTests: XCTestCase {
func testCommandHintRequiresCommandOnlyModifier() {
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))
@ -251,74 +521,6 @@ final class ShortcutHintDebugSettingsTests: XCTestCase {
}
}
final class KeyboardShortcutSettingsTests: XCTestCase {
func testBrowserSplitShortcutDefaults() {
let keys = [
KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey,
KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey
]
let defaults = UserDefaults.standard
let previousValues = keys.map { key in (key, defaults.data(forKey: key)) }
defer {
for (key, value) in previousValues {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
keys.forEach { defaults.removeObject(forKey: $0) }
XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserRight).displayString, "⌥⌘D")
XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserDown).displayString, "⌥⇧⌘D")
}
@MainActor
func testWorkspaceConfiguresSplitButtonTooltipsWithEffectiveShortcuts() throws {
let keys = [
KeyboardShortcutSettings.Action.newSurface.defaultsKey,
KeyboardShortcutSettings.Action.openBrowser.defaultsKey,
KeyboardShortcutSettings.Action.splitRight.defaultsKey,
KeyboardShortcutSettings.Action.splitDown.defaultsKey
]
let defaults = UserDefaults.standard
let previousValues = keys.map { key in (key, defaults.data(forKey: key)) }
defer {
for (key, value) in previousValues {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
let customPairs: [(KeyboardShortcutSettings.Action, StoredShortcut)] = [
(.newSurface, StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)),
(.openBrowser, StoredShortcut(key: "2", command: true, shift: false, option: false, control: false)),
(.splitRight, StoredShortcut(key: "3", command: true, shift: false, option: false, control: false)),
(.splitDown, StoredShortcut(key: "4", command: true, shift: false, option: false, control: false)),
]
for (action, shortcut) in customPairs {
guard let data = try? JSONEncoder().encode(shortcut) else {
XCTFail("Failed to encode shortcut for \(action.rawValue)")
return
}
defaults.set(data, forKey: action.defaultsKey)
}
let workspace = Workspace(title: "Tooltip Test")
let tooltips = workspace.bonsplitController.configuration.appearance.splitButtonTooltips
XCTAssertEqual(tooltips.newTerminal, "New Terminal (⌘1)")
XCTAssertEqual(tooltips.newBrowser, "New Browser (⌘2)")
XCTAssertEqual(tooltips.splitRight, "Split Right (⌘3)")
XCTAssertEqual(tooltips.splitDown, "Split Down (⌘4)")
}
}
final class ShortcutHintLanePlannerTests: XCTestCase {
func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() {
let intervals: [ClosedRange<CGFloat>] = [0...20, 28...40, 48...64]
@ -485,62 +687,56 @@ final class AppearanceSettingsTests: XCTestCase {
XCTAssertEqual(resolved, .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
}
func testResolvedModeMigratesLegacyAndInvalidValuesToSystem() {
let suiteName = "AppearanceSettingsTests.Migrate.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(AppearanceMode.auto.rawValue, forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
defaults.set("invalid-value", forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
}
func testResolvedModePreservesExplicitLightAndDark() {
let suiteName = "AppearanceSettingsTests.Preserve.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(AppearanceMode.light.rawValue, forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .light)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.light.rawValue)
defaults.set(AppearanceMode.dark.rawValue, forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .dark)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.dark.rawValue)
}
}
final class UpdateFeedResolverTests: XCTestCase {
final class UpdateChannelSettingsTests: XCTestCase {
func testDefaultNightlyPreferenceIsDisabled() {
XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds)
}
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults)
XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL)
XCTAssertFalse(resolved.isNightly)
XCTAssertTrue(resolved.usedFallback)
}
func testResolvedFeedUsesInfoFeedForStableChannel() {
let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let infoFeed = "https://example.com/custom/appcast.xml"
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults)
XCTAssertEqual(resolved.url, infoFeed)
XCTAssertFalse(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
func testResolvedFeedDetectsNightlyChannelFromInfoFeed() {
let infoFeed = "https://example.com/nightly/appcast.xml"
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
XCTAssertEqual(resolved.url, infoFeed)
func testResolvedFeedUsesNightlyWhenPreferenceEnabled() {
let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey)
let resolved = UpdateChannelSettings.resolvedFeedURLString(
infoFeedURL: "https://example.com/custom/appcast.xml",
defaults: defaults
)
XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL)
XCTAssertTrue(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
@ -884,54 +1080,6 @@ final class SidebarDropPlannerTests: XCTestCase {
}
}
final class SidebarOutsideDropResetPolicyTests: XCTestCase {
func testOutsideDropResetsOnlyWhenDragIsActiveAndPayloadMatches() {
let tabId = UUID()
XCTAssertTrue(
SidebarOutsideDropResetPolicy.shouldResetDrag(
draggedTabId: tabId,
hasSidebarDragPayload: true
)
)
XCTAssertFalse(
SidebarOutsideDropResetPolicy.shouldResetDrag(
draggedTabId: nil,
hasSidebarDragPayload: true
)
)
XCTAssertFalse(
SidebarOutsideDropResetPolicy.shouldResetDrag(
draggedTabId: tabId,
hasSidebarDragPayload: false
)
)
}
}
final class SidebarDragFailsafePolicyTests: XCTestCase {
func testRequestsClearOnlyWhenDragIsActiveAndMouseIsUp() {
XCTAssertTrue(
SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: true,
isLeftMouseButtonDown: false
)
)
XCTAssertFalse(
SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: true,
isLeftMouseButtonDown: true
)
)
XCTAssertFalse(
SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: false,
isLeftMouseButtonDown: false
)
)
}
}
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
@ -1462,39 +1610,6 @@ final class OmnibarSuggestionRankingTests: XCTestCase {
XCTAssertEqual(results.first?.completion, "https://gmail.com/")
}
func testNavigateSuggestionRanksAheadOfSwitchToTabForSameResolvedURL() throws {
let targetURL = try XCTUnwrap(URL(string: "http://http.badssl.com/"))
let results = buildOmnibarSuggestions(
query: targetURL.absoluteString,
engineName: "Google",
historyEntries: [],
openTabMatches: [
.init(
tabId: UUID(),
panelId: UUID(),
url: targetURL.absoluteString,
title: "http.badssl.com",
isKnownOpenTab: true
),
],
remoteQueries: [],
resolvedURL: targetURL,
limit: 8,
now: fixedNow
)
guard let first = results.first else {
XCTFail("Expected at least one suggestion")
return
}
guard case .navigate(let navigateURL) = first.kind else {
XCTFail("Expected first suggestion to be navigate, got \(first.kind)")
return
}
XCTAssertEqual(navigateURL, targetURL.absoluteString)
}
func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
@ -2197,22 +2312,6 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
state = hostedView.debugInactiveOverlayState()
XCTAssertTrue(state.isHidden)
}
func testUnreadNotificationRingVisibilityTracksRequestedState() {
let hostedView = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
)
hostedView.setNotificationRing(visible: true)
var state = hostedView.debugNotificationRingState()
XCTAssertFalse(state.isHidden)
XCTAssertEqual(state.opacity, 1, accuracy: 0.001)
hostedView.setNotificationRing(visible: false)
state = hostedView.debugNotificationRingState()
XCTAssertTrue(state.isHidden)
XCTAssertEqual(state.opacity, 0, accuracy: 0.001)
}
}
@MainActor
@ -2408,6 +2507,222 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
}
}
@MainActor
final class BrowserWindowPortalLifecycleTests: XCTestCase {
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
window.contentView?.layoutSubtreeIfNeeded()
}
func testPortalHostInstallsAboveContentViewForVisibility() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let portal = WindowBrowserPortal(window: window)
_ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
guard let contentView = window.contentView,
let container = contentView.superview else {
XCTFail("Expected content container")
return
}
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
XCTFail("Expected host/content views in same container")
return
}
XCTAssertGreaterThan(
hostIndex,
contentIndex,
"Browser portal host must remain above content view so portal-hosted web views stay visible"
)
}
func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor1, visibleInUI: true)
let firstSuperview = webView.superview
XCTAssertNotNil(firstSuperview)
XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
portal.bind(webView: webView, to: anchor2, visibleInUI: true)
XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor2)
guard let slot = webView.superview as? WindowBrowserSlotView,
let host = slot.superview as? WindowBrowserHostView else {
XCTFail("Expected browser slot + host views")
return
}
let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
}
func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
// Simulate a transient oversized anchor rect during split churn.
let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected web view slot")
return
}
XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
}
func testPortalSyncNormalizesOutOfBoundsWebFrame() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}
// Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
portal.synchronizeWebViewForAnchor(anchor)
XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
}
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView,
let host = slot.superview as? WindowBrowserHostView else {
XCTFail("Expected portal slot + host views")
return
}
XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
}
func testRegistryDetachRemovesPortalHostedWebView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
XCTAssertNotNil(webView.superview)
BrowserWindowPortalRegistry.detach(webView: webView)
XCTAssertNil(webView.superview)
}
}
final class BrowserLinkOpenSettingsTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!

View file

@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad
## Workflow
1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`.
- Menu path in app: `Debug``Debug Windows` → window entry.
- The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`).
- Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu.
2. Keep these actions available in `Menu("Debug Windows")`:
- `Sidebar Debug…`
- `Background Debug…`

View file

@ -5,8 +5,8 @@ usage() {
cat <<'USAGE'
Usage: debug_windows_snapshot.sh [--domain <defaults-domain>] [--copy]
Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults
and print a combined payload. Use --copy to also copy the payload to clipboard.
Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values
from macOS defaults and print a combined payload. Use --copy to also copy the payload.
Examples:
debug_windows_snapshot.sh
@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle
menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)"
legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')"
if [[ -n "$legacySingleDigitX" ]]; then
menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)"
menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)"
else
menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)"
fi
menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)"
menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)"
browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')"
browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)"
payload="$(cat <<PAYLOAD
# Defaults domain
$domain
@ -166,6 +169,10 @@ menubarDebugMultiDigitYOffset=$menubarDebugMultiDigitYOffset
menubarDebugSingleDigitXAdjust=$menubarDebugSingleDigitXAdjust
menubarDebugMultiDigitXAdjust=$menubarDebugMultiDigitXAdjust
menubarDebugTextRectWidthAdjust=$menubarDebugTextRectWidthAdjust
# Browser DevTools Button
browserDevToolsIconName=$browserDevToolsIconName
browserDevToolsIconColor=$browserDevToolsIconColor
PAYLOAD
)"