diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7fb33e30a9..4242672d58 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15540,8 +15540,7 @@ private extension AppDelegate { target: target, sender: sender, allowFallback: Self.allowsWindowFallback(for: action) - ), - BrowserPanel.isDetachedInspectorWindow(window) else { return false } + ) else { return false } for panel in allBrowserPanelsForInspectorWindowClose() { if panel.closeDeveloperToolsFromDetachedInspectorWindowUserAction( diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 02e0bdb931..f725ec45d1 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3384,6 +3384,35 @@ final class BrowserPanel: Panel, ObservableObject { case attached case detached } + private enum DeveloperToolsLifecyclePhase: String { + case hidden + case opening + case visible + case closing + case restoring + + var preservesVisibleIntent: Bool { + switch self { + case .opening, .visible, .restoring: + return true + case .hidden, .closing: + return false + } + } + + var allowsHostHiddenManualClose: Bool { + self == .visible + } + + var isPendingVisibleIntent: Bool { + switch self { + case .opening, .restoring: + return true + case .hidden, .visible, .closing: + return false + } + } + } private var activePortalHostLease: PortalHostLease? private var pendingDistinctPortalHostReplacementPaneId: UUID? private var lockedPortalHost: PortalHostLock? @@ -3445,6 +3474,7 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsVisibilityLossCheckWorkItem: DispatchWorkItem? private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 private let developerToolsAttachedManualCloseDetectionDelay: TimeInterval = 0.35 + private var developerToolsLifecyclePhase: DeveloperToolsLifecyclePhase = .hidden private var developerToolsLastAttachedHostAt: Date? private var developerToolsLastKnownVisibleAt: Date? private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol? @@ -5837,7 +5867,7 @@ extension BrowserPanel { setPreferredDeveloperToolsVisible(false) preferredDeveloperToolsPresentation = .unknown forceDeveloperToolsRefreshOnNextAttach = false - developerToolsDetachedOpenGraceDeadline = nil + markDeveloperToolsLifecycleHidden() developerToolsRestoreRetryAttempt = 0 preferredAttachedDeveloperToolsWidth = nil preferredAttachedDeveloperToolsWidthFraction = nil @@ -6141,29 +6171,13 @@ extension BrowserPanel { isMainFrameProvisionalNavigationActive = false } - private static func windowContainsInspectorViews(_ root: NSView) -> Bool { - if cmuxIsWebInspectorObject(root) { - return true - } - for subview in root.subviews where windowContainsInspectorViews(subview) { - return true - } - return false - } - - static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool { - guard window.title.hasPrefix("Web Inspector") else { return false } - guard let contentView = window.contentView else { return false } - return windowContainsInspectorViews(contentView) + private func isDetachedDeveloperToolsWindow(_ window: NSWindow) -> Bool { + detachedDeveloperToolsWindowBelongsToPanel(window) } private func detachedDeveloperToolsWindows() -> [NSWindow] { - let mainWindow = webView.window return NSApp.windows.filter { candidate in - if let mainWindow, candidate === mainWindow { - return false - } - return Self.isDetachedInspectorWindow(candidate) + isDetachedDeveloperToolsWindow(candidate) } } @@ -6186,6 +6200,30 @@ extension BrowserPanel { preferredDeveloperToolsVisible = next } + private func markDeveloperToolsLifecycleVisible() { + developerToolsLifecyclePhase = .visible + developerToolsDetachedOpenGraceDeadline = nil + developerToolsLastKnownVisibleAt = Date() + } + + private func markDeveloperToolsLifecycleHidden() { + developerToolsLifecyclePhase = .hidden + developerToolsDetachedOpenGraceDeadline = nil + developerToolsLastKnownVisibleAt = nil + } + + private func markDeveloperToolsLifecyclePendingVisible() { + guard preferredDeveloperToolsVisible else { return } + switch developerToolsLifecyclePhase { + case .visible: + developerToolsLifecyclePhase = .restoring + case .hidden, .closing: + developerToolsLifecyclePhase = .opening + case .opening, .restoring: + break + } + } + private func reevaluateHiddenWebViewDiscardAfterDeveloperToolsHidden() { guard !preferredDeveloperToolsVisible, !isDeveloperToolsVisible() else { return } reevaluateHiddenWebViewDiscardScheduling(reason: "developer_tools_visibility_changed") @@ -6211,7 +6249,7 @@ extension BrowserPanel { let window = notification.object as? NSWindow else { return } guard Thread.isMainThread else { return } let handledDetachedInspector = MainActor.assumeIsolated { - guard Self.isDetachedInspectorWindow(window) else { return false } + guard self.isDetachedDeveloperToolsWindow(window) else { return false } return self.closeDeveloperToolsFromDetachedInspectorWindowWillClose(window) } guard handledDetachedInspector else { return } @@ -6220,8 +6258,8 @@ extension BrowserPanel { guard self.preferredDeveloperToolsPresentation == .detached else { return } guard self.preferredDeveloperToolsVisible else { return } guard !self.isDeveloperToolsVisible() else { return } - self.developerToolsDetachedOpenGraceDeadline = nil self.setPreferredDeveloperToolsVisible(false) + self.markDeveloperToolsLifecycleHidden() self.reevaluateHiddenWebViewDiscardAfterDeveloperToolsHidden() self.cancelDeveloperToolsRestoreRetry() #if DEBUG @@ -6264,6 +6302,9 @@ extension BrowserPanel { } private func detachedDeveloperToolsWindowBelongsToPanel(_ window: NSWindow) -> Bool { + if let mainWindow = webView.window, window === mainWindow { + return false + } guard let frontendWebView = webView.cmuxInspectorFrontendWebView(), let contentView = window.contentView else { return false @@ -6277,9 +6318,8 @@ extension BrowserPanel { private func dismissDetachedDeveloperToolsWindowsIfNeeded() { guard shouldDismissDetachedDeveloperToolsWindows() else { return } - guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(), - let mainWindow = webView.window else { return } - for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) { + guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return } + for window in detachedDeveloperToolsWindows() { #if DEBUG cmuxDebugLog( "browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " + @@ -6314,8 +6354,7 @@ extension BrowserPanel { private func revealDeveloperTools(_ inspector: NSObject) -> Bool { let isVisibleSelector = NSSelectorFromString("isVisible") if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false { - developerToolsDetachedOpenGraceDeadline = nil - developerToolsLastKnownVisibleAt = Date() + markDeveloperToolsLifecycleVisible() return true } @@ -6326,7 +6365,7 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: showSelector) let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false if visibleAfterShow { - developerToolsLastKnownVisibleAt = Date() + markDeveloperToolsLifecycleVisible() } if preferredDeveloperToolsPresentation == .detached { developerToolsDetachedOpenGraceDeadline = visibleAfterShow @@ -6369,6 +6408,9 @@ extension BrowserPanel { if let developerToolsTransitionTargetVisible { return developerToolsTransitionTargetVisible } + if preferredDeveloperToolsVisible || developerToolsLifecyclePhase.preservesVisibleIntent { + return true + } return isDeveloperToolsVisible() } @@ -6387,8 +6429,24 @@ extension BrowserPanel { pendingDeveloperToolsTransitionTargetVisible = nil developerToolsTransitionTargetVisible = nil - guard let pendingTargetVisible else { return } - guard pendingTargetVisible != isDeveloperToolsVisible() else { return } + guard let pendingTargetVisible else { + if isDeveloperToolsVisible() { + markDeveloperToolsLifecycleVisible() + } else if preferredDeveloperToolsVisible { + markDeveloperToolsLifecyclePendingVisible() + } else { + markDeveloperToolsLifecycleHidden() + } + return + } + guard pendingTargetVisible != isDeveloperToolsVisible() else { + if pendingTargetVisible { + markDeveloperToolsLifecycleVisible() + } else { + markDeveloperToolsLifecycleHidden() + } + return + } _ = performDeveloperToolsVisibilityTransition(to: pendingTargetVisible, source: "\(source).queued") } @@ -6401,9 +6459,12 @@ extension BrowserPanel { pendingDeveloperToolsTransitionTargetVisible = targetVisible setPreferredDeveloperToolsVisible(targetVisible) if !targetVisible { + developerToolsLifecyclePhase = .closing developerToolsDetachedOpenGraceDeadline = nil forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() + } else { + developerToolsLifecyclePhase = .opening } #if DEBUG cmuxDebugLog( @@ -6427,6 +6488,7 @@ extension BrowserPanel { let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false setPreferredDeveloperToolsVisible(targetVisible) + developerToolsLifecyclePhase = targetVisible ? .opening : .closing developerToolsTransitionTargetVisible = targetVisible if targetVisible { reevaluateHiddenWebViewDiscardScheduling(reason: "developer_tools_visibility_changed") @@ -6443,6 +6505,7 @@ extension BrowserPanel { syncDeveloperToolsPresentationPreferenceFromUI() guard concealDeveloperTools(inspector) else { developerToolsTransitionTargetVisible = nil + markDeveloperToolsLifecycleVisible() return false } } @@ -6452,14 +6515,17 @@ extension BrowserPanel { if targetVisible { let visibleAfterTransition = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false if visibleAfterTransition { + markDeveloperToolsLifecycleVisible() syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() } else { + markDeveloperToolsLifecyclePendingVisible() developerToolsRestoreRetryAttempt = 0 scheduleDeveloperToolsRestoreRetry() } } else { + markDeveloperToolsLifecycleHidden() cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false reevaluateHiddenWebViewDiscardAfterDeveloperToolsHidden() @@ -6532,12 +6598,11 @@ extension BrowserPanel { developerToolsTransitionSettleWorkItem = nil pendingDeveloperToolsTransitionTargetVisible = nil developerToolsTransitionTargetVisible = nil - developerToolsDetachedOpenGraceDeadline = nil - developerToolsLastKnownVisibleAt = nil forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() let closed = WebViewInspectorTeardown.closeInspector(for: webView) + markDeveloperToolsLifecycleHidden() setPreferredDeveloperToolsVisible(false) return closed } @@ -6550,38 +6615,109 @@ extension BrowserPanel { let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible setPreferredDeveloperToolsVisible(targetVisible) if targetVisible, visible { - developerToolsDetachedOpenGraceDeadline = nil + markDeveloperToolsLifecycleVisible() syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() } else if !targetVisible { - developerToolsDetachedOpenGraceDeadline = nil + markDeveloperToolsLifecycleHidden() forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() + } else { + markDeveloperToolsLifecyclePendingVisible() } return } if visible { - developerToolsDetachedOpenGraceDeadline = nil + markDeveloperToolsLifecycleVisible() syncDeveloperToolsPresentationPreferenceFromUI() setPreferredDeveloperToolsVisible(true) - developerToolsLastKnownVisibleAt = Date() cancelDeveloperToolsRestoreRetry() return } + if preferredDeveloperToolsVisible && developerToolsLifecyclePhase.isPendingVisibleIntent { + markDeveloperToolsLifecyclePendingVisible() + return + } if preserveVisibleIntent && preferredDeveloperToolsVisible { + markDeveloperToolsLifecyclePendingVisible() return } setPreferredDeveloperToolsVisible(false) - developerToolsLastKnownVisibleAt = nil + markDeveloperToolsLifecycleHidden() + reevaluateHiddenWebViewDiscardAfterDeveloperToolsHidden() + cancelDeveloperToolsRestoreRetry() + } + + private func recordDeveloperToolsManualCloseDuringStableHostUpdate() { + developerToolsTransitionSettleWorkItem?.cancel() + developerToolsTransitionSettleWorkItem = nil + developerToolsTransitionTargetVisible = nil + pendingDeveloperToolsTransitionTargetVisible = nil + setPreferredDeveloperToolsVisible(false) + markDeveloperToolsLifecycleHidden() + forceDeveloperToolsRefreshOnNextAttach = false reevaluateHiddenWebViewDiscardAfterDeveloperToolsHidden() cancelDeveloperToolsRestoreRetry() +#if DEBUG + cmuxDebugLog( + "browser.devtools stableHost.manualClose panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif } func noteDeveloperToolsHostAttached() { cancelPendingDeveloperToolsVisibilityLossCheck() developerToolsLastAttachedHostAt = Date() if isDeveloperToolsVisible() { - developerToolsLastKnownVisibleAt = Date() + markDeveloperToolsLifecycleVisible() + } else if preferredDeveloperToolsVisible && developerToolsLifecyclePhase.isPendingVisibleIntent { + markDeveloperToolsLifecyclePendingVisible() + } + } + + func reconcileDeveloperToolsAfterHostUpdate( + wasVisibleBeforeHostUpdate: Bool, + didAttachHost: Bool, + didChangeHostVisibility: Bool, + preserveVisibleIntentWhileDetached: Bool = false + ) { + guard webView.superview != nil, webView.window != nil else { + syncDeveloperToolsPreferenceFromInspector( + preserveVisibleIntent: preserveVisibleIntentWhileDetached || + shouldPreserveDeveloperToolsIntentWhileDetached() + ) + return + } + + noteDeveloperToolsHostAttached() + + let hasPendingRestore = + forceDeveloperToolsRefreshOnNextAttach || + developerToolsRestoreRetryWorkItem != nil || + (preferredDeveloperToolsVisible && developerToolsLifecyclePhase.isPendingVisibleIntent && !isDeveloperToolsVisible()) + let shouldRestore = + hasPendingRestore || + (wasVisibleBeforeHostUpdate && (didAttachHost || didChangeHostVisibility)) + + if wasVisibleBeforeHostUpdate, + !isDeveloperToolsVisible(), + !didAttachHost, + !didChangeHostVisibility, + developerToolsLifecyclePhase.allowsHostHiddenManualClose, + !isDeveloperToolsTransitionInFlight, + !hasPendingRestore { + recordDeveloperToolsManualCloseDuringStableHostUpdate() + return + } + + if shouldRestore { + restoreDeveloperToolsAfterAttachIfNeeded() + } else { + syncDeveloperToolsPreferenceFromInspector( + preserveVisibleIntent: preserveVisibleIntentWhileDetached || + developerToolsLifecyclePhase.isPendingVisibleIntent + ) } } @@ -6614,6 +6750,7 @@ extension BrowserPanel { guard preferredDeveloperToolsVisible else { return false } guard preferredDeveloperToolsPresentation != .detached else { return false } guard !isDeveloperToolsTransitionInFlight else { return false } + guard developerToolsLifecyclePhase.allowsHostHiddenManualClose else { return false } guard webView.superview != nil, webView.window != nil else { return false } guard let developerToolsLastAttachedHostAt else { return false } guard Date().timeIntervalSince(developerToolsLastAttachedHostAt) >= developerToolsAttachedManualCloseDetectionDelay else { @@ -6623,13 +6760,12 @@ extension BrowserPanel { guard let inspector = inspector ?? webView.cmuxInspectorObject() else { return false } guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return false } guard !visible else { - developerToolsLastKnownVisibleAt = Date() + markDeveloperToolsLifecycleVisible() return false } setPreferredDeveloperToolsVisible(false) - developerToolsDetachedOpenGraceDeadline = nil - developerToolsLastKnownVisibleAt = nil + markDeveloperToolsLifecycleHidden() forceDeveloperToolsRefreshOnNextAttach = false reevaluateHiddenWebViewDiscardAfterDeveloperToolsHidden() cancelDeveloperToolsRestoreRetry() @@ -6647,9 +6783,23 @@ extension BrowserPanel { guard preferredDeveloperToolsVisible else { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false + if !isDeveloperToolsVisible() { + markDeveloperToolsLifecycleHidden() + } + return + } + markDeveloperToolsLifecyclePendingVisible() + let transitionTargetVisible = + pendingDeveloperToolsTransitionTargetVisible ?? + developerToolsTransitionTargetVisible ?? + preferredDeveloperToolsVisible + let canForceRefreshDuringVisibleTransition = + forceDeveloperToolsRefreshOnNextAttach && + isDeveloperToolsTransitionInFlight && + transitionTargetVisible + guard !isDeveloperToolsTransitionInFlight || canForceRefreshDuringVisibleTransition else { return } - guard !isDeveloperToolsTransitionInFlight else { return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return @@ -6660,9 +6810,8 @@ extension BrowserPanel { let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visible { - developerToolsDetachedOpenGraceDeadline = nil + markDeveloperToolsLifecycleVisible() syncDeveloperToolsPresentationPreferenceFromUI() - developerToolsLastKnownVisibleAt = Date() #if DEBUG if shouldForceRefresh { cmuxDebugLog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") @@ -6675,7 +6824,7 @@ extension BrowserPanel { let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling { setPreferredDeveloperToolsVisible(false) - developerToolsDetachedOpenGraceDeadline = nil + markDeveloperToolsLifecycleHidden() cancelDeveloperToolsRestoreRetry() #if DEBUG cmuxDebugLog( @@ -6704,11 +6853,12 @@ extension BrowserPanel { setPreferredDeveloperToolsVisible(true) let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { + markDeveloperToolsLifecycleVisible() syncDeveloperToolsPresentationPreferenceFromUI() - developerToolsLastKnownVisibleAt = Date() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() } else { + markDeveloperToolsLifecyclePendingVisible() scheduleDeveloperToolsRestoreRetry() } } @@ -6734,6 +6884,9 @@ extension BrowserPanel { func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { guard preferredDeveloperToolsVisible else { return } forceDeveloperToolsRefreshOnNextAttach = true + if !isDeveloperToolsVisible() { + markDeveloperToolsLifecyclePendingVisible() + } #if DEBUG cmuxDebugLog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") #endif @@ -6754,7 +6907,9 @@ extension BrowserPanel { } func shouldUseLocalInlineDeveloperToolsHosting() -> Bool { - guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return false } + guard preferredDeveloperToolsVisible || + developerToolsLifecyclePhase.preservesVisibleIntent || + isDeveloperToolsVisible() else { return false } if preferredDeveloperToolsPresentation == .detached { return false } @@ -7494,6 +7649,9 @@ private extension BrowserPanel { guard preferredDeveloperToolsVisible else { return } guard developerToolsRestoreRetryWorkItem == nil else { return } guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return } + if !isDeveloperToolsVisible() { + markDeveloperToolsLifecyclePendingVisible() + } developerToolsRestoreRetryAttempt += 1 let work = DispatchWorkItem { [weak self] in @@ -7532,6 +7690,10 @@ extension BrowserPanel { } } + func debugDismissDetachedDeveloperToolsWindowsForTesting() { + dismissDetachedDeveloperToolsWindowsIfNeeded() + } + func presentInsecureHTTPAlertForTesting( url: URL, recordTypedNavigation: Bool = false @@ -7581,7 +7743,7 @@ extension BrowserPanel { let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 let transitionTarget = developerToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" let pendingTarget = pendingDeveloperToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)" + return "pref=\(preferred) vis=\(visible) phase=\(developerToolsLifecyclePhase.rawValue) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)" } func debugDeveloperToolsGeometrySummary() -> String { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 36b74356e5..5cdad516ed 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -6804,6 +6804,7 @@ struct WebViewRepresentable: NSViewRepresentable { guard let host = nsView as? HostContainerView else { return false } let slotView = host.ensureLocalInlineSlotView() let isAlreadyInLocalHost = host.containsManagedLocalInlineContent(webView) + let wasDeveloperToolsVisibleBeforeHostUpdate = panel.isDeveloperToolsVisible() let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost( for: webView, relativeTo: host.window @@ -6913,9 +6914,11 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.lastPortalHostId = nil coordinator.lastSynchronizedHostGeometryRevision = 0 if host.window != nil && !shouldPreserveExternalFullscreenHost { - let wasDeveloperToolsVisible = panel.isDeveloperToolsVisible() - panel.noteDeveloperToolsHostAttached() - panel.restoreDeveloperToolsAfterAttachIfNeeded() + panel.reconcileDeveloperToolsAfterHostUpdate( + wasVisibleBeforeHostUpdate: wasDeveloperToolsVisibleBeforeHostUpdate, + didAttachHost: didAttachWebViewToLocalHost, + didChangeHostVisibility: false + ) if let sourceSuperview = Self.localInlineTransferRoot(for: webView), didAttachWebViewToLocalHost || sourceSuperview === slotView { Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( @@ -6929,7 +6932,7 @@ struct WebViewRepresentable: NSViewRepresentable { } host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView()) let didRevealDeveloperToolsAfterAttach = - !wasDeveloperToolsVisible && panel.isDeveloperToolsVisible() + !wasDeveloperToolsVisibleBeforeHostUpdate && panel.isDeveloperToolsVisible() webView.needsLayout = true webView.layoutSubtreeIfNeeded() slotView.layoutSubtreeIfNeeded() @@ -6993,6 +6996,7 @@ struct WebViewRepresentable: NSViewRepresentable { private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { guard let host = nsView as? HostContainerView else { return false } + let wasDeveloperToolsVisibleBeforeHostUpdate = panel.isDeveloperToolsVisible() if panel.shouldUseLocalInlineDeveloperToolsHosting() { host.clearStaleHostedInspectorOwnershipState() host.releaseHostedWebViewConstraints() @@ -7023,13 +7027,20 @@ struct WebViewRepresentable: NSViewRepresentable { let hostId = ObjectIdentifier(host) let previousVisible = coordinator.desiredPortalVisibleInUI let previousZPriority = coordinator.desiredPortalZPriority + let portalAnchorView = panel.portalAnchorView coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner coordinator.desiredPortalZPriority = portalZPriority coordinator.attachGeneration += 1 + let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) + let didReattachPortalHost = + coordinator.lastPortalHostId != hostId || + webView.superview == nil || + portalEntryMissing + let didChangeHostVisibility = previousVisible != coordinator.desiredPortalVisibleInUI + let didChangeZPriority = previousZPriority != portalZPriority let generation = coordinator.attachGeneration let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil - let portalAnchorView = panel.portalAnchorView let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden" let didReleasePortalHost: Bool if !shouldAttachWebView || !isCurrentPaneOwner { @@ -7167,13 +7178,10 @@ struct WebViewRepresentable: NSViewRepresentable { if host.window != nil, portalHostAccepted { let geometryRevision = host.geometryRevision - let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) let shouldBindNow = - coordinator.lastPortalHostId != hostId || - webView.superview == nil || - portalEntryMissing || - previousVisible != shouldAttachWebView || - previousZPriority != portalZPriority + didReattachPortalHost || + didChangeHostVisibility || + didChangeZPriority if shouldBindNow { Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.bind( @@ -7232,7 +7240,14 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.updateOmnibarSuggestions(for: webView, configuration: activeOmnibarSuggestions) } - panel.restoreDeveloperToolsAfterAttachIfNeeded() + if host.window != nil, portalHostAccepted { + panel.reconcileDeveloperToolsAfterHostUpdate( + wasVisibleBeforeHostUpdate: wasDeveloperToolsVisibleBeforeHostUpdate, + didAttachHost: didReattachPortalHost, + didChangeHostVisibility: didChangeHostVisibility, + preserveVisibleIntentWhileDetached: panel.shouldPreserveDeveloperToolsIntentWhileDetached() + ) + } #if DEBUG Self.logDevToolsState( diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index de158d48b2..e8ebac11c9 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -3382,6 +3382,63 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(browserPanel.isDeveloperToolsVisible()) } + func testDetachedInspectorCloseActionDoesNotRequireWebInspectorWindowTitle() { + AppDelegate.installWindowResponderSwizzlesForTesting() + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + guard let mainWindow = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let browserPanelId = manager.openBrowser(inWorkspace: workspace.id, preferSplitRight: true), + let browserPanel = workspace.browserPanel(for: browserPanelId) else { + XCTFail("Expected main window with browser panel") + return + } + appDelegate.suppressClosedWindowHistoryForTesting(windowId: windowId) + defer { tearDownMainWindow(mainWindow, manager: manager) } + + let inspector = FakeInspector() + browserPanel.webView.cmuxSetUnitTestInspector(inspector) + if browserPanel.webView.superview == nil { + browserPanel.webView.frame = mainWindow.contentView?.bounds ?? .zero + mainWindow.contentView?.addSubview(browserPanel.webView) + } + + let inspectorWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + inspectorWindow.title = "" + let frontendWebView = WKInspectorProbeWebView( + frame: inspectorWindow.contentView?.bounds ?? .zero, + configuration: WKWebViewConfiguration() + ) + inspectorWindow.contentView?.addSubview(frontendWebView) + inspector.setFrontendWebView(frontendWebView) + defer { closeWindow(inspectorWindow) } + + inspectorWindow.makeKeyAndOrderFront(nil) + inspectorWindow.makeKey() + XCTAssertTrue(browserPanel.showDeveloperTools()) + XCTAssertEqual(inspector.closeCount, 0) + + let handled = NSApp.sendAction(NSSelectorFromString("__close"), to: inspectorWindow, from: nil) + + XCTAssertTrue(handled) + XCTAssertEqual( + inspector.closeCount, + 1, + "Detached inspector close routing must use the owning inspector frontend, not a title prefix" + ) + XCTAssertFalse(browserPanel.isDeveloperToolsVisible()) + } + func testDetachedInspectorNilTargetMenuItemCloseActionUsesKeyWindow() { AppDelegate.installWindowResponderSwizzlesForTesting() guard let appDelegate = AppDelegate.shared else { @@ -3500,6 +3557,62 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertTrue(browserPanel.isDeveloperToolsVisible()) } + func testAttachedPanelDismissDoesNotCloseOtherDetachedInspectorWindow() { + let (panel, _) = makePanelWithInspector() + defer { closeBrowserPanel(panel) } + let mainWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + mainWindow.isReleasedWhenClosed = false + let otherInspectorWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + otherInspectorWindow.isReleasedWhenClosed = false + defer { + closeWindow(otherInspectorWindow) + closeWindow(mainWindow) + } + guard let mainContentView = mainWindow.contentView, + let otherContentView = otherInspectorWindow.contentView else { + XCTFail("Expected test windows to have content views") + return + } + + let attachedHost = NSView(frame: mainContentView.bounds) + mainContentView.addSubview(attachedHost) + panel.webView.frame = NSRect(x: 0, y: 0, width: 260, height: attachedHost.bounds.height) + attachedHost.addSubview(panel.webView) + let attachedInspectorView = WKInspectorProbeView( + frame: NSRect(x: 260, y: 0, width: 260, height: attachedHost.bounds.height) + ) + attachedHost.addSubview(attachedInspectorView) + + otherInspectorWindow.title = "Web Inspector — other panel" + otherContentView.addSubview(WKInspectorProbeView(frame: otherContentView.bounds)) + + mainWindow.makeKeyAndOrderFront(nil) + otherInspectorWindow.makeKeyAndOrderFront(nil) + mainWindow.displayIfNeeded() + otherInspectorWindow.displayIfNeeded() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertTrue(otherInspectorWindow.isVisible) + + panel.debugDismissDetachedDeveloperToolsWindowsForTesting() + + XCTAssertTrue( + otherInspectorWindow.isVisible, + "Attached DevTools cleanup must not close a detached inspector window that is not owned by this panel" + ) + } + func testNilTargetControllerCloseActionDoesNotCloseDetachedInspector() { AppDelegate.installWindowResponderSwizzlesForTesting() guard let appDelegate = AppDelegate.shared else { @@ -3655,6 +3768,104 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testPendingAttachedOpenSurvivesHiddenInspectorSyncDuringHostChurn() { + let (panel, inspector) = makePanelWithInspector(requiresAttachmentToShow: true) + defer { closeBrowserPanel(panel) } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { closeWindow(window) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + panel.webView.frame = contentView.bounds + contentView.addSubview(panel.webView) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertFalse( + panel.isDeveloperToolsVisible(), + "The fake inspector should keep WebKit visibility false while the attached frontend is not ready" + ) + waitForDeveloperToolsTransitions() + + panel.syncDeveloperToolsPreferenceFromInspector() + + XCTAssertTrue( + panel.shouldUseLocalInlineDeveloperToolsHosting(), + "Host churn must preserve a pending visible DevTools intent even when WebKit still reports the inspector hidden" + ) + XCTAssertGreaterThan(inspector.showCount, 1) + } + + func testLocalInlineHostUpdateRespectsManualDeveloperToolsClose() { + let (panel, inspector) = makePanelWithInspector() + defer { closeBrowserPanel(panel) } + XCTAssertTrue(panel.showDeveloperTools()) + + let representable = WebViewRepresentable( + panel: panel, + paneId: PaneID(id: UUID()), + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + omnibarSuggestions: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { closeWindow(window) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostingView = NSHostingView(rootView: representable) + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + defer { hostingView.removeFromSuperview() } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostingView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + let showCountAfterInitialHost = inspector.showCount + + inspector.close() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + + hostingView.rootView = representable + contentView.layoutSubtreeIfNeeded() + hostingView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + panel.isDeveloperToolsVisible(), + "A stable local inline host update must not reopen DevTools after the user closed them" + ) + XCTAssertEqual(inspector.showCount, showCountAfterInitialHost) + XCTAssertFalse(panel.shouldUseLocalInlineDeveloperToolsHosting()) + } + func testSyncDoesNotRepublishHiddenDeveloperToolsIntentWhenInspectorAlreadyHidden() { let (panel, inspector) = makePanelWithInspector(hideBehavior: .hides) defer { closeBrowserPanel(panel) }