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

492 lines
20 KiB
Swift

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<String> = [
"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[..<idx].trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? nil : String(name)
}
}
private func sessionCookieHeader(from header: String) -> 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
}
}