Files
steipete--codexbar/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift
T
2026-07-13 12:22:33 +08:00

783 lines
28 KiB
Swift

#if os(macOS)
import AppKit
import Foundation
import WebKit
struct OpenAIDashboardWebViewLease {
let webView: WKWebView
let log: (String) -> Void
let setPreserveLoadedPageOnRelease: (Bool) -> Void
let release: () -> Void
}
@MainActor
final class OpenAIDashboardWebViewCache {
static let shared = OpenAIDashboardWebViewCache()
fileprivate static let log = CodexBarLog.logger(LogCategories.openAIWebview)
private final class ReleaseState {
var preserveLoadedPageOnRelease: Bool
init(preserveLoadedPageOnRelease: Bool) {
self.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease
}
}
private struct AcquireOptions {
let allowTimeoutRetry: Bool
let preserveLoadedPageOnRelease: Bool
}
@MainActor
private final class NavigationCancellationState {
private weak var webView: WKWebView?
private var delegate: NavigationDelegate?
private var isCancelled = false
func install(webView: WKWebView, delegate: NavigationDelegate) {
self.webView = webView
self.delegate = delegate
if self.isCancelled {
self.cancel()
}
}
func cancel() {
self.isCancelled = true
guard let webView, let delegate else { return }
delegate.cancel()
if webView.codexNavigationDelegate === delegate {
webView.stopLoading()
webView.navigationDelegate = nil
webView.codexNavigationDelegate = nil
}
self.delegate = nil
self.webView = nil
}
}
private final class Entry {
let webView: WKWebView
let host: OffscreenWebViewHost
var lastUsedAt: Date
var isBusy: Bool
var preservedPageExpiresAt: Date?
var preservedPageExpiryWorkItem: DispatchWorkItem?
init(
webView: WKWebView,
host: OffscreenWebViewHost,
lastUsedAt: Date,
isBusy: Bool,
preservedPageExpiresAt: Date? = nil)
{
self.webView = webView
self.host = host
self.lastUsedAt = lastUsedAt
self.isBusy = isBusy
self.preservedPageExpiresAt = preservedPageExpiresAt
}
func armPreservedPage(until expiry: Date) {
self.preservedPageExpiresAt = expiry
}
func setPreservedPageExpiryWorkItem(_ workItem: DispatchWorkItem?) {
self.preservedPageExpiryWorkItem?.cancel()
self.preservedPageExpiryWorkItem = workItem
}
func clearPreservedPage() {
self.preservedPageExpiresAt = nil
self.preservedPageExpiryWorkItem?.cancel()
self.preservedPageExpiryWorkItem = nil
}
func consumePreservedPageReuseIfAvailable(now: Date) -> Bool {
guard let preservedPageExpiresAt else { return false }
self.preservedPageExpiresAt = nil
self.preservedPageExpiryWorkItem?.cancel()
self.preservedPageExpiryWorkItem = nil
return preservedPageExpiresAt > now
}
func hasExpiredPreservedPage(now: Date) -> Bool {
guard let preservedPageExpiresAt else { return false }
return preservedPageExpiresAt <= now
}
}
private var entries: [ObjectIdentifier: Entry] = [:]
/// Keep the WebView alive only long enough for immediate retries/menu reopens.
/// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups.
private let idleTimeout: TimeInterval
private var idlePruneWorkItem: DispatchWorkItem?
private var idlePruneGeneration = 0
#if DEBUG
private(set) var idlePruneDeadlineForTesting: Date?
#endif
/// Reuse the validated analytics page only for the immediate next handoff.
private let preservedPageHandoffTimeout: TimeInterval = 5
private let blankURL = URL(string: "about:blank")!
private let idlePageClearScript = """
(() => {
try {
document.documentElement.innerHTML = '';
return true;
} catch {
return false;
}
})();
"""
private let reusablePageResetScript = """
(() => {
try {
delete window.__codexbarDidScrollToCredits;
delete window.__codexbarUsageBreakdownJSON;
delete window.__codexbarUsageBreakdownDebug;
return true;
} catch {
return false;
}
})();
"""
private let preferredLanguageScript = """
(() => {
const define = (target, name, value) => {
try {
Object.defineProperty(target, name, {
get: () => value,
configurable: true
});
} catch {}
};
define(Navigator.prototype, 'language', 'en-US');
define(Navigator.prototype, 'languages', ['en-US', 'en']);
define(navigator, 'language', 'en-US');
define(navigator, 'languages', ['en-US', 'en']);
})();
"""
init(idleTimeout: TimeInterval = 60) {
self.idleTimeout = idleTimeout
}
nonisolated static func remainingNavigationTimeout(
until deadline: Date,
now: Date = Date()) throws -> TimeInterval
{
let remaining = deadline.timeIntervalSince(now)
guard remaining > 0 else { throw URLError(.timedOut) }
return remaining
}
private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) {
entry.isBusy = false
let now = Date()
entry.lastUsedAt = now
self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage)
self.prepareCachedWebViewForIdle(
entry.webView,
host: entry.host,
preserveLoadedPage: preserveLoadedPage)
self.prune(now: now)
self.scheduleNextIdlePrune(now: now)
}
private func releaseNewEntry(_ entry: Entry, webView: WKWebView, preserveLoadedPage: Bool) {
entry.isBusy = false
let now = Date()
entry.lastUsedAt = now
self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage)
self.prepareCachedWebViewForIdle(
webView,
host: entry.host,
preserveLoadedPage: preserveLoadedPage)
self.prune(now: now)
self.scheduleNextIdlePrune(now: now)
}
// MARK: - Testing support
#if DEBUG
/// Number of cached WebView entries (for testing).
var entryCount: Int {
self.entries.count
}
/// Check if a WebView is cached for the given data store (for testing).
func hasCachedEntry(for websiteDataStore: WKWebsiteDataStore) -> Bool {
let key = ObjectIdentifier(websiteDataStore)
return self.entries[key] != nil
}
/// Force prune with a custom "now" timestamp (for testing idle timeout).
func pruneForTesting(now: Date) {
self.prune(now: now)
}
var idleTimeoutForTesting: TimeInterval {
self.idleTimeout
}
var preservedPageHandoffTimeoutForTesting: TimeInterval {
self.preservedPageHandoffTimeout
}
func hasPreservedPageForTesting(for websiteDataStore: WKWebsiteDataStore) -> Bool {
let key = ObjectIdentifier(websiteDataStore)
return self.entries[key]?.preservedPageExpiresAt != nil
}
func markPreservedPageForTesting(
websiteDataStore: WKWebsiteDataStore,
expiresAt: Date = .init().addingTimeInterval(5))
{
let key = ObjectIdentifier(websiteDataStore)
guard let entry = self.entries[key] else { return }
entry.armPreservedPage(until: expiresAt)
self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt)
}
func consumePreservedPageForTesting(websiteDataStore: WKWebsiteDataStore, now: Date = Date()) -> Bool {
let key = ObjectIdentifier(websiteDataStore)
guard let entry = self.entries[key] else { return false }
return entry.consumePreservedPageReuseIfAvailable(now: now)
}
/// Seed a cached entry without navigating a real page (for test stability).
@discardableResult
func cacheEntryForTesting(
websiteDataStore: WKWebsiteDataStore,
lastUsedAt: Date = Date(),
isBusy: Bool = false) -> WKWebView
{
let key = ObjectIdentifier(websiteDataStore)
if let existing = self.entries.removeValue(forKey: key) {
existing.host.close()
}
let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore)
let entry = Entry(webView: webView, host: host, lastUsedAt: lastUsedAt, isBusy: isBusy)
self.entries[key] = entry
if isBusy {
host.show()
} else {
host.hide()
}
return webView
}
/// Clear all cached entries (for test isolation).
func clearAllForTesting() {
self.cancelIdlePrune()
for (_, entry) in self.entries {
entry.clearPreservedPage()
entry.host.close()
}
self.entries.removeAll()
}
func resetReusablePageStateForTesting(_ webView: WKWebView) async -> Bool {
await self.resetReusablePageState(webView)
}
#endif
func acquire(
websiteDataStore: WKWebsiteDataStore,
usageURL: URL,
logger: ((String) -> Void)?,
navigationTimeout: TimeInterval = 15,
allowTimeoutRetry: Bool = true,
preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease
{
let deadline = Date().addingTimeInterval(max(navigationTimeout, 0.01))
return try await self.acquire(
websiteDataStore: websiteDataStore,
usageURL: usageURL,
logger: logger,
deadline: deadline,
options: .init(
allowTimeoutRetry: allowTimeoutRetry,
preserveLoadedPageOnRelease: preserveLoadedPageOnRelease))
}
private func acquire(
websiteDataStore: WKWebsiteDataStore,
usageURL: URL,
logger: ((String) -> Void)?,
deadline: Date,
options: AcquireOptions) async throws -> OpenAIDashboardWebViewLease
{
let now = Date()
self.prune(now: now)
let log: (String) -> Void = { message in
logger?("[webview] \(message)")
}
let key = ObjectIdentifier(websiteDataStore)
let remainingTimeout = try Self.remainingNavigationTimeout(until: deadline, now: now)
if let entry = self.entries[key] {
if entry.isBusy {
log("Cached WebView busy; using a temporary WebView.")
let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore)
host.show()
do {
try await self.prepareWebView(
webView,
usageURL: usageURL,
timeout: remainingTimeout,
canReuseLoadedPage: false)
} catch {
if options.allowTimeoutRetry, Self.isPrepareTimeout(error) {
host.close()
log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.")
return try await self.acquireTemporaryWebView(
websiteDataStore: websiteDataStore,
usageURL: usageURL,
log: log,
deadline: deadline)
}
host.close()
throw error
}
return OpenAIDashboardWebViewLease(
webView: webView,
log: log,
setPreserveLoadedPageOnRelease: { _ in },
release: { host.close() })
}
entry.isBusy = true
entry.lastUsedAt = now
let canReuseLoadedPage = entry.consumePreservedPageReuseIfAvailable(now: now)
let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)
entry.host.show()
do {
try await self.prepareWebView(
entry.webView,
usageURL: usageURL,
timeout: remainingTimeout,
canReuseLoadedPage: canReuseLoadedPage)
} catch {
if options.allowTimeoutRetry, Self.isPrepareTimeout(error) {
entry.isBusy = false
entry.lastUsedAt = Date()
entry.clearPreservedPage()
entry.host.close()
self.entries.removeValue(forKey: key)
log("Cached OpenAI WebView timed out; recreating it.")
return try await self.acquire(
websiteDataStore: websiteDataStore,
usageURL: usageURL,
logger: logger,
deadline: deadline,
options: .init(
allowTimeoutRetry: false,
preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease))
}
entry.isBusy = false
entry.lastUsedAt = Date()
entry.clearPreservedPage()
entry.host.close()
self.entries.removeValue(forKey: key)
Self.log.warning("OpenAI webview prepare failed")
throw error
}
return OpenAIDashboardWebViewLease(
webView: entry.webView,
log: log,
setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in
releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease
},
release: { [weak self, weak entry] in
guard let self, let entry else { return }
self.releaseCachedEntry(
entry,
preserveLoadedPage: releaseState.preserveLoadedPageOnRelease)
})
}
let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore)
let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true)
self.entries[key] = entry
host.show()
let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)
do {
try await self.prepareWebView(
webView,
usageURL: usageURL,
timeout: remainingTimeout,
canReuseLoadedPage: false)
} catch {
if options.allowTimeoutRetry, Self.isPrepareTimeout(error) {
self.entries.removeValue(forKey: key)
host.close()
log("OpenAI WebView timed out during prepare; retrying once.")
return try await self.acquire(
websiteDataStore: websiteDataStore,
usageURL: usageURL,
logger: logger,
deadline: deadline,
options: .init(
allowTimeoutRetry: false,
preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease))
}
self.entries.removeValue(forKey: key)
host.close()
Self.log.warning("OpenAI webview prepare failed")
throw error
}
return OpenAIDashboardWebViewLease(
webView: webView,
log: log,
setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in
releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease
},
release: { [weak self, weak entry] in
guard let self, let entry else { return }
self.releaseNewEntry(
entry,
webView: webView,
preserveLoadedPage: releaseState.preserveLoadedPageOnRelease)
})
}
func evict(websiteDataStore: WKWebsiteDataStore) {
let key = ObjectIdentifier(websiteDataStore)
guard let entry = self.entries.removeValue(forKey: key) else { return }
entry.clearPreservedPage()
Self.log.debug("OpenAI webview evicted")
entry.host.close()
self.scheduleNextIdlePrune()
}
func evictAll() {
self.cancelIdlePrune()
let existing = self.entries
self.entries.removeAll()
for (_, entry) in existing {
entry.clearPreservedPage()
entry.host.close()
}
if !existing.isEmpty {
Self.log.debug("OpenAI webview evicted all")
}
}
func evictIdle() {
let idleEntries = self.entries.filter { _, entry in
!entry.isBusy
}
guard !idleEntries.isEmpty else { return }
for (key, entry) in idleEntries {
entry.clearPreservedPage()
entry.host.close()
self.entries.removeValue(forKey: key)
}
Self.log.debug("OpenAI idle webviews evicted", metadata: ["count": "\(idleEntries.count)"])
self.scheduleNextIdlePrune()
}
private func prepareCachedWebViewForIdle(
_ webView: WKWebView,
host: OffscreenWebViewHost,
preserveLoadedPage: Bool)
{
webView.navigationDelegate = nil
webView.codexNavigationDelegate = nil
if preserveLoadedPage {
host.hide()
return
}
// Detach the heavyweight ChatGPT SPA as soon as a scrape completes. Keeping the WebView object around
// still helps with immediate reuse, but letting chatgpt.com remain the active document is too expensive.
webView.stopLoading()
webView.evaluateJavaScript(self.idlePageClearScript, completionHandler: nil)
_ = webView.load(URLRequest(url: self.blankURL))
host.hide()
}
/// Schedule against the oldest idle entry so later releases cannot postpone its eviction.
private func scheduleNextIdlePrune(now: Date = Date()) {
self.cancelIdlePrune()
guard let nextExpiry = self.entries.values
.filter({ !$0.isBusy })
.map({ $0.lastUsedAt.addingTimeInterval(self.idleTimeout) })
.min()
else { return }
let generation = self.idlePruneGeneration
let workItem = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
guard let self, self.idlePruneGeneration == generation else { return }
self.idlePruneWorkItem = nil
#if DEBUG
self.idlePruneDeadlineForTesting = nil
#endif
let pruneTime = Date()
self.prune(now: pruneTime)
self.scheduleNextIdlePrune(now: pruneTime)
}
}
self.idlePruneWorkItem = workItem
#if DEBUG
self.idlePruneDeadlineForTesting = nextExpiry
#endif
let delay = max(0, nextExpiry.timeIntervalSince(now)) + 0.01
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func cancelIdlePrune() {
self.idlePruneGeneration &+= 1
self.idlePruneWorkItem?.cancel()
self.idlePruneWorkItem = nil
#if DEBUG
self.idlePruneDeadlineForTesting = nil
#endif
}
private func prune(now: Date) {
for entry in self.entries.values where !entry.isBusy && entry.hasExpiredPreservedPage(now: now) {
entry.clearPreservedPage()
self.prepareCachedWebViewForIdle(
entry.webView,
host: entry.host,
preserveLoadedPage: false)
Self.log.debug("OpenAI webview preserved page expired")
}
let expired = self.entries.filter { _, entry in
!entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) >= self.idleTimeout
}
for (key, entry) in expired {
entry.host.close()
self.entries.removeValue(forKey: key)
Self.log.debug("OpenAI webview pruned")
}
}
private func makeWebView(websiteDataStore: WKWebsiteDataStore) -> (WKWebView, OffscreenWebViewHost) {
let config = WKWebViewConfiguration()
config.websiteDataStore = websiteDataStore
let userContentController = WKUserContentController()
userContentController.addUserScript(WKUserScript(
source: self.preferredLanguageScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: false))
config.userContentController = userContentController
if #available(macOS 14.0, *) {
config.preferences.inactiveSchedulingPolicy = .suspend
}
let webView = WKWebView(frame: .zero, configuration: config)
let host = OffscreenWebViewHost(webView: webView)
return (webView, host)
}
private func prepareWebView(
_ webView: WKWebView,
usageURL: URL,
timeout: TimeInterval,
canReuseLoadedPage: Bool) async throws
{
#if DEBUG
if usageURL.absoluteString == "about:blank" {
_ = webView.loadHTMLString("", baseURL: nil)
return
}
#endif
if canReuseLoadedPage,
let currentURL = webView.url?.absoluteString,
OpenAIDashboardFetcher.isUsageRoute(currentURL)
{
if await self.resetReusablePageState(webView) {
return
}
Self.log.debug("OpenAI preserved page reset failed; reloading usage URL")
}
try Task.checkCancellation()
let cancellationState = NavigationCancellationState()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let delegate = NavigationDelegate { result in
cont.resume(with: result)
}
webView.navigationDelegate = delegate
webView.codexNavigationDelegate = delegate
cancellationState.install(webView: webView, delegate: delegate)
delegate.armTimeout(seconds: timeout)
_ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL))
if Task.isCancelled {
cancellationState.cancel()
}
}
} onCancel: {
Task { @MainActor in
cancellationState.cancel()
}
}
}
private func acquireTemporaryWebView(
websiteDataStore: WKWebsiteDataStore,
usageURL: URL,
log: @escaping (String) -> Void,
deadline: Date) async throws -> OpenAIDashboardWebViewLease
{
let remainingTimeout = try Self.remainingNavigationTimeout(until: deadline)
let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore)
host.show()
do {
try await self.prepareWebView(
webView,
usageURL: usageURL,
timeout: remainingTimeout,
canReuseLoadedPage: false)
} catch {
host.close()
throw error
}
return OpenAIDashboardWebViewLease(
webView: webView,
log: log,
setPreserveLoadedPageOnRelease: { _ in },
release: { host.close() })
}
private static func isPrepareTimeout(_ error: Error) -> Bool {
let nsError = error as NSError
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
}
private func updatePreservedPageState(for entry: Entry, preserveLoadedPage: Bool) {
if preserveLoadedPage {
let expiresAt = Date().addingTimeInterval(self.preservedPageHandoffTimeout)
entry.armPreservedPage(until: expiresAt)
if let key = self.entries.first(where: { $0.value === entry })?.key {
self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt)
}
} else {
entry.clearPreservedPage()
}
}
private func schedulePreservedPageExpiry(
for key: ObjectIdentifier,
entry: Entry,
expiresAt: Date)
{
let delay = max(0, expiresAt.timeIntervalSinceNow)
let workItem = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
self?.expirePreservedPageIfNeeded(for: key, expectedExpiry: expiresAt)
}
}
entry.setPreservedPageExpiryWorkItem(workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func expirePreservedPageIfNeeded(for key: ObjectIdentifier, expectedExpiry: Date) {
guard let entry = self.entries[key],
!entry.isBusy,
let preservedPageExpiresAt = entry.preservedPageExpiresAt,
preservedPageExpiresAt == expectedExpiry,
preservedPageExpiresAt <= Date()
else {
return
}
entry.clearPreservedPage()
self.prepareCachedWebViewForIdle(
entry.webView,
host: entry.host,
preserveLoadedPage: false)
Self.log.debug("OpenAI webview preserved page expired")
self.prune(now: Date())
}
private func resetReusablePageState(_ webView: WKWebView) async -> Bool {
do {
let any = try await webView.evaluateJavaScript(self.reusablePageResetScript)
return (any as? Bool) ?? true
} catch {
return false
}
}
}
@MainActor
private final class OffscreenWebViewHost {
private let window: NSWindow
private weak var webView: WKWebView?
init(webView: WKWebView) {
// WebKit throttles timers/RAF aggressively when a WKWebView is not considered "visible".
// The Codex usage page uses streaming SSR + client hydration; if RAF is throttled, the
// dashboard never becomes part of the visible DOM and `document.body.innerText` stays tiny.
//
// Keep a transparent (mouse-ignoring) window technically "on-screen" while scraping, but
// place it almost entirely off-screen so we never ghost-render dashboard UI over the desktop.
let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 900, height: 700)
let frame = OpenAIDashboardFetcher.offscreenHostWindowFrame(for: visibleFrame)
let window = NSWindow(
contentRect: frame,
styleMask: [.borderless],
backing: .buffered,
defer: false)
window.isReleasedWhenClosed = false
window.backgroundColor = .clear
window.isOpaque = false
// Keep it effectively invisible, but non-zero alpha so WebKit treats it as "visible" and doesn't
// stall hydration (we've observed a head-only HTML shell for minutes at alpha=0).
window.alphaValue = OpenAIDashboardFetcher.offscreenHostAlphaValue()
window.hasShadow = false
window.ignoresMouseEvents = true
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.isExcludedFromWindowsMenu = true
window.contentView = webView
self.window = window
self.webView = webView
}
func show() {
OpenAIDashboardWebViewCache.log.debug("OpenAI webview show")
self.window.alphaValue = OpenAIDashboardFetcher.offscreenHostAlphaValue()
self.window.orderFrontRegardless()
}
func hide() {
// Set alpha to 0 so WebKit recognizes the page as inactive and applies
// its scheduling policy (throttle/suspend), reducing CPU when idle.
OpenAIDashboardWebViewCache.log.debug("OpenAI webview hide")
self.window.alphaValue = 0.0
self.window.orderOut(nil)
}
func close() {
OpenAIDashboardWebViewCache.log.debug("OpenAI webview close")
WebKitTeardown.scheduleCleanup(
owner: self,
window: self.window,
webView: self.webView,
closeWindow: { [window] in
window.orderOut(nil)
window.close()
})
}
}
#endif