From fabcb068914723df2fe0cb8e72c03379e512744f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 17 Mar 2026 16:28:19 -0700 Subject: [PATCH 1/4] Fix browser devtools X-close persistence --- Sources/Panels/BrowserPanel.swift | 52 +++++++++++++++++++++++++++ Sources/Panels/BrowserPanelView.swift | 10 ++++++ 2 files changed, 62 insertions(+) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5a214652..f92e7e57 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2152,6 +2152,9 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingDeveloperToolsTransitionTargetVisible: Bool? private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 + private let developerToolsAttachedManualCloseDetectionDelay: TimeInterval = 0.35 + private var developerToolsLastAttachedHostAt: Date? + private var developerToolsLastKnownVisibleAt: Date? private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol? private var preferredAttachedDeveloperToolsWidth: CGFloat? private var preferredAttachedDeveloperToolsWidthFraction: CGFloat? @@ -3971,6 +3974,7 @@ extension BrowserPanel { let isVisibleSelector = NSSelectorFromString("isVisible") if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false { developerToolsDetachedOpenGraceDeadline = nil + developerToolsLastKnownVisibleAt = Date() return true } @@ -3980,6 +3984,9 @@ extension BrowserPanel { guard inspector.responds(to: showSelector) else { return false } inspector.cmuxCallVoid(selector: showSelector) let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if visibleAfterShow { + developerToolsLastKnownVisibleAt = Date() + } if preferredDeveloperToolsPresentation == .detached { developerToolsDetachedOpenGraceDeadline = visibleAfterShow ? nil @@ -4196,6 +4203,7 @@ extension BrowserPanel { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() preferredDeveloperToolsVisible = true + developerToolsLastKnownVisibleAt = Date() cancelDeveloperToolsRestoreRetry() return } @@ -4203,9 +4211,47 @@ extension BrowserPanel { return } preferredDeveloperToolsVisible = false + developerToolsLastKnownVisibleAt = nil cancelDeveloperToolsRestoreRetry() } + func noteDeveloperToolsHostAttached() { + developerToolsLastAttachedHostAt = Date() + developerToolsLastKnownVisibleAt = isDeveloperToolsVisible() ? Date() : nil + } + + @discardableResult + func consumeAttachedDeveloperToolsManualCloseIfNeeded(inspector: NSObject? = nil) -> Bool { + guard preferredDeveloperToolsVisible else { return false } + guard preferredDeveloperToolsPresentation != .detached else { return false } + guard !isDeveloperToolsTransitionInFlight 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 { + return false + } + guard developerToolsLastKnownVisibleAt != nil else { return false } + 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() + return false + } + + preferredDeveloperToolsVisible = false + developerToolsDetachedOpenGraceDeadline = nil + developerToolsLastKnownVisibleAt = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() +#if DEBUG + dlog( + "browser.devtools attachedClose.consume panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif + return true + } + /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. func restoreDeveloperToolsAfterAttachIfNeeded() { guard preferredDeveloperToolsVisible else { @@ -4226,6 +4272,7 @@ extension BrowserPanel { if visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() + developerToolsLastKnownVisibleAt = Date() #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") @@ -4249,6 +4296,10 @@ extension BrowserPanel { return } + if consumeAttachedDeveloperToolsManualCloseIfNeeded(inspector: inspector) { + return + } + #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") @@ -4264,6 +4315,7 @@ extension BrowserPanel { let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { syncDeveloperToolsPresentationPreferenceFromUI() + developerToolsLastKnownVisibleAt = Date() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() } else { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 596820de..2d45cfa9 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -513,6 +513,14 @@ struct BrowserPanelView: View { refreshSuggestions() } } + .onChange(of: isVisibleInUI) { visibleInUI in + guard !visibleInUI else { return } + // The attached WebKit inspector close button can hide DevTools without + // touching BrowserPanel's persisted intent. Capture the actual inspector + // visibility before the surface detaches so switching away and back + // does not resurrect a manually closed inspector. + panel.syncDeveloperToolsPreferenceFromInspector() + } .onChange(of: isFocused) { focused in #if DEBUG logBrowserFocusState( @@ -5689,6 +5697,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.lastPortalHostId = nil coordinator.lastSynchronizedHostGeometryRevision = 0 if didAttachWebViewToLocalHost { + panel.noteDeveloperToolsHostAttached() panel.restoreDeveloperToolsAfterAttachIfNeeded() webView.needsLayout = true webView.layoutSubtreeIfNeeded() @@ -5702,6 +5711,7 @@ struct WebViewRepresentable: NSViewRepresentable { host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update.async") } } else { + panel.consumeAttachedDeveloperToolsManualCloseIfNeeded() host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update") } From 79fade5add6546c40b6ce72590fa7aad66eb1bd2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 17 Mar 2026 16:32:59 -0700 Subject: [PATCH 2/4] Sync browser devtools preference on surface focus loss --- Sources/Panels/BrowserPanelView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 2d45cfa9..96c468c7 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -536,6 +536,13 @@ struct BrowserPanelView: View { panel.invalidateAddressBarPageFocusRestoreAttempts() hideSuggestions() setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") + // Surface switches in split layouts can keep the browser visible, so + // `isVisibleInUI` never flips to false. Sample the real inspector + // state when focus leaves the panel as well, otherwise an X-close can + // leave the persisted DevTools intent stale until the next reattach. + DispatchQueue.main.async { + panel.syncDeveloperToolsPreferenceFromInspector() + } } syncWebViewResponderPolicyWithViewState( reason: "panelFocusChanged", From 8397b320676cc07188db5fda09ba0d611dd79a5d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 17 Mar 2026 17:13:51 -0700 Subject: [PATCH 3/4] Debounce browser devtools visibility-loss sync --- Sources/Panels/BrowserPanel.swift | 23 +++++++++++++++++++++++ Sources/Panels/BrowserPanelView.swift | 21 +++++++++++---------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f92e7e57..21fa45e7 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2151,6 +2151,7 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsTransitionTargetVisible: Bool? private var pendingDeveloperToolsTransitionTargetVisible: Bool? private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? + private var developerToolsVisibilityLossCheckWorkItem: DispatchWorkItem? private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 private let developerToolsAttachedManualCloseDetectionDelay: TimeInterval = 0.35 private var developerToolsLastAttachedHostAt: Date? @@ -3591,6 +3592,8 @@ final class BrowserPanel: Panel, ObservableObject { developerToolsRestoreRetryWorkItem = nil developerToolsTransitionSettleWorkItem?.cancel() developerToolsTransitionSettleWorkItem = nil + developerToolsVisibilityLossCheckWorkItem?.cancel() + developerToolsVisibilityLossCheckWorkItem = nil if let detachedDeveloperToolsWindowCloseObserver { NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver) } @@ -4216,10 +4219,30 @@ extension BrowserPanel { } func noteDeveloperToolsHostAttached() { + cancelPendingDeveloperToolsVisibilityLossCheck() developerToolsLastAttachedHostAt = Date() developerToolsLastKnownVisibleAt = isDeveloperToolsVisible() ? Date() : nil } + func scheduleDeveloperToolsVisibilityLossCheck() { + developerToolsVisibilityLossCheckWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.developerToolsVisibilityLossCheckWorkItem = nil + _ = self.consumeAttachedDeveloperToolsManualCloseIfNeeded() + } + developerToolsVisibilityLossCheckWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + developerToolsTransitionSettleDelay, + execute: workItem + ) + } + + func cancelPendingDeveloperToolsVisibilityLossCheck() { + developerToolsVisibilityLossCheckWorkItem?.cancel() + developerToolsVisibilityLossCheckWorkItem = nil + } + @discardableResult func consumeAttachedDeveloperToolsManualCloseIfNeeded(inspector: NSObject? = nil) -> Bool { guard preferredDeveloperToolsVisible else { return false } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 96c468c7..bc98416b 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -514,12 +514,14 @@ struct BrowserPanelView: View { } } .onChange(of: isVisibleInUI) { visibleInUI in - guard !visibleInUI else { return } - // The attached WebKit inspector close button can hide DevTools without - // touching BrowserPanel's persisted intent. Capture the actual inspector - // visibility before the surface detaches so switching away and back - // does not resurrect a manually closed inspector. - panel.syncDeveloperToolsPreferenceFromInspector() + if visibleInUI { + panel.cancelPendingDeveloperToolsVisibilityLossCheck() + return + } + // Pane/workspace churn can briefly mark the browser hidden before the + // final host settles. Only treat a stable hide as a signal to consume + // an attached-inspector X-close. + panel.scheduleDeveloperToolsVisibilityLossCheck() } .onChange(of: isFocused) { focused in #if DEBUG @@ -537,11 +539,10 @@ struct BrowserPanelView: View { hideSuggestions() setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") // Surface switches in split layouts can keep the browser visible, so - // `isVisibleInUI` never flips to false. Sample the real inspector - // state when focus leaves the panel as well, otherwise an X-close can - // leave the persisted DevTools intent stale until the next reattach. + // `isVisibleInUI` never flips to false. Check for an attached-inspector + // X-close when focus leaves as well so the persisted intent stays in sync. DispatchQueue.main.async { - panel.syncDeveloperToolsPreferenceFromInspector() + _ = panel.consumeAttachedDeveloperToolsManualCloseIfNeeded() } } syncWebViewResponderPolicyWithViewState( From 48426bf1ec1eb797550c1bc031e0f87f1a6f38d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 17 Mar 2026 17:37:01 -0700 Subject: [PATCH 4/4] Tighten browser devtools close timing --- Sources/Panels/BrowserPanel.swift | 11 +++++++++-- Sources/Panels/BrowserPanelView.swift | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 21fa45e7..d0f07d8c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4221,11 +4221,18 @@ extension BrowserPanel { func noteDeveloperToolsHostAttached() { cancelPendingDeveloperToolsVisibilityLossCheck() developerToolsLastAttachedHostAt = Date() - developerToolsLastKnownVisibleAt = isDeveloperToolsVisible() ? Date() : nil + if isDeveloperToolsVisible() { + developerToolsLastKnownVisibleAt = Date() + } } func scheduleDeveloperToolsVisibilityLossCheck() { developerToolsVisibilityLossCheckWorkItem?.cancel() + let attachedAge = developerToolsLastAttachedHostAt.map { Date().timeIntervalSince($0) } ?? 0 + let delay = max( + developerToolsTransitionSettleDelay, + developerToolsAttachedManualCloseDetectionDelay - attachedAge + ) let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.developerToolsVisibilityLossCheckWorkItem = nil @@ -4233,7 +4240,7 @@ extension BrowserPanel { } developerToolsVisibilityLossCheckWorkItem = workItem DispatchQueue.main.asyncAfter( - deadline: .now() + developerToolsTransitionSettleDelay, + deadline: .now() + max(0, delay), execute: workItem ) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index bc98416b..c7204076 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -542,7 +542,7 @@ struct BrowserPanelView: View { // `isVisibleInUI` never flips to false. Check for an attached-inspector // X-close when focus leaves as well so the persisted intent stays in sync. DispatchQueue.main.async { - _ = panel.consumeAttachedDeveloperToolsManualCloseIfNeeded() + panel.scheduleDeveloperToolsVisibilityLossCheck() } } syncWebViewResponderPolicyWithViewState(