diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d69a5be5..4777e4cc 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 */; }; @@ -135,6 +136,7 @@ A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; + A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; @@ -309,6 +311,7 @@ A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, A5001531 /* TerminalWindowPortal.swift */, + A5001533 /* BrowserWindowPortal.swift */, A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, @@ -535,6 +538,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 */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f4f5a63e..cc5e863b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2068,7 +2068,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let beforeResponder = keyWindow.firstResponder let movedToWebView = keyWindow.makeFirstResponder(browser.webView) let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) - browser.requestDeveloperToolsRefreshAfterNextAttach(reason: "split.\(directionLabel).inspectorFirstResponder") #if DEBUG let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift new file mode 100644 index 00000000..726a90d7 --- /dev/null +++ b/Sources/BrowserWindowPortal.swift @@ -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.. 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 { + 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) { + 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 +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9cd75c87..628931d6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1244,6 +1244,10 @@ final class BrowserPanel: Panel, ObservableObject { deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil + let webView = webView + Task { @MainActor in + BrowserWindowPortalRegistry.detach(webView: webView) + } webViewObservers.removeAll() } } @@ -1398,17 +1402,13 @@ extension BrowserPanel { let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach forceDeveloperToolsRefreshOnNextAttach = false - let closeSelector = NSSelectorFromString("close") - if shouldForceRefresh, - inspector.responds(to: closeSelector) { - #if DEBUG - dlog("browser.devtools refresh.forceClose panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") - #endif - inspector.cmuxCallVoid(selector: closeSelector) - } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible && !shouldForceRefresh { + if visible { + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif cancelDeveloperToolsRestoreRetry() return } @@ -1420,7 +1420,7 @@ extension BrowserPanel { } #if DEBUG if shouldForceRefresh { - dlog("browser.devtools refresh.forceShow panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif inspector.cmuxCallVoid(selector: selector) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 50fc3ab3..1d849576 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -127,6 +127,7 @@ 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 @@ -496,7 +497,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). @@ -2552,6 +2554,7 @@ struct WebViewRepresentable: NSViewRepresentable { let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool + let portalZPriority: Int final class Coordinator { weak var panel: BrowserPanel? @@ -2559,6 +2562,41 @@ struct WebViewRepresentable: NSViewRepresentable { 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 @@ -2586,11 +2624,15 @@ struct WebViewRepresentable: NSViewRepresentable { 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) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) fr=\(responderDescription(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 @@ -2644,11 +2686,85 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeNSView(context: Context) -> NSView { - let container = NSView() + let container = HostContainerView() container.wantsLayer = true return container } + 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. @@ -2770,6 +2886,28 @@ struct WebViewRepresentable: NSViewRepresentable { 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. @@ -2938,20 +3076,18 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 - if panel.hasPendingDeveloperToolsRefreshAfterAttach() { - #if DEBUG + let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach() + panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + if hadPendingRefresh { Self.logDevToolsState( panel, - event: "attach.alreadyAttached.deferPendingRefresh", + event: "attach.alreadyAttached.consumePendingRefresh", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) - #endif - } else { - panel.restoreDeveloperToolsAfterAttachIfNeeded() } - #if DEBUG Self.logDevToolsState( panel, event: "attach.alreadyAttached", @@ -2962,23 +3098,37 @@ struct WebViewRepresentable: NSViewRepresentable { #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) } } @@ -2987,10 +3137,34 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryWorkItem = nil coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 + 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 diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 9049e166..f4c16fbc 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -37,6 +37,7 @@ struct PanelContentView: View { panel: browserPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, onRequestPanelFocus: onRequestPanelFocus ) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index f16b434a..af8ae749 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -297,7 +297,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } - func testForcedRefreshAfterAttachReopensVisibleInspectorOnce() { + func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { let (panel, inspector) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) @@ -309,13 +309,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel.restoreDeveloperToolsAfterAttachIfNeeded() XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertEqual(inspector.showCount, 2) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) // The force-refresh request should be one-shot. panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertEqual(inspector.showCount, 2) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) } func testRefreshRequestTracksPendingStateUntilRestoreRuns() { @@ -349,7 +349,8 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel: panel, shouldAttachWebView: true, shouldFocusWebView: false, - isPanelFocused: true + isPanelFocused: true, + portalZPriority: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -369,7 +370,8 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel: panel, shouldAttachWebView: true, shouldFocusWebView: false, - isPanelFocused: true + isPanelFocused: true, + portalZPriority: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2450,3 +2452,219 @@ 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) + } +}