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:
commit
9649a90163
13 changed files with 2712 additions and 293 deletions
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
867
Sources/BrowserWindowPortal.swift
Normal file
867
Sources/BrowserWindowPortal.swift
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ struct PanelContentView: View {
|
|||
panel: browserPanel,
|
||||
isFocused: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: portalPriority,
|
||||
onRequestPanelFocus: onRequestPanelFocus
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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…`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue