diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 40007065..10e0b8b0 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1672,12 +1672,15 @@ final class WindowBrowserSlotView: NSView { func pinHostedWebView(_ webView: WKWebView) { guard webView.superview === self else { return } + let needsPlainWebViewFrameReset = + !Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) && + Self.frameDiffersFromBounds(webView.frame, bounds: bounds) let needsFrameHosting = hostedWebView !== webView || !hostedWebViewConstraints.isEmpty || + needsPlainWebViewFrameReset || !webView.translatesAutoresizingMaskIntoConstraints || - webView.autoresizingMask != [.width, .height] || - !Self.rectApproximatelyEqual(webView.frame, bounds) + webView.autoresizingMask != [.width, .height] guard needsFrameHosting else { needsLayout = true layoutSubtreeIfNeeded() @@ -1688,7 +1691,8 @@ final class WindowBrowserSlotView: NSView { hostedWebViewConstraints = [] hostedWebView = webView // Attached Web Inspector mutates the moved WKWebView's frame directly. - // Edge constraints fight side-docked resizing and cause visible churn. + // Re-pin plain web views after cross-host reattach, but preserve the + // WebKit-managed split frame when docked DevTools siblings are present. webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] webView.frame = bounds @@ -1696,6 +1700,27 @@ final class WindowBrowserSlotView: NSView { layoutSubtreeIfNeeded() } + private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(frame.minX - bounds.minX) > epsilon || + abs(frame.minY - bounds.minY) > epsilon || + abs(frame.width - bounds.width) > epsilon || + abs(frame.height - bounds.height) > epsilon + } + + private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool { + var stack = host.subviews.filter { $0 !== primaryWebView } + while let current = stack.popLast() { + if current.isDescendant(of: primaryWebView) { + continue + } + if String(describing: type(of: current)).contains("WK") { + return true + } + stack.append(contentsOf: current.subviews) + } + return false + } + func effectivePaneTopChromeHeight() -> CGFloat { paneTopChromeHeight } @@ -1975,6 +2000,7 @@ final class WindowBrowserPortal: NSObject { guard let webView = entry.webView, let containerView = entry.containerView, !containerView.isHidden else { continue } + guard webView.superview === containerView else { continue } refreshHostedWebViewPresentation( webView, in: containerView, @@ -2650,7 +2676,7 @@ final class WindowBrowserPortal: NSObject { containerView.setPaneTopChromeHeight(0) containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) - if !containerView.isHidden { + if !containerView.isHidden, webView.superview === containerView { webView.browserPortalNotifyHidden(reason: reason) } containerView.isHidden = true @@ -2752,7 +2778,18 @@ final class WindowBrowserPortal: NSObject { hostView.addSubview(containerView, positioned: .above, relativeTo: nil) refreshReasons.append("syncAttachContainer") } - if webView.superview !== containerView { + let shouldPreserveExternalHostForHiddenEntry = + !entry.visibleInUI && + webView.superview !== containerView + if shouldPreserveExternalHostForHiddenEntry { +#if DEBUG + dlog( + "browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " + + "reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + } else if webView.superview !== containerView { #if DEBUG dlog( "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + @@ -2943,15 +2980,16 @@ final class WindowBrowserPortal: NSObject { refreshReasons.append("bounds") } + let containerOwnsWebView = webView.superview === containerView let containerBounds = containerView.bounds - let preNormalizeWebFrame = webView.frame + let preNormalizeWebFrame = containerOwnsWebView ? webView.frame : .zero 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) { + if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { let oldWebFrame = preNormalizeWebFrame CATransaction.begin() CATransaction.setDisableActions(true) @@ -3010,14 +3048,16 @@ final class WindowBrowserPortal: NSObject { if transientRecoveryReason == nil { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } - if !shouldHide, !refreshReasons.isEmpty { + if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty { refreshHostedWebViewPresentation( webView, in: containerView, reason: "\(source):" + refreshReasons.joined(separator: ",") ) } - hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") + if containerOwnsWebView { + hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") + } #if DEBUG dlog( "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + @@ -3027,6 +3067,7 @@ final class WindowBrowserPortal: NSObject { "old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " + "target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + "entryVisible=\(entry.visibleInUI ? 1 : 0) " + + "containerOwnsWeb=\(containerOwnsWebView ? 1 : 0) " + "containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " + "containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " + "preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " + diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c9130d59..1b962264 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3786,8 +3786,7 @@ struct WebViewRepresentable: NSViewRepresentable { hostedWebView !== webView || !hostedWebViewConstraints.isEmpty || !webView.translatesAutoresizingMaskIntoConstraints || - webView.autoresizingMask != [.width, .height] || - webView.frame != container.bounds + webView.autoresizingMask != [.width, .height] guard needsFrameHosting else { needsLayout = true layoutSubtreeIfNeeded() @@ -3799,8 +3798,8 @@ struct WebViewRepresentable: NSViewRepresentable { hostedWebView = webView // WebKit's attached inspector does not reliably dock into a constraint-managed - // WKWebView hierarchy on macOS. Host the moved webview with autoresizing so - // the inspector can resize the content view in place. + // WKWebView hierarchy on macOS. Host the moved webview with autoresizing and + // keep WebKit-owned page frames intact when DevTools is side-docked. webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] webView.frame = container.bounds @@ -4487,6 +4486,34 @@ struct WebViewRepresentable: NSViewRepresentable { ) } + let shouldPreserveExistingExternalLocalHost = + host.window == nil && + webView.superview != nil && + webView.superview !== slotView + if shouldPreserveExistingExternalLocalHost { + // Split zoom can instantiate a replacement local host before it joins a window. + // Never let that off-window host steal the live page + inspector hierarchy away + // from the currently visible local host. + host.setLocalInlineSlotHidden(true) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 +#if DEBUG + dlog( + "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + + "reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " + + "host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))" + ) + Self.logDevToolsState( + panel, + event: "localHost.skip", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) +#endif + return false + } + if webView.superview !== slotView { if let sourceSuperview = webView.superview { Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 786368c2..4d4eda6f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2481,6 +2481,18 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { return (panel, inspector) } + private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? { + if let host = root as? WebViewRepresentable.HostContainerView { + return host + } + for subview in root.subviews { + if let host = findHostContainerView(in: subview) { + return host + } + } + return nil + } + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { let (panel, inspector) = makePanelWithInspector() @@ -2691,6 +2703,89 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } + + func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let paneId = PaneID(id: UUID()) + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let visibleHosting = NSHostingView(rootView: representable) + visibleHosting.frame = contentView.bounds + visibleHosting.autoresizingMask = [.width, .height] + contentView.addSubview(visibleHosting) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + visibleHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let visibleHost = findHostContainerView(in: visibleHosting) else { + XCTFail("Expected visible local host") + return + } + guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected visible local inline slot") + return + } + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + visibleSlot.addSubview(inspectorView) + panel.webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: visibleSlot.bounds.width, + height: visibleSlot.bounds.height - inspectorView.frame.height + ) + visibleSlot.layoutSubtreeIfNeeded() + + let detachedRoot = NSView(frame: visibleHosting.frame) + let offWindowHosting = NSHostingView(rootView: representable) + offWindowHosting.frame = detachedRoot.bounds + offWindowHosting.autoresizingMask = [.width, .height] + detachedRoot.addSubview(offWindowHosting) + detachedRoot.layoutSubtreeIfNeeded() + offWindowHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host") + XCTAssertTrue(visibleHost.window === window) + XCTAssertTrue( + panel.webView.superview === visibleSlot, + "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn" + ) + XCTAssertTrue( + inspectorView.superview === visibleSlot, + "An off-window replacement host should leave DevTools companion views in the visible local host" + ) + } } final class WorkspaceShortcutMapperTests: XCTestCase { @@ -9370,6 +9465,33 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { XCTAssertEqual(webView.autoresizingMask, [.width, .height]) XCTAssertEqual(webView.frame, slot.bounds) } + + func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180)) + let webView = WKWebView(frame: .zero) + slot.addSubview(webView) + slot.pinHostedWebView(webView) + XCTAssertEqual(webView.frame, slot.bounds) + + let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180)) + webView.removeFromSuperview() + externalHost.addSubview(webView) + webView.frame = externalHost.bounds + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + + slot.addSubview(webView) + slot.pinHostedWebView(webView) + + slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180) + slot.layoutSubtreeIfNeeded() + + XCTAssertEqual( + webView.frame, + slot.bounds, + "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host" + ) + } } @MainActor @@ -11010,6 +11132,8 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { } } + private final class WKInspectorProbeView: NSView {} + private func realizeWindowLayout(_ window: NSWindow) { window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -11274,6 +11398,145 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) } + func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, 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: 260, height: 180)) + 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 + } + + let initialInspectorWidth: CGFloat = 110 + let inspectorContainer = NSView( + frame: NSRect( + x: slot.bounds.width - initialInspectorWidth, + y: 0, + width: initialInspectorWidth, + height: slot.bounds.height + ) + ) + inspectorContainer.autoresizingMask = [.minXMargin, .height] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + + webView.frame = NSRect( + x: 0, + y: 0, + width: slot.bounds.width - initialInspectorWidth, + height: slot.bounds.height + ) + webView.autoresizingMask = [.width, .height] + slot.layoutSubtreeIfNeeded() + + anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible") + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "Side-docked inspector should still own part of the slot after pane resize" + ) + } + + func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, 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: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden") + + let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame) + contentView.addSubview(localInlineSlot) + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + localInlineSlot.addSubview(inspectorView) + + localInlineSlot.addSubview(webView) + webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: localInlineSlot.bounds.width, + height: localInlineSlot.bounds.height - inspectorView.frame.height + ) + localInlineSlot.layoutSubtreeIfNeeded() + + anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) + localInlineSlot.frame = anchor.frame + contentView.layoutSubtreeIfNeeded() + localInlineSlot.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertTrue( + webView.superview === localInlineSlot, + "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize" + ) + XCTAssertTrue( + inspectorView.superview === localInlineSlot, + "Hidden portal sync should leave local DevTools companion views in the local inline host" + ) + XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting") + } + func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),