import AppKit import Bonsplit import ObjectiveC 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: ®ions) 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.. 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.. 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 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 { static func zone(for location: CGPoint, in size: CGSize) -> DropZone { let edgeRatio: CGFloat = 0.25 let horizontalEdge = max(80, size.width * edgeRatio) let verticalEdge = max(80, size.height * edgeRatio) if location.x < horizontalEdge { return .left } else if location.x > size.width - horizontalEdge { return .right } else if location.y > size.height - verticalEdge { return .top } else if location.y < verticalEdge { return .bottom } else { return .center } } 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) 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) 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 forwardedDropZone: DropZone? private var portalDragDropZone: DropZone? private var displayedDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 private var isRefreshingInteractionLayers = false 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) addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } @available(*, unavailable) required init?(coder: NSCoder) { nil } override func layout() { super.layout() paneDropTargetView.frame = bounds applyResolvedDropZoneOverlay() } func setDropZoneOverlay(zone: DropZone?) { forwardedDropZone = zone applyResolvedDropZoneOverlay() } func setPortalDragDropZone(_ zone: DropZone?) { portalDragDropZone = zone applyResolvedDropZoneOverlay() } func setPaneDropContext(_ context: BrowserPaneDropContext?) { paneDropTargetView.dropContext = context } override func didAddSubview(_ subview: NSView) { super.didAddSubview(subview) guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return } bringInteractionLayersToFrontIfNeeded() } private var activeDropZone: DropZone? { portalDragDropZone ?? forwardedDropZone } 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 } 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 } if view === dropZoneOverlayView { return 2 } return 0 } private func bringInteractionLayersToFrontIfNeeded() { guard !isRefreshingInteractionLayers else { return } isRefreshingInteractionLayers = true defer { isRefreshingInteractionLayers = false } if paneDropTargetView.superview !== self { addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) } if dropZoneOverlayView.superview !== self { 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.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 padding: CGFloat = 4 switch zone { case .center: return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) case .left: return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .right: return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .top: return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) case .bottom: return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) } } 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 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) return existing } let created = WindowBrowserSlotView(frame: .zero) created.setPaneDropContext(entry.paneDropContext) #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 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, 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, 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 else { return } entry.transientRecoveryRetriesRemaining = 0 entriesByWebViewId[webViewId] = entry } private func scheduleTransientRecoveryRetryIfNeeded( forWebViewId webViewId: ObjectIdentifier, entry: inout Entry, webView: WKWebView, reason: String ) -> Bool { if entry.transientRecoveryRetriesRemaining == 0 { entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget } 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 } guard let anchorView = entry.anchorView, let window else { if entry.visibleInUI { _ = scheduleTransientRecoveryRetryIfNeeded( forWebViewId: webViewId, entry: &entry, webView: webView, reason: "missingAnchorOrWindow" ) } else { 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.setDropZoneOverlay(zone: nil) containerView.isHidden = true return } guard anchorView.window === window else { #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 { _ = scheduleTransientRecoveryRetryIfNeeded( forWebViewId: webViewId, entry: &entry, webView: webView, reason: "anchorWindowMismatch" ) } else { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } 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.setDropZoneOverlay(zone: nil) containerView.isHidden = true if entry.visibleInUI { _ = scheduleTransientRecoveryRetryIfNeeded( forWebViewId: webViewId, entry: &entry, webView: webView, reason: "hostBoundsNotReady" ) } else { scheduleDeferredFullSynchronizeAll() } 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.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 { 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) { 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 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 }