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 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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