works
This commit is contained in:
parent
b824147dcb
commit
eea6cdc1bd
5 changed files with 416 additions and 27 deletions
|
|
@ -1744,7 +1744,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private var insecureHTTPAlertFactory: () -> NSAlert
|
||||
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
|
||||
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
||||
private var preferredDeveloperToolsVisible: Bool = false
|
||||
@Published private(set) var preferredDeveloperToolsVisible: Bool = false
|
||||
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
||||
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
||||
private var developerToolsRestoreRetryAttempt: Int = 0
|
||||
|
|
@ -2752,6 +2752,70 @@ extension BrowserPanel {
|
|||
webView.stopLoading()
|
||||
}
|
||||
|
||||
private func attachDeveloperToolsIfSupported(_ inspector: NSObject) {
|
||||
let attachSelector = NSSelectorFromString("attach")
|
||||
if inspector.responds(to: attachSelector) {
|
||||
inspector.cmuxCallVoid(selector: attachSelector)
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeveloperToolsAttached(_ inspector: NSObject) -> Bool? {
|
||||
inspector.cmuxCallBool(selector: NSSelectorFromString("isAttached"))
|
||||
}
|
||||
|
||||
private static func windowContainsInspectorViews(_ root: NSView) -> Bool {
|
||||
if String(describing: type(of: root)).contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
for subview in root.subviews where windowContainsInspectorViews(subview) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private 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 dismissDetachedDeveloperToolsWindowsIfNeeded() {
|
||||
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(),
|
||||
let mainWindow = webView.window else { return }
|
||||
for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " +
|
||||
"title=\(window.title) frame=\(NSStringFromRect(window.frame))"
|
||||
)
|
||||
#endif
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleDetachedDeveloperToolsWindowDismissal() {
|
||||
for delay in [0.0, 0.15] {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.dismissDetachedDeveloperToolsWindowsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func revealDeveloperTools(_ inspector: NSObject) -> Bool {
|
||||
attachDeveloperToolsIfSupported(inspector)
|
||||
|
||||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false {
|
||||
return true
|
||||
}
|
||||
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
return inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleDeveloperTools() -> Bool {
|
||||
#if DEBUG
|
||||
|
|
@ -2764,14 +2828,19 @@ extension BrowserPanel {
|
|||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
let targetVisible = !visible
|
||||
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
|
||||
if targetVisible {
|
||||
_ = revealDeveloperTools(inspector)
|
||||
} else {
|
||||
let selector = NSSelectorFromString("close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = targetVisible
|
||||
if targetVisible {
|
||||
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
if visibleAfterToggle {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
|
|
@ -2800,14 +2869,14 @@ extension BrowserPanel {
|
|||
func showDeveloperTools() -> Bool {
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if !visible {
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
let attached = isDeveloperToolsAttached(inspector) ?? false
|
||||
if !visible || !attached {
|
||||
guard revealDeveloperTools(inspector) || visible else { return false }
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
|
@ -2866,7 +2935,8 @@ extension BrowserPanel {
|
|||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
let attached = isDeveloperToolsAttached(inspector) ?? false
|
||||
if visible && attached {
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
|
|
@ -2876,26 +2946,22 @@ extension BrowserPanel {
|
|||
return
|
||||
}
|
||||
|
||||
let selector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: selector) else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
}
|
||||
#endif
|
||||
// WebKit inspector "show" can trigger transient first-responder churn while
|
||||
// WebKit inspector attach/show can trigger transient first-responder churn while
|
||||
// panel attachment is still stabilizing. Keep this auto-restore path from
|
||||
// mutating first responder so AppKit doesn't walk tearing-down responder chains.
|
||||
cmuxWithWindowFirstResponderBypass {
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
_ = revealDeveloperTools(inspector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visibleAfterShow {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
|
@ -2941,6 +3007,20 @@ extension BrowserPanel {
|
|||
forceDeveloperToolsRefreshOnNextAttach
|
||||
}
|
||||
|
||||
func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool {
|
||||
preferredDeveloperToolsVisible &&
|
||||
(
|
||||
forceDeveloperToolsRefreshOnNextAttach ||
|
||||
developerToolsRestoreRetryWorkItem != nil ||
|
||||
webView.superview == nil ||
|
||||
webView.window == nil
|
||||
)
|
||||
}
|
||||
|
||||
func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
|
||||
preferredDeveloperToolsVisible || isDeveloperToolsVisible()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func zoomIn() -> Bool {
|
||||
applyPageZoom(webView.pageZoom + pageZoomStep)
|
||||
|
|
|
|||
|
|
@ -313,9 +313,16 @@ struct BrowserPanelView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private var owningWorkspace: Workspace? {
|
||||
guard let app = AppDelegate.shared,
|
||||
let manager = app.tabManagerFor(tabId: panel.workspaceId) else {
|
||||
return nil
|
||||
}
|
||||
return manager.tabs.first(where: { $0.id == panel.workspaceId })
|
||||
}
|
||||
|
||||
private var isCurrentPaneOwner: Bool {
|
||||
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
|
||||
let currentPaneId = workspace.paneId(forPanelId: panel.id) else {
|
||||
guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else {
|
||||
return false
|
||||
}
|
||||
return currentPaneId.id == paneId.id
|
||||
|
|
@ -468,7 +475,10 @@ struct BrowserPanelView: View {
|
|||
hideSuggestions()
|
||||
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
|
||||
syncWebViewResponderPolicyWithViewState(
|
||||
reason: "panelFocusChanged",
|
||||
isPanelFocusedOverride: focused
|
||||
)
|
||||
}
|
||||
.onChange(of: addressBarFocused) { focused in
|
||||
#if DEBUG
|
||||
|
|
@ -802,12 +812,18 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private var webView: some View {
|
||||
Group {
|
||||
let useLocalInlineDeveloperToolsHosting =
|
||||
panel.shouldUseLocalInlineDeveloperToolsHosting() &&
|
||||
isVisibleInUI &&
|
||||
isCurrentPaneOwner
|
||||
|
||||
return Group {
|
||||
if panel.shouldRenderWebView {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner,
|
||||
shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting,
|
||||
useLocalInlineHosting: useLocalInlineDeveloperToolsHosting,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority,
|
||||
|
|
@ -881,15 +897,20 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func syncWebViewResponderPolicyWithViewState(reason: String) {
|
||||
private func syncWebViewResponderPolicyWithViewState(
|
||||
reason: String,
|
||||
isPanelFocusedOverride: Bool? = nil
|
||||
) {
|
||||
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
|
||||
let next = isFocused && !panel.shouldSuppressWebViewFocus()
|
||||
let isPanelFocused = isPanelFocusedOverride ?? isFocused
|
||||
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
|
||||
if cmuxWebView.allowsFirstResponderAcquisition != next {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"new=\(next ? 1 : 0) reason=\(reason)"
|
||||
"new=\(next ? 1 : 0) reason=\(reason) " +
|
||||
"panelFocusedUsed=\(isPanelFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
|
@ -3519,6 +3540,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
let panel: BrowserPanel
|
||||
let paneId: PaneID
|
||||
let shouldAttachWebView: Bool
|
||||
let useLocalInlineHosting: Bool
|
||||
let shouldFocusWebView: Bool
|
||||
let isPanelFocused: Bool
|
||||
let portalZPriority: Int
|
||||
|
|
@ -3541,6 +3563,10 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
var onGeometryChanged: (() -> Void)?
|
||||
private(set) var geometryRevision: UInt64 = 0
|
||||
private var lastReportedGeometryState: GeometryState?
|
||||
private weak var hostedWebView: WKWebView?
|
||||
private var hostedWebViewConstraints: [NSLayoutConstraint] = []
|
||||
private weak var localInlineSlotView: WindowBrowserSlotView?
|
||||
private var localInlineSlotConstraints: [NSLayoutConstraint] = []
|
||||
private struct HostedInspectorDividerHit {
|
||||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
|
|
@ -3701,6 +3727,65 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
func ensureLocalInlineSlotView() -> WindowBrowserSlotView {
|
||||
if let localInlineSlotView, localInlineSlotView.superview === self {
|
||||
localInlineSlotView.isHidden = false
|
||||
return localInlineSlotView
|
||||
}
|
||||
|
||||
let slotView = WindowBrowserSlotView(frame: bounds)
|
||||
slotView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(slotView, positioned: .above, relativeTo: nil)
|
||||
localInlineSlotConstraints = [
|
||||
slotView.topAnchor.constraint(equalTo: topAnchor),
|
||||
slotView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
slotView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
slotView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(localInlineSlotConstraints)
|
||||
localInlineSlotView = slotView
|
||||
return slotView
|
||||
}
|
||||
|
||||
func setLocalInlineSlotHidden(_ hidden: Bool) {
|
||||
localInlineSlotView?.isHidden = hidden
|
||||
}
|
||||
|
||||
func releaseHostedWebViewConstraints() {
|
||||
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
|
||||
hostedWebViewConstraints = []
|
||||
hostedWebView = nil
|
||||
}
|
||||
|
||||
func pinHostedWebView(_ webView: WKWebView, in container: NSView) {
|
||||
guard webView.superview === container else { return }
|
||||
|
||||
let needsFrameHosting =
|
||||
hostedWebView !== webView ||
|
||||
!hostedWebViewConstraints.isEmpty ||
|
||||
!webView.translatesAutoresizingMaskIntoConstraints ||
|
||||
webView.autoresizingMask != [.width, .height] ||
|
||||
webView.frame != container.bounds
|
||||
guard needsFrameHosting else {
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
|
||||
hostedWebViewConstraints = []
|
||||
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.
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = container.bounds
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
if window == nil {
|
||||
|
|
@ -4279,6 +4364,40 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
host.onGeometryChanged = nil
|
||||
}
|
||||
|
||||
private static func moveWebKitRelatedSubviewsIntoHostIfNeeded(
|
||||
from sourceSuperview: NSView,
|
||||
to container: WindowBrowserSlotView,
|
||||
primaryWebView: WKWebView,
|
||||
reason: String
|
||||
) {
|
||||
guard sourceSuperview !== container else { return }
|
||||
let relatedSubviews = sourceSuperview.subviews.filter { view in
|
||||
if view === primaryWebView { return true }
|
||||
return String(describing: type(of: view)).contains("WK")
|
||||
}
|
||||
guard !relatedSubviews.isEmpty else { return }
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " +
|
||||
"container=\(Self.objectID(container)) count=\(relatedSubviews.count) " +
|
||||
"sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))"
|
||||
)
|
||||
#endif
|
||||
for view in relatedSubviews {
|
||||
let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
|
||||
let className = String(describing: type(of: view))
|
||||
view.removeFromSuperview()
|
||||
container.addSubview(view, positioned: .above, relativeTo: nil)
|
||||
view.frame = container.convert(frameInWindow, from: nil)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " +
|
||||
"view=\(Self.objectID(view))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
|
||||
// SwiftUI can keep transient replacement hosts alive off-window during split
|
||||
// reparenting. Never let those hosts steal the shared portal anchor, or the
|
||||
|
|
@ -4307,8 +4426,66 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
host.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
|
||||
guard let host = nsView as? HostContainerView else { return false }
|
||||
let slotView = host.ensureLocalInlineSlotView()
|
||||
|
||||
let coordinator = context.coordinator
|
||||
coordinator.desiredPortalVisibleInUI = false
|
||||
coordinator.desiredPortalZPriority = 0
|
||||
coordinator.attachGeneration += 1
|
||||
|
||||
if panel.releasePortalHostIfOwned(
|
||||
hostId: ObjectIdentifier(host),
|
||||
reason: "localInlineHosting"
|
||||
) {
|
||||
BrowserWindowPortalRegistry.hide(
|
||||
webView: webView,
|
||||
source: "viewStateChanged.localInlineHosting"
|
||||
)
|
||||
}
|
||||
|
||||
if webView.superview !== slotView {
|
||||
if let sourceSuperview = webView.superview {
|
||||
Self.moveWebKitRelatedSubviewsIntoHostIfNeeded(
|
||||
from: sourceSuperview,
|
||||
to: slotView,
|
||||
primaryWebView: webView,
|
||||
reason: "attachLocalHost"
|
||||
)
|
||||
} else {
|
||||
slotView.addSubview(webView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
slotView.isHidden = false
|
||||
host.pinHostedWebView(webView, in: slotView)
|
||||
coordinator.lastPortalHostId = nil
|
||||
coordinator.lastSynchronizedHostGeometryRevision = 0
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
webView.needsLayout = true
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
slotView.layoutSubtreeIfNeeded()
|
||||
host.displayIfNeeded()
|
||||
slotView.displayIfNeeded()
|
||||
webView.displayIfNeeded()
|
||||
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "localHost.update",
|
||||
generation: coordinator.attachGeneration,
|
||||
retryCount: 0,
|
||||
details: Self.attachContext(webView: webView, host: host)
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
|
||||
guard let host = nsView as? HostContainerView else { return false }
|
||||
host.setLocalInlineSlotHidden(true)
|
||||
host.releaseHostedWebViewConstraints()
|
||||
|
||||
let coordinator = context.coordinator
|
||||
let paneDropContext = currentPaneDropContext()
|
||||
|
|
@ -4431,7 +4608,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
if !shouldAttachWebView {
|
||||
// In portal mode we no longer detach/re-attach to preserve DevTools state.
|
||||
// Sync the inspector preference directly so manual closes are respected.
|
||||
panel.syncDeveloperToolsPreferenceFromInspector()
|
||||
panel.syncDeveloperToolsPreferenceFromInspector(
|
||||
preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached()
|
||||
)
|
||||
}
|
||||
|
||||
if host.window != nil, portalHostAccepted {
|
||||
|
|
@ -4518,7 +4697,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.webView = webView
|
||||
|
||||
Self.clearPortalCallbacks(for: nsView)
|
||||
let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView)
|
||||
let hostOwnsPortal = useLocalInlineHosting
|
||||
? updateUsingLocalInlineHosting(nsView, context: context, webView: webView)
|
||||
: updateUsingWindowPortal(nsView, context: context, webView: webView)
|
||||
Self.applyWebViewFirstResponderPolicy(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
|
|
@ -4658,7 +4839,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
private func currentPaneDropContext() -> BrowserPaneDropContext? {
|
||||
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
|
||||
guard let app = AppDelegate.shared,
|
||||
let manager = app.tabManagerFor(tabId: panel.workspaceId),
|
||||
let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }),
|
||||
let paneId = workspace.paneId(forPanelId: panel.id) else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3656,6 +3656,9 @@ class TerminalController {
|
|||
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
|
||||
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
|
||||
]
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible()
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2389,14 +2389,26 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
private final class WKInspectorProbeView: NSView {}
|
||||
|
||||
private final class FakeInspector: NSObject {
|
||||
private(set) var attachCount = 0
|
||||
private(set) var showCount = 0
|
||||
private(set) var closeCount = 0
|
||||
private var visible = false
|
||||
private var attached = false
|
||||
|
||||
@objc func isVisible() -> Bool {
|
||||
visible
|
||||
}
|
||||
|
||||
@objc func isAttached() -> Bool {
|
||||
attached
|
||||
}
|
||||
|
||||
@objc func attach() {
|
||||
attachCount += 1
|
||||
attached = true
|
||||
show()
|
||||
}
|
||||
|
||||
@objc func show() {
|
||||
showCount += 1
|
||||
visible = true
|
||||
|
|
@ -2405,6 +2417,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
@objc func close() {
|
||||
closeCount += 1
|
||||
visible = false
|
||||
attached = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
110
tests_v2/test_browser_devtools_visibility_stability.py
Normal file
110
tests_v2/test_browser_devtools_visibility_stability.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#!/usr/bin/env python3
|
||||
"""v2 regression: browser DevTools stays open after a single toggle."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _wait_until(pred, timeout_s: float, label: str) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_exc = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if pred():
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
time.sleep(0.05)
|
||||
if last_exc is not None:
|
||||
raise cmuxError(f"Timed out waiting for {label}: {last_exc}")
|
||||
raise cmuxError(f"Timed out waiting for {label}")
|
||||
|
||||
|
||||
def _surface_row(c: cmux, workspace_id: str, surface_id: str) -> dict:
|
||||
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
for row in payload.get("surfaces") or []:
|
||||
if str(row.get("id") or "") == surface_id:
|
||||
return row
|
||||
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
|
||||
|
||||
|
||||
def _devtools_visible(c: cmux, workspace_id: str, surface_id: str) -> bool:
|
||||
row = _surface_row(c, workspace_id, surface_id)
|
||||
return bool(row.get("developer_tools_visible"))
|
||||
|
||||
|
||||
def _focus_browser_webview(c: cmux, surface_id: str, timeout_s: float = 2.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_exc = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
c.focus_surface(surface_id)
|
||||
c.focus_webview(surface_id)
|
||||
if c.is_webview_focused(surface_id):
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
time.sleep(0.05)
|
||||
raise cmuxError(f"Timed out waiting for browser webview focus: {last_exc}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
workspace_id = c.new_workspace()
|
||||
try:
|
||||
c.select_workspace(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
surface_id = c.new_surface(panel_type="browser", url="https://example.com")
|
||||
_wait_until(
|
||||
lambda: _surface_row(c, workspace_id, surface_id).get("type") == "browser",
|
||||
timeout_s=5.0,
|
||||
label="browser surface in surface.list",
|
||||
)
|
||||
_focus_browser_webview(c, surface_id, timeout_s=3.0)
|
||||
|
||||
_must(
|
||||
_devtools_visible(c, workspace_id, surface_id) is False,
|
||||
"Expected DevTools to start closed",
|
||||
)
|
||||
|
||||
c.simulate_shortcut("cmd+opt+i")
|
||||
|
||||
_wait_until(
|
||||
lambda: _devtools_visible(c, workspace_id, surface_id),
|
||||
timeout_s=3.0,
|
||||
label="DevTools visible after toggle",
|
||||
)
|
||||
|
||||
deadline = time.time() + 1.5
|
||||
while time.time() < deadline:
|
||||
_must(
|
||||
_devtools_visible(c, workspace_id, surface_id) is True,
|
||||
"DevTools reopened/closed unexpectedly after initial open",
|
||||
)
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
try:
|
||||
c.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: browser DevTools stays open after a single toggle")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue