chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
|
||||
extension OpenAIDashboardBrowserCookieImporter {
|
||||
private struct PendingCookieStoreMutation {
|
||||
let token: UUID
|
||||
let task: Task<Void, Never>
|
||||
}
|
||||
|
||||
@MainActor private static var pendingCookieStoreMutations: [ObjectIdentifier: PendingCookieStoreMutation] = [:]
|
||||
private nonisolated static let cookieCacheQueue = DispatchQueue(
|
||||
label: "com.steipete.codexbar.openai-cookie-cache")
|
||||
private nonisolated static let deadlineQueue = DispatchQueue(
|
||||
label: "com.steipete.codexbar.openai-cookie-deadline",
|
||||
qos: .userInitiated)
|
||||
|
||||
private final class CookieLoadCompletion: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didFinish = false
|
||||
|
||||
func finish(_ action: () -> Void) {
|
||||
let shouldFinish = self.lock.withLock {
|
||||
guard !self.didFinish else { return false }
|
||||
self.didFinish = true
|
||||
return true
|
||||
}
|
||||
if shouldFinish { action() }
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func remainingTimeout(
|
||||
until deadline: Date?,
|
||||
cappedAt localLimit: TimeInterval? = nil,
|
||||
now: Date = Date()) throws -> TimeInterval
|
||||
{
|
||||
guard let deadline else {
|
||||
return localLimit.map(OpenAIDashboardFetcher.sanitizedTimeout) ?? .greatestFiniteMagnitude
|
||||
}
|
||||
let remaining = deadline.timeIntervalSince(now)
|
||||
guard remaining > 0 else { throw URLError(.timedOut) }
|
||||
guard let localLimit else { return remaining }
|
||||
return min(OpenAIDashboardFetcher.sanitizedTimeout(localLimit), remaining)
|
||||
}
|
||||
|
||||
nonisolated static func runBoundedCookieLoad<T: Sendable>(
|
||||
deadline: Date?,
|
||||
timeoutObserver: (@Sendable () -> Void)? = nil,
|
||||
operation: @escaping @Sendable () throws -> T) async throws -> T
|
||||
{
|
||||
// The detached/GCD loader does not inherit TaskLocal prompt policy or retry selection.
|
||||
let contextualOperation = BrowserCookieAccessGate.operationPreservingAccessContext(operation)
|
||||
guard let deadline else {
|
||||
return try await Task.detached(priority: .userInitiated, operation: contextualOperation).value
|
||||
}
|
||||
let timeout = try self.remainingTimeout(until: deadline)
|
||||
let completion = CookieLoadCompletion()
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result = Result(catching: contextualOperation)
|
||||
completion.finish { continuation.resume(with: result) }
|
||||
}
|
||||
self.deadlineQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
completion.finish {
|
||||
timeoutObserver?()
|
||||
continuation.resume(throwing: URLError(.timedOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func runBoundedCookieCacheOperation<T: Sendable>(
|
||||
deadline: Date?,
|
||||
operation: @escaping @Sendable () throws -> T) async throws -> T
|
||||
{
|
||||
guard let deadline else {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
self.cookieCacheQueue.async {
|
||||
continuation.resume(with: Result(catching: operation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = try self.remainingTimeout(until: deadline)
|
||||
let completion = CookieLoadCompletion()
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
self.cookieCacheQueue.async {
|
||||
let result = Result(catching: operation)
|
||||
completion.finish { continuation.resume(with: result) }
|
||||
}
|
||||
self.deadlineQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
completion.finish { continuation.resume(throwing: URLError(.timedOut)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func runBoundedCallback(
|
||||
deadline: Date?,
|
||||
timeoutObserver: (@Sendable () -> Void)? = nil,
|
||||
start: (@escaping @Sendable () -> Void) -> Void) async throws
|
||||
{
|
||||
let completion = CookieLoadCompletion()
|
||||
guard let deadline else {
|
||||
await withCheckedContinuation { continuation in
|
||||
start {
|
||||
completion.finish { continuation.resume() }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let timeout = try self.remainingTimeout(until: deadline)
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
start {
|
||||
completion.finish { continuation.resume() }
|
||||
}
|
||||
self.deadlineQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
completion.finish {
|
||||
timeoutObserver?()
|
||||
continuation.resume(throwing: URLError(.timedOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func runBoundedValueCallback<T: Sendable>(
|
||||
deadline: Date?,
|
||||
timeoutObserver: (@Sendable () -> Void)? = nil,
|
||||
start: (@escaping @Sendable (T) -> Void) -> Void) async throws -> T
|
||||
{
|
||||
let completion = CookieLoadCompletion()
|
||||
guard let deadline else {
|
||||
return await withCheckedContinuation { continuation in
|
||||
start { value in
|
||||
completion.finish { continuation.resume(returning: value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = try self.remainingTimeout(until: deadline)
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
start { value in
|
||||
completion.finish { continuation.resume(returning: value) }
|
||||
}
|
||||
self.deadlineQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
completion.finish {
|
||||
timeoutObserver?()
|
||||
continuation.resume(throwing: URLError(.timedOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func runSerializedCallback(
|
||||
key: ObjectIdentifier,
|
||||
deadline: Date?,
|
||||
start: @escaping (@escaping @Sendable () -> Void) -> Void) async throws
|
||||
{
|
||||
while let pending = self.pendingCookieStoreMutations[key] {
|
||||
try await self.waitForMutation(pending.task, deadline: deadline)
|
||||
if self.pendingCookieStoreMutations[key]?.token == pending.token {
|
||||
self.pendingCookieStoreMutations[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
let token = UUID()
|
||||
let task = Task { @MainActor in
|
||||
await withCheckedContinuation { continuation in
|
||||
start { continuation.resume() }
|
||||
}
|
||||
}
|
||||
self.pendingCookieStoreMutations[key] = PendingCookieStoreMutation(token: token, task: task)
|
||||
Task { @MainActor in
|
||||
await task.value
|
||||
if self.pendingCookieStoreMutations[key]?.token == token {
|
||||
self.pendingCookieStoreMutations[key] = nil
|
||||
}
|
||||
}
|
||||
try await self.waitForMutation(task, deadline: deadline)
|
||||
}
|
||||
|
||||
private static func waitForMutation(_ task: Task<Void, Never>, deadline: Date?) async throws {
|
||||
try await self.runBoundedCallback(deadline: deadline) { completion in
|
||||
Task { @MainActor in
|
||||
await task.value
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
|
||||
extension OpenAIDashboardFetcher {
|
||||
struct ReturnableDashboardDataInput {
|
||||
let codeReview: Double?
|
||||
let events: [CreditEvent]
|
||||
let usageBreakdown: [OpenAIDashboardDailyBreakdown]
|
||||
let hasUsageLimits: Bool
|
||||
let creditsRemaining: Double?
|
||||
let codexCreditLimit: CodexCreditLimitSnapshot?
|
||||
}
|
||||
|
||||
nonisolated static func hasReturnableDashboardData(_ input: ReturnableDashboardDataInput) -> Bool {
|
||||
input.codeReview != nil
|
||||
|| !input.events.isEmpty
|
||||
|| !input.usageBreakdown.isEmpty
|
||||
|| input.hasUsageLimits
|
||||
|| input.creditsRemaining != nil
|
||||
|| input.codexCreditLimit != nil
|
||||
}
|
||||
|
||||
nonisolated static func hasAnyDashboardSignal(
|
||||
hasReturnableData: Bool,
|
||||
creditsHeaderPresent: Bool) -> Bool
|
||||
{
|
||||
hasReturnableData || creditsHeaderPresent
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
// MARK: - Navigation helper (revived from the old credits scraper)
|
||||
|
||||
@MainActor
|
||||
final class NavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
private let completion: (Result<Void, Error>) -> Void
|
||||
private var hasCompleted: Bool = false
|
||||
private var timeoutWorkItem: DispatchWorkItem?
|
||||
private var postCommitWorkItem: DispatchWorkItem?
|
||||
static var associationKey: UInt8 = 0
|
||||
nonisolated static let postCommitSuccessDelay: TimeInterval = 0.75
|
||||
|
||||
init(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func armTimeout(seconds: TimeInterval) {
|
||||
self.timeoutWorkItem?.cancel()
|
||||
let delay = max(seconds, 0)
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
self?.completeOnce(.failure(URLError(.timedOut)))
|
||||
}
|
||||
}
|
||||
self.timeoutWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
self.completeOnce(.failure(CancellationError()))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
self.completeOnce(.success(()))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
guard !self.hasCompleted else { return }
|
||||
self.postCommitWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
self?.completeOnce(.success(()))
|
||||
}
|
||||
}
|
||||
self.postCommitWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.postCommitSuccessDelay, execute: workItem)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.shouldIgnoreNavigationError(error) { return }
|
||||
self.completeOnce(.failure(error))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.shouldIgnoreNavigationError(error) { return }
|
||||
self.completeOnce(.failure(error))
|
||||
}
|
||||
|
||||
nonisolated static func shouldIgnoreNavigationError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
|
||||
return true
|
||||
}
|
||||
|
||||
if nsError.domain == "WebKitErrorDomain", nsError.code == 102 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func completeOnce(_ result: Result<Void, Error>) {
|
||||
guard !self.hasCompleted else { return }
|
||||
self.hasCompleted = true
|
||||
self.timeoutWorkItem?.cancel()
|
||||
self.timeoutWorkItem = nil
|
||||
self.postCommitWorkItem?.cancel()
|
||||
self.postCommitWorkItem = nil
|
||||
self.completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
extension WKWebView {
|
||||
var codexNavigationDelegate: NavigationDelegate? {
|
||||
get {
|
||||
objc_getAssociatedObject(self, &NavigationDelegate.associationKey) as? NavigationDelegate
|
||||
}
|
||||
set {
|
||||
objc_setAssociatedObject(
|
||||
self,
|
||||
&NavigationDelegate.associationKey,
|
||||
newValue,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,551 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenAIDashboardParser {
|
||||
/// Extracts the signed-in email from the embedded `client-bootstrap` JSON payload, if present.
|
||||
///
|
||||
/// The Codex usage dashboard currently ships a JSON blob in:
|
||||
/// `<script type="application/json" id="client-bootstrap">…</script>`.
|
||||
/// WebKit `document.body.innerText` often does not include the email, so we parse it from HTML.
|
||||
public static func parseSignedInEmailFromClientBootstrap(html: String) -> String? {
|
||||
guard let data = self.clientBootstrapJSONData(fromHTML: html) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
|
||||
// Fast path: common structure.
|
||||
if let dict = json as? [String: Any] {
|
||||
if let session = dict["session"] as? [String: Any],
|
||||
let user = session["user"] as? [String: Any],
|
||||
let email = user["email"] as? String,
|
||||
email.contains("@")
|
||||
{
|
||||
return email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
if let user = dict["user"] as? [String: Any],
|
||||
let email = user["email"] as? String,
|
||||
email.contains("@")
|
||||
{
|
||||
return email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: BFS scan for an email key/value.
|
||||
var queue: [Any] = [json]
|
||||
var seen = 0
|
||||
while !queue.isEmpty, seen < 4000 {
|
||||
let cur = queue.removeFirst()
|
||||
seen += 1
|
||||
if let dict = cur as? [String: Any] {
|
||||
for (k, v) in dict {
|
||||
if k.lowercased() == "email", let email = v as? String, email.contains("@") {
|
||||
return email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
queue.append(v)
|
||||
}
|
||||
} else if let arr = cur as? [Any] {
|
||||
queue.append(contentsOf: arr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Extracts the auth status from `client-bootstrap`, if present.
|
||||
/// Expected values include `logged_in` and `logged_out`.
|
||||
public static func parseAuthStatusFromClientBootstrap(html: String) -> String? {
|
||||
guard let data = self.clientBootstrapJSONData(fromHTML: html) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
guard let dict = json as? [String: Any] else { return nil }
|
||||
if let authStatus = dict["authStatus"] as? String, !authStatus.isEmpty {
|
||||
return authStatus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func parseCodeReviewRemainingPercent(bodyText: String) -> Double? {
|
||||
let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
|
||||
for regex in self.codeReviewRegexes {
|
||||
let range = NSRange(cleaned.startIndex..<cleaned.endIndex, in: cleaned)
|
||||
guard let match = regex.firstMatch(in: cleaned, options: [], range: range),
|
||||
match.numberOfRanges >= 2,
|
||||
let r = Range(match.range(at: 1), in: cleaned)
|
||||
else { continue }
|
||||
if let val = Double(cleaned[r]) { return min(100, max(0, val)) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func parseCreditsRemaining(bodyText: String) -> Double? {
|
||||
let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
|
||||
let patterns = [
|
||||
#"credits\s*remaining[^0-9]*([0-9][0-9.,]*)"#,
|
||||
#"remaining\s*credits[^0-9]*([0-9][0-9.,]*)"#,
|
||||
#"credit\s*balance[^0-9]*([0-9][0-9.,]*)"#,
|
||||
]
|
||||
for pattern in patterns {
|
||||
if let val = TextParsing.firstNumber(pattern: pattern, text: cleaned) { return val }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func parseRateLimits(
|
||||
bodyText: String,
|
||||
now: Date = .init()) -> (primary: RateWindow?, secondary: RateWindow?)
|
||||
{
|
||||
let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
|
||||
let lines = cleaned
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let primary = self.parseRateWindow(
|
||||
lines: lines,
|
||||
match: self.isFiveHourLimitLine,
|
||||
windowMinutes: 5 * 60,
|
||||
now: now)
|
||||
let secondary = self.parseRateWindow(
|
||||
lines: lines,
|
||||
match: self.isWeeklyLimitLine,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
now: now)
|
||||
return (primary, secondary)
|
||||
}
|
||||
|
||||
public static func parseCodeReviewLimit(bodyText: String, now: Date = .init()) -> RateWindow? {
|
||||
let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
|
||||
let lines = cleaned
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
return self.parseRateWindow(
|
||||
lines: lines,
|
||||
match: self.isCodeReviewLimitLine,
|
||||
windowMinutes: nil,
|
||||
now: now)
|
||||
}
|
||||
|
||||
public static func parsePlanFromHTML(html: String) -> String? {
|
||||
if let data = self.clientBootstrapJSONData(fromHTML: html),
|
||||
let plan = self.findPlan(in: data)
|
||||
{
|
||||
return plan
|
||||
}
|
||||
if let data = self.nextDataJSONData(fromHTML: html),
|
||||
let plan = self.findPlan(in: data)
|
||||
{
|
||||
return plan
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func parseCreditEvents(rows: [[String]]) -> [CreditEvent] {
|
||||
let formatter = self.creditDateFormatter()
|
||||
|
||||
return rows.compactMap { row in
|
||||
guard row.count >= 3 else { return nil }
|
||||
let dateString = row[0]
|
||||
let service = row[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let amountString = row[2]
|
||||
guard let date = formatter.date(from: dateString) else { return nil }
|
||||
let creditsUsed = Self.parseCreditsUsed(amountString)
|
||||
return CreditEvent(date: date, service: service, creditsUsed: creditsUsed)
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private static func parseCreditsUsed(_ text: String) -> Double {
|
||||
guard let raw = self.firstNumberToken(in: text) else { return 0 }
|
||||
let token = raw
|
||||
.replacingOccurrences(of: "\u{00A0}", with: "")
|
||||
.replacingOccurrences(of: "\u{202F}", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
let hasComma = token.contains(",")
|
||||
let hasDot = token.contains(".")
|
||||
if hasComma, hasDot {
|
||||
return TextParsing.firstNumber(pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, text: token) ?? 0
|
||||
}
|
||||
if hasComma {
|
||||
if self.usesLocalizedDecimalCommaCreditLabel(text) {
|
||||
return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||
}
|
||||
if token.range(of: #"^\d{1,3}(,\d{3})+$"#, options: .regularExpression) != nil {
|
||||
return Double(token.replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||
}
|
||||
return Double(token) ?? 0
|
||||
}
|
||||
|
||||
private static func usesLocalizedDecimalCommaCreditLabel(_ text: String) -> Bool {
|
||||
text
|
||||
.lowercased()
|
||||
.contains("crédit")
|
||||
}
|
||||
|
||||
private static func firstNumberToken(in text: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#,
|
||||
options: [])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: range),
|
||||
match.numberOfRanges >= 2,
|
||||
let tokenRange = Range(match.range(at: 1), in: text)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(text[tokenRange])
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static let codeReviewRegexes: [NSRegularExpression] = {
|
||||
let patterns = [
|
||||
#"Code\s*review[^0-9%]*([0-9]{1,3})%\s*remaining"#,
|
||||
#"Core\s*review[^0-9%]*([0-9]{1,3})%\s*remaining"#,
|
||||
]
|
||||
return patterns.compactMap { pattern in
|
||||
try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
|
||||
}
|
||||
}()
|
||||
|
||||
private static let creditDateFormatterKey = "OpenAIDashboardParser.creditDateFormatter"
|
||||
private static let clientBootstrapNeedle = Data("id=\"client-bootstrap\"".utf8)
|
||||
private static let nextDataNeedle = Data("id=\"__NEXT_DATA__\"".utf8)
|
||||
private static let scriptCloseNeedle = Data("</script>".utf8)
|
||||
|
||||
private static func creditDateFormatter() -> DateFormatter {
|
||||
let threadDict = Thread.current.threadDictionary
|
||||
if let cached = threadDict[self.creditDateFormatterKey] as? DateFormatter {
|
||||
return cached
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
threadDict[self.creditDateFormatterKey] = formatter
|
||||
return formatter
|
||||
}
|
||||
|
||||
private static func clientBootstrapJSONData(fromHTML html: String) -> Data? {
|
||||
let data = Data(html.utf8)
|
||||
guard let idRange = data.range(of: self.clientBootstrapNeedle) else { return nil }
|
||||
|
||||
guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil }
|
||||
let contentStart = data.index(after: openTagEnd)
|
||||
guard let closeRange = data.range(
|
||||
of: self.scriptCloseNeedle,
|
||||
options: [],
|
||||
in: contentStart..<data.endIndex)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let rawData = data[contentStart..<closeRange.lowerBound]
|
||||
let trimmed = self.trimASCIIWhitespace(Data(rawData))
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func nextDataJSONData(fromHTML html: String) -> Data? {
|
||||
let data = Data(html.utf8)
|
||||
guard let idRange = data.range(of: self.nextDataNeedle) else { return nil }
|
||||
|
||||
guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil }
|
||||
let contentStart = data.index(after: openTagEnd)
|
||||
guard let closeRange = data.range(
|
||||
of: self.scriptCloseNeedle,
|
||||
options: [],
|
||||
in: contentStart..<data.endIndex)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let rawData = data[contentStart..<closeRange.lowerBound]
|
||||
let trimmed = self.trimASCIIWhitespace(Data(rawData))
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func trimASCIIWhitespace(_ data: Data) -> Data {
|
||||
guard !data.isEmpty else { return data }
|
||||
var start = data.startIndex
|
||||
var end = data.endIndex
|
||||
|
||||
while start < end, data[start].isASCIIWhitespace {
|
||||
start = data.index(after: start)
|
||||
}
|
||||
while end > start {
|
||||
let prev = data.index(before: end)
|
||||
if data[prev].isASCIIWhitespace {
|
||||
end = prev
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return data.subdata(in: start..<end)
|
||||
}
|
||||
|
||||
private static func parseRateWindow(
|
||||
lines: [String],
|
||||
match: (String) -> Bool,
|
||||
windowMinutes: Int?,
|
||||
now: Date) -> RateWindow?
|
||||
{
|
||||
for idx in lines.indices where match(lines[idx]) {
|
||||
let end = min(lines.count - 1, idx + 5)
|
||||
let windowLines = Array(lines[idx...end])
|
||||
|
||||
var percentValue: Double?
|
||||
var isRemaining = true
|
||||
for line in windowLines {
|
||||
if let percent = self.parsePercent(from: line) {
|
||||
percentValue = percent.value
|
||||
isRemaining = percent.isRemaining
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let percentValue else { continue }
|
||||
let usedPercent = isRemaining ? max(0, min(100, 100 - percentValue)) : max(0, min(100, percentValue))
|
||||
|
||||
let resetLine = windowLines.first { $0.localizedCaseInsensitiveContains("reset") }
|
||||
let resetDescription = resetLine?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resetsAt = resetLine.flatMap { self.parseResetDate(from: $0, now: now) }
|
||||
let fallbackDescription = resetsAt.map { UsageFormatter.resetDescription(from: $0) }
|
||||
|
||||
return RateWindow(
|
||||
usedPercent: usedPercent,
|
||||
windowMinutes: windowMinutes,
|
||||
resetsAt: resetsAt,
|
||||
resetDescription: resetDescription ?? fallbackDescription)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parsePercent(from line: String) -> (value: Double, isRemaining: Bool)? {
|
||||
guard let percent = TextParsing.firstNumber(pattern: #"([0-9]{1,3})\s*%"#, text: line) else { return nil }
|
||||
let lower = line.lowercased()
|
||||
let isRemaining = lower.contains("remaining") || lower.contains("left")
|
||||
let isUsed = lower.contains("used") || lower.contains("spent") || lower.contains("consumed")
|
||||
if isUsed { return (percent, false) }
|
||||
if isRemaining { return (percent, true) }
|
||||
return (percent, true)
|
||||
}
|
||||
|
||||
private static func isFiveHourLimitLine(_ line: String) -> Bool {
|
||||
let lower = line.lowercased()
|
||||
if lower.contains("5h") { return true }
|
||||
if lower.range(of: #"\b5\s*h\b"#, options: .regularExpression) != nil { return true }
|
||||
if lower.contains("5-hour") { return true }
|
||||
if lower.contains("5 hour") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isWeeklyLimitLine(_ line: String) -> Bool {
|
||||
let lower = line.lowercased()
|
||||
if lower.contains("weekly") { return true }
|
||||
if lower.contains("7-day") { return true }
|
||||
if lower.contains("7 day") { return true }
|
||||
if lower.contains("7d") { return true }
|
||||
if lower.range(of: #"\b7\s*d\b"#, options: .regularExpression) != nil { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isCodeReviewLimitLine(_ line: String) -> Bool {
|
||||
let lower = line.lowercased()
|
||||
guard lower.contains("code review") || lower.contains("core review") else { return false }
|
||||
if lower.contains("github code review") { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private static func parseResetDate(from line: String, now: Date) -> Date? {
|
||||
var raw = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression)
|
||||
raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive)
|
||||
raw = raw.replacingOccurrences(of: " on ", with: " ", options: .caseInsensitive)
|
||||
raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let monthDayFormatter = DateFormatter()
|
||||
monthDayFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
monthDayFormatter.timeZone = TimeZone.current
|
||||
monthDayFormatter.dateFormat = "MMM d"
|
||||
|
||||
var candidate = raw
|
||||
let lower = candidate.lowercased()
|
||||
var usedRelativeDay = false
|
||||
|
||||
if lower.contains("today") {
|
||||
usedRelativeDay = true
|
||||
let dateText = monthDayFormatter.string(from: now)
|
||||
candidate = candidate.replacingOccurrences(of: "today", with: dateText, options: .caseInsensitive)
|
||||
} else if lower.contains("tomorrow") {
|
||||
usedRelativeDay = true
|
||||
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) {
|
||||
let dateText = monthDayFormatter.string(from: tomorrow)
|
||||
candidate = candidate.replacingOccurrences(of: "tomorrow", with: dateText, options: .caseInsensitive)
|
||||
}
|
||||
}
|
||||
|
||||
if let weekdayMatch = self.weekdayMatch(in: candidate) {
|
||||
usedRelativeDay = true
|
||||
let target = self.nextWeekdayDate(weekday: weekdayMatch.weekday, now: now, calendar: calendar)
|
||||
let dateText = monthDayFormatter.string(from: target)
|
||||
candidate = candidate.replacingOccurrences(
|
||||
of: weekdayMatch.matched,
|
||||
with: dateText,
|
||||
options: .caseInsensitive)
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.defaultDate = now
|
||||
|
||||
let formats = [
|
||||
"MMM d h:mma",
|
||||
"MMM d, h:mma",
|
||||
"MMM d h:mm a",
|
||||
"MMM d, h:mm a",
|
||||
"MMM d HH:mm",
|
||||
"MMM d, HH:mm",
|
||||
"MMM d",
|
||||
"M/d h:mma",
|
||||
"M/d h:mm a",
|
||||
"M/d/yyyy h:mm a",
|
||||
"M/d/yy h:mm a",
|
||||
"M/d",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy-MM-dd h:mm a",
|
||||
"yyyy-MM-dd",
|
||||
]
|
||||
|
||||
for format in formats {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: candidate) {
|
||||
if usedRelativeDay, date < now {
|
||||
if lower.contains("today"),
|
||||
let bumped = calendar.date(byAdding: .day, value: 1, to: date)
|
||||
{
|
||||
return bumped
|
||||
}
|
||||
if let bumped = calendar.date(byAdding: .day, value: 7, to: date) {
|
||||
return bumped
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct WeekdayMatch {
|
||||
let matched: String
|
||||
let weekday: Int
|
||||
}
|
||||
|
||||
private static func weekdayMatch(in text: String) -> WeekdayMatch? {
|
||||
// The optional "(day)?" suffix requires each alternative to be long enough that
|
||||
// adding "day" reproduces the full weekday name. "wed"+"day"="wedday" and
|
||||
// "sat"+"day"="satday", so the longer prefixes "wednes" and "satur" are needed
|
||||
// to cover the full "Wednesday" and "Saturday" forms — mirroring how the existing
|
||||
// "tue|tues" and "thu|thur|thurs" alternatives layer up to "tuesday"/"thursday".
|
||||
let pattern = #"\b(mon|tue|tues|wed|wednes|thu|thur|thurs|fri|sat|satur|sun)(day)?\b"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: range),
|
||||
let r = Range(match.range(at: 0), in: text)
|
||||
else { return nil }
|
||||
let matched = String(text[r])
|
||||
let lower = matched.lowercased()
|
||||
let weekday = switch lower.prefix(3) {
|
||||
case "mon": 2
|
||||
case "tue": 3
|
||||
case "wed": 4
|
||||
case "thu": 5
|
||||
case "fri": 6
|
||||
case "sat": 7
|
||||
default: 1
|
||||
}
|
||||
return WeekdayMatch(matched: matched, weekday: weekday)
|
||||
}
|
||||
|
||||
private static func nextWeekdayDate(weekday: Int, now: Date, calendar: Calendar) -> Date {
|
||||
let currentWeekday = calendar.component(.weekday, from: now)
|
||||
var delta = weekday - currentWeekday
|
||||
if delta < 0 { delta += 7 }
|
||||
guard let next = calendar.date(byAdding: .day, value: delta, to: calendar.startOfDay(for: now)) else {
|
||||
return now
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private static func findPlan(in data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
return self.findPlan(in: json)
|
||||
}
|
||||
|
||||
private static func findPlan(in json: Any) -> String? {
|
||||
var queue: [Any] = [json]
|
||||
var seen = 0
|
||||
while !queue.isEmpty, seen < 6000 {
|
||||
let cur = queue.removeFirst()
|
||||
seen += 1
|
||||
if let dict = cur as? [String: Any] {
|
||||
for (k, v) in dict {
|
||||
if let plan = self.planCandidate(forKey: k, value: v) { return plan }
|
||||
queue.append(v)
|
||||
}
|
||||
} else if let arr = cur as? [Any] {
|
||||
queue.append(contentsOf: arr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func planCandidate(forKey key: String, value: Any) -> String? {
|
||||
guard self.isPlanKey(key) else { return nil }
|
||||
if let str = value as? String {
|
||||
return self.normalizePlanValue(str)
|
||||
}
|
||||
if let dict = value as? [String: Any] {
|
||||
if let name = dict["name"] as? String, let plan = self.normalizePlanValue(name) { return plan }
|
||||
if let display = dict["displayName"] as? String, let plan = self.normalizePlanValue(display) { return plan }
|
||||
if let tier = dict["tier"] as? String, let plan = self.normalizePlanValue(tier) { return plan }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isPlanKey(_ key: String) -> Bool {
|
||||
let lower = key.lowercased()
|
||||
return lower.contains("plan") || lower.contains("tier") || lower.contains("subscription")
|
||||
}
|
||||
|
||||
private static func normalizePlanValue(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
let allowed = [
|
||||
"free",
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"enterprise",
|
||||
"business",
|
||||
"edu",
|
||||
"education",
|
||||
"gov",
|
||||
"premium",
|
||||
"essential",
|
||||
]
|
||||
guard allowed.contains(where: { lower.contains($0) }) else { return nil }
|
||||
return CodexPlanFormatting.displayName(trimmed) ?? UsageFormatter.cleanPlanName(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
extension UInt8 {
|
||||
fileprivate var isASCIIWhitespace: Bool {
|
||||
switch self {
|
||||
case 9, 10, 13, 32: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
#if os(macOS)
|
||||
let openAIDashboardScrapeScript = """
|
||||
|
||||
(() => {
|
||||
const textOf = el => {
|
||||
const raw = el && (el.innerText || el.textContent) ? String(el.innerText || el.textContent) : '';
|
||||
return raw.trim();
|
||||
};
|
||||
const parseHexColor = (color) => {
|
||||
if (!color) return null;
|
||||
const c = String(color).trim().toLowerCase();
|
||||
if (c.startsWith('#')) {
|
||||
if (c.length === 4) {
|
||||
return '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3];
|
||||
}
|
||||
if (c.length === 7) return c;
|
||||
return c;
|
||||
}
|
||||
const m = c.match(/^rgba?\\(([^)]+)\\)$/);
|
||||
if (m) {
|
||||
const parts = m[1].split(',').map(x => parseFloat(x.trim())).filter(x => Number.isFinite(x));
|
||||
if (parts.length >= 3) {
|
||||
const r = Math.max(0, Math.min(255, Math.round(parts[0])));
|
||||
const g = Math.max(0, Math.min(255, Math.round(parts[1])));
|
||||
const b = Math.max(0, Math.min(255, Math.round(parts[2])));
|
||||
const toHex = n => n.toString(16).padStart(2, '0');
|
||||
return '#' + toHex(r) + toHex(g) + toHex(b);
|
||||
}
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const reactPropsOf = (el) => {
|
||||
if (!el) return null;
|
||||
try {
|
||||
const keys = Object.keys(el);
|
||||
const propsKey = keys.find(k => k.startsWith('__reactProps$'));
|
||||
if (propsKey) return el[propsKey] || null;
|
||||
const fiberKey = keys.find(k => k.startsWith('__reactFiber$'));
|
||||
if (fiberKey) {
|
||||
const fiber = el[fiberKey];
|
||||
return (fiber && (fiber.memoizedProps || fiber.pendingProps)) || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
const reactFiberOf = (el) => {
|
||||
if (!el) return null;
|
||||
try {
|
||||
const keys = Object.keys(el);
|
||||
const fiberKey = keys.find(k => k.startsWith('__reactFiber$'));
|
||||
return fiberKey ? (el[fiberKey] || null) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const nestedBarMetaOf = (root) => {
|
||||
if (!root || typeof root !== 'object') return null;
|
||||
const queue = [root];
|
||||
const seen = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
||||
let steps = 0;
|
||||
while (queue.length && steps < 250) {
|
||||
const cur = queue.shift();
|
||||
steps++;
|
||||
if (!cur || typeof cur !== 'object') continue;
|
||||
if (seen) {
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
}
|
||||
if (cur.payload && (cur.dataKey || cur.name || cur.value !== undefined)) return cur;
|
||||
const values = Array.isArray(cur) ? cur : Object.values(cur);
|
||||
for (const v of values) {
|
||||
if (v && typeof v === 'object') queue.push(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const barMetaFromElement = (el) => {
|
||||
const direct = reactPropsOf(el);
|
||||
if (direct && direct.payload && (direct.dataKey || direct.name || direct.value !== undefined)) return direct;
|
||||
|
||||
const fiber = reactFiberOf(el);
|
||||
if (fiber) {
|
||||
let cur = fiber;
|
||||
for (let i = 0; i < 10 && cur; i++) {
|
||||
const props = (cur.memoizedProps || cur.pendingProps) || null;
|
||||
if (props && props.payload && (props.dataKey || props.name || props.value !== undefined)) return props;
|
||||
const nested = props ? nestedBarMetaOf(props) : null;
|
||||
if (nested) return nested;
|
||||
cur = cur.return || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (direct) {
|
||||
const nested = nestedBarMetaOf(direct);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const normalizeHref = (raw) => {
|
||||
if (!raw) return null;
|
||||
const href = String(raw).trim();
|
||||
if (!href) return null;
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
||||
if (href.startsWith('//')) return window.location.protocol + href;
|
||||
if (href.startsWith('/')) return window.location.origin + href;
|
||||
return window.location.origin + '/' + href;
|
||||
};
|
||||
const isLikelyCreditsURL = (raw) => {
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const url = new URL(raw, window.location.origin);
|
||||
if (!url.host || !url.host.includes('chatgpt.com')) return false;
|
||||
const path = url.pathname.toLowerCase();
|
||||
return (
|
||||
path.includes('settings') ||
|
||||
path.includes('usage') ||
|
||||
path.includes('billing') ||
|
||||
path.includes('credits')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const purchaseTextMatches = (text) => {
|
||||
const lower = String(text || '').trim().toLowerCase();
|
||||
if (!lower) return false;
|
||||
if (lower.includes('add more')) return true;
|
||||
if (!lower.includes('credit')) return false;
|
||||
return (
|
||||
lower.includes('buy') ||
|
||||
lower.includes('add') ||
|
||||
lower.includes('purchase') ||
|
||||
lower.includes('top up') ||
|
||||
lower.includes('top-up')
|
||||
);
|
||||
};
|
||||
const elementLabel = (el) => {
|
||||
if (!el) return '';
|
||||
return (
|
||||
textOf(el) ||
|
||||
el.getAttribute('aria-label') ||
|
||||
el.getAttribute('title') ||
|
||||
''
|
||||
);
|
||||
};
|
||||
const urlFromProps = (props) => {
|
||||
if (!props || typeof props !== 'object') return null;
|
||||
const candidates = [
|
||||
props.href,
|
||||
props.to,
|
||||
props.url,
|
||||
props.link,
|
||||
props.destination,
|
||||
props.navigateTo
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return normalizeHref(candidate);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const purchaseURLFromElement = (el) => {
|
||||
if (!el) return null;
|
||||
const isAnchor = el.tagName && el.tagName.toLowerCase() === 'a';
|
||||
const anchor = isAnchor ? el : (el.closest ? el.closest('a') : null);
|
||||
const anchorHref = anchor ? anchor.getAttribute('href') : null;
|
||||
const dataHref = el.getAttribute
|
||||
? (el.getAttribute('data-href') ||
|
||||
el.getAttribute('data-url') ||
|
||||
el.getAttribute('data-link') ||
|
||||
el.getAttribute('data-destination'))
|
||||
: null;
|
||||
const propHref = urlFromProps(reactPropsOf(el)) || urlFromProps(reactPropsOf(anchor));
|
||||
const normalized = normalizeHref(anchorHref || dataHref || propHref);
|
||||
return normalized && isLikelyCreditsURL(normalized) ? normalized : null;
|
||||
};
|
||||
const cleanPlanName = (raw) => String(raw || '')
|
||||
.replace(/\\b(claude|codex|account|plan)\\b/gi, ' ')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\\s+/g, ' ')
|
||||
.trim();
|
||||
const codexPlanDisplayName = (raw) => {
|
||||
const trimmed = String(raw || '').trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
const exact = {
|
||||
pro: 'Pro 20x',
|
||||
prolite: 'Pro 5x',
|
||||
'pro_lite': 'Pro 5x',
|
||||
'pro-lite': 'Pro 5x',
|
||||
'pro lite': 'Pro 5x'
|
||||
};
|
||||
if (exact[lower]) return exact[lower];
|
||||
const cleaned = cleanPlanName(trimmed);
|
||||
if (!cleaned) return trimmed;
|
||||
if (exact[cleaned.toLowerCase()]) return exact[cleaned.toLowerCase()];
|
||||
return cleaned.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(word => {
|
||||
const wordLower = word.toLowerCase();
|
||||
if (wordLower === 'cbp' || wordLower === 'k12') return wordLower.toUpperCase();
|
||||
if (word === word.toUpperCase() && /[a-z]/i.test(word)) return word;
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join(' ') || cleaned;
|
||||
};
|
||||
const normalizePlanValue = (value) => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
const allowed = [
|
||||
'free',
|
||||
'plus',
|
||||
'pro',
|
||||
'team',
|
||||
'enterprise',
|
||||
'business',
|
||||
'edu',
|
||||
'education',
|
||||
'gov',
|
||||
'premium',
|
||||
'essential'
|
||||
];
|
||||
if (!allowed.some(token => lower.includes(token))) return null;
|
||||
return codexPlanDisplayName(trimmed) || cleanPlanName(trimmed);
|
||||
};
|
||||
const planCandidate = (key, value) => {
|
||||
const lower = String(key || '').toLowerCase();
|
||||
if (!lower.includes('plan') && !lower.includes('tier') && !lower.includes('subscription')) return null;
|
||||
if (typeof value === 'string') return normalizePlanValue(value);
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return normalizePlanValue(value.name) ||
|
||||
normalizePlanValue(value.displayName) ||
|
||||
normalizePlanValue(value.tier);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const findPlan = (root) => {
|
||||
if (!root || typeof root !== 'object') return null;
|
||||
const queue = [root];
|
||||
const seenObjects = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
||||
let index = 0;
|
||||
let seen = 0;
|
||||
while (index < queue.length && seen < 6000) {
|
||||
const cur = queue[index++];
|
||||
seen++;
|
||||
if (!cur || typeof cur !== 'object') continue;
|
||||
if (seenObjects) {
|
||||
if (seenObjects.has(cur)) continue;
|
||||
seenObjects.add(cur);
|
||||
}
|
||||
if (Array.isArray(cur)) {
|
||||
for (const v of cur) {
|
||||
if (v && typeof v === 'object') queue.push(v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const [k, v] of Object.entries(cur)) {
|
||||
const plan = planCandidate(k, v);
|
||||
if (plan) return plan;
|
||||
if (v && typeof v === 'object') queue.push(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const parseJSONScript = (id) => {
|
||||
try {
|
||||
const node = document.getElementById(id);
|
||||
const raw = node && node.textContent ? String(node.textContent) : '';
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const pickLikelyPurchaseButton = (buttons) => {
|
||||
if (!buttons || buttons.length === 0) return null;
|
||||
const labeled = buttons.find(btn => {
|
||||
const label = elementLabel(btn);
|
||||
if (purchaseTextMatches(label)) return true;
|
||||
const aria = String(btn.getAttribute('aria-label') || '').toLowerCase();
|
||||
return aria.includes('credit') || aria.includes('buy') || aria.includes('add');
|
||||
});
|
||||
return labeled || buttons[0];
|
||||
};
|
||||
const findCreditsPurchaseButton = () => {
|
||||
const nodes = Array.from(document.querySelectorAll('h1,h2,h3,div,span,p'));
|
||||
const labelMatch = nodes.find(node => {
|
||||
const lower = textOf(node).toLowerCase();
|
||||
return lower === 'credits remaining' || (lower.includes('credits') && lower.includes('remaining'));
|
||||
});
|
||||
if (!labelMatch) return null;
|
||||
let cur = labelMatch;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
const buttons = Array.from(cur.querySelectorAll('button, a'));
|
||||
const picked = pickLikelyPurchaseButton(buttons);
|
||||
if (picked) return picked;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const dayKeyFromPayload = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const localDayKeyForDate = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const keys = ['day', 'date', 'name', 'label', 'x', 'time', 'timestamp'];
|
||||
for (const k of keys) {
|
||||
const v = payload[k];
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s;
|
||||
const iso = s.match(/^(\\d{4}-\\d{2}-\\d{2})/);
|
||||
if (iso) return iso[1];
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v) && (k === 'timestamp' || k === 'time' || k === 'x')) {
|
||||
try {
|
||||
const d = new Date(v);
|
||||
if (!isNaN(d.getTime())) return localDayKeyForDate(d);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const isSkillUsageServiceKey = (raw) => {
|
||||
const key = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase();
|
||||
return key.startsWith('skillusage:');
|
||||
};
|
||||
const displayNameForUsageServiceKey = (raw) => {
|
||||
const key = raw === null || raw === undefined ? '' : String(raw).trim();
|
||||
if (!key) return key;
|
||||
if (isSkillUsageServiceKey(key)) return null;
|
||||
if (key.toUpperCase() === key && key.length <= 6) return key;
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'cli') return 'CLI';
|
||||
if (lower.includes('github') && lower.includes('review')) return 'GitHub Code Review';
|
||||
const words = lower.replace(/[_-]+/g, ' ').split(' ').filter(Boolean);
|
||||
return words.map(w => w.length <= 2 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
};
|
||||
const isLikelyCodexUsageService = (raw) => {
|
||||
const service = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase();
|
||||
return (
|
||||
service === 'cli' ||
|
||||
service === 'desktop' ||
|
||||
service === 'desktop app' ||
|
||||
service === 'vscode' ||
|
||||
service === 'vs code' ||
|
||||
service === 'unknown' ||
|
||||
(service.includes('github') && service.includes('review'))
|
||||
);
|
||||
};
|
||||
const usageChartRootForPath = (path) => {
|
||||
if (!path || !path.closest) return null;
|
||||
return (
|
||||
path.closest('.recharts-wrapper') ||
|
||||
path.closest('svg.recharts-surface') ||
|
||||
path.closest('section') ||
|
||||
path.parentElement ||
|
||||
null
|
||||
);
|
||||
};
|
||||
const uniqueUsageChartRoots = (paths) => {
|
||||
const roots = [];
|
||||
for (const path of paths) {
|
||||
const root = usageChartRootForPath(path);
|
||||
if (root && !roots.includes(root)) roots.push(root);
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
const usageBreakdownTitleScore = (title) => {
|
||||
const lower = String(title || '').trim().toLowerCase().replace(/\\s+/g, ' ');
|
||||
if (!lower) return 0;
|
||||
if (lower === 'usage breakdown') return 1000000;
|
||||
if (lower.includes('usage breakdown')) return 900000;
|
||||
if (lower === 'personal usage') return 800000;
|
||||
if (lower.includes('threads') ||
|
||||
lower.includes('turns') ||
|
||||
lower.includes('client') ||
|
||||
lower.includes('skill') ||
|
||||
lower.includes('invocation')) return -1000000;
|
||||
return 0;
|
||||
};
|
||||
const titleLikeElements = (scope) => {
|
||||
try {
|
||||
return Array.from(scope.querySelectorAll('h1,h2,h3,[role=\"heading\"],div,span,p'))
|
||||
.filter(el => {
|
||||
const title = textOf(el);
|
||||
const lower = title.toLowerCase();
|
||||
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
||||
const isHeading = tag === 'h1' ||
|
||||
tag === 'h2' ||
|
||||
tag === 'h3' ||
|
||||
String(el.getAttribute('role') || '').toLowerCase() === 'heading';
|
||||
return title.length > 0 &&
|
||||
title.length <= 80 &&
|
||||
(
|
||||
isHeading ||
|
||||
usageBreakdownTitleScore(title) !== 0 ||
|
||||
lower.includes('usage breakdown') ||
|
||||
lower.includes('threads') ||
|
||||
lower.includes('turns') ||
|
||||
lower.includes('client') ||
|
||||
lower.includes('skill') ||
|
||||
lower.includes('invocation')
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const titleNodePrecedesRoot = (titleNode, root) => {
|
||||
if (!titleNode || titleNode === root || root.contains(titleNode) || titleNode.contains(root)) return false;
|
||||
const relation = titleNode.compareDocumentPosition(root);
|
||||
return Boolean(relation & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
};
|
||||
const nearestScoredChartTitleInScope = (scope, root) => {
|
||||
let best = null;
|
||||
for (const titleNode of titleLikeElements(scope)) {
|
||||
if (!titleNodePrecedesRoot(titleNode, root)) continue;
|
||||
const title = textOf(titleNode);
|
||||
const score = usageBreakdownTitleScore(title);
|
||||
if (score === 0) continue;
|
||||
if (!best || score >= best.score) best = { title, score };
|
||||
}
|
||||
return best ? best.title : '';
|
||||
};
|
||||
const chartTitleBoundaryForRoot = (root) => {
|
||||
if (!root) return null;
|
||||
try {
|
||||
return root.closest('section,[role=\"region\"],article') || root.parentElement || null;
|
||||
} catch {
|
||||
return root.parentElement || null;
|
||||
}
|
||||
};
|
||||
const nearestTitleTextInScope = (scope, root) => {
|
||||
if (!scope) return '';
|
||||
let nearest = null;
|
||||
for (const titleNode of titleLikeElements(scope)) {
|
||||
if (titleNodePrecedesRoot(titleNode, root)) nearest = titleNode;
|
||||
}
|
||||
return textOf(nearest);
|
||||
};
|
||||
const nearestChartTitleTextForRoot = (root) => {
|
||||
if (!root) return '';
|
||||
try {
|
||||
const boundary = chartTitleBoundaryForRoot(root) || root.parentElement || null;
|
||||
let ancestor = root.parentElement || null;
|
||||
for (let i = 0; i < 8 && ancestor; i++) {
|
||||
const scoredTitle = nearestScoredChartTitleInScope(ancestor, root);
|
||||
if (scoredTitle) return scoredTitle;
|
||||
if (ancestor === boundary) break;
|
||||
ancestor = ancestor.parentElement || null;
|
||||
}
|
||||
|
||||
return nearestTitleTextInScope(boundary, root);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const legendMapForUsageChartRoot = (root) => {
|
||||
const legendMap = {};
|
||||
const scopes = [
|
||||
root,
|
||||
root && root.parentElement,
|
||||
root && root.closest ? root.closest('section') : null
|
||||
].filter(Boolean);
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const legendItems = Array.from(scope.querySelectorAll('div[title]'));
|
||||
for (const item of legendItems) {
|
||||
const title = item.getAttribute('title') ? String(item.getAttribute('title')).trim() : '';
|
||||
const square = item.querySelector('div[style*=\"background-color\"]');
|
||||
const color = (square && square.style && square.style.backgroundColor)
|
||||
? square.style.backgroundColor
|
||||
: null;
|
||||
const hex = parseHexColor(color);
|
||||
if (title && hex) legendMap[hex] = title;
|
||||
}
|
||||
} catch {}
|
||||
if (Object.keys(legendMap).length > 0) break;
|
||||
}
|
||||
return legendMap;
|
||||
};
|
||||
const parseUsageBreakdownFromChartPaths = (paths, legendMap) => {
|
||||
const totalsByDay = {}; // day -> service -> value
|
||||
const addValue = (day, service, value) => {
|
||||
if (!day || !service || isSkillUsageServiceKey(service)) return false;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return false;
|
||||
if (!totalsByDay[day]) totalsByDay[day] = {};
|
||||
totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value;
|
||||
return true;
|
||||
};
|
||||
let pointCount = 0;
|
||||
for (const path of paths) {
|
||||
const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null;
|
||||
if (!meta) continue;
|
||||
|
||||
const payload = meta.payload || null;
|
||||
const day = dayKeyFromPayload(payload);
|
||||
if (!day) continue;
|
||||
|
||||
const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null;
|
||||
if (valuesObj) {
|
||||
for (const [k, v] of Object.entries(valuesObj)) {
|
||||
const service = displayNameForUsageServiceKey(k);
|
||||
if (addValue(day, service, v)) pointCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value;
|
||||
if (value === null && typeof meta.value === 'string') {
|
||||
const v = parseFloat(meta.value.replace(/,/g, ''));
|
||||
if (Number.isFinite(v)) value = v;
|
||||
}
|
||||
if (value === null) continue;
|
||||
|
||||
const fill = parseHexColor(meta.fill || path.getAttribute('fill'));
|
||||
const service =
|
||||
(fill && legendMap[fill]) ||
|
||||
(typeof meta.name === 'string' && meta.name) ||
|
||||
null;
|
||||
if (addValue(day, service, value)) pointCount++;
|
||||
}
|
||||
|
||||
const dayKeys = Object.keys(totalsByDay)
|
||||
.filter(day => Object.keys(totalsByDay[day] || {}).length > 0)
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
.slice(0, 30);
|
||||
const breakdown = dayKeys.map(day => {
|
||||
const servicesMap = totalsByDay[day] || {};
|
||||
const services = Object.keys(servicesMap).map(service => ({
|
||||
service,
|
||||
creditsUsed: servicesMap[service]
|
||||
})).sort((a, b) => {
|
||||
if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service);
|
||||
return b.creditsUsed - a.creditsUsed;
|
||||
});
|
||||
const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0);
|
||||
return { day, services, totalCreditsUsed };
|
||||
});
|
||||
const services = Array.from(new Set(breakdown.flatMap(day => day.services.map(service => service.service))));
|
||||
const totalCreditsUsed = breakdown.reduce((sum, day) => sum + (Number(day.totalCreditsUsed) || 0), 0);
|
||||
const likelyCodexServiceCount = services.filter(isLikelyCodexUsageService).length;
|
||||
return {
|
||||
breakdown,
|
||||
pointCount,
|
||||
services,
|
||||
totalCreditsUsed,
|
||||
likelyCodexServiceCount,
|
||||
score: likelyCodexServiceCount * 1000 + services.length * 100 + pointCount + totalCreditsUsed / 1000
|
||||
};
|
||||
};
|
||||
const usageBreakdownJSON = (() => {
|
||||
try {
|
||||
if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON;
|
||||
|
||||
const paths = Array.from(document.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle'));
|
||||
let debug = {
|
||||
pathCount: paths.length,
|
||||
chartCount: 0,
|
||||
eligibleCandidateCount: 0,
|
||||
selectedCandidateTitle: null,
|
||||
candidateSummaries: [],
|
||||
sampleReactKeys: null,
|
||||
sampleMetaKeys: null,
|
||||
samplePayloadKeys: null,
|
||||
sampleValuesKeys: null,
|
||||
sampleDayKey: null
|
||||
};
|
||||
try {
|
||||
const sample = paths[0] || null;
|
||||
if (sample) {
|
||||
const names = Object.getOwnPropertyNames(sample);
|
||||
debug.sampleReactKeys = names.filter(k => k.includes('react')).slice(0, 10);
|
||||
const metaSample = barMetaFromElement(sample) || barMetaFromElement(sample.parentElement) || null;
|
||||
if (metaSample) {
|
||||
debug.sampleMetaKeys = Object.keys(metaSample).slice(0, 12);
|
||||
const payload = metaSample.payload || null;
|
||||
if (payload && typeof payload === 'object') {
|
||||
debug.samplePayloadKeys = Object.keys(payload).slice(0, 12);
|
||||
debug.sampleDayKey = dayKeyFromPayload(payload);
|
||||
const values = payload.values || null;
|
||||
if (values && typeof values === 'object') {
|
||||
debug.sampleValuesKeys = Object.keys(values).slice(0, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const roots = uniqueUsageChartRoots(paths);
|
||||
debug.chartCount = roots.length;
|
||||
const candidates = roots.map(root => {
|
||||
const chartPaths = paths.filter(path => usageChartRootForPath(path) === root);
|
||||
const title = nearestChartTitleTextForRoot(root);
|
||||
const titleScore = usageBreakdownTitleScore(title);
|
||||
const parsed = parseUsageBreakdownFromChartPaths(chartPaths, legendMapForUsageChartRoot(root));
|
||||
return {
|
||||
root,
|
||||
title,
|
||||
titleScore,
|
||||
pathCount: chartPaths.length,
|
||||
...parsed,
|
||||
score: titleScore + parsed.score
|
||||
};
|
||||
}).filter(candidate => candidate.breakdown.length > 0);
|
||||
const rejectedTitleCandidates = candidates.filter(candidate => candidate.titleScore < 0);
|
||||
const titledCandidates = candidates.filter(candidate => candidate.titleScore > 0);
|
||||
const unknownTitleCandidates = candidates.filter(candidate => candidate.titleScore === 0);
|
||||
const eligibleCandidates = titledCandidates;
|
||||
eligibleCandidates.sort((a, b) => b.score - a.score);
|
||||
debug.eligibleCandidateCount = eligibleCandidates.length;
|
||||
debug.selectedCandidateTitle = eligibleCandidates[0] ? eligibleCandidates[0].title : null;
|
||||
if (eligibleCandidates.length === 0 && candidates.length > 0) {
|
||||
if (unknownTitleCandidates.length > 0) {
|
||||
debug.error = 'No English usage breakdown chart title found. Candidate titles: ' +
|
||||
candidates.map(candidate => candidate.title || 'Untitled chart').join(', ');
|
||||
} else if (rejectedTitleCandidates.length > 0) {
|
||||
debug.error = 'Only non-usage chart candidates found: ' +
|
||||
rejectedTitleCandidates.map(candidate => candidate.title || 'Untitled chart').join(', ');
|
||||
}
|
||||
}
|
||||
debug.candidateSummaries = candidates.slice(0, 6).map(candidate => ({
|
||||
title: candidate.title,
|
||||
titleScore: candidate.titleScore,
|
||||
pathCount: candidate.pathCount,
|
||||
dayCount: candidate.breakdown.length,
|
||||
pointCount: candidate.pointCount,
|
||||
serviceCount: candidate.services.length,
|
||||
likelyCodexServiceCount: candidate.likelyCodexServiceCount,
|
||||
services: candidate.services.slice(0, 8)
|
||||
}));
|
||||
|
||||
const breakdown = eligibleCandidates[0] ? eligibleCandidates[0].breakdown : [];
|
||||
const json = (breakdown.length > 0) ? JSON.stringify(breakdown) : null;
|
||||
window.__codexbarUsageBreakdownJSON = json;
|
||||
window.__codexbarUsageBreakdownDebug = json ? null : JSON.stringify(debug);
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const usageBreakdownDebug = (() => {
|
||||
try {
|
||||
return window.__codexbarUsageBreakdownDebug || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const usageBreakdownError = (() => {
|
||||
try {
|
||||
if (!usageBreakdownDebug) return null;
|
||||
const parsed = JSON.parse(usageBreakdownDebug);
|
||||
return parsed && parsed.error ? String(parsed.error) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const bodyText = document.body ? String(document.body.innerText || '').trim() : '';
|
||||
const href = window.location ? String(window.location.href || '') : '';
|
||||
const workspacePicker = bodyText.includes('Select a workspace');
|
||||
const title = document.title ? String(document.title || '') : '';
|
||||
const cloudflareInterstitial =
|
||||
title.toLowerCase().includes('just a moment') ||
|
||||
bodyText.toLowerCase().includes('checking your browser') ||
|
||||
bodyText.toLowerCase().includes('cloudflare');
|
||||
const authSelector = [
|
||||
'input[type="email"]',
|
||||
'input[type="password"]',
|
||||
'input[name="username"]'
|
||||
].join(', ');
|
||||
const hasAuthInputs = !!document.querySelector(authSelector);
|
||||
const lower = bodyText.toLowerCase();
|
||||
const loginCTA =
|
||||
lower.includes('sign in') ||
|
||||
lower.includes('log in') ||
|
||||
lower.includes('continue with google') ||
|
||||
lower.includes('continue with apple') ||
|
||||
lower.includes('continue with microsoft');
|
||||
const loginRequired =
|
||||
href.includes('/auth/') ||
|
||||
href.includes('/login') ||
|
||||
(hasAuthInputs && loginCTA) ||
|
||||
(!hasAuthInputs && loginCTA && href.includes('chatgpt.com'));
|
||||
const scrollY = (typeof window.scrollY === 'number') ? window.scrollY : 0;
|
||||
const scrollHeight = document.documentElement ? (document.documentElement.scrollHeight || 0) : 0;
|
||||
const viewportHeight = (typeof window.innerHeight === 'number') ? window.innerHeight : 0;
|
||||
|
||||
let creditsHeaderPresent = false;
|
||||
let creditsHeaderInViewport = false;
|
||||
let didScrollToCredits = false;
|
||||
let rows = [];
|
||||
try {
|
||||
const looksLikeCreditsEventRow = (cells) => {
|
||||
if (!cells || cells.length < 3) return false;
|
||||
const first = String(cells[0] || '');
|
||||
const amount = String(cells[2] || '');
|
||||
return /\\d{4}|\\d{1,2}[\\/.\\-]\\d{1,2}/.test(first) && /\\d/.test(amount);
|
||||
};
|
||||
const allTableRows = () => Array.from(document.querySelectorAll('tbody tr')).map(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td));
|
||||
return cells;
|
||||
}).filter(looksLikeCreditsEventRow);
|
||||
const headings = Array.from(document.querySelectorAll('h1,h2,h3'));
|
||||
const header = headings.find(h => textOf(h).toLowerCase() === 'credits usage history');
|
||||
if (header) {
|
||||
creditsHeaderPresent = true;
|
||||
const rect = header.getBoundingClientRect();
|
||||
creditsHeaderInViewport = rect.top >= 0 && rect.top <= viewportHeight;
|
||||
|
||||
// Only scrape rows from the *credits usage history* table. The page can contain other tables,
|
||||
// and treating any <table> as credits history can prevent our scroll-to-load logic from running.
|
||||
const container = header.closest('section') || header.parentElement || document;
|
||||
const table = container.querySelector('table') || null;
|
||||
const scope = table || container;
|
||||
rows = Array.from(scope.querySelectorAll('tbody tr')).map(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td));
|
||||
return cells;
|
||||
}).filter(r => r.length >= 3);
|
||||
if (rows.length === 0) {
|
||||
rows = allTableRows();
|
||||
}
|
||||
if (rows.length === 0 && !window.__codexbarDidScrollToCredits) {
|
||||
window.__codexbarDidScrollToCredits = true;
|
||||
// If the table is virtualized/lazy-loaded, we need to scroll to trigger rendering even if the
|
||||
// header is already in view.
|
||||
header.scrollIntoView({ block: 'start', inline: 'nearest' });
|
||||
if (creditsHeaderInViewport) {
|
||||
window.scrollBy(0, Math.max(220, viewportHeight * 0.6));
|
||||
}
|
||||
didScrollToCredits = true;
|
||||
}
|
||||
} else if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) {
|
||||
rows = allTableRows();
|
||||
if (rows.length > 0) {
|
||||
creditsHeaderPresent = true;
|
||||
creditsHeaderInViewport = true;
|
||||
}
|
||||
}
|
||||
if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) {
|
||||
// The credits history section often isn't part of the DOM until you scroll down. Nudge the page
|
||||
// once so subsequent scrapes can find the header and rows.
|
||||
window.__codexbarDidScrollToCredits = true;
|
||||
window.scrollTo(0, Math.max(0, scrollHeight - viewportHeight - 40));
|
||||
didScrollToCredits = true;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let creditsPurchaseURL = null;
|
||||
try {
|
||||
const creditsButton = findCreditsPurchaseButton();
|
||||
if (creditsButton) {
|
||||
const url = purchaseURLFromElement(creditsButton);
|
||||
if (url) creditsPurchaseURL = url;
|
||||
}
|
||||
const candidates = Array.from(document.querySelectorAll('a, button'));
|
||||
for (const node of candidates) {
|
||||
const label = elementLabel(node);
|
||||
if (!purchaseTextMatches(label)) continue;
|
||||
const url = purchaseURLFromElement(node);
|
||||
if (url) {
|
||||
creditsPurchaseURL = url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!creditsPurchaseURL) {
|
||||
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
||||
for (const anchor of anchors) {
|
||||
const label = elementLabel(anchor);
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
const hrefLooksRelevant = /credits|billing/i.test(href);
|
||||
if (!hrefLooksRelevant && !purchaseTextMatches(label)) continue;
|
||||
const url = normalizeHref(href);
|
||||
if (url) {
|
||||
creditsPurchaseURL = url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let signedInEmail = null;
|
||||
let authStatus = null;
|
||||
let accountPlan = null;
|
||||
try {
|
||||
const next = window.__NEXT_DATA__ || null;
|
||||
const props = (next && next.props && next.props.pageProps) ? next.props.pageProps : null;
|
||||
const userEmail = (props && props.user) ? props.user.email : null;
|
||||
const sessionEmail = (props && props.session && props.session.user) ? props.session.user.email : null;
|
||||
signedInEmail = userEmail || sessionEmail || null;
|
||||
} catch {}
|
||||
|
||||
const clientBootstrap = parseJSONScript('client-bootstrap');
|
||||
if (clientBootstrap) {
|
||||
try {
|
||||
authStatus = typeof clientBootstrap.authStatus === 'string' ? clientBootstrap.authStatus : null;
|
||||
if (!signedInEmail) {
|
||||
const session = clientBootstrap.session || null;
|
||||
const user = (session && session.user) || clientBootstrap.user || null;
|
||||
const email = user && typeof user.email === 'string' ? user.email : null;
|
||||
if (email && email.includes('@')) signedInEmail = email;
|
||||
}
|
||||
if (!accountPlan) accountPlan = findPlan(clientBootstrap);
|
||||
} catch {}
|
||||
}
|
||||
if (!accountPlan) {
|
||||
try {
|
||||
accountPlan = findPlan(window.__NEXT_DATA__ || parseJSONScript('__NEXT_DATA__'));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!signedInEmail) {
|
||||
try {
|
||||
const obj = parseJSONScript('__NEXT_DATA__');
|
||||
if (obj) {
|
||||
const queue = [obj];
|
||||
let seen = 0;
|
||||
while (queue.length && seen < 2000 && !signedInEmail) {
|
||||
const cur = queue.shift();
|
||||
seen++;
|
||||
if (!cur) continue;
|
||||
if (typeof cur === 'string') {
|
||||
if (cur.includes('@')) signedInEmail = cur;
|
||||
continue;
|
||||
}
|
||||
if (typeof cur !== 'object') continue;
|
||||
for (const [k, v] of Object.entries(cur)) {
|
||||
if (signedInEmail) break;
|
||||
if (k === 'email' && typeof v === 'string' && v.includes('@')) {
|
||||
signedInEmail = v;
|
||||
break;
|
||||
}
|
||||
if (typeof v === 'object' && v) queue.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!signedInEmail) {
|
||||
try {
|
||||
const emailRe = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/ig;
|
||||
const found = (bodyText.match(emailRe) || []).map(x => String(x).trim().toLowerCase());
|
||||
const unique = Array.from(new Set(found));
|
||||
if (unique.length === 1) {
|
||||
signedInEmail = unique[0];
|
||||
} else if (unique.length > 1) {
|
||||
signedInEmail = unique[0];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!signedInEmail) {
|
||||
// Last resort: open the account menu so the email becomes part of the DOM text.
|
||||
try {
|
||||
const emailRe = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/ig;
|
||||
const hasMenu = Boolean(document.querySelector('[role="menu"]'));
|
||||
if (!hasMenu) {
|
||||
const button =
|
||||
document.querySelector('button[aria-haspopup="menu"]') ||
|
||||
document.querySelector('button[aria-expanded]');
|
||||
if (button && !button.disabled) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
const afterText = document.body ? String(document.body.innerText || '').trim() : '';
|
||||
const found = (afterText.match(emailRe) || []).map(x => String(x).trim().toLowerCase());
|
||||
const unique = Array.from(new Set(found));
|
||||
if (unique.length === 1) {
|
||||
signedInEmail = unique[0];
|
||||
} else if (unique.length > 1) {
|
||||
signedInEmail = unique[0];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
loginRequired,
|
||||
workspacePicker,
|
||||
cloudflareInterstitial,
|
||||
href,
|
||||
bodyText,
|
||||
signedInEmail,
|
||||
authStatus,
|
||||
accountPlan,
|
||||
creditsPurchaseURL,
|
||||
rows,
|
||||
usageBreakdownJSON,
|
||||
usageBreakdownDebug,
|
||||
usageBreakdownError,
|
||||
scrollY,
|
||||
scrollHeight,
|
||||
viewportHeight,
|
||||
creditsHeaderPresent,
|
||||
creditsHeaderInViewport,
|
||||
didScrollToCredits
|
||||
};
|
||||
})();
|
||||
|
||||
"""
|
||||
#endif
|
||||
@@ -0,0 +1,782 @@
|
||||
#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
|
||||
@@ -0,0 +1,118 @@
|
||||
#if os(macOS)
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// Per-account persistent `WKWebsiteDataStore` for the OpenAI dashboard scrape.
|
||||
///
|
||||
/// Why: `WKWebsiteDataStore.default()` is a single shared cookie jar. If the user switches Codex accounts,
|
||||
/// we want to keep multiple signed-in dashboard sessions around (one per email) without clearing cookies.
|
||||
///
|
||||
/// Implementation detail: macOS 14+ supports `WKWebsiteDataStore.dataStore(forIdentifier:)`, which creates
|
||||
/// persistent isolated stores keyed by an identifier. We derive a stable UUID from the email and optional
|
||||
/// Codex source scope so distinct profiles with the same email never share a cookie store.
|
||||
///
|
||||
/// Important: We cache the `WKWebsiteDataStore` instances so the same object is returned for the same
|
||||
/// account email. This ensures `OpenAIDashboardWebViewCache` can use object identity for cache lookups.
|
||||
@MainActor
|
||||
public enum OpenAIDashboardWebsiteDataStore {
|
||||
/// Cached data store instances keyed by normalized email and optional Codex source scope.
|
||||
/// Using the same instance ensures stable object identity for WebView cache lookups.
|
||||
private static var cachedStores: [String: WKWebsiteDataStore] = [:]
|
||||
|
||||
public static func store(
|
||||
forAccountEmail email: String?,
|
||||
scope: CookieHeaderCache.Scope? = nil) -> WKWebsiteDataStore
|
||||
{
|
||||
guard let normalized = normalizeEmail(email) else { return .default() }
|
||||
let storageKey = self.storageKey(normalizedEmail: normalized, scope: scope)
|
||||
|
||||
// Return cached instance if available to maintain stable object identity
|
||||
if let cached = cachedStores[storageKey] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let id = Self.identifier(forStorageKey: storageKey)
|
||||
let store = WKWebsiteDataStore(forIdentifier: id)
|
||||
self.cachedStores[storageKey] = store
|
||||
return store
|
||||
}
|
||||
|
||||
/// Clears the persistent cookie store for a single account email.
|
||||
///
|
||||
/// Note: this does *not* impact other accounts, and is safe to use when the stored session is "stuck"
|
||||
/// or signed in to a different account than expected.
|
||||
public static func clearStore(
|
||||
forAccountEmail email: String?,
|
||||
scope: CookieHeaderCache.Scope? = nil) async
|
||||
{
|
||||
// Clear only ChatGPT/OpenAI domain data for the per-account store.
|
||||
// Avoid deleting the entire persistent store (WebKit requires all WKWebViews using it to be released).
|
||||
let store = self.store(forAccountEmail: email, scope: scope)
|
||||
await withCheckedContinuation { cont in
|
||||
store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
|
||||
let filtered = records.filter { record in
|
||||
let name = record.displayName.lowercased()
|
||||
return name.contains("chatgpt.com") || name.contains("openai.com")
|
||||
}
|
||||
store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: filtered) {
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from cache so a fresh instance is created on next access
|
||||
if let normalized = normalizeEmail(email) {
|
||||
self.cachedStores.removeValue(forKey: self.storageKey(normalizedEmail: normalized, scope: scope))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Clear all cached store instances (for test isolation).
|
||||
public static func clearCacheForTesting() {
|
||||
self.cachedStores.removeAll()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func normalizeEmail(_ email: String?) -> String? {
|
||||
guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
||||
return raw.lowercased()
|
||||
}
|
||||
|
||||
private static func storageKey(normalizedEmail: String, scope: CookieHeaderCache.Scope?) -> String {
|
||||
guard let scope else { return normalizedEmail }
|
||||
return "\(normalizedEmail)|\(scope.isolationIdentifier)"
|
||||
}
|
||||
|
||||
private static func identifier(forStorageKey storageKey: String) -> UUID {
|
||||
let digest = SHA256.hash(data: Data(storageKey.utf8))
|
||||
var bytes = Array(digest.prefix(16))
|
||||
|
||||
// Make it a well-formed UUID (v4 + RFC4122 variant) while staying deterministic.
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80
|
||||
|
||||
let uuidBytes: uuid_t = (
|
||||
bytes[0],
|
||||
bytes[1],
|
||||
bytes[2],
|
||||
bytes[3],
|
||||
bytes[4],
|
||||
bytes[5],
|
||||
bytes[6],
|
||||
bytes[7],
|
||||
bytes[8],
|
||||
bytes[9],
|
||||
bytes[10],
|
||||
bytes[11],
|
||||
bytes[12],
|
||||
bytes[13],
|
||||
bytes[14],
|
||||
bytes[15])
|
||||
return UUID(uuid: uuidBytes)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user