codeburn/mac/Sources/CodeBurnMenubar/Data/CopilotSubscriptionService.swift
ozymandiashh 1b4c00f8d5 fix(menubar): follow GH_HOST and gh's own host for the Copilot env and gh rungs
#1286 taught the credential files to carry the GitHub host their token came
from, but the environment and `gh auth token` rungs carried none, so they were
always addressed to api.github.com. A user whose only Copilot credential is a
GitHub Enterprise Cloud login — GH_TOKEN exported alongside GH_HOST, or
`gh auth login --hostname <tenant>.ghe.com` — got a 401 and a terminal failure
with nothing to act on.

The environment rung now reads GH_HOST out of the same environment as the
token. The gh rung resolves the host from gh's own hosts.yml, found via
GH_CONFIG_DIR, then XDG_CONFIG_HOME/gh, then ~/.config/gh, and picked the way
gh picks the host for `gh auth token`: GH_HOST, else the single configured
host, else dotcom. That is a file read rather than a second `gh auth status`
spawn, because gh writes hosts.yml for every login, keyring-backed ones
included.

Both are new untrusted inputs, so they stay raw all the way to
`apiHost(for:)`, where #1286's character validation refuses a crafted value
such as `evil.com?.ghe.com` before any URL exists — that host passes the
`.ghe.com` suffix check but builds a URL whose host is api.evil.com, which
would then receive the Authorization header.
2026-09-12 12:02:13 -07:00

604 lines
28 KiB
Swift

import Foundation
/// Live quota snapshot for a GitHub Copilot plan. The API reports per-feature
/// quota snapshots ("Premium requests", "Chat") as percent REMAINING; windows
/// render percent USED, so `usedPercent` is the inverted value. `details`
/// keeps the endpoint's order (premium first), so `primary` is the premium
/// window when present and chat otherwise.
struct CopilotUsage: Sendable, Equatable {
struct Window: Sendable, Equatable {
let label: String
let usedPercent: Double // 0.0 ... 100.0
/// The endpoint reports no reset times — always nil.
let resetsAt: Date?
}
/// Premium-requests window first, then chat; either may be absent.
let details: [Window]
var primary: Window? { details.first }
/// Plan label derived from copilot_plan (Free / Individual / Pro /
/// Business / Enterprise / Educators, unknown tiers title-cased).
let plan: String?
let fetchedAt: Date
/// Host that answered, so Settings can name it: `api.github.com`, or the
/// tenant's `api.<tenant>.ghe.com` on GitHub Enterprise Cloud (#1286).
var apiHost: String = CopilotHostEndpoint.defaultAPIHost
}
/// A discovered Copilot token together with the GitHub host it belongs to, so
/// the quota request can be addressed to that host's API instead of assuming
/// dotcom (#1286).
struct CopilotCredential: Sendable, Equatable {
let token: String
/// Host the token was read from, or nil when the source carries none,
/// which is dotcom. Every rung can carry one: the credential files are
/// keyed by host, the environment rung reads `GH_HOST`, the `gh` rung
/// resolves the host of the login that answered, and a pasted token is
/// saved with the host typed next to it (#1306).
let host: String?
init(token: String, host: String? = nil) {
self.token = token
self.host = host
}
/// Normalized host, defaulting to dotcom for a source without one.
var resolvedHost: String {
CopilotHostEndpoint.normalize(host) ?? CopilotHostEndpoint.defaultHost
}
}
/// Live GitHub Copilot quota via the editor plugins' internal usage endpoint
/// (derived from observed GitHub Copilot client traffic):
///
/// - GET https://api.github.com/copilot_internal/user → plan + quota snapshots
///
/// The host is not fixed: a credential read for a GitHub Enterprise Cloud
/// tenant is queried on that tenant's own API host
/// (`https://api.<tenant>.ghe.com/copilot_internal/user`) and is never sent to
/// api.github.com. See `CopilotHostEndpoint`.
///
/// This is an INTERNAL, UNDOCUMENTED API that may drift without notice; every
/// failure must degrade to the normal connection states and never crash the
/// panel.
///
/// Credential: any GitHub token already on the machine from a signed-in
/// Copilot client. Modern clients no longer write the legacy plugin files, so
/// discovery walks an ordered chain (see `readCredential`). Read-only throughout;
/// nothing is copied or written, and there is no refresh path: an expired or
/// revoked token stays dead until the user signs in again with whichever
/// client owns it.
enum CopilotSubscriptionService {
private static let usageBlockedUntilKey = "codeburn.copilot.usage.blockedUntil"
/// Capacity Dock provider id, used for the pasted-token rung.
static let providerID = "copilot"
/// Honoured in the same order the Copilot CLI honours them.
static let environmentTokenNames = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
enum FetchError: Error, LocalizedError {
case noCredentials
/// 401 where re-reading the chain yielded the same (or no) token. An
/// active client session rotates this token, so this is transient: the
/// next refresh picks up the rotated one from whichever source holds it.
case tokenRejected
case rateLimited(retryAt: Date)
case usageHTTPError(Int)
case usageDecodeFailed
case network(Error, host: String)
/// The credential belongs to a GitHub host whose Copilot quota
/// endpoint this build cannot derive (a self-hosted GitHub Enterprise
/// Server install, say). Never retried against api.github.com.
case unsupportedHost(String)
var errorDescription: String? {
switch self {
case .noCredentials:
return "No GitHub Copilot credentials found. Usage tracking still works. "
+ "To show live quota, sign in with the Copilot CLI, run gh auth login, "
+ "or paste a GitHub token in Settings."
case .tokenRejected:
return "GitHub rejected the Copilot token found on this Mac. It will be retried once you sign in again."
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "GitHub rate-limited the Copilot quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code):
return "Copilot quota fetch failed (HTTP \(code)). Sign in to Copilot again, then Reconnect."
case .usageDecodeFailed:
return "Copilot quota response was malformed."
case let .network(err, host):
return "Network error reaching \(host): \(err.localizedDescription)"
case let .unsupportedHost(host):
return "Copilot quota is not available for the GitHub host \(host). "
+ "CodeBurn can read github.com and GitHub Enterprise Cloud (*.ghe.com) hosts."
}
}
var isTerminal: Bool {
switch self {
case .noCredentials, .unsupportedHost:
return true
case let .usageHTTPError(code):
return (400..<500).contains(code)
case .tokenRejected, .rateLimited, .usageDecodeFailed, .network:
return false
}
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Injectable seams (tests drive fixtures through these)
struct Deps: Sendable {
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
/// Read-only credential file load. Never writes.
var readFile: @Sendable (URL) -> Data?
var hostsURL: URL
var appsURL: URL
/// The Copilot CLI's config directory (~/.copilot).
var copilotDirURL: URL
/// `gh`'s `hosts.yml`, read only to learn which host the `gh` rung's
/// token belongs to. Never a token source: `gh auth token` owns that.
var ghHostsURL: URL = CopilotHostEndpoint.ghHostsFileURL()
var environment: @Sendable (String) -> String?
/// `gh auth token`. Uncached: the service owns the caching so tests can
/// drive the probe directly.
var ghAuthToken: @Sendable () -> String?
/// Token the user pasted into CodeBurn's own Copilot settings, with
/// the host saved beside it.
var savedCredential: @Sendable () -> CopilotCredential?
var now: @Sendable () -> Date
static let live = Deps(
fetch: { request in
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1)
}
return (data, http)
},
readFile: { url in FileManager.default.contents(atPath: url.path) },
hostsURL: URL(fileURLWithPath: NSHomeDirectory() + "/.config/github-copilot/hosts.json"),
appsURL: URL(fileURLWithPath: NSHomeDirectory() + "/.config/github-copilot/apps.json"),
copilotDirURL: URL(fileURLWithPath: NSHomeDirectory() + "/.copilot"),
ghHostsURL: CopilotHostEndpoint.ghHostsFileURL(),
environment: { ProcessInfo.processInfo.environment[$0] },
ghAuthToken: { runGhAuthToken() },
savedCredential: { savedCopilotCredential() },
now: { Date() }
)
}
// MARK: - Credential discovery
/// Cheap eligibility answer for the dock and the initial load state. It
/// must never spawn a subprocess, so the `gh` rung is approximated by an
/// installed `gh` binary rather than an actual login probe. A false
/// positive costs one probe inside `refresh`; a false negative would
/// silently never try at all.
static var hasCredential: Bool {
let deps = Deps.live
let manager = FileManager.default
for url in [deps.hostsURL, deps.appsURL, deps.copilotDirURL]
where manager.fileExists(atPath: url.path) {
return true
}
if environmentTokenNames.contains(where: { nonEmpty(deps.environment($0)) != nil }) { return true }
if CapacityDockProviderCredentialPresence.contains(providerID) { return true }
return ghExecutableURL() != nil
}
/// Ordered, read-only credential chain; the first rung that yields a token
/// wins and every rung tolerates absent or malformed data:
///
/// 1. legacy editor-plugin files: `hosts.json` then `apps.json` (both
/// carry the GitHub host the token belongs to)
/// 2. `~/.copilot/config.json` then `~/.copilot/settings.json`
/// 3. `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, with `GH_HOST`
/// as the host those tokens belong to
/// 4. `gh auth token`, with the host taken from `gh`'s own `hosts.yml`
/// 5. a token pasted into CodeBurn's Copilot settings, with the host saved
/// beside it
///
/// Every rung can therefore name a GitHub Enterprise Cloud tenant; only a
/// source that genuinely carries no host falls back to dotcom (#1306).
///
/// The Copilot CLI's own Keychain item (service `copilot-cli`) is
/// deliberately not read: it is written by a Node keyring library, so
/// `/usr/bin/security` is not in its partition list and every read would
/// raise the "allow access?" dialog. `gh auth token` reaches the same
/// users, because the Copilot CLI itself falls back to gh.
private static func readCredential(deps: Deps) -> CopilotCredential? {
// hosts.json is keyed by GitHub host; apps.json by "<host>:<app id>"
// (older releases wrote a bare app name, which carries no host).
if let data = deps.readFile(deps.hostsURL),
let credential = credentialFromMap(data, host: { $0 }) { return credential }
if let data = deps.readFile(deps.appsURL),
let credential = credentialFromMap(data, host: { hostFromAppsKey($0) }) { return credential }
for name in ["config.json", "settings.json"] {
if let data = deps.readFile(deps.copilotDirURL.appendingPathComponent(name)),
let token = tokenFromCopilotCLIJSON(data) { return CopilotCredential(token: token) }
}
for name in environmentTokenNames {
if let token = nonEmpty(deps.environment(name)) {
return CopilotCredential(
token: token,
host: nonEmpty(deps.environment(CopilotHostEndpoint.hostEnvironmentName)))
}
}
if let token = cachedGhToken(deps: deps) {
return CopilotCredential(token: token, host: ghHost(deps: deps))
}
return deps.savedCredential()
}
/// Host of the login `gh auth token` answered for. Read from `hosts.yml`
/// rather than by spawning `gh auth status`: the `gh` rung already costs
/// one process, and `gh` writes that file for every login.
private static func ghHost(deps: Deps) -> String? {
let config = deps.readFile(deps.ghHostsURL).flatMap { String(data: $0, encoding: .utf8) }
return CopilotHostEndpoint.ghHost(
environmentHost: deps.environment(CopilotHostEndpoint.hostEnvironmentName),
hostsConfig: config)
}
/// Reads the `oauth_token` entries out of a credential map and picks one
/// with `CopilotHostEndpoint.preferredHost`, so the token we send and the
/// host we send it to always come from the same entry. Keys are visited in
/// sorted order, making the pick stable when several entries qualify.
private static func credentialFromMap(
_ data: Data,
host keyToHost: (String) -> String?
) -> CopilotCredential? {
guard let map = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let entries: [CopilotCredential] = map.keys.sorted().compactMap { key in
guard let token = (map[key] as? [String: Any])?["oauth_token"] as? String, !token.isEmpty else {
return nil
}
return CopilotCredential(token: token, host: keyToHost(key))
}
guard let preferred = CopilotHostEndpoint.preferredHost(among: entries.map(\.host)) else { return nil }
return entries.first { $0.resolvedHost == preferred }
}
/// apps.json keys look like `github.com:Iv1.<app id>` on the newer
/// plugins and like a bare app name ("Visual Studio Code") on the older
/// ones; only the former carries a host.
private static func hostFromAppsKey(_ key: String) -> String? {
guard let separator = key.firstIndex(of: ":") else { return nil }
let candidate = String(key[..<separator])
return candidate.contains(".") ? candidate : nil
}
/// GitHub access-token prefixes. `ghr_` (refresh) is deliberately absent:
/// the usage endpoint rejects it.
private static let tokenPrefixes = ["gho_", "ghu_", "ghp_", "ghs_", "github_pat_"]
static func looksLikeGitHubToken(_ value: String) -> Bool {
tokenPrefixes.contains { value.hasPrefix($0) && value.count > $0.count }
}
/// The ~/.copilot schema is undocumented and the key holding the token has
/// moved between releases, so rather than guess key names take the first
/// value carrying a GitHub access-token prefix.
private static func tokenFromCopilotCLIJSON(_ data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) else { return nil }
return firstGitHubToken(in: json)
}
/// Dictionary keys are visited in sorted order so the pick stays stable
/// when more than one field qualifies.
private static func firstGitHubToken(in value: Any) -> String? {
switch value {
case let text as String:
return looksLikeGitHubToken(text) ? text : nil
case let array as [Any]:
return array.lazy.compactMap { firstGitHubToken(in: $0) }.first
case let map as [String: Any]:
return map.keys.sorted().lazy.compactMap { firstGitHubToken(in: map[$0] as Any) }.first
default:
return nil
}
}
private static func nonEmpty(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}
// MARK: - Subprocess probes
/// `gh auth token` costs a process spawn, and the panel refreshes far more
/// often than a login changes, so its answer is reused for this long.
static let probeCacheTTL: TimeInterval = 5 * 60
private final class ProbeCache: @unchecked Sendable {
private let lock = NSLock()
private var entry: (value: String?, at: Date)?
func value(now: Date, ttl: TimeInterval, probe: () -> String?) -> String? {
lock.lock()
defer { lock.unlock() }
if let entry, now.timeIntervalSince(entry.at) < ttl { return entry.value }
let value = probe()
entry = (value, now)
return value
}
func reset() {
lock.lock()
defer { lock.unlock() }
entry = nil
}
}
private static let probeCache = ProbeCache()
private static func cachedGhToken(deps: Deps) -> String? {
probeCache.value(now: deps.now(), ttl: probeCacheTTL) { nonEmpty(deps.ghAuthToken()) }
}
/// Drops the cached `gh` answer so the next read re-probes.
static func resetProbeCache() {
probeCache.reset()
}
/// `Process` is not Sendable; the watchdog only calls `terminate()`, which
/// is safe from another thread.
private final class ProcessBox: @unchecked Sendable {
let process: Process
init(_ process: Process) { self.process = process }
}
/// Runs a short-lived command and returns trimmed stdout, or nil on any
/// failure. stdin is /dev/null so a child can never block on a prompt, and
/// the deadline guarantees the caller's refresh cannot wedge.
private static func runCapturingStdout(
_ executable: URL,
_ arguments: [String],
timeout: TimeInterval = 5
) -> String? {
let process = Process()
process.executableURL = executable
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
process.standardInput = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
let box = ProcessBox(process)
let watchdog = DispatchWorkItem {
if box.process.isRunning { box.process.terminate() }
}
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout, execute: watchdog)
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)
process.waitUntilExit()
watchdog.cancel()
guard process.terminationStatus == 0 else { return nil }
return nonEmpty(output)
}
/// `gh auth token` resolves keyring vs ~/.config/gh/hosts.yml itself, so we
/// only have to find the binary. A missing `gh` is simply absent.
private static func runGhAuthToken() -> String? {
guard let gh = ghExecutableURL() else { return nil }
return runCapturingStdout(gh, ["auth", "token"])
}
/// Spotlight-launched apps inherit a minimal PATH, so reuse the same PATH
/// augmentation that finds the codeburn CLI (Homebrew included).
private static func ghExecutableURL(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> URL? {
let path = CodeburnCLI.augmentedPath(
environment["PATH"] ?? "",
homeDirectory: NSHomeDirectory(),
environment: environment
)
for directory in path.split(separator: ":", omittingEmptySubsequences: true) {
let candidate = "\(directory)/gh"
if FileManager.default.isExecutableFile(atPath: candidate) {
return URL(fileURLWithPath: candidate)
}
}
return nil
}
/// Gated on the non-secret presence index so the common "nothing pasted"
/// case never touches Keychain at all. The host comes out of the same
/// record as the token, so the two cannot drift; a record saved before the
/// host field existed carries none and stays dotcom.
private static func savedCopilotCredential() -> CopilotCredential? {
guard CapacityDockProviderCredentialPresence.contains(providerID) else { return nil }
let override = (try? CapacityDockProviderCredentialStore.load(for: providerID))?.sanitizedOverride
guard let token = nonEmpty(override?.apiKey) else { return nil }
return CopilotCredential(token: token, host: override?.host)
}
// MARK: - Fetch
static func refresh(deps: Deps = .live) async throws -> CopilotUsage {
if let until = usageBlockedUntil(), until > deps.now() {
throw FetchError.rateLimited(retryAt: until)
}
guard var credential = readCredential(deps: deps) else { throw FetchError.noCredentials }
var (data, response) = try await send(request(credential), deps: deps)
if response.statusCode == 401 {
// An active client session rotates this token; re-read once before
// giving up so we don't report a failure the source already fixed.
// The subprocess rungs are cached, so drop those answers first.
resetProbeCache()
guard let reread = readCredential(deps: deps), reread != credential else {
throw FetchError.tokenRejected
}
credential = reread
(data, response) = try await send(request(credential), deps: deps)
}
if response.statusCode == 429 {
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
retryAfterSeconds: parseRetryAfterHeader(response.value(forHTTPHeaderField: "Retry-After"))))
}
guard (200..<300).contains(response.statusCode) else {
throw FetchError.usageHTTPError(response.statusCode)
}
return try decodeUsage(
data: data,
now: deps.now(),
apiHost: apiHost(for: credential))
}
/// The API host a credential is queried on. Throws rather than falling
/// back to dotcom, so an enterprise token is never sent to api.github.com.
private static func apiHost(for credential: CopilotCredential) throws -> String {
guard let host = CopilotHostEndpoint.apiHost(for: credential.host) else {
throw FetchError.unsupportedHost(credential.resolvedHost)
}
return host
}
private static func request(_ credential: CopilotCredential) throws -> URLRequest {
guard let url = CopilotHostEndpoint.usageURL(for: credential.host) else {
throw FetchError.unsupportedHost(credential.resolvedHost)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 30
// Headers mirror the VS Code Copilot Chat plugin.
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version")
request.setValue("copilot-chat/0.26.7", forHTTPHeaderField: "Editor-Plugin-Version")
request.setValue("GitHubCopilotChat/0.26.7", forHTTPHeaderField: "User-Agent")
request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version")
request.setValue("token \(credential.token)", forHTTPHeaderField: "Authorization")
return request
}
private static func send(_ request: URLRequest, deps: Deps) async throws -> (Data, HTTPURLResponse) {
do {
return try await deps.fetch(request)
} catch let error as FetchError {
throw error
} catch {
// Name the host: on an unreachable enterprise tenant this is the
// only place the user learns which endpoint was tried.
throw FetchError.network(error, host: request.url?.host ?? CopilotHostEndpoint.defaultAPIHost)
}
}
// MARK: - Decode (internal so tests can drive fixtures)
static func decodeUsage(
data: Data,
now: Date = Date(),
apiHost: String = CopilotHostEndpoint.defaultAPIHost
) throws -> CopilotUsage {
let parsed: Any
do {
parsed = try JSONSerialization.jsonObject(with: data)
} catch {
// Never log the body — account data readable via `log stream`.
throw FetchError.usageDecodeFailed
}
// Field names have shipped both camelCase and snake_case; read each
// alias rather than trusting one spelling.
let root = parsed as? [String: Any] ?? [:]
let snapshots = (root["quota_snapshots"] ?? root["quotaSnapshots"]) as? [String: Any]
let premium = window(label: "Premium requests",
snapshot: snapshots?["premium_interactions"] ?? snapshots?["premiumInteractions"])
let chat = window(label: "Chat", snapshot: snapshots?["chat"])
return CopilotUsage(
details: [premium, chat].compactMap { $0 },
plan: planLabel(root["copilot_plan"] ?? root["copilotPlan"]),
fetchedAt: now,
apiHost: apiHost
)
}
private static func window(label: String, snapshot: Any?) -> CopilotUsage.Window? {
guard let row = snapshot as? [String: Any],
let remaining = fraction(row["percent_remaining"] ?? row["percentRemaining"]) else { return nil }
// A plan without this quota reports entitlement 0 and 0% remaining,
// which would render as a full 100% used; unlimited windows have no
// meaningful percent either. Neither is a window to show.
if let entitlement = row["entitlement"] as? NSNumber, entitlement.doubleValue == 0 { return nil }
if let unlimited = row["unlimited"] as? Bool, unlimited { return nil }
// Round away float dust from the 1-remaining subtraction
// (1-0.7 != 0.3), mirroring the desktop decoder's toFixed(6) before
// scaling to 0..100.
let used = ((1 - remaining) * 1_000_000).rounded() / 1_000_000 * 100
return CopilotUsage.Window(label: label, usedPercent: used, resetsAt: nil)
}
/// Percent-remaining (0..100) as a clamped 0..1 fraction. Non-numeric
/// values (and booleans, which bridge to NSNumber) yield nil.
private static func fraction(_ value: Any?) -> Double? {
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
let fraction = number.doubleValue / 100
guard fraction.isFinite else { return nil }
return min(1, max(0, fraction))
}
/// nil for missing/blank; known tiers get display names; unknown values
/// are title-cased on _ and - boundaries (some_future_tier →
/// "Some Future Tier").
private static func planLabel(_ value: Any?) -> String? {
guard let raw = (value as? String)?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { return nil }
let lower = raw.lowercased()
let known = [
"free": "Free", "individual": "Individual", "pro": "Pro", "business": "Business",
"enterprise": "Enterprise", "for_educators": "Educators", "for-educators": "Educators",
]
if let label = known[lower] { return label }
return lower
.components(separatedBy: CharacterSet(charactersIn: "_-"))
.filter { !$0.isEmpty }
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
static func disconnect() {
clearUsageBlock()
resetProbeCache()
}
}