-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Keep background Browser WebViews automatable #6027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3397,6 +3397,7 @@ final class BrowserPanel: Panel, ObservableObject { | |
| private var backgroundPreloadWindow: NSWindow? | ||
| private let visualAutomationCaptureGate = BrowserScreenshotCaptureGate() | ||
| private var activeVisualAutomationCaptureCount: Int = 0 | ||
| private var webViewLastAutomationActivityAt: Date? | ||
| private struct PendingInteractiveBrowserPrompt { | ||
| let present: (NSWindow, @escaping () -> Void) -> Void | ||
| let cancel: () -> Void | ||
|
|
@@ -3788,6 +3789,7 @@ final class BrowserPanel: Panel, ObservableObject { | |
| webViewLastHiddenAt = nil | ||
| webViewLastVisibilityChangeAt = nil | ||
| webViewLastVisibilityChangeReason = nil | ||
| webViewLastAutomationActivityAt = nil | ||
| isWebViewVisibleInUI = false | ||
| } | ||
| hiddenWebViewDiscardManager.resetMetadata() | ||
|
|
@@ -6395,6 +6397,10 @@ extension BrowserPanel: BrowserHiddenWebViewDiscardManagerDelegate { | |
| webViewLastHiddenAt | ||
| } | ||
|
|
||
| var hiddenWebViewDiscardLastAutomationActivityAt: Date? { | ||
| webViewLastAutomationActivityAt | ||
| } | ||
|
|
||
| var hiddenWebViewDiscardWebViewInstanceID: UUID { | ||
| webViewInstanceID | ||
| } | ||
|
|
@@ -7580,6 +7586,34 @@ extension BrowserPanel { | |
| } | ||
| } | ||
|
|
||
| @discardableResult | ||
| func beginAutomationCommandLease(reason: String) -> BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease? { | ||
| webViewLastAutomationActivityAt = Date() | ||
| activeVisualAutomationCaptureCount += 1 | ||
| cancelHiddenWebViewDiscard() | ||
| restoreDiscardedWebViewIfNeeded(reason: "\(reason).restore") | ||
| refreshWebViewLifecycleState() | ||
|
|
||
| guard shouldUseOffscreenRenderHostForVisualAutomation else { return nil } | ||
| return BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease( | ||
| webView: webView, | ||
| viewportSize: visualAutomationViewportSize() | ||
| ) | ||
| } | ||
|
|
||
| func endAutomationCommandLease( | ||
| _ lease: BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease?, | ||
| reason: String | ||
| ) { | ||
| lease?.end() | ||
| webViewLastAutomationActivityAt = Date() | ||
| activeVisualAutomationCaptureCount = max(0, activeVisualAutomationCaptureCount - 1) | ||
| refreshWebViewLifecycleState() | ||
| if activeVisualAutomationCaptureCount == 0, !isWebViewVisibleInUI { | ||
| scheduleHiddenWebViewDiscardIfNeeded(reason: "\(reason).finished") | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lease ends during active captureMedium Severity
Reviewed by Cursor Bugbot for commit e3fd55b. Configure here. |
||
|
|
||
| @discardableResult | ||
| func ensureVisualAutomationRestoreHostIfNeeded(reason: String) -> Bool { | ||
| guard shouldUseOffscreenRenderHostForVisualAutomation else { return false } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,129 @@ enum BrowserScreenshotCaptureBounds { | |
|
|
||
| @MainActor | ||
| enum BrowserScreenshotWebViewSnapshotter { | ||
| @MainActor | ||
| final class OffscreenRenderHostLease { | ||
| private weak var webView: WKWebView? | ||
| private let previousSuperview: NSView? | ||
| private let previousFrame: NSRect | ||
| private let previousBounds: NSRect | ||
| private let previousAutoresizingMask: NSView.AutoresizingMask | ||
| private let previousTranslatesAutoresizingMaskIntoConstraints: Bool | ||
| private let restoreAnchor: NSView? | ||
| private let restorePosition: NSWindow.OrderingMode | ||
| private let window: BrowserScreenshotOffscreenRenderPanel | ||
| private var isActive = true | ||
|
|
||
| init(webView: WKWebView, viewportSize: NSSize) { | ||
| self.webView = webView | ||
| previousSuperview = webView.superview | ||
| let previousSubviews = previousSuperview?.subviews ?? [] | ||
| let previousIndex = previousSubviews.firstIndex(of: webView) | ||
| previousFrame = webView.frame | ||
| previousBounds = webView.bounds | ||
| previousAutoresizingMask = webView.autoresizingMask | ||
| previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints | ||
|
|
||
| if let previousIndex, previousIndex > 0 { | ||
| restoreAnchor = previousSubviews[previousIndex - 1] | ||
| restorePosition = .above | ||
| } else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 { | ||
| restoreAnchor = previousSubviews[1] | ||
| restorePosition = .below | ||
| } else { | ||
| restoreAnchor = nil | ||
| restorePosition = .above | ||
| } | ||
|
|
||
| let normalizedSize = Self.normalizedViewportSize(viewportSize) | ||
| let frame = NSRect( | ||
| x: -100_000 - normalizedSize.width, | ||
| y: -100_000 - normalizedSize.height, | ||
| width: normalizedSize.width, | ||
| height: normalizedSize.height | ||
| ) | ||
| let window = BrowserScreenshotOffscreenRenderPanel( | ||
| contentRect: frame, | ||
| styleMask: [.borderless, .nonactivatingPanel], | ||
| backing: .buffered, | ||
| defer: false | ||
| ) | ||
| window.isReleasedWhenClosed = false | ||
| window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender") | ||
| window.hasShadow = false | ||
| window.isOpaque = false | ||
| window.backgroundColor = .clear | ||
| window.alphaValue = 0.01 | ||
| window.ignoresMouseEvents = true | ||
| window.hidesOnDeactivate = false | ||
| window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces] | ||
| window.isExcludedFromWindowsMenu = true | ||
| self.window = window | ||
|
|
||
| let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize)) | ||
| contentView.wantsLayer = true | ||
| webView.removeFromSuperview() | ||
| webView.frame = contentView.bounds | ||
| webView.autoresizingMask = [.width, .height] | ||
| contentView.addSubview(webView) | ||
| window.contentView = contentView | ||
| window.orderFrontRegardless() | ||
| } | ||
|
|
||
| func end() { | ||
| guard isActive else { return } | ||
| isActive = false | ||
| if let webView { | ||
| Self.restoreWebView( | ||
| webView, | ||
| to: previousSuperview, | ||
| frame: previousFrame, | ||
| bounds: previousBounds, | ||
| autoresizingMask: previousAutoresizingMask, | ||
| translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints, | ||
| anchor: restoreAnchor, | ||
| position: restorePosition | ||
| ) | ||
| } | ||
| window.orderOut(nil) | ||
| window.contentView = nil | ||
| window.close() | ||
| } | ||
|
|
||
| deinit { | ||
| guard isActive else { return } | ||
| MainActor.assumeIsolated { | ||
| end() | ||
| } | ||
| } | ||
|
Comment on lines
+152
to
+157
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the current flow the guard short-circuits in every reachable path ( |
||
|
|
||
| private static func normalizedViewportSize(_ viewportSize: NSSize) -> NSSize { | ||
| BrowserScreenshotWebViewSnapshotter.normalizedViewportSize(viewportSize) | ||
| } | ||
|
|
||
| private static func restoreWebView( | ||
| _ webView: WKWebView, | ||
| to superview: NSView?, | ||
| frame: NSRect, | ||
| bounds: NSRect, | ||
| autoresizingMask: NSView.AutoresizingMask, | ||
| translatesAutoresizingMaskIntoConstraints: Bool, | ||
| anchor: NSView?, | ||
| position: NSWindow.OrderingMode | ||
| ) { | ||
| BrowserScreenshotWebViewSnapshotter.restoreWebView( | ||
| webView, | ||
| to: superview, | ||
| frame: frame, | ||
| bounds: bounds, | ||
| autoresizingMask: autoresizingMask, | ||
| translatesAutoresizingMaskIntoConstraints: translatesAutoresizingMaskIntoConstraints, | ||
| anchor: anchor, | ||
| position: position | ||
| ) | ||
| } | ||
| } | ||
|
Comment on lines
+63
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Extract the lease/panel helpers to a dedicated file to stay within the Swift file-size budget. This new lease abstraction is coherent, but adding it here pushes As per coding guidelines, 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| static func captureFullPage( | ||
| from webView: WKWebView, | ||
| afterScreenUpdates: Bool = true | ||
|
|
@@ -180,73 +303,9 @@ enum BrowserScreenshotWebViewSnapshotter { | |
| expectedURL: URL?, | ||
| operation: () async throws -> T | ||
| ) async throws -> T { | ||
| let previousSuperview = webView.superview | ||
| let previousSubviews = previousSuperview?.subviews ?? [] | ||
| let previousIndex = previousSubviews.firstIndex(of: webView) | ||
| let previousFrame = webView.frame | ||
| let previousBounds = webView.bounds | ||
| let previousAutoresizingMask = webView.autoresizingMask | ||
| let previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints | ||
| let restoreAnchor: NSView? | ||
| let restorePosition: NSWindow.OrderingMode | ||
| if let previousIndex, previousIndex > 0 { | ||
| restoreAnchor = previousSubviews[previousIndex - 1] | ||
| restorePosition = .above | ||
| } else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 { | ||
| restoreAnchor = previousSubviews[1] | ||
| restorePosition = .below | ||
| } else { | ||
| restoreAnchor = nil | ||
| restorePosition = .above | ||
| } | ||
|
|
||
| let normalizedSize = normalizedViewportSize(viewportSize) | ||
| let frame = NSRect( | ||
| x: -100_000 - normalizedSize.width, | ||
| y: -100_000 - normalizedSize.height, | ||
| width: normalizedSize.width, | ||
| height: normalizedSize.height | ||
| ) | ||
| let window = BrowserScreenshotOffscreenRenderPanel( | ||
| contentRect: frame, | ||
| styleMask: [.borderless, .nonactivatingPanel], | ||
| backing: .buffered, | ||
| defer: false | ||
| ) | ||
| window.isReleasedWhenClosed = false | ||
| window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender") | ||
| window.hasShadow = false | ||
| window.isOpaque = false | ||
| window.backgroundColor = .clear | ||
| window.alphaValue = 0.01 | ||
| window.ignoresMouseEvents = true | ||
| window.hidesOnDeactivate = false | ||
| window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces] | ||
| window.isExcludedFromWindowsMenu = true | ||
|
|
||
| let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize)) | ||
| contentView.wantsLayer = true | ||
| webView.removeFromSuperview() | ||
| webView.frame = contentView.bounds | ||
| webView.autoresizingMask = [.width, .height] | ||
| contentView.addSubview(webView) | ||
| window.contentView = contentView | ||
| window.orderFrontRegardless() | ||
|
|
||
| let lease = OffscreenRenderHostLease(webView: webView, viewportSize: viewportSize) | ||
| defer { | ||
| restoreWebView( | ||
| webView, | ||
| to: previousSuperview, | ||
| frame: previousFrame, | ||
| bounds: previousBounds, | ||
| autoresizingMask: previousAutoresizingMask, | ||
| translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints, | ||
| anchor: restoreAnchor, | ||
| position: restorePosition | ||
| ) | ||
| window.orderOut(nil) | ||
| window.contentView = nil | ||
| window.close() | ||
| lease.end() | ||
| } | ||
|
|
||
| try await prepareForVisualCapture(webView, expectedURL: expectedURL) | ||
|
|
@@ -261,79 +320,15 @@ enum BrowserScreenshotWebViewSnapshotter { | |
| operation: @escaping (@escaping (Result<T, Error>) -> Void) -> Void, | ||
| completion: @escaping (Result<T, Error>) -> Void | ||
| ) { | ||
| let previousSuperview = webView.superview | ||
| let previousSubviews = previousSuperview?.subviews ?? [] | ||
| let previousIndex = previousSubviews.firstIndex(of: webView) | ||
| let previousFrame = webView.frame | ||
| let previousBounds = webView.bounds | ||
| let previousAutoresizingMask = webView.autoresizingMask | ||
| let previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints | ||
| let restoreAnchor: NSView? | ||
| let restorePosition: NSWindow.OrderingMode | ||
| if let previousIndex, previousIndex > 0 { | ||
| restoreAnchor = previousSubviews[previousIndex - 1] | ||
| restorePosition = .above | ||
| } else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 { | ||
| restoreAnchor = previousSubviews[1] | ||
| restorePosition = .below | ||
| } else { | ||
| restoreAnchor = nil | ||
| restorePosition = .above | ||
| } | ||
|
|
||
| let normalizedSize = normalizedViewportSize(viewportSize) | ||
| let frame = NSRect( | ||
| x: -100_000 - normalizedSize.width, | ||
| y: -100_000 - normalizedSize.height, | ||
| width: normalizedSize.width, | ||
| height: normalizedSize.height | ||
| ) | ||
| let window = BrowserScreenshotOffscreenRenderPanel( | ||
| contentRect: frame, | ||
| styleMask: [.borderless, .nonactivatingPanel], | ||
| backing: .buffered, | ||
| defer: false | ||
| ) | ||
| window.isReleasedWhenClosed = false | ||
| window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender") | ||
| window.hasShadow = false | ||
| window.isOpaque = false | ||
| window.backgroundColor = .clear | ||
| window.alphaValue = 0.01 | ||
| window.ignoresMouseEvents = true | ||
| window.hidesOnDeactivate = false | ||
| window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces] | ||
| window.isExcludedFromWindowsMenu = true | ||
|
|
||
| let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize)) | ||
| contentView.wantsLayer = true | ||
| webView.removeFromSuperview() | ||
| webView.frame = contentView.bounds | ||
| webView.autoresizingMask = [.width, .height] | ||
| contentView.addSubview(webView) | ||
| window.contentView = contentView | ||
| window.orderFrontRegardless() | ||
|
|
||
| let lease = OffscreenRenderHostLease(webView: webView, viewportSize: viewportSize) | ||
| var didFinish = false | ||
| var timeoutTimer: Timer? | ||
| let finish: (Result<T, Error>) -> Void = { result in | ||
| guard !didFinish else { return } | ||
| didFinish = true | ||
| timeoutTimer?.invalidate() | ||
| timeoutTimer = nil | ||
| restoreWebView( | ||
| webView, | ||
| to: previousSuperview, | ||
| frame: previousFrame, | ||
| bounds: previousBounds, | ||
| autoresizingMask: previousAutoresizingMask, | ||
| translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints, | ||
| anchor: restoreAnchor, | ||
| position: restorePosition | ||
| ) | ||
| window.orderOut(nil) | ||
| window.contentView = nil | ||
| window.close() | ||
| lease.end() | ||
| completion(result) | ||
| } | ||
|
|
||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.