cmux/Sources/BrowserWindowPortal.swift
2026-03-05 21:55:26 -08:00

2244 lines
89 KiB
Swift

import AppKit
import Bonsplit
import ObjectiveC
import SwiftUI
import WebKit
private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
guard let view else { return "nil" }
let ptr = Unmanaged.passUnretained(view).toOpaque()
return String(describing: ptr)
}
private func browserPortalDebugFrame(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
#endif
final class WindowBrowserHostView: NSView {
private struct DividerRegion {
let rectInWindow: NSRect
let isVertical: Bool
}
private enum DividerCursorKind: Equatable {
case vertical
case horizontal
var cursor: NSCursor {
switch self {
case .vertical: return .resizeLeftRight
case .horizontal: return .resizeUpDown
}
}
}
override var isOpaque: Bool { false }
private static let sidebarLeadingEdgeEpsilon: CGFloat = 1
private static let minimumVisibleLeadingContentWidth: CGFloat = 24
private var cachedSidebarDividerX: CGFloat?
private var sidebarDividerMissCount = 0
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
}
window?.invalidateCursorRects(for: self)
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
}
override func resetCursorRects() {
super.resetCursorRects()
guard let window, let rootView = window.contentView else { return }
var regions: [DividerRegion] = []
Self.collectSplitDividerRegions(in: rootView, into: &regions)
let expansion: CGFloat = 4
for region in regions {
var rectInHost = convert(region.rectInWindow, from: nil)
rectInHost = rectInHost.insetBy(
dx: region.isVertical ? -expansion : 0,
dy: region.isVertical ? 0 : -expansion
)
let clipped = rectInHost.intersection(bounds)
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue }
addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown)
}
}
override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [
.inVisibleRect,
.activeAlways,
.cursorUpdate,
.mouseMoved,
.mouseEnteredAndExited,
.enabledDuringMouseDrag,
]
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
addTrackingArea(next)
trackingArea = next
super.updateTrackingAreas()
}
override func cursorUpdate(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
updateDividerCursor(at: point)
}
override func mouseMoved(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
updateDividerCursor(at: point)
}
override func mouseExited(with event: NSEvent) {
clearActiveDividerCursor(restoreArrow: true)
}
override func hitTest(_ point: NSPoint) -> NSView? {
updateDividerCursor(at: point)
if shouldPassThroughToTitlebar(at: point) {
return nil
}
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
// Mirror terminal portal routing: while tab-reorder drags are active,
// pass through to SwiftUI drop targets behind the portal host.
// Browser hover routing also arrives as cursor/enter events and may not
// report a pressed-button state, so include that path here.
if Self.shouldPassThroughToDragTargets(
pasteboardTypes: NSPasteboard(name: .drag).types,
eventType: NSApp.currentEvent?.type
) {
return nil
}
let hitView = super.hitTest(point)
return hitView === self ? nil : hitView
}
private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool {
guard let window else { return false }
// Window-level portal hosts sit above SwiftUI content. Never intercept
// hits that land in native titlebar space or the custom titlebar strip
// we reserve directly under it for window drag/double-click behaviors.
let windowPoint = convert(point, to: nil)
let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight))
let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5
return windowPoint.y >= interactionBandMinY
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone.
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// If content is flush to the leading edge, sidebar is effectively hidden.
// In that state, treating any internal split edge as a sidebar divider
// steals split-divider cursor/drag behavior.
let hasLeadingContent = visibleSlots.contains {
$0.frame.minX <= Self.sidebarLeadingEdgeEpsilon
&& $0.frame.maxX > Self.minimumVisibleLeadingContentWidth
}
if hasLeadingContent {
if cachedSidebarDividerX != nil {
sidebarDividerMissCount += 1
if sidebarDividerMissCount >= 2 {
cachedSidebarDividerX = nil
sidebarDividerMissCount = 0
}
}
return false
}
// Ignore transient 0-origin slots during layout churn and preserve the last
// known-good divider edge.
let dividerCandidates = visibleSlots
.map(\.frame.minX)
.filter { $0 > Self.sidebarLeadingEdgeEpsilon }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
sidebarDividerMissCount = 0
} else if cachedSidebarDividerX != nil {
// Keep cache briefly for layout churn, but clear if we miss repeatedly
// so stale divider positions don't steal pointer routing.
sidebarDividerMissCount += 1
if sidebarDividerMissCount >= 4 {
cachedSidebarDividerX = nil
sidebarDividerMissCount = 0
}
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
return point.x >= regionMinX && point.x <= regionMaxX
}
private func updateDividerCursor(at point: NSPoint) {
if shouldPassThroughToSidebarResizer(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard let nextKind = splitDividerCursorKind(at: point) else {
clearActiveDividerCursor(restoreArrow: true)
return
}
activeDividerCursorKind = nextKind
nextKind.cursor.set()
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
activeDividerCursorKind = nil
if restoreArrow {
NSCursor.arrow.set()
}
}
private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? {
guard let window else { return nil }
let windowPoint = convert(point, to: nil)
guard let rootView = window.contentView else { return nil }
return Self.dividerCursorKind(at: windowPoint, in: rootView)
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
splitDividerCursorKind(at: point) != nil
}
static func shouldPassThroughToDragTargets(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) -> Bool {
if DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
pasteboardTypes: pasteboardTypes,
eventType: eventType
) {
return true
}
guard let eventType else { return false }
switch eventType {
case .cursorUpdate, .mouseEntered, .mouseExited, .mouseMoved:
// Browser-side tab drags can surface as hover events with a mixed
// pasteboard payload (tabtransfer plus promised-file UTIs). Prefer
// the explicit Bonsplit drag types so WKWebView cannot steal the
// session as a file upload.
return DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
default:
return false
}
}
private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? {
guard !view.isHidden else { return nil }
if let splitView = view as? NSSplitView {
let pointInSplit = splitView.convert(windowPoint, from: nil)
if splitView.bounds.contains(pointInSplit) {
let expansion: CGFloat = 5
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
// Keep divider hit-testing active even when one side is nearly collapsed,
// so users can drag the divider back out from the border.
// But ignore transient states where both panes are effectively 0-width.
guard first.width > 1 || second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(
x: x,
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
// Same behavior for horizontal splits with a near-zero-height pane.
guard first.height > 1 || second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(
x: 0,
y: y,
width: splitView.bounds.width,
height: thickness
)
}
let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion)
if expanded.contains(pointInSplit) {
return splitView.isVertical ? .vertical : .horizontal
}
}
}
}
for subview in view.subviews.reversed() {
if let kind = dividerCursorKind(at: windowPoint, in: subview) {
return kind
}
}
return nil
}
private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) {
guard !view.isHidden else { return }
if let splitView = view as? NSSplitView {
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1 || second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height)
} else {
guard first.height > 1 || second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness)
}
let dividerRectInWindow = splitView.convert(dividerRect, to: nil)
guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue }
result.append(
DividerRegion(
rectInWindow: dividerRectInWindow,
isVertical: splitView.isVertical
)
)
}
}
for subview in view.subviews {
collectSplitDividerRegions(in: subview, into: &result)
}
}
}
private final class BrowserDropZoneOverlayView: NSView {
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
struct BrowserPortalSearchOverlayConfiguration {
let panelId: UUID
let searchState: BrowserSearchState
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
}
struct BrowserPaneDropContext: Equatable {
let workspaceId: UUID
let panelId: UUID
let paneId: PaneID
}
struct BrowserPaneDragTransfer: Equatable {
let tabId: UUID
let sourcePaneId: UUID
let sourceProcessId: Int32
var isFromCurrentProcess: Bool {
sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier)
}
static func decode(from pasteboard: NSPasteboard) -> BrowserPaneDragTransfer? {
if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) {
return decode(from: data)
}
if let raw = pasteboard.string(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) {
return decode(from: Data(raw.utf8))
}
return nil
}
static func decode(from data: Data) -> BrowserPaneDragTransfer? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tab = json["tab"] as? [String: Any],
let tabIdRaw = tab["id"] as? String,
let tabId = UUID(uuidString: tabIdRaw),
let sourcePaneIdRaw = json["sourcePaneId"] as? String,
let sourcePaneId = UUID(uuidString: sourcePaneIdRaw) else {
return nil
}
let sourceProcessId = (json["sourceProcessId"] as? NSNumber)?.int32Value ?? -1
return BrowserPaneDragTransfer(
tabId: tabId,
sourcePaneId: sourcePaneId,
sourceProcessId: sourceProcessId
)
}
}
struct BrowserPaneSplitTarget: Equatable {
let orientation: SplitOrientation
let insertFirst: Bool
}
enum BrowserPaneDropAction: Equatable {
case noOp
case move(
tabId: UUID,
targetWorkspaceId: UUID,
targetPane: PaneID,
splitTarget: BrowserPaneSplitTarget?
)
}
enum BrowserPaneDropRouting {
private static let padding: CGFloat = 4
private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize {
CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight))
}
static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
let edgeRatio: CGFloat = 0.25
let horizontalEdge = max(80, fullPaneSize.width * edgeRatio)
let verticalEdge = max(80, fullPaneSize.height * edgeRatio)
if location.x < horizontalEdge {
return .left
} else if location.x > fullPaneSize.width - horizontalEdge {
return .right
} else if location.y > fullPaneSize.height - verticalEdge {
return .top
} else if location.y < verticalEdge {
return .bottom
} else {
return .center
}
}
static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
switch zone {
case .center:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height - padding * 2
)
case .left:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .right:
return CGRect(
x: fullPaneSize.width / 2,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .top:
return CGRect(
x: padding,
y: fullPaneSize.height / 2,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
case .bottom:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
}
}
static func action(
for transfer: BrowserPaneDragTransfer,
target: BrowserPaneDropContext,
zone: DropZone
) -> BrowserPaneDropAction? {
if zone == .center, transfer.sourcePaneId == target.paneId.id {
return .noOp
}
let splitTarget: BrowserPaneSplitTarget?
switch zone {
case .center:
splitTarget = nil
case .left:
splitTarget = BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: true)
case .right:
splitTarget = BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false)
case .top:
splitTarget = BrowserPaneSplitTarget(orientation: .vertical, insertFirst: true)
case .bottom:
splitTarget = BrowserPaneSplitTarget(orientation: .vertical, insertFirst: false)
}
return .move(
tabId: transfer.tabId,
targetWorkspaceId: target.workspaceId,
targetPane: target.paneId,
splitTarget: splitTarget
)
}
}
final class BrowserPaneDropTargetView: NSView {
weak var slotView: WindowBrowserSlotView?
var dropContext: BrowserPaneDropContext?
private var activeZone: DropZone?
#if DEBUG
private var lastHitTestSignature: String?
#endif
override var acceptsFirstResponder: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
registerForDraggedTypes([DragOverlayRoutingPolicy.bonsplitTabTransferType])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
static func shouldCaptureHitTesting(
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) -> Bool {
guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false }
guard let eventType else { return false }
switch eventType {
case .cursorUpdate,
.mouseEntered,
.mouseExited,
.mouseMoved,
.leftMouseDragged,
.rightMouseDragged,
.otherMouseDragged,
.appKitDefined,
.applicationDefined,
.systemDefined,
.periodic:
return true
default:
return false
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point), dropContext != nil else { return nil }
let pasteboardTypes = NSPasteboard(name: .drag).types
let eventType = NSApp.currentEvent?.type
let capture = Self.shouldCaptureHitTesting(
pasteboardTypes: pasteboardTypes,
eventType: eventType
)
#if DEBUG
logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType)
#endif
return capture ? self : nil
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDragState(sender, phase: "entered")
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDragState(sender, phase: "updated")
}
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
clearDragState(phase: "exited")
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
defer {
clearDragState(phase: "perform.clear")
}
guard let dropContext,
let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
transfer.isFromCurrentProcess else {
#if DEBUG
dlog("browser.paneDrop.perform allowed=0 reason=missingTransfer")
#endif
return false
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
guard let action = BrowserPaneDropRouting.action(
for: transfer,
target: dropContext,
zone: zone
) else {
#if DEBUG
dlog(
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"reason=noAction zone=\(zone)"
)
#endif
return false
}
switch action {
case .noOp:
#if DEBUG
dlog(
"browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) action=noop"
)
#endif
return true
case .move(let tabId, let workspaceId, let targetPane, let splitTarget):
let moved = AppDelegate.shared?.moveBonsplitTab(
tabId: tabId,
toWorkspace: workspaceId,
targetPane: targetPane,
splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) },
focus: true,
focusWindow: true
) ?? false
#if DEBUG
let splitLabel = splitTarget.map {
"\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)"
} ?? "none"
dlog(
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " +
"split=\(splitLabel) moved=\(moved ? 1 : 0)"
)
#endif
return moved
}
}
private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
guard let dropContext,
let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
transfer.isFromCurrentProcess else {
clearDragState(phase: "\(phase).reject")
return []
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
activeZone = zone
slotView?.setPortalDragDropZone(zone)
#if DEBUG
dlog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " +
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)"
)
#endif
return .move
}
private func clearDragState(phase: String) {
guard activeZone != nil else { return }
activeZone = nil
slotView?.setPortalDragDropZone(nil)
#if DEBUG
if let dropContext {
dlog(
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none"
)
}
#endif
}
#if DEBUG
private func logHitTestDecision(
capture: Bool,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
eventType: NSEvent.EventType?
) {
let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
guard hasTransferType || capture else { return }
let signature = [
capture ? "1" : "0",
hasTransferType ? "1" : "0",
String(describing: dropContext != nil),
eventType.map { String($0.rawValue) } ?? "nil",
].joined(separator: "|")
guard lastHitTestSignature != signature else { return }
lastHitTestSignature = signature
let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-"
dlog(
"browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " +
"hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " +
"event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)"
)
}
#endif
}
final class WindowBrowserSlotView: NSView {
override var isOpaque: Bool { false }
private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero)
private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero)
private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
private var forwardedDropZone: DropZone?
private var portalDragDropZone: DropZone?
private var displayedDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var isRefreshingInteractionLayers = false
private var paneTopChromeHeight: CGFloat = 0
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.masksToBounds = true
translatesAutoresizingMaskIntoConstraints = true
autoresizingMask = []
paneDropTargetView.slotView = self
dropZoneOverlayView.wantsLayer = true
dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor
dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor
dropZoneOverlayView.layer?.borderWidth = 2
dropZoneOverlayView.layer?.cornerRadius = 8
dropZoneOverlayView.isHidden = true
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override func layout() {
super.layout()
paneDropTargetView.frame = bounds
applyResolvedDropZoneOverlay()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
attachDropZoneOverlayIfNeeded()
applyResolvedDropZoneOverlay()
}
func setDropZoneOverlay(zone: DropZone?) {
forwardedDropZone = zone
applyResolvedDropZoneOverlay()
}
func setPortalDragDropZone(_ zone: DropZone?) {
portalDragDropZone = zone
applyResolvedDropZoneOverlay()
}
func setPaneDropContext(_ context: BrowserPaneDropContext?) {
paneDropTargetView.dropContext = context
}
func setPaneTopChromeHeight(_ height: CGFloat) {
let resolvedHeight = max(0, height)
guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
paneTopChromeHeight = resolvedHeight
applyResolvedDropZoneOverlay()
}
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
guard let configuration else {
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
return
}
let rootView = BrowserSearchOverlay(
panelId: configuration.panelId,
searchState: configuration.searchState,
onNext: configuration.onNext,
onPrevious: configuration.onPrevious,
onClose: configuration.onClose
)
if let overlay = searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
return
}
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
searchOverlayHostingView = overlay
}
func effectivePaneTopChromeHeight() -> CGFloat {
paneTopChromeHeight
}
override func didAddSubview(_ subview: NSView) {
super.didAddSubview(subview)
guard subview !== paneDropTargetView else { return }
bringInteractionLayersToFrontIfNeeded()
}
private var activeDropZone: DropZone? {
portalDragDropZone ?? forwardedDropZone
}
private func overlayContainerView() -> NSView {
superview ?? self
}
private func attachDropZoneOverlayIfNeeded() {
let container = overlayContainerView()
guard dropZoneOverlayView.superview !== container else { return }
dropZoneOverlayView.removeFromSuperview()
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
private func applyResolvedDropZoneOverlay() {
let resolvedZone = activeDropZone
if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) {
bringInteractionLayersToFrontIfNeeded()
return
}
let previousZone = displayedDropZone
displayedDropZone = resolvedZone
let previousFrame = dropZoneOverlayView.frame
guard let zone = resolvedZone else {
guard !dropZoneOverlayView.isHidden else {
bringInteractionLayersToFrontIfNeeded()
return
}
dropZoneOverlayAnimationGeneration &+= 1
let animationGeneration = dropZoneOverlayAnimationGeneration
dropZoneOverlayView.layer?.removeAllAnimations()
bringInteractionLayersToFrontIfNeeded()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.14
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
dropZoneOverlayView.animator().alphaValue = 0
} completionHandler: { [weak self] in
guard let self else { return }
guard self.dropZoneOverlayAnimationGeneration == animationGeneration else { return }
guard self.displayedDropZone == nil else { return }
self.dropZoneOverlayView.isHidden = true
self.dropZoneOverlayView.alphaValue = 1
}
return
}
attachDropZoneOverlayIfNeeded()
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame)
let zoneChanged = previousZone != zone
if !dropZoneOverlayView.isHidden && !needsFrameUpdate && !zoneChanged {
bringInteractionLayersToFrontIfNeeded()
return
}
dropZoneOverlayAnimationGeneration &+= 1
dropZoneOverlayView.layer?.removeAllAnimations()
if dropZoneOverlayView.isHidden {
applyDropZoneOverlayFrame(targetFrame)
dropZoneOverlayView.alphaValue = 0
dropZoneOverlayView.isHidden = false
bringInteractionLayersToFrontIfNeeded()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
dropZoneOverlayView.animator().alphaValue = 1
}
return
}
bringInteractionLayersToFrontIfNeeded()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
if needsFrameUpdate {
dropZoneOverlayView.animator().frame = targetFrame
}
if dropZoneOverlayView.alphaValue < 1 {
dropZoneOverlayView.animator().alphaValue = 1
}
}
}
private func interactionLayerPriority(of view: NSView) -> Int {
if view === paneDropTargetView { return 1 }
return 0
}
private func bringInteractionLayersToFrontIfNeeded() {
guard !isRefreshingInteractionLayers else { return }
isRefreshingInteractionLayers = true
defer { isRefreshingInteractionLayers = false }
if paneDropTargetView.superview !== self {
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
}
let overlayContainer = overlayContainerView()
if dropZoneOverlayView.superview !== overlayContainer {
attachDropZoneOverlayIfNeeded()
} else if overlayContainer.subviews.last !== dropZoneOverlayView {
overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
let context = Unmanaged.passUnretained(self).toOpaque()
sortSubviews({ lhs, rhs, context in
guard let context else { return .orderedSame }
let slotView = Unmanaged<WindowBrowserSlotView>.fromOpaque(context).takeUnretainedValue()
let lhsPriority = slotView.interactionLayerPriority(of: lhs)
let rhsPriority = slotView.interactionLayerPriority(of: rhs)
if lhsPriority == rhsPriority { return .orderedSame }
return lhsPriority < rhsPriority ? .orderedAscending : .orderedDescending
}, context: context)
}
private func applyDropZoneOverlayFrame(_ frame: CGRect) {
if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
dropZoneOverlayView.frame = frame
CATransaction.commit()
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let localFrame = BrowserPaneDropRouting.overlayFrame(
for: zone,
in: size,
topChromeHeight: paneTopChromeHeight
)
guard let superview else { return localFrame }
return superview.convert(localFrame, from: self)
}
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
}
@MainActor
final class WindowBrowserPortal: NSObject {
private static let transientRecoveryRetryBudget: Int = 12
private weak var window: NSWindow?
private let hostView = WindowBrowserHostView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
private struct Entry {
weak var webView: WKWebView?
weak var containerView: WindowBrowserSlotView?
weak var anchorView: NSView?
var visibleInUI: Bool
var zPriority: Int
var dropZone: DropZone?
var paneDropContext: BrowserPaneDropContext?
var searchOverlay: BrowserPortalSearchOverlayConfiguration?
var paneTopChromeHeight: CGFloat
var transientRecoveryReason: String?
var transientRecoveryRetriesRemaining: Int
}
private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:]
private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = []
installGeometryObservers(for: window)
_ = ensureInstalled()
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
synchronizeAllWebViews(excluding: nil, source: "externalGeometry")
for entry in entriesByWebViewId.values {
guard let webView = entry.webView,
let containerView = entry.containerView,
!containerView.isHidden else { continue }
refreshHostedWebViewPresentation(
webView,
in: containerView,
reason: "externalGeometry"
)
}
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
guard let (container, reference) = installationTarget(for: window) else { return false }
let placementReference = preferredHostPlacementReference(in: container, fallback: reference)
if hostView.superview !== container ||
installedContainerView !== container ||
installedReferenceView !== reference {
hostView.removeFromSuperview()
container.addSubview(hostView, positioned: .above, relativeTo: placementReference)
installedContainerView = container
installedReferenceView = reference
} else {
let aboveReference = Self.isView(hostView, above: reference, in: container)
let abovePlacementReference = placementReference === reference
|| Self.isView(hostView, above: placementReference, in: container)
if !aboveReference || !abovePlacementReference {
container.addSubview(hostView, positioned: .above, relativeTo: placementReference)
}
}
synchronizeHostFrameToReference()
return true
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " +
"frame=\(browserPortalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let contentView = window.contentView else { return nil }
if contentView.className == "NSGlassEffectView",
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
return (contentView, foreground)
}
guard let themeFrame = contentView.superview else { return nil }
return (themeFrame, contentView)
}
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the
/// visible split pane during rearrangement; intersecting through ancestor bounds keeps the
/// portal locked to the pane the user can actually see.
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
frame.maxX > bounds.maxX + epsilon ||
frame.maxY > bounds.maxY + epsilon
}
#if DEBUG
private static func inspectorSubviewCount(in root: NSView) -> Int {
var stack: [NSView] = [root]
var count = 0
while let current = stack.popLast() {
for subview in current.subviews {
if String(describing: type(of: subview)).contains("WKInspector") {
count += 1
}
stack.append(subview)
}
}
return count
}
#endif
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
return false
}
return viewIndex > referenceIndex
}
private func preferredHostPlacementReference(in container: NSView, fallback reference: NSView) -> NSView {
container.subviews.last(where: {
$0 !== hostView && ($0 === reference || $0 is WindowTerminalHostView)
}) ?? reference
}
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
if let existing = entry.containerView {
existing.setPaneDropContext(entry.paneDropContext)
existing.setSearchOverlay(entry.searchOverlay)
existing.setPaneTopChromeHeight(entry.paneTopChromeHeight)
return existing
}
let created = WindowBrowserSlotView(frame: .zero)
created.setPaneDropContext(entry.paneDropContext)
created.setSearchOverlay(entry.searchOverlay)
created.setPaneTopChromeHeight(entry.paneTopChromeHeight)
#if DEBUG
dlog(
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(created))"
)
#endif
return created
}
private func refreshHostedWebViewPresentation(
_ webView: WKWebView,
in containerView: WindowBrowserSlotView,
reason: String
) {
guard !containerView.isHidden else { return }
containerView.needsLayout = true
containerView.needsDisplay = true
containerView.setNeedsDisplay(containerView.bounds)
if let scrollView = webView.enclosingScrollView {
scrollView.needsLayout = true
scrollView.needsDisplay = true
scrollView.setNeedsDisplay(scrollView.bounds)
}
webView.needsLayout = true
webView.needsDisplay = true
webView.setNeedsDisplay(webView.bounds)
DispatchQueue.main.async { [weak self, weak webView, weak containerView] in
guard let self, let webView, let containerView, !containerView.isHidden else { return }
containerView.layoutSubtreeIfNeeded()
if let scrollView = webView.enclosingScrollView {
scrollView.layoutSubtreeIfNeeded()
scrollView.displayIfNeeded()
}
webView.layoutSubtreeIfNeeded()
containerView.displayIfNeeded()
webView.displayIfNeeded()
(webView.window ?? self.hostView.window)?.displayIfNeeded()
#if DEBUG
dlog(
"browser.portal.refresh web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) reason=\(reason) " +
"frame=\(browserPortalDebugFrame(containerView.frame))"
)
#endif
}
}
private func moveWebKitRelatedSubviewsIfNeeded(
from sourceSuperview: NSView,
to containerView: WindowBrowserSlotView,
primaryWebView: WKWebView,
reason: String
) {
guard sourceSuperview !== containerView else { return }
// When Web Inspector is docked, WebKit can inject companion WK* subviews
// next to the primary WKWebView. Move those with the web view so inspector
// UI state does not get orphaned in the old host during split churn.
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.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " +
"container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " +
"sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " +
"sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " +
"sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))"
)
#endif
for view in relatedSubviews {
let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
let className = String(describing: type(of: view))
view.removeFromSuperview()
containerView.addSubview(view, positioned: .above, relativeTo: nil)
let convertedFrame = containerView.convert(frameInWindow, from: nil)
view.frame = convertedFrame
#if DEBUG
dlog(
"browser.portal.reparent.batch.item reason=\(reason) class=\(className) " +
"view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " +
"converted=\(browserPortalDebugFrame(convertedFrame))"
)
#endif
}
}
func detachWebView(withId webViewId: ObjectIdentifier) {
guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return }
if let anchor = entry.anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
#if DEBUG
let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0
let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1
dlog(
"browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " +
"container=\(browserPortalDebugToken(entry.containerView)) " +
"anchor=\(browserPortalDebugToken(entry.anchorView)) " +
"hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)"
)
#endif
entry.webView?.removeFromSuperview()
entry.containerView?.removeFromSuperview()
}
/// Update the visibleInUI/zPriority state on an existing entry without rebinding.
/// Used when a bind is deferred (host not yet in window) so stale portal syncs
/// do not keep an old anchor visible.
func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.visibleInUI = visibleInUI
entry.zPriority = zPriority
entriesByWebViewId[webViewId] = entry
}
func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.dropZone = zone
entriesByWebViewId[webViewId] = entry
entry.containerView?.setDropZoneOverlay(zone: zone)
}
func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.paneDropContext = context
entriesByWebViewId[webViewId] = entry
entry.containerView?.setPaneDropContext(context)
}
func updateSearchOverlay(
forWebViewId webViewId: ObjectIdentifier,
configuration: BrowserPortalSearchOverlayConfiguration?
) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.searchOverlay = configuration
entriesByWebViewId[webViewId] = entry
entry.containerView?.setSearchOverlay(configuration)
}
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
guard var entry = entriesByWebViewId[webViewId] else { return }
let resolvedHeight = max(0, height)
guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
entry.paneTopChromeHeight = resolvedHeight
entriesByWebViewId[webViewId] = entry
entry.containerView?.setPaneTopChromeHeight(resolvedHeight)
}
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
let webViewId = ObjectIdentifier(webView)
let anchorId = ObjectIdentifier(anchorView)
let previousEntry = entriesByWebViewId[webViewId]
let containerView = ensureContainerView(
for: previousEntry ?? Entry(
webView: nil,
containerView: nil,
anchorView: nil,
visibleInUI: false,
zPriority: 0,
dropZone: nil,
paneDropContext: nil,
searchOverlay: nil,
paneTopChromeHeight: 0,
transientRecoveryReason: nil,
transientRecoveryRetriesRemaining: 0
),
webView: webView
)
if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId {
#if DEBUG
let previousToken = entriesByWebViewId[previousWebViewId]
.map { browserPortalDebugToken($0.webView) }
?? String(describing: previousWebViewId)
dlog(
"browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " +
"oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))"
)
#endif
detachWebView(withId: previousWebViewId)
}
if let oldEntry = entriesByWebViewId[webViewId],
let oldAnchor = oldEntry.anchorView,
oldAnchor !== anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
}
webViewByAnchorId[anchorId] = webViewId
entriesByWebViewId[webViewId] = Entry(
webView: webView,
containerView: containerView,
anchorView: anchorView,
visibleInUI: visibleInUI,
zPriority: zPriority,
dropZone: previousEntry?.dropZone,
paneDropContext: previousEntry?.paneDropContext,
searchOverlay: previousEntry?.searchOverlay,
paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0,
transientRecoveryReason: previousEntry?.transientRecoveryReason,
transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0
)
let didChangeAnchor: Bool = {
guard let previousAnchor = previousEntry?.anchorView else { return true }
return previousAnchor !== anchorView
}()
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
#if DEBUG
if previousEntry == nil ||
didChangeAnchor ||
becameVisible ||
priorityIncreased ||
webView.superview !== containerView ||
containerView.superview !== hostView {
dlog(
"browser.portal.bind web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) " +
"anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " +
"visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " +
"z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)"
)
}
#endif
if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
"reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
if let sourceSuperview = webView.superview {
moveWebKitRelatedSubviewsIfNeeded(
from: sourceSuperview,
to: containerView,
primaryWebView: webView,
reason: "bind.attachContainer"
)
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
if containerView.superview !== hostView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
"reason=attach super=\(browserPortalDebugToken(containerView.superview))"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
} else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " +
"didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " +
"priorityIncreased=\(priorityIncreased ? 1 : 0)"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
}
synchronizeWebView(
withId: webViewId,
source: "bind",
forcePresentationRefresh: didChangeAnchor
)
pruneDeadEntries()
}
func synchronizeWebViewForAnchor(_ anchorView: NSView) {
pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView)
let primaryWebViewId = webViewByAnchorId[anchorId]
if let primaryWebViewId {
synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary")
}
synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary")
scheduleDeferredFullSynchronizeAll()
}
private func scheduleDeferredFullSynchronizeAll() {
guard !hasDeferredFullSyncScheduled else { return }
hasDeferredFullSyncScheduled = true
#if DEBUG
dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)")
#endif
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasDeferredFullSyncScheduled = false
#if DEBUG
dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)")
#endif
self.synchronizeAllWebViews(excluding: nil, source: "deferredTick")
}
}
private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) {
guard ensureInstalled() else { return }
pruneDeadEntries()
let webViewIds = Array(entriesByWebViewId.keys)
for webViewId in webViewIds {
if webViewId == webViewIdToSkip { continue }
synchronizeWebView(withId: webViewId, source: source)
}
}
private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) {
guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return }
entry.transientRecoveryReason = nil
entry.transientRecoveryRetriesRemaining = 0
entriesByWebViewId[webViewId] = entry
}
private func scheduleTransientRecoveryRetryIfNeeded(
forWebViewId webViewId: ObjectIdentifier,
entry: inout Entry,
webView: WKWebView,
reason: String
) -> Bool {
if entry.transientRecoveryReason != reason {
entry.transientRecoveryReason = reason
entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget
}
#if DEBUG
if entry.transientRecoveryRetriesRemaining <= 0 {
dlog(
"browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " +
"reason=\(reason) exhausted=1"
)
}
#endif
guard entry.transientRecoveryRetriesRemaining > 0 else { return false }
entry.transientRecoveryRetriesRemaining -= 1
entriesByWebViewId[webViewId] = entry
#if DEBUG
dlog(
"browser.portal.sync.deferRecover web=\(browserPortalDebugToken(webView)) " +
"reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)"
)
#endif
if entry.transientRecoveryRetriesRemaining > 0 {
scheduleDeferredFullSynchronizeAll()
}
return true
}
private func synchronizeWebView(
withId webViewId: ObjectIdentifier,
source: String,
forcePresentationRefresh: Bool = false
) {
guard ensureInstalled() else { return }
guard var entry = entriesByWebViewId[webViewId] else { return }
guard let webView = entry.webView else {
entriesByWebViewId.removeValue(forKey: webViewId)
return
}
guard let containerView = entry.containerView else {
entriesByWebViewId.removeValue(forKey: webViewId)
if let anchor = entry.anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
return
}
func scheduleTransientDetachRecovery(reason: String) -> Bool {
guard entry.visibleInUI else { return false }
let didSchedule = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: reason
)
let shouldPreserve = didSchedule && !containerView.isHidden
#if DEBUG
if shouldPreserve {
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))"
)
}
#endif
return shouldPreserve
}
guard let anchorView = entry.anchorView, let window else {
if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") {
containerView.setDropZoneOverlay(zone: nil)
return
}
if !entry.visibleInUI {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
#if DEBUG
if !containerView.isHidden {
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow"
)
}
#endif
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
}
guard anchorView.window === window else {
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
containerView.setDropZoneOverlay(zone: nil)
return
}
#if DEBUG
if !containerView.isHidden {
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=1 " +
"reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))"
)
}
#endif
if !entry.visibleInUI {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
}
var refreshReasons: [String] = []
if containerView.superview !== hostView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
"reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
refreshReasons.append("syncAttachContainer")
}
if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
"reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
if let sourceSuperview = webView.superview {
moveWebKitRelatedSubviewsIfNeeded(
from: sourceSuperview,
to: containerView,
primaryWebView: webView,
reason: "sync.attachContainer"
)
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
refreshReasons.append("syncAttachWebView")
}
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) " +
"reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " +
"anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
if entry.visibleInUI {
let shouldPreserveVisibleOnTransient = !containerView.isHidden &&
scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "hostBoundsNotReady"
)
if shouldPreserveVisibleOnTransient {
#if DEBUG
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=hostBoundsNotReady frame=\(browserPortalDebugFrame(containerView.frame))"
)
#endif
return
}
} else {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "hostBoundsNotReady"
)
} else {
scheduleDeferredFullSynchronizeAll()
}
containerView.setPaneTopChromeHeight(0)
return
}
let oldFrame = containerView.frame
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1
let outsideHostBounds = !hasVisibleIntersection
let shouldHide =
!entry.visibleInUI ||
anchorHidden ||
tinyFrame ||
!hasFiniteFrame ||
outsideHostBounds
let transientRecoveryReason: String? = {
guard entry.visibleInUI else { return nil }
if anchorHidden { return "anchorHidden" }
if !hasFiniteFrame { return "nonFiniteFrame" }
if outsideHostBounds { return "outsideHostBounds" }
if tinyFrame { return "tinyFrame" }
return nil
}()
let didScheduleTransientRecovery: Bool = {
guard let transientRecoveryReason else { return false }
return scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: transientRecoveryReason
)
}()
let shouldPreserveVisibleOnTransientGeometry =
didScheduleTransientRecovery &&
shouldHide &&
entry.visibleInUI &&
!containerView.isHidden
#if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny {
dlog(
"browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
)
} else if restoredFromTiny {
dlog(
"browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
)
}
#endif
if shouldPreserveVisibleOnTransientGeometry {
#if DEBUG
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame))"
)
#endif
}
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin()
CATransaction.setDisableActions(true)
containerView.frame = targetFrame
CATransaction.commit()
refreshReasons.append("frame")
}
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) {
let oldContainerBounds = containerView.bounds
CATransaction.begin()
CATransaction.setDisableActions(true)
containerView.bounds = expectedContainerBounds
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " +
"target=\(browserPortalDebugFrame(expectedContainerBounds))"
)
#endif
refreshReasons.append("bounds")
}
let containerBounds = containerView.bounds
let preNormalizeWebFrame = webView.frame
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
#if DEBUG
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
#endif
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
let oldWebFrame = preNormalizeWebFrame
CATransaction.begin()
CATransaction.setDisableActions(true)
webView.frame = containerBounds
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " +
"new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " +
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
"inspectorSubviews=\(inspectorSubviews) " +
"source=\(source)"
)
#endif
refreshReasons.append("webFrame")
}
let revealedForDisplay = !shouldHide && containerView.isHidden
if shouldHide, !containerView.isHidden, !shouldPreserveVisibleOnTransientGeometry {
#if DEBUG
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
#endif
containerView.isHidden = true
} else if !shouldHide, containerView.isHidden {
#if DEBUG
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=0 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
#endif
containerView.isHidden = false
}
containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight)
containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay)
containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone)
if revealedForDisplay {
refreshReasons.append("reveal")
}
if forcePresentationRefresh {
refreshReasons.append("anchor")
}
if transientRecoveryReason == nil {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
if !shouldHide, !refreshReasons.isEmpty {
refreshHostedWebViewPresentation(
webView,
in: containerView,
reason: "\(source):" + refreshReasons.joined(separator: ",")
)
}
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
"container=\(browserPortalDebugToken(containerView)) " +
"anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
"webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " +
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
"inspectorSubviews=\(inspectorSubviews)"
)
#endif
}
private func pruneDeadEntries() {
let currentWindow = window
let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in
guard entry.webView != nil else { return webViewId }
guard let container = entry.containerView else { return webViewId }
guard let anchor = entry.anchorView else {
return entry.visibleInUI ? nil : webViewId
}
if container.superview == nil || !container.isDescendant(of: hostView) {
return webViewId
}
let anchorInvalidForCurrentHost =
anchor.window !== currentWindow ||
anchor.superview == nil ||
(installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false)
if anchorInvalidForCurrentHost {
return entry.visibleInUI ? nil : webViewId
}
return nil
}
for webViewId in deadWebViewIds {
detachWebView(withId: webViewId)
}
let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in
entry.anchorView.map { ObjectIdentifier($0) }
})
webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func webViewIds() -> Set<ObjectIdentifier> {
Set(entriesByWebViewId.keys)
}
func tearDown() {
removeGeometryObservers()
for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId)
}
hostView.removeFromSuperview()
installedContainerView = nil
installedReferenceView = nil
}
#if DEBUG
func debugEntryCount() -> Int {
entriesByWebViewId.count
}
func debugHostedSubviewCount() -> Int {
hostView.subviews.count
}
#endif
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
for subview in hostView.subviews.reversed() {
guard let container = subview as? WindowBrowserSlotView else { continue }
guard !container.isHidden else { continue }
guard container.frame.contains(point) else { continue }
guard let webView = entriesByWebViewId
.first(where: { _, entry in entry.containerView === container })?
.value
.webView else { continue }
return webView
}
return nil
}
}
@MainActor
enum BrowserWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window)
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak window] _ in
MainActor.assumeIsolated {
if let window {
removePortal(for: window)
} else {
removePortal(windowId: windowId, window: nil)
}
}
}
objc_setAssociatedObject(
window,
&cmuxWindowBrowserPortalCloseObserverKey,
observer,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private static func removePortal(for window: NSWindow) {
removePortal(windowId: ObjectIdentifier(window), window: window)
}
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
portal.tearDown()
}
webViewToWindowId = webViewToWindowId.filter { $0.value != windowId }
guard let window else { return }
if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) {
NotificationCenter.default.removeObserver(observer)
}
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set<ObjectIdentifier>) {
webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in
mappedWindowId != windowId || validWebViewIds.contains(webViewId)
}
}
private static func portal(for window: NSWindow) -> WindowBrowserPortal {
if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
installWindowCloseObserverIfNeeded(for: window)
return existing
}
let portal = WindowBrowserPortal(window: window)
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
portalsByWindowId[ObjectIdentifier(window)] = portal
installWindowCloseObserverIfNeeded(for: window)
return portal
}
static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let webViewId = ObjectIdentifier(webView)
let nextPortal = portal(for: window)
if let oldWindowId = webViewToWindowId[webViewId],
oldWindowId != windowId {
portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId)
}
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
webViewToWindowId[webViewId] = windowId
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
}
static func synchronizeForAnchor(_ anchorView: NSView) {
guard let window = anchorView.window else { return }
let portal = portal(for: window)
portal.synchronizeWebViewForAnchor(anchorView)
}
/// Update visibleInUI/zPriority on an existing portal entry without rebinding.
/// Called when a bind is deferred because the new host is temporarily off-window.
static func updateEntryVisibility(for webView: WKWebView, visibleInUI: Bool, zPriority: Int) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
}
static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateDropZoneOverlay(forWebViewId: webViewId, zone: zone)
}
static func updatePaneDropContext(for webView: WKWebView, context: BrowserPaneDropContext?) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updatePaneDropContext(forWebViewId: webViewId, context: context)
}
static func updateSearchOverlay(
for webView: WKWebView,
configuration: BrowserPortalSearchOverlayConfiguration?
) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
}
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height)
}
static func detach(webView: WKWebView) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
}
static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return nil }
return portal.webViewAtWindowPoint(windowPoint)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
#endif
}