783 lines
28 KiB
Swift
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
|