Add devtools split diagnostics and restore retries
This commit is contained in:
parent
c186cb5722
commit
397e46a667
4 changed files with 198 additions and 8 deletions
|
|
@ -2042,8 +2042,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
@discardableResult
|
||||
func performSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
#if DEBUG
|
||||
let directionLabel: String
|
||||
switch direction {
|
||||
case .left: directionLabel = "left"
|
||||
case .right: directionLabel = "right"
|
||||
case .up: directionLabel = "up"
|
||||
case .down: directionLabel = "down"
|
||||
}
|
||||
if let browser = tabManager?.focusedBrowserPanel {
|
||||
dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())")
|
||||
} else {
|
||||
dlog("split.shortcut dir=\(directionLabel) pre panel=nil")
|
||||
}
|
||||
#endif
|
||||
|
||||
tabManager?.createSplit(direction: direction)
|
||||
#if DEBUG
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||
if let browser = self?.tabManager?.focusedBrowserPanel {
|
||||
dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())")
|
||||
} else {
|
||||
dlog("split.shortcut dir=\(directionLabel) post panel=nil")
|
||||
}
|
||||
}
|
||||
recordGotoSplitSplitIfNeeded(direction: direction)
|
||||
#endif
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -827,6 +827,10 @@ 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 developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
||||
private var developerToolsRestoreRetryAttempt: Int = 0
|
||||
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
|
||||
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
||||
|
||||
var displayTitle: String {
|
||||
if !pageTitle.isEmpty {
|
||||
|
|
@ -1236,6 +1240,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
|
||||
deinit {
|
||||
developerToolsRestoreRetryWorkItem?.cancel()
|
||||
developerToolsRestoreRetryWorkItem = nil
|
||||
webViewObservers.removeAll()
|
||||
}
|
||||
}
|
||||
|
|
@ -1312,6 +1318,11 @@ extension BrowserPanel {
|
|||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
preferredDeveloperToolsVisible = targetVisible
|
||||
if targetVisible {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
} else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -1325,6 +1336,11 @@ extension BrowserPanel {
|
|||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -1349,23 +1365,49 @@ extension BrowserPanel {
|
|||
}
|
||||
|
||||
/// Called before WKWebView detaches so manual inspector closes are respected.
|
||||
func syncDeveloperToolsPreferenceFromInspector() {
|
||||
func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) {
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return }
|
||||
if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) {
|
||||
preferredDeveloperToolsVisible = visible
|
||||
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
|
||||
if visible {
|
||||
preferredDeveloperToolsVisible = true
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
if preserveVisibleIntent && preferredDeveloperToolsVisible {
|
||||
return
|
||||
}
|
||||
preferredDeveloperToolsVisible = false
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
||||
/// Called after WKWebView reattaches to keep inspector stable across split/layout churn.
|
||||
func restoreDeveloperToolsAfterAttachIfNeeded() {
|
||||
guard preferredDeveloperToolsVisible else { return }
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return }
|
||||
guard preferredDeveloperToolsVisible else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
guard let inspector = webView.cmuxInspectorObject() else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
guard !visible else { return }
|
||||
guard !visible else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
let selector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: selector) else { return }
|
||||
guard inspector.responds(to: selector) else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
preferredDeveloperToolsVisible = true
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visibleAfterShow {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -1384,6 +1426,7 @@ extension BrowserPanel {
|
|||
inspector.cmuxCallVoid(selector: selector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = false
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -1495,6 +1538,42 @@ extension BrowserPanel {
|
|||
|
||||
}
|
||||
|
||||
private extension BrowserPanel {
|
||||
func scheduleDeveloperToolsRestoreRetry() {
|
||||
guard preferredDeveloperToolsVisible else { return }
|
||||
guard developerToolsRestoreRetryWorkItem == nil else { return }
|
||||
guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return }
|
||||
|
||||
developerToolsRestoreRetryAttempt += 1
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.developerToolsRestoreRetryWorkItem = nil
|
||||
self.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
}
|
||||
developerToolsRestoreRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work)
|
||||
}
|
||||
|
||||
func cancelDeveloperToolsRestoreRetry() {
|
||||
developerToolsRestoreRetryWorkItem?.cancel()
|
||||
developerToolsRestoreRetryWorkItem = nil
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BrowserPanel {
|
||||
func debugDeveloperToolsStateSummary() -> String {
|
||||
let preferred = preferredDeveloperToolsVisible ? 1 : 0
|
||||
let visible = isDeveloperToolsVisible() ? 1 : 0
|
||||
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)"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private extension BrowserPanel {
|
||||
@discardableResult
|
||||
func applyPageZoom(_ candidate: CGFloat) -> Bool {
|
||||
|
|
|
|||
|
|
@ -2560,6 +2560,19 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
var attachGeneration: Int = 0
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static func logDevToolsState(
|
||||
_ panel: BrowserPanel,
|
||||
event: String,
|
||||
generation: Int,
|
||||
retryCount: Int
|
||||
) {
|
||||
dlog(
|
||||
"browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
|
||||
var r = start
|
||||
var hops = 0
|
||||
|
|
@ -2632,6 +2645,16 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// is in a window during bonsplit tree updates; moving the webview too early can be flaky.
|
||||
guard host.window != nil else {
|
||||
coordinator.attachRetryCount += 1
|
||||
#if DEBUG
|
||||
if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 {
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "retry.waitingForWindow",
|
||||
generation: generation,
|
||||
retryCount: coordinator.attachRetryCount
|
||||
)
|
||||
}
|
||||
#endif
|
||||
// Be generous here: bonsplit structural updates can keep a representable
|
||||
// container off-window longer than a few seconds under load.
|
||||
if coordinator.attachRetryCount < 400 {
|
||||
|
|
@ -2651,6 +2674,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.attachRetryCount = 0
|
||||
attachWebView(webView, to: host)
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
#if DEBUG
|
||||
logDevToolsState(panel, event: "retry.attached", generation: generation, retryCount: 0)
|
||||
#endif
|
||||
}
|
||||
|
||||
coordinator.attachRetryWorkItem = work
|
||||
|
|
@ -2665,7 +2691,23 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// 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.
|
||||
if !shouldAttachWebView {
|
||||
panel.syncDeveloperToolsPreferenceFromInspector()
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.beforeSync",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount
|
||||
)
|
||||
#endif
|
||||
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.afterSync",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount
|
||||
)
|
||||
#endif
|
||||
context.coordinator.attachRetryWorkItem?.cancel()
|
||||
context.coordinator.attachRetryWorkItem = nil
|
||||
context.coordinator.attachRetryCount = 0
|
||||
|
|
@ -2681,6 +2723,14 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
webView.removeFromSuperview()
|
||||
}
|
||||
nsView.subviews.forEach { $0.removeFromSuperview() }
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.done",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2693,6 +2743,14 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
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.
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.defer.offWindow",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount
|
||||
)
|
||||
#endif
|
||||
Self.scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
|
|
@ -2703,6 +2761,14 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
} else {
|
||||
Self.attachWebView(webView, to: nsView)
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.immediate",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount
|
||||
)
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Already attached; no need for any pending retry.
|
||||
|
|
@ -2711,6 +2777,14 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
context.coordinator.attachRetryCount = 0
|
||||
context.coordinator.attachGeneration += 1
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.alreadyAttached",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
// Focus handling. Avoid fighting the address bar when it is focused.
|
||||
|
|
|
|||
|
|
@ -281,6 +281,21 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
}
|
||||
|
||||
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
|
||||
let (panel, inspector) = makePanelWithInspector()
|
||||
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
|
||||
// Simulate a transient close caused by view detach, not user intent.
|
||||
inspector.close()
|
||||
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 2)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceShortcutMapperTests: XCTestCase {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue