diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8091005e..f4f5a63e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2040,9 +2040,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + browser.requestDeveloperToolsRefreshAfterNextAttach(reason: "split.\(directionLabel).inspectorFirstResponder") + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let afterResponder = keyWindow.firstResponder + let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" + let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog( + "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" + ) + #endif + } + @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { - #if DEBUG let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2050,6 +2093,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .up: directionLabel = "up" case .down: directionLabel = "down" } + + #if DEBUG let keyWindow = NSApp.keyWindow let firstResponder = keyWindow?.firstResponder let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -2073,6 +2118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) tabManager?.createSplit(direction: direction) #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5ed71a7b..106dc646 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2,6 +2,7 @@ import Foundation import Combine import WebKit import AppKit +import Bonsplit enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google @@ -827,6 +828,7 @@ final class BrowserPanel: Panel, ObservableObject { private let pageZoomStep: CGFloat = 0.1 // Persist user intent across WebKit detach/reattach churn (split/layout updates). private var preferredDeveloperToolsVisible: Bool = false + private var forceDeveloperToolsRefreshOnNextAttach: Bool = false private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 @@ -1322,6 +1324,7 @@ extension BrowserPanel { developerToolsRestoreRetryAttempt = 0 } else { cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false } return true } @@ -1384,22 +1387,42 @@ extension BrowserPanel { func restoreDeveloperToolsAfterAttachIfNeeded() { guard preferredDeveloperToolsVisible else { cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return } + + 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 - guard !visible else { + if visible && !shouldForceRefresh { cancelDeveloperToolsRestoreRetry() return } + let selector = NSSelectorFromString("show") guard inspector.responds(to: selector) else { cancelDeveloperToolsRestoreRetry() return } + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.forceShow panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false @@ -1426,6 +1449,7 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = false + forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() return true } @@ -1437,6 +1461,14 @@ extension BrowserPanel { preferredDeveloperToolsVisible } + func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { + guard preferredDeveloperToolsVisible else { return } + forceDeveloperToolsRefreshOnNextAttach = true + #if DEBUG + dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") + #endif + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1576,7 +1608,8 @@ extension BrowserPanel { let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt)" + let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" } } #endif diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 954d3768..dc094f9e 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2605,6 +2605,38 @@ struct WebViewRepresentable: NSViewRepresentable { return false } + private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private static func firstResponderResignState( + _ responder: NSResponder?, + webView: WKWebView + ) -> (needsResign: Bool, flags: String) { + let inWebViewChain = responderChainContains(responder, target: webView) + let inspectorResponder = isLikelyInspectorResponder(responder) + let needsResign = inWebViewChain || inspectorResponder + return ( + needsResign: needsResign, + flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" + ) + } + func makeCoordinator() -> Coordinator { let coordinator = Coordinator() coordinator.panel = panel @@ -2620,9 +2652,20 @@ struct WebViewRepresentable: NSViewRepresentable { private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. - if let window = webView.window, - responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } + } + + // The target host can already be in-window while the source host is tearing down. + // Re-check against the target window too (it can differ during split churn). + if let window = host.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } } // Detach from any previous host (bonsplit/SwiftUI may rearrange views). @@ -2773,9 +2816,20 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachGeneration += 1 // Resign focus if WebKit currently owns first responder. - if let window = webView.window, - Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.resignFirstResponder", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + window.makeFirstResponder(nil) + } } if webView.superview != nil { @@ -2800,6 +2854,31 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachGeneration += 1 + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + let resigned = window.makeFirstResponder(nil) + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.end", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)" + ) + #endif + } + } + if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. @@ -2891,8 +2970,22 @@ struct WebViewRepresentable: NSViewRepresentable { // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window - if let window, responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.resignFirstResponder", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif + window.makeFirstResponder(nil) + } } // During split/layout churn, SwiftUI may tear down a host view while a new one is still diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a51ba07b..45b19aca 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -297,6 +297,27 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testForcedRefreshAfterAttachReopensVisibleInspectorOnce() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertEqual(inspector.showCount, 2) + + // The force-refresh request should be one-shot. + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertEqual(inspector.showCount, 2) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector()