import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif #if os(macOS) import SweetCookieKit #endif public enum AmpUsageError: LocalizedError, Sendable { case notLoggedIn case invalidCredentials case missingAPIToken case invalidAPIToken case parseFailed(String) case networkError(String) case noSessionCookie public var errorDescription: String? { switch self { case .notLoggedIn: "Not logged in to Amp. Please log in via ampcode.com." case .invalidCredentials: "Amp session cookie expired. Please log in again." case .missingAPIToken: "Amp access token not configured. Set AMP_API_KEY or add it in Settings." case .invalidAPIToken: "Amp access token is invalid or expired." case let .parseFailed(message): "Could not parse Amp usage: \(message)" case let .networkError(message): "Amp request failed: \(message)" case .noSessionCookie: "No Amp session cookie found. Please log in to ampcode.com in your browser." } } } #if os(macOS) private let ampCookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.amp]?.browserCookieOrder ?? Browser.defaultImportOrder public enum AmpCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = ["ampcode.com", "www.ampcode.com"] private static let sessionCookieNames: Set = [ "session", ] public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] public let sourceLabel: String public init(cookies: [HTTPCookie], sourceLabel: String) { self.cookies = cookies self.sourceLabel = sourceLabel } public var cookieHeader: String { self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } } public static func importSession( browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) throws -> SessionInfo { let log: (String) -> Void = { msg in logger?("[amp-cookie] \(msg)") } let installed = ampCookieImportOrder.cookieImportCandidates(using: browserDetection) for browserSource in installed { do { let query = BrowserCookieQuery(domains: self.cookieDomains) let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) for source in sources where !source.records.isEmpty { let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) guard !cookies.isEmpty else { continue } let names = cookies.map(\.name).joined(separator: ", ") log("\(source.label) cookies: \(names)") let sessionCookies = cookies.filter { Self.sessionCookieNames.contains($0.name) } if !sessionCookies.isEmpty { log("Found Amp session cookie in \(source.label)") return SessionInfo(cookies: sessionCookies, sourceLabel: source.label) } log("\(source.label) cookies found, but no Amp session cookie present") log("Expected one of: \(Self.sessionCookieNames.joined(separator: ", "))") } } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } } throw AmpUsageError.noSessionCookie } } #endif public struct AmpUsageFetcher: Sendable { private static let settingsURL = URL(string: "https://ampcode.com/settings")! static let usageURL = URL(string: "https://ampcode.com/api/internal?userDisplayBalanceInfo")! @MainActor private static var recentDumps: [String] = [] public let browserDetection: BrowserDetection public init(browserDetection: BrowserDetection) { self.browserDetection = browserDetection } public func fetch( cookieHeaderOverride: String? = nil, logger: ((String) -> Void)? = nil, now: Date = Date()) async throws -> AmpUsageSnapshot { let log: (String) -> Void = { msg in logger?("[amp] \(msg)") } let cookieHeader = try await self.resolveCookieHeader(override: cookieHeaderOverride, logger: log) if let logger { let names = self.cookieNames(from: cookieHeader) if !names.isEmpty { logger("[amp] Cookie names: \(names.joined(separator: ", "))") } let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: logger) do { let (html, responseInfo) = try await self.fetchLegacyHTMLWithDiagnostics( cookieHeader: cookieHeader, diagnostics: diagnostics) self.logDiagnostics(responseInfo: responseInfo, diagnostics: diagnostics, logger: logger) return try AmpUsageParser.parse(html: html, now: now) } catch { self.logDiagnostics(responseInfo: nil, diagnostics: diagnostics, logger: logger) logger("[amp] Fetch failed: \(error.localizedDescription)") throw error } } let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: nil) let (html, _) = try await self.fetchLegacyHTMLWithDiagnostics( cookieHeader: cookieHeader, diagnostics: diagnostics) return try AmpUsageParser.parse(html: html, now: now) } public func fetch( apiToken: String, logger: ((String) -> Void)? = nil, now: Date = Date()) async throws -> AmpUsageSnapshot { guard let token = AmpSettingsReader.cleaned(apiToken) else { throw AmpUsageError.missingAPIToken } let request = try Self.makeUsageAPIRequest(apiToken: token) let diagnostics = APIRedirectDiagnostics(logger: logger) let session = URLSession(configuration: .ephemeral, delegate: diagnostics, delegateQueue: nil) let httpResponse = try await session.response(for: request) logger?("[amp] API response: \(httpResponse.statusCode) " + "\(httpResponse.response.url?.absoluteString ?? "unknown")") try Self.validateAPIResponse(httpResponse) return try Self.parseUsageAPIResponse(httpResponse.data, now: now) } public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String { let stamp = ISO8601DateFormatter().string(from: Date()) var lines: [String] = [] lines.append("=== Amp Debug Probe @ \(stamp) ===") lines.append("") do { let cookieHeader = try await self.resolveCookieHeader( override: cookieHeaderOverride, logger: { msg in lines.append("[cookie] \(msg)") }) let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: nil) let cookieNames = CookieHeaderNormalizer.pairs(from: cookieHeader).map(\.name) lines.append("Cookie names: \(cookieNames.joined(separator: ", "))") let (html, responseInfo) = try await self.fetchLegacyHTMLWithDiagnostics( cookieHeader: cookieHeader, diagnostics: diagnostics) let snapshot = try AmpUsageParser.parse(html: html) lines.append("") lines.append("Fetch Success") lines.append("Status: \(responseInfo.statusCode) \(responseInfo.url)") if !diagnostics.redirects.isEmpty { lines.append("") lines.append("Redirects:") for entry in diagnostics.redirects { lines.append(" \(entry)") } } lines.append("") lines.append("Amp Free:") lines.append(" quota=\(snapshot.freeQuota?.description ?? "nil")") lines.append(" used=\(snapshot.freeUsed?.description ?? "nil")") lines.append(" hourlyReplenishment=\(snapshot.hourlyReplenishment?.description ?? "nil")") lines.append(" windowHours=\(snapshot.windowHours?.description ?? "nil")") lines.append(" individualCredits=\(snapshot.individualCredits?.description ?? "nil")") for workspace in snapshot.workspaceBalances { lines.append(" workspace[\(workspace.name)]=\(workspace.remaining)") } let output = lines.joined(separator: "\n") Task { @MainActor in Self.recordDump(output) } return output } catch { lines.append("") lines.append("Probe Failed: \(error.localizedDescription)") let output = lines.joined(separator: "\n") Task { @MainActor in Self.recordDump(output) } return output } } public static func latestDumps() async -> String { await MainActor.run { let result = Self.recentDumps.joined(separator: "\n\n---\n\n") return result.isEmpty ? "No Amp probe dumps captured yet." : result } } private func resolveCookieHeader( override: String?, logger: ((String) -> Void)?) async throws -> String { if let override = CookieHeaderNormalizer.normalize(override) { if let sessionHeader = self.sessionCookieHeader(from: override) { logger?("[amp] Using manual session cookie") return sessionHeader } throw AmpUsageError.noSessionCookie } #if os(macOS) let session = try AmpCookieImporter.importSession(browserDetection: self.browserDetection, logger: logger) logger?("[amp] Using cookies from \(session.sourceLabel)") return session.cookieHeader #else throw AmpUsageError.noSessionCookie #endif } static func makeUsageAPIRequest(apiToken: String) throws -> URLRequest { var request = URLRequest(url: Self.usageURL) request.httpMethod = "POST" request.httpBody = try JSONSerialization.data(withJSONObject: [ "method": "userDisplayBalanceInfo", "params": [:], ]) request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") request.setValue("application/json", forHTTPHeaderField: "content-type") return request } private func fetchLegacyHTMLWithDiagnostics( cookieHeader: String, diagnostics: RedirectDiagnostics) async throws -> (String, ResponseInfo) { var request = URLRequest(url: Self.settingsURL) request.httpMethod = "GET" request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") request.setValue( "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "accept") Self.applyBrowserHeaders(to: &request) let session = URLSession(configuration: .ephemeral, delegate: diagnostics, delegateQueue: nil) let httpResponse = try await session.response(for: request) let responseInfo = ResponseInfo( statusCode: httpResponse.statusCode, url: httpResponse.response.url?.absoluteString ?? "unknown") try Self.validateBrowserResponse(response: httpResponse, diagnostics: diagnostics) let html = String(data: httpResponse.data, encoding: .utf8) ?? "" return (html, responseInfo) } static func parseUsageAPIResponse(_ data: Data, now: Date = Date()) throws -> AmpUsageSnapshot { let response: UsageAPIResponse do { response = try JSONDecoder().decode(UsageAPIResponse.self, from: data) } catch { throw AmpUsageError.parseFailed("Invalid Amp usage API response.") } guard response.ok else { if response.error?.code == "auth-required" { throw AmpUsageError.invalidAPIToken } throw AmpUsageError.networkError(response.error?.message ?? "Amp usage API returned an error.") } guard let displayText = response.result?.displayText, !displayText.isEmpty else { throw AmpUsageError.parseFailed("Missing Amp usage display text.") } return try AmpUsageParser.parse(displayText: displayText, now: now) } private static func applyBrowserHeaders(to request: inout URLRequest) { request.setValue( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", forHTTPHeaderField: "user-agent") request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") request.setValue("https://ampcode.com", forHTTPHeaderField: "origin") request.setValue(self.settingsURL.absoluteString, forHTTPHeaderField: "referer") } private static func validateBrowserResponse( response: ProviderHTTPResponse, diagnostics: RedirectDiagnostics) throws { guard response.statusCode == 200 else { if response.statusCode == 401 || response.statusCode == 403 || diagnostics.detectedLoginRedirect { throw AmpUsageError.invalidCredentials } throw AmpUsageError.networkError("HTTP \(response.statusCode)") } } private static func validateAPIResponse(_ response: ProviderHTTPResponse) throws { guard response.statusCode == 200 else { if response.statusCode == 401 || response.statusCode == 403 { throw AmpUsageError.invalidAPIToken } throw AmpUsageError.networkError("HTTP \(response.statusCode)") } } @MainActor private static func recordDump(_ text: String) { if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() } self.recentDumps.append(text) } private final class RedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let cookieHeader: String private let logger: ((String) -> Void)? var redirects: [String] = [] private(set) var detectedLoginRedirect = false init(cookieHeader: String, logger: ((String) -> Void)?) { self.cookieHeader = cookieHeader self.logger = logger } func urlSession( _: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { let from = response.url?.absoluteString ?? "unknown" let to = request.url?.absoluteString ?? "unknown" self.redirects.append("\(response.statusCode) \(from) -> \(to)") if let toURL = request.url, AmpUsageFetcher.isLoginRedirect(toURL) { if let logger { logger("[amp] Detected login redirect, aborting (invalid session)") } self.detectedLoginRedirect = true completionHandler(nil) return } var updated = request if AmpUsageFetcher.shouldAttachCookie(to: request.url), !self.cookieHeader.isEmpty { updated.setValue(self.cookieHeader, forHTTPHeaderField: "Cookie") } else { updated.setValue(nil, forHTTPHeaderField: "Cookie") } if let referer = response.url?.absoluteString { updated.setValue(referer, forHTTPHeaderField: "referer") } if let logger { logger("[amp] Redirect \(response.statusCode) \(from) -> \(to)") } completionHandler(updated) } } /// Amp's balance RPC should not redirect. Refusing redirects guarantees the bearer token cannot cross hosts. private final class APIRedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let logger: ((String) -> Void)? init(logger: ((String) -> Void)?) { self.logger = logger } func urlSession( _: URLSession, task _: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { let from = response.url?.absoluteString ?? "unknown" let to = request.url?.absoluteString ?? "unknown" self.logger?("[amp] API redirect blocked: \(response.statusCode) \(from) -> \(to)") completionHandler(nil) } } private struct ResponseInfo { let statusCode: Int let url: String } private struct UsageAPIResponse: Decodable { let ok: Bool let result: Result? let error: APIError? struct Result: Decodable { let displayText: String } struct APIError: Decodable { let code: String? let message: String? } } private func logDiagnostics( responseInfo: ResponseInfo?, diagnostics: RedirectDiagnostics, logger: (String) -> Void) { if let responseInfo { logger("[amp] Response: \(responseInfo.statusCode) \(responseInfo.url)") } if !diagnostics.redirects.isEmpty { logger("[amp] Redirects:") for entry in diagnostics.redirects { logger("[amp] \(entry)") } } } private func cookieNames(from header: String) -> [String] { header.split(separator: ";").compactMap { part in let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) guard let idx = trimmed.firstIndex(of: "=") else { return nil } let name = trimmed[.. String? { let pairs = CookieHeaderNormalizer.pairs(from: header) let sessionPairs = pairs.filter { $0.name == "session" } guard !sessionPairs.isEmpty else { return nil } return sessionPairs.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } static func shouldAttachCookie(to url: URL?) -> Bool { guard url?.scheme?.lowercased() == "https" else { return false } return self.isAmpHost(url) } private static func isAmpHost(_ url: URL?) -> Bool { guard let host = url?.host?.lowercased() else { return false } if host == "ampcode.com" || host == "www.ampcode.com" { return true } return host.hasSuffix(".ampcode.com") } static func isLoginRedirect(_ url: URL) -> Bool { guard self.isAmpHost(url) else { return false } if url.host?.lowercased() == "auth.ampcode.com" { return true } let path = url.path.lowercased() let components = path.split(separator: "/").map(String.init) if components.contains("login") { return true } if components.contains("signin") { return true } if components.contains("sign-in") { return true } // Amp currently redirects to /auth/sign-in?returnTo=... when session is invalid. Keep this slightly broader // than one exact path so we keep working if Amp changes auth routes. if components.contains("auth") { let query = url.query?.lowercased() ?? "" if query.contains("returnto=") { return true } if query.contains("redirect=") { return true } if query.contains("redirectto=") { return true } } return false } }