This commit is contained in:
austinpower1258 2026-03-10 19:18:30 -07:00
parent b824147dcb
commit eea6cdc1bd
5 changed files with 416 additions and 27 deletions

View file

@ -1744,7 +1744,7 @@ final class BrowserPanel: Panel, ObservableObject {
private var insecureHTTPAlertFactory: () -> NSAlert private var insecureHTTPAlertFactory: () -> NSAlert
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
// Persist user intent across WebKit detach/reattach churn (split/layout updates). // 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 forceDeveloperToolsRefreshOnNextAttach: Bool = false
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
private var developerToolsRestoreRetryAttempt: Int = 0 private var developerToolsRestoreRetryAttempt: Int = 0
@ -2752,6 +2752,70 @@ extension BrowserPanel {
webView.stopLoading() 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 @discardableResult
func toggleDeveloperTools() -> Bool { func toggleDeveloperTools() -> Bool {
#if DEBUG #if DEBUG
@ -2764,14 +2828,19 @@ extension BrowserPanel {
let isVisibleSelector = NSSelectorFromString("isVisible") let isVisibleSelector = NSSelectorFromString("isVisible")
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
let targetVisible = !visible let targetVisible = !visible
let selector = NSSelectorFromString(targetVisible ? "show" : "close") if targetVisible {
guard inspector.responds(to: selector) else { return false } _ = revealDeveloperTools(inspector)
inspector.cmuxCallVoid(selector: selector) } else {
let selector = NSSelectorFromString("close")
guard inspector.responds(to: selector) else { return false }
inspector.cmuxCallVoid(selector: selector)
}
preferredDeveloperToolsVisible = targetVisible preferredDeveloperToolsVisible = targetVisible
if targetVisible { if targetVisible {
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
if visibleAfterToggle { if visibleAfterToggle {
cancelDeveloperToolsRestoreRetry() cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else { } else {
developerToolsRestoreRetryAttempt = 0 developerToolsRestoreRetryAttempt = 0
scheduleDeveloperToolsRestoreRetry() scheduleDeveloperToolsRestoreRetry()
@ -2800,14 +2869,14 @@ extension BrowserPanel {
func showDeveloperTools() -> Bool { func showDeveloperTools() -> Bool {
guard let inspector = webView.cmuxInspectorObject() else { return false } guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if !visible { let attached = isDeveloperToolsAttached(inspector) ?? false
let showSelector = NSSelectorFromString("show") if !visible || !attached {
guard inspector.responds(to: showSelector) else { return false } guard revealDeveloperTools(inspector) || visible else { return false }
inspector.cmuxCallVoid(selector: showSelector)
} }
preferredDeveloperToolsVisible = true preferredDeveloperToolsVisible = true
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
cancelDeveloperToolsRestoreRetry() cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else { } else {
scheduleDeveloperToolsRestoreRetry() scheduleDeveloperToolsRestoreRetry()
} }
@ -2866,7 +2935,8 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach = false forceDeveloperToolsRefreshOnNextAttach = false
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visible { let attached = isDeveloperToolsAttached(inspector) ?? false
if visible && attached {
#if DEBUG #if DEBUG
if shouldForceRefresh { if shouldForceRefresh {
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
@ -2876,26 +2946,22 @@ extension BrowserPanel {
return return
} }
let selector = NSSelectorFromString("show")
guard inspector.responds(to: selector) else {
cancelDeveloperToolsRestoreRetry()
return
}
#if DEBUG #if DEBUG
if shouldForceRefresh { if shouldForceRefresh {
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
} }
#endif #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 // panel attachment is still stabilizing. Keep this auto-restore path from
// mutating first responder so AppKit doesn't walk tearing-down responder chains. // mutating first responder so AppKit doesn't walk tearing-down responder chains.
cmuxWithWindowFirstResponderBypass { cmuxWithWindowFirstResponderBypass {
inspector.cmuxCallVoid(selector: selector) _ = revealDeveloperTools(inspector)
} }
preferredDeveloperToolsVisible = true preferredDeveloperToolsVisible = true
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow { if visibleAfterShow {
cancelDeveloperToolsRestoreRetry() cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else { } else {
scheduleDeveloperToolsRestoreRetry() scheduleDeveloperToolsRestoreRetry()
} }
@ -2941,6 +3007,20 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach forceDeveloperToolsRefreshOnNextAttach
} }
func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool {
preferredDeveloperToolsVisible &&
(
forceDeveloperToolsRefreshOnNextAttach ||
developerToolsRestoreRetryWorkItem != nil ||
webView.superview == nil ||
webView.window == nil
)
}
func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
preferredDeveloperToolsVisible || isDeveloperToolsVisible()
}
@discardableResult @discardableResult
func zoomIn() -> Bool { func zoomIn() -> Bool {
applyPageZoom(webView.pageZoom + pageZoomStep) applyPageZoom(webView.pageZoom + pageZoomStep)

View file

@ -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 { private var isCurrentPaneOwner: Bool {
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else {
let currentPaneId = workspace.paneId(forPanelId: panel.id) else {
return false return false
} }
return currentPaneId.id == paneId.id return currentPaneId.id == paneId.id
@ -468,7 +475,10 @@ struct BrowserPanelView: View {
hideSuggestions() hideSuggestions()
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
} }
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged") syncWebViewResponderPolicyWithViewState(
reason: "panelFocusChanged",
isPanelFocusedOverride: focused
)
} }
.onChange(of: addressBarFocused) { focused in .onChange(of: addressBarFocused) { focused in
#if DEBUG #if DEBUG
@ -802,12 +812,18 @@ struct BrowserPanelView: View {
} }
private var webView: some View { private var webView: some View {
Group { let useLocalInlineDeveloperToolsHosting =
panel.shouldUseLocalInlineDeveloperToolsHosting() &&
isVisibleInUI &&
isCurrentPaneOwner
return Group {
if panel.shouldRenderWebView { if panel.shouldRenderWebView {
WebViewRepresentable( WebViewRepresentable(
panel: panel, panel: panel,
paneId: paneId, paneId: paneId,
shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner, shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting,
useLocalInlineHosting: useLocalInlineDeveloperToolsHosting,
shouldFocusWebView: isFocused && !addressBarFocused, shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused, isPanelFocused: isFocused,
portalZPriority: portalPriority, 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 } 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 cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG #if DEBUG
dlog( dlog(
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + "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 #endif
} }
@ -3519,6 +3540,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel let panel: BrowserPanel
let paneId: PaneID let paneId: PaneID
let shouldAttachWebView: Bool let shouldAttachWebView: Bool
let useLocalInlineHosting: Bool
let shouldFocusWebView: Bool let shouldFocusWebView: Bool
let isPanelFocused: Bool let isPanelFocused: Bool
let portalZPriority: Int let portalZPriority: Int
@ -3541,6 +3563,10 @@ struct WebViewRepresentable: NSViewRepresentable {
var onGeometryChanged: (() -> Void)? var onGeometryChanged: (() -> Void)?
private(set) var geometryRevision: UInt64 = 0 private(set) var geometryRevision: UInt64 = 0
private var lastReportedGeometryState: GeometryState? 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 { private struct HostedInspectorDividerHit {
let containerView: NSView let containerView: NSView
let pageView: NSView let pageView: NSView
@ -3701,6 +3727,65 @@ struct WebViewRepresentable: NSViewRepresentable {
onGeometryChanged?() 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() { override func viewDidMoveToWindow() {
super.viewDidMoveToWindow() super.viewDidMoveToWindow()
if window == nil { if window == nil {
@ -4279,6 +4364,40 @@ struct WebViewRepresentable: NSViewRepresentable {
host.onGeometryChanged = nil 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) { private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
// SwiftUI can keep transient replacement hosts alive off-window during split // SwiftUI can keep transient replacement hosts alive off-window during split
// reparenting. Never let those hosts steal the shared portal anchor, or the // reparenting. Never let those hosts steal the shared portal anchor, or the
@ -4307,8 +4426,66 @@ struct WebViewRepresentable: NSViewRepresentable {
host.layoutSubtreeIfNeeded() 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 { private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false } guard let host = nsView as? HostContainerView else { return false }
host.setLocalInlineSlotHidden(true)
host.releaseHostedWebViewConstraints()
let coordinator = context.coordinator let coordinator = context.coordinator
let paneDropContext = currentPaneDropContext() let paneDropContext = currentPaneDropContext()
@ -4431,7 +4608,9 @@ struct WebViewRepresentable: NSViewRepresentable {
if !shouldAttachWebView { if !shouldAttachWebView {
// In portal mode we no longer detach/re-attach to preserve DevTools state. // In portal mode we no longer detach/re-attach to preserve DevTools state.
// Sync the inspector preference directly so manual closes are respected. // Sync the inspector preference directly so manual closes are respected.
panel.syncDeveloperToolsPreferenceFromInspector() panel.syncDeveloperToolsPreferenceFromInspector(
preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached()
)
} }
if host.window != nil, portalHostAccepted { if host.window != nil, portalHostAccepted {
@ -4518,7 +4697,9 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.webView = webView coordinator.webView = webView
Self.clearPortalCallbacks(for: nsView) 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( Self.applyWebViewFirstResponderPolicy(
panel: panel, panel: panel,
webView: webView, webView: webView,
@ -4658,7 +4839,9 @@ struct WebViewRepresentable: NSViewRepresentable {
} }
private func currentPaneDropContext() -> BrowserPaneDropContext? { 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 { let paneId = workspace.paneId(forPanelId: panel.id) else {
return nil return nil
} }

View file

@ -3656,6 +3656,9 @@ class TerminalController {
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]), "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]) "selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
] ]
if let browserPanel = panel as? BrowserPanel {
item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible()
}
return item return item
} }

View file

@ -2389,14 +2389,26 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class WKInspectorProbeView: NSView {} private final class WKInspectorProbeView: NSView {}
private final class FakeInspector: NSObject { private final class FakeInspector: NSObject {
private(set) var attachCount = 0
private(set) var showCount = 0 private(set) var showCount = 0
private(set) var closeCount = 0 private(set) var closeCount = 0
private var visible = false private var visible = false
private var attached = false
@objc func isVisible() -> Bool { @objc func isVisible() -> Bool {
visible visible
} }
@objc func isAttached() -> Bool {
attached
}
@objc func attach() {
attachCount += 1
attached = true
show()
}
@objc func show() { @objc func show() {
showCount += 1 showCount += 1
visible = true visible = true
@ -2405,6 +2417,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
@objc func close() { @objc func close() {
closeCount += 1 closeCount += 1
visible = false visible = false
attached = false
} }
} }

View 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())