Files
2026-07-13 12:22:33 +08:00

802 lines
32 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if os(macOS)
import SweetCookieKit
#endif
public struct CopilotBudgetWebFetcher: Sendable {
public enum Error: Swift.Error, LocalizedError, Equatable {
case noSessionCookie
case notLoggedIn
case accountMismatch(expected: String, actual: String?)
case badStatus(Int)
case invalidResponse
public var errorDescription: String? {
switch self {
case .noSessionCookie:
"No GitHub browser session cookie found."
case .notLoggedIn:
"GitHub browser session is not logged in."
case let .accountMismatch(expected, actual):
"GitHub browser session belongs to \(actual ?? "an unknown account"), expected \(expected)."
case let .badStatus(status):
"GitHub budgets request failed with HTTP \(status)."
case .invalidResponse:
"GitHub budgets response could not be decoded."
}
}
}
struct BudgetResponse: Decodable {
let budgets: [Budget]
let hasNextPage: Bool?
private enum CodingKeys: String, CodingKey {
case budgets
case payload
case hasNextPage
case hasNextPageSnake = "has_next_page"
}
init(budgets: [Budget], hasNextPage: Bool? = nil) {
self.budgets = budgets
self.hasNextPage = hasNextPage
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let payload = try container.decodeIfPresent(BudgetResponse.self, forKey: .payload) {
self = payload
return
}
self.budgets = try container.decodeIfPresent([Budget].self, forKey: .budgets) ?? []
self.hasNextPage = try container.decodeIfPresent(Bool.self, forKey: .hasNextPage)
?? container.decodeIfPresent(Bool.self, forKey: .hasNextPageSnake)
}
}
struct Budget: Decodable, Equatable {
let id: String?
let name: String?
let budgetType: String?
let budgetProductSkus: [String]
let budgetScope: String?
let budgetEntityName: String?
let budgetAmount: Double
let currentAmount: Double
init(
id: String? = nil,
name: String? = nil,
budgetType: String? = nil,
budgetProductSkus: [String] = [],
budgetScope: String? = nil,
budgetEntityName: String? = nil,
budgetAmount: Double,
currentAmount: Double = 0)
{
self.id = id
self.name = name
self.budgetType = budgetType
self.budgetProductSkus = budgetProductSkus
self.budgetScope = budgetScope
self.budgetEntityName = budgetEntityName
self.budgetAmount = budgetAmount
self.currentAmount = currentAmount
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
self.id = Self.decodeString(container: container, keys: ["id", "uuid", "budget_id", "budgetId"])
self.name = Self.decodeString(container: container, keys: ["name", "display_name", "displayName", "title"])
self.budgetType = Self.decodeString(
container: container,
keys: ["budget_type", "budgetType", "type", "pricing_target_type", "pricingTargetType"])
self.budgetProductSkus = Self.decodeStringArray(
container: container,
keys: [
"budget_product_skus",
"budgetProductSkus",
"budget_product_sku",
"budgetProductSku",
"product_skus",
"productSkus",
"skus",
"sku",
"product",
"product_name",
"productName",
"pricing_target_id",
"pricingTargetId",
])
self.budgetScope = Self.decodeString(container: container, keys: ["budget_scope", "budgetScope", "scope"])
self.budgetEntityName = Self.decodeString(
container: container,
keys: [
"budget_entity_name",
"budgetEntityName",
"entity_name",
"entityName",
"target_name",
"targetName",
])
self.budgetAmount = Self.decodeDouble(
container: container,
keys: [
"budget_amount",
"budgetAmount",
"target_amount",
"targetAmount",
"spending_limit",
"spendingLimit",
"limit",
"amount",
"max",
]) ?? 0
self.currentAmount = Self.decodeDouble(
container: container,
keys: [
"current_usage",
"currentUsage",
"current_amount",
"currentAmount",
"usage_amount",
"usageAmount",
"usage",
"spent",
"amount_used",
"amountUsed",
]) ?? 0
}
var normalizedSelectors: Set<String> {
let values = self.budgetProductSkus + [
self.budgetType,
self.budgetEntityName,
self.name,
].compactMap(\.self)
return Set(values.compactMap(CopilotBudgetWebFetcher.normalizedBillingIdentifier))
}
private static func decodeString(
container: KeyedDecodingContainer<DynamicCodingKey>,
keys: [String]) -> String?
{
for key in keys {
guard let codingKey = DynamicCodingKey(key) else { continue }
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), !value.isEmpty {
return value
}
if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
return String(value)
}
}
return nil
}
private static func decodeStringArray(
container: KeyedDecodingContainer<DynamicCodingKey>,
keys: [String]) -> [String]
{
for key in keys {
guard let codingKey = DynamicCodingKey(key) else { continue }
if let values = try? container.decodeIfPresent([String].self, forKey: codingKey), !values.isEmpty {
return values
}
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), !value.isEmpty {
return [value]
}
if let values = try? container.decodeIfPresent([ProductSKU].self, forKey: codingKey),
!values.isEmpty
{
return values.flatMap(\.selectors)
}
}
return []
}
private static func decodeDouble(
container: KeyedDecodingContainer<DynamicCodingKey>,
keys: [String]) -> Double?
{
for key in keys {
guard let codingKey = DynamicCodingKey(key) else { continue }
if let value = try? container.decodeIfPresent(Double.self, forKey: codingKey) {
return value
}
if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
return Double(value)
}
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey),
let parsed = Self.parseAmount(value)
{
return parsed
}
if let value = try? container.decodeIfPresent(AmountValue.self, forKey: codingKey) {
return value.amount
}
}
return nil
}
fileprivate static func parseAmount(_ value: String) -> Double? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let isNegative = trimmed.first == "-"
guard !trimmed.dropFirst(isNegative ? 1 : 0).contains("-") else { return nil }
let unsigned = trimmed.filter { $0.isNumber || $0 == "." }
guard !unsigned.isEmpty else { return nil }
return Double(isNegative ? "-\(unsigned)" : unsigned)
}
}
struct GitHubWebIdentity: Equatable {
let id: String?
let login: String?
init(id: String?, login: String?) {
let trimmedID = id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trimmedLogin = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.id = trimmedID.isEmpty ? nil : trimmedID
self.login = trimmedLogin.isEmpty ? nil : trimmedLogin
}
var displayName: String? {
self.login ?? self.id.map { "github:user:\($0)" }
}
}
private struct ProductSKU: Decodable, Equatable {
let selectors: [String]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
self.selectors = [
"sku",
"name",
"display_name",
"displayName",
"product",
"product_name",
"productName",
].compactMap { key in
guard let codingKey = DynamicCodingKey(key) else { return nil }
return try? container.decodeIfPresent(String.self, forKey: codingKey)
}
}
}
private struct AmountValue: Decodable {
let amount: Double?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
self.amount = [
"amount",
"value",
"total",
"cents",
"formatted",
].lazy.compactMap { key -> Double? in
guard let codingKey = DynamicCodingKey(key) else { return nil }
if let value = try? container.decodeIfPresent(Double.self, forKey: codingKey) {
return key == "cents" ? value / 100 : value
}
if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
return key == "cents" ? Double(value) / 100 : Double(value)
}
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
return Budget.parseAmount(value)
}
return nil
}.first
}
}
private struct DynamicCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init?(_ stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}
init?(stringValue: String) {
self.init(stringValue)
}
init?(intValue: Int) {
self.stringValue = String(intValue)
self.intValue = intValue
}
}
private static let copilotProductID = "copilot"
private static let copilotPremiumRequestSKU = "copilot_premium_request"
private static let copilotAgentPremiumRequestSKU = "copilot_agent_premium_request"
private static let sparkPremiumRequestSKU = "spark_premium_request"
private static let copilotBudgetSelectors: Set<String> = [
copilotProductID,
copilotPremiumRequestSKU,
copilotAgentPremiumRequestSKU,
sparkPremiumRequestSKU,
]
private let cookieHeaderOverride: String?
private let expectedGitHubAccountIdentifier: String?
private let browserDetection: BrowserDetection
private let transport: any ProviderHTTPTransport
private let now: @Sendable () -> Date
public init(
cookieHeaderOverride: String? = nil,
expectedGitHubAccountIdentifier: String? = nil,
browserDetection: BrowserDetection = BrowserDetection(),
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
now: @escaping @Sendable () -> Date = { Date() })
{
self.cookieHeaderOverride = CookieHeaderNormalizer.normalize(cookieHeaderOverride ?? "")
self.expectedGitHubAccountIdentifier = Self.normalizedExpectedAccountIdentifier(
expectedGitHubAccountIdentifier)
self.browserDetection = browserDetection
self.transport = transport
self.now = now
}
public func fetchBudgetWindows() async throws -> [NamedRateWindow] {
if let cookieHeaderOverride, !cookieHeaderOverride.isEmpty {
return try await self.fetchBudgetWindows(cookieHeader: cookieHeaderOverride)
}
if let cached = CookieHeaderCache.load(provider: .copilot),
!cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
do {
return try await self.fetchBudgetWindows(cookieHeader: cached.cookieHeader)
} catch {
if case Error.notLoggedIn = error {
CookieHeaderCache.clear(provider: .copilot)
} else if case Error.accountMismatch = error {
CookieHeaderCache.clear(provider: .copilot)
} else {
throw error
}
}
}
#if os(macOS)
for session in CopilotGitHubCookieImporter.importSessions(browserDetection: self.browserDetection) {
do {
let windows = try await self.fetchBudgetWindows(cookieHeader: session.cookieHeader)
CookieHeaderCache.store(
provider: .copilot,
cookieHeader: session.cookieHeader,
sourceLabel: session.sourceLabel)
return windows
} catch {
if case Error.notLoggedIn = error {
continue
}
if case Error.accountMismatch = error {
continue
}
throw error
}
}
#endif
throw Error.noSessionCookie
}
func fetchBudgetWindows(cookieHeader: String) async throws -> [NamedRateWindow] {
let nonce = try await self.boundFetchNonce(cookieHeader: cookieHeader)
var allBudgets: [Budget] = []
var page = 1
let maxPages = 20
var shouldContinue = true
while shouldContinue, page <= maxPages {
let response = try await self.fetchBudgetPage(
cookieHeader: cookieHeader,
nonce: nonce,
page: page)
allBudgets.append(contentsOf: response.budgets)
shouldContinue = response.hasNextPage == true
page += 1
}
if shouldContinue {
CodexBarLog.logger(LogCategories.providers).warning(
"Copilot budget pagination reached page cap",
metadata: ["pageCap": "\(maxPages)"])
}
return Self.extraRateWindows(from: allBudgets, now: self.now())
}
private func boundFetchNonce(cookieHeader: String) async throws -> String? {
guard self.expectedGitHubAccountIdentifier != nil else {
return await self.bestEffortFetchNonce(cookieHeader: cookieHeader)
}
let metadata = try await self.fetchBudgetPageMetadata(cookieHeader: cookieHeader)
try self.verifyExpectedGitHubAccount(metadata.identity)
return metadata.nonce
}
private func bestEffortFetchNonce(cookieHeader: String) async -> String? {
do {
return try await self.fetchBudgetPageMetadata(cookieHeader: cookieHeader).nonce
} catch {
// GitHub accepts some budget requests without a nonce. Keep auth failures and
// page-shape changes non-fatal so a valid Cookie header can still be tried.
return nil
}
}
private struct BudgetPageMetadata {
let nonce: String?
let identity: GitHubWebIdentity?
}
private func fetchBudgetPageMetadata(cookieHeader: String) async throws -> BudgetPageMetadata {
guard let url = URL(string: "https://github.com/settings/billing/budgets") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.timeoutInterval = 15
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
request.setValue("CodexBar", forHTTPHeaderField: "User-Agent")
let response = try await self.transport.response(for: request)
switch response.statusCode {
case 200:
guard let html = String(data: response.data, encoding: .utf8) else { throw Error.invalidResponse }
return BudgetPageMetadata(
nonce: Self.extractFetchNonce(from: html),
identity: Self.extractGitHubWebIdentity(from: html))
case 401, 403:
throw Error.notLoggedIn
default:
throw Error.badStatus(response.statusCode)
}
}
private func verifyExpectedGitHubAccount(_ actual: GitHubWebIdentity?) throws {
guard let expected = self.expectedGitHubAccountIdentifier else { return }
guard let actual else { throw Error.accountMismatch(expected: expected, actual: nil) }
guard Self.webIdentity(actual, matches: expected) else {
throw Error.accountMismatch(expected: expected, actual: actual.displayName)
}
}
private func fetchBudgetPage(cookieHeader: String, nonce: String?, page: Int) async throws -> BudgetResponse {
guard var components = URLComponents(string: "https://github.com/settings/billing/budgets") else {
throw URLError(.badURL)
}
components.queryItems = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "page_size", value: "10"),
URLQueryItem(name: "scope", value: "customer"),
]
guard let url = components.url else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.timeoutInterval = 15
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("https://github.com/settings/billing/budgets", forHTTPHeaderField: "Referer")
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue("true", forHTTPHeaderField: "GitHub-Verified-Fetch")
request.setValue("CodexBar", forHTTPHeaderField: "User-Agent")
if let nonce, !nonce.isEmpty {
request.setValue(nonce, forHTTPHeaderField: "X-Fetch-Nonce")
}
let response = try await self.transport.response(for: request)
switch response.statusCode {
case 200:
do {
return try JSONDecoder().decode(BudgetResponse.self, from: response.data)
} catch {
throw Error.invalidResponse
}
case 401, 403:
throw Error.notLoggedIn
default:
throw Error.badStatus(response.statusCode)
}
}
static func extractFetchNonce(from html: String) -> String? {
let patterns = [
#"x-fetch-nonce"\s+content="([^"]+)""#,
#"X-Fetch-Nonce"\s*:\s*"([^"]+)""#,
#"fetchNonce"\s*:\s*"([^"]+)""#,
#"data-fetch-nonce="([^"]+)""#,
]
for pattern in patterns {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
continue
}
let range = NSRange(html.startIndex..<html.endIndex, in: html)
guard let match = regex.firstMatch(in: html, range: range),
let nonceRange = Range(match.range(at: 1), in: html)
else { continue }
return String(html[nonceRange])
}
return nil
}
static func extractGitHubWebIdentity(from html: String) -> GitHubWebIdentity? {
let id = self.extractMetaContent(
named: [
"octolytics-actor-id",
"analytics-user-id",
"user-id",
],
from: html)
let login = self.extractMetaContent(
named: [
"user-login",
"octolytics-actor-login",
"analytics-user-login",
],
from: html)
let identity = GitHubWebIdentity(id: id, login: login)
return identity.id == nil && identity.login == nil ? nil : identity
}
private static let metaTagRegex = try? NSRegularExpression(
pattern: #"<meta\b[^>]*>"#,
options: [.caseInsensitive])
private static let metaAttributeRegex = try? NSRegularExpression(
pattern: #"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(['"])(.*?)\2"#,
options: [.caseInsensitive])
private static func extractMetaContent(named names: [String], from html: String) -> String? {
guard let metaTagRegex, let metaAttributeRegex else { return nil }
let expectedNames = Set(names.map { $0.lowercased() })
var contentByName: [String: String] = [:]
let htmlRange = NSRange(html.startIndex..<html.endIndex, in: html)
for tagMatch in metaTagRegex.matches(in: html, range: htmlRange) {
guard let tagRange = Range(tagMatch.range, in: html) else { continue }
let tag = String(html[tagRange])
let tagNSRange = NSRange(tag.startIndex..<tag.endIndex, in: tag)
var attributes: [String: String] = [:]
for attributeMatch in metaAttributeRegex.matches(in: tag, range: tagNSRange) {
guard let keyRange = Range(attributeMatch.range(at: 1), in: tag),
let valueRange = Range(attributeMatch.range(at: 3), in: tag)
else { continue }
attributes[String(tag[keyRange]).lowercased()] = String(tag[valueRange])
}
guard let name = attributes["name"]?.lowercased(),
expectedNames.contains(name),
contentByName[name] == nil,
let content = attributes["content"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!content.isEmpty
else { continue }
contentByName[name] = content
}
for name in names {
if let content = contentByName[name.lowercased()] {
return content
}
}
return nil
}
static func webIdentity(_ identity: GitHubWebIdentity?, matches expectedIdentifier: String?) -> Bool {
guard let expected = self.normalizedExpectedAccountIdentifier(expectedIdentifier),
let identity
else { return false }
if let expectedID = self.githubUserID(from: expected) {
return identity.id == expectedID
}
return identity.login?.lowercased() == expected
}
static func normalizedGitHubAccountIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String {
"github:user:\(identity.id)"
}
private static func normalizedExpectedAccountIdentifier(_ identifier: String?) -> String? {
let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func githubUserID(from identifier: String) -> String? {
let prefix = "github:user:"
guard identifier.hasPrefix(prefix) else { return nil }
let suffix = String(identifier.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
return suffix.isEmpty ? nil : suffix
}
static func extraRateWindows(from budgets: [Budget], now: Date) -> [NamedRateWindow] {
var usedIDs = Set<String>()
let resetDate = self.approximateNextMonthResetDate(now: now)
return budgets
.compactMap { budget in
let selectors = budget.normalizedSelectors
guard self.isCopilotBudget(budget, selectors: selectors) else { return nil }
let id = self.uniqueWindowID(for: budget, selectors: selectors, usedIDs: &usedIDs)
let usedPercent = budget.budgetAmount > 0
? min(999, max(0, budget.currentAmount / budget.budgetAmount * 100))
: 0
let window = RateWindow(
usedPercent: usedPercent,
windowMinutes: nil,
resetsAt: resetDate,
resetDescription: resetDate.map {
UsageFormatter.resetDescription(from: $0, now: now)
})
return NamedRateWindow(
id: id,
title: self.windowTitle(for: budget, selectors: selectors),
window: window)
}
}
private static func isCopilotBudget(_ budget: Budget, selectors: Set<String>) -> Bool {
guard budget.budgetAmount > 0 else { return false }
return !selectors.isDisjoint(with: self.copilotBudgetSelectors)
}
static func normalizedBillingIdentifier(_ value: String?) -> String? {
guard let value else { return nil }
let slug = self.slug(value)
guard !slug.isEmpty else { return nil }
let underscored = slug.replacingOccurrences(of: "-", with: "_")
if underscored == self.copilotProductID {
return self.copilotProductID
}
if underscored == "premium_request" || underscored == "premium_requests" {
return self.copilotPremiumRequestSKU
}
if underscored == "coding_agent_premium_request" || underscored == "coding_agent_premium_requests" {
return self.copilotAgentPremiumRequestSKU
}
if underscored.contains("spark"), underscored.contains("premium"), underscored.contains("request") {
return self.sparkPremiumRequestSKU
}
if underscored.contains("cloud") || underscored.contains("coding"),
underscored.contains("agent"),
underscored.contains("premium"),
underscored.contains("request")
{
return self.copilotAgentPremiumRequestSKU
}
if underscored.contains("bundled"), underscored.contains("premium"), underscored.contains("request") {
return self.copilotPremiumRequestSKU
}
if underscored.contains("copilot"),
underscored.contains("agent"),
underscored.contains("premium"),
underscored.contains("request")
{
return self.copilotAgentPremiumRequestSKU
}
if underscored.contains("copilot"), underscored.contains("premium"), underscored.contains("request") {
return self.copilotPremiumRequestSKU
}
return underscored
}
private static func windowTitle(for budget: Budget, selectors: Set<String>) -> String {
let budgetType = if selectors == [self.copilotProductID] {
"Copilot"
} else if selectors.contains(self.copilotAgentPremiumRequestSKU) {
"Copilot Agent Premium Requests"
} else if selectors.contains(self.sparkPremiumRequestSKU) {
"Spark Premium Requests"
} else if selectors.contains(self.copilotPremiumRequestSKU) {
"All Premium Request SKUs"
} else if let name = budget.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty
{
name
} else {
"Copilot Premium Requests"
}
return "Budget - \(budgetType)"
}
private static func uniqueWindowID(
for budget: Budget,
selectors: Set<String>,
usedIDs: inout Set<String>) -> String
{
let source = budget.id ?? budget.budgetProductSkus.joined(separator: "-")
let slug = self.slug(source.isEmpty ? self.windowTitle(for: budget, selectors: selectors) : source)
let base = slug.isEmpty ? "copilot-budget" : "copilot-budget-\(slug)"
var candidate = base
var suffix = 2
while !usedIDs.insert(candidate).inserted {
candidate = "\(base)-\(suffix)"
suffix += 1
}
return candidate
}
private static func approximateNextMonthResetDate(now: Date) -> Date? {
// GitHub's budget response does not expose a reset instant. Use the local
// start of next month as a display-only approximation.
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
let components = calendar.dateComponents([.year, .month], from: now)
guard let monthStart = calendar.date(from: DateComponents(
year: components.year,
month: components.month,
day: 1))
else {
return nil
}
return calendar.date(byAdding: .month, value: 1, to: monthStart)
}
private static func slug(_ value: String) -> String {
var result = ""
var lastWasDash = false
for scalar in value.lowercased().unicodeScalars {
if CharacterSet.alphanumerics.contains(scalar) {
result.unicodeScalars.append(scalar)
lastWasDash = false
} else if !lastWasDash {
result.append("-")
lastWasDash = true
}
}
return result.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
}
}
#if os(macOS)
private enum CopilotGitHubCookieImporter {
private static let cookieClient = BrowserCookieClient()
private static let cookieImportOrder: BrowserCookieImportOrder =
ProviderDefaults.metadata[.copilot]?.browserCookieOrder ?? [.chrome]
private static let sessionCookieNames: Set<String> = [
"user_session",
"__Host-user_session_same_site",
"_gh_sess",
"logged_in",
"dotcom_user",
]
struct SessionInfo {
let cookies: [HTTPCookie]
let sourceLabel: String
var cookieHeader: String {
self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
}
}
static func importSessions(browserDetection: BrowserDetection) -> [SessionInfo] {
let installedBrowsers = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
return installedBrowsers.flatMap { browser -> [SessionInfo] in
do {
return try self.importSessions(from: browser)
} catch {
BrowserCookieAccessGate.recordIfNeeded(error)
return []
}
}
}
private static func importSessions(from browser: Browser) throws -> [SessionInfo] {
let query = BrowserCookieQuery(domains: ["github.com", "www.github.com"])
let sources = try self.cookieClient.codexBarRecords(matching: query, in: browser)
return sources.compactMap { source -> SessionInfo? in
guard !source.records.isEmpty else { return nil }
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
guard cookies.contains(where: { self.sessionCookieNames.contains($0.name) }) else {
return nil
}
return SessionInfo(cookies: cookies, sourceLabel: source.label)
}
}
}
#endif