diff --git a/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift new file mode 100644 index 000000000..d4b1cec73 --- /dev/null +++ b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct LongCatProviderImplementation: ProviderImplementation { + let id: UsageProvider = .longcat + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.longcatUsageDataSource + _ = settings.longcatCookieSource + _ = settings.longcatManualCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .longcat(context.settings.longcatSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.longcatUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.longcatUsageDataSource { + case .web: .web + case .auto, .api, .cli, .oauth: .auto + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.longcatCookieSource.rawValue }, + set: { raw in + context.settings.longcatCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.longcatCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports longcat.chat cookies from your browser.", + manual: "Paste a Cookie header copied from longcat.chat.", + off: "LongCat cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "longcat-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports longcat.chat cookies from your browser.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "longcat-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}", + binding: context.stringBinding(\.longcatManualCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "longcat-open-console", + title: "Open Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://longcat.chat/platform/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.longcatCookieSource == .manual }, + onActivate: { context.settings.ensureLongCatCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift new file mode 100644 index 000000000..f0747b19d --- /dev/null +++ b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift @@ -0,0 +1,54 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var longcatUsageDataSource: ProviderSourceMode { + get { self.configSnapshot.providerConfig(for: .longcat)?.source ?? .auto } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .web: .web + case .api, .cli, .oauth: .auto + } + self.updateProviderConfig(provider: .longcat) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .longcat, field: "usageSource", value: newValue.rawValue) + } + } + + var longcatManualCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .longcat)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .longcat) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .longcat, field: "cookieHeader", value: newValue) + } + } + + var longcatCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .longcat, fallback: .auto) } + set { + self.updateProviderConfig(provider: .longcat) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .longcat, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureLongCatCookieLoaded() {} +} + +extension SettingsStore { + func longcatSettingsSnapshot(tokenOverride: TokenAccountOverride?) + -> ProviderSettingsSnapshot.LongCatProviderSettings + { + self.ensureLongCatCookieLoaded() + return self.resolvedCookieSettings( + provider: .longcat, + configuredSource: self.longcatCookieSource, + configuredHeader: self.longcatManualCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 098e69325..16c3949cf 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -66,6 +66,7 @@ enum ProviderImplementationRegistry { case .deepgram: DeepgramProviderImplementation() case .poe: PoeProviderImplementation() case .chutes: ChutesProviderImplementation() + case .longcat: LongCatProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-longcat.svg b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg new file mode 100644 index 000000000..dd1201c95 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6c4143188..9e7914303 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1086,7 +1086,7 @@ extension UsageStore { case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, - .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes: + .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .longcat: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 4b866f2df..08da5cf7c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "800a06dead603ea7" + static let value = "910b475f0fded3e5" } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index f0321295e..0ea4d8143 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -42,6 +42,9 @@ public enum LogCategories { public static let kimiTokenStore = "kimi-token-store" public static let kimiWeb = "kimi-web" public static let kiro = "kiro" + public static let longcatAPI = "longcat-api" + public static let longcatCookie = "longcat-cookie" + public static let longcatWeb = "longcat-web" public static let launchAtLogin = "launch-at-login" public static let login = "login" public static let logging = "logging" diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift new file mode 100644 index 000000000..5f8d741c4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum LongCatAPIError: LocalizedError, Sendable, Equatable { + case missingCookies + case invalidSession + case invalidRequest(String) + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookies: + "LongCat session cookies are missing. Sign in at longcat.chat, or paste a cookie header." + case .invalidSession: + "LongCat session is invalid or expired. Please sign in again at longcat.chat." + case let .invalidRequest(message): + "Invalid request: \(message)" + case let .networkError(message): + "LongCat network error: \(message)" + case let .apiError(message): + "LongCat API error: \(message)" + case let .parseFailed(message): + "Failed to parse LongCat usage data: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift new file mode 100644 index 000000000..fe3e07006 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift @@ -0,0 +1,77 @@ +import Foundation + +public struct LongCatCookieOverride: Sendable { + /// Full `Cookie:` header value (e.g. `name=value; name2=value2`). + public let cookieHeader: String + + public init(cookieHeader: String) { + self.cookieHeader = cookieHeader + } +} + +public enum LongCatCookieHeader { + private static let log = CodexBarLog.logger(LogCategories.longcatCookie) + private static let headerPatterns: [String] = [ + #"(?i)-H\s*'Cookie:\s*([^']+)'"#, + #"(?i)-H\s*"Cookie:\s*([^"]+)""#, + #"(?i)\bcookie:\s*'([^']+)'"#, + #"(?i)\bcookie:\s*"([^"]+)""#, + #"(?i)\bcookie:\s*([^\r\n]+)"#, + ] + + public static func resolveCookieOverride(context: ProviderFetchContext) -> LongCatCookieOverride? { + // Off disables LongCat web auth entirely — including a lingering env cookie. + if context.settings?.longcat?.cookieSource == .off { + return nil + } + + if let settings = context.settings?.longcat, settings.cookieSource == .manual { + if let manual = settings.manualCookieHeader, !manual.isEmpty { + return self.override(from: manual) + } + } + + // Route env cookies through the settings reader so the lower-case + // `longcat_manual_cookie` alias and quote-trimming apply on the env path too. + if let envValue = LongCatSettingsReader.cookieHeader(environment: context.env), + let envHeader = self.override(from: envValue) + { + return envHeader + } + + return nil + } + + public static func override(from raw: String?) -> LongCatCookieOverride? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + if let header = self.extractHeader(from: raw) { + return LongCatCookieOverride(cookieHeader: header) + } + + // A bare `name=value; ...` string is itself a usable cookie header. + if raw.contains("=") { + return LongCatCookieOverride(cookieHeader: raw) + } + + return nil + } + + private static func extractHeader(from raw: String) -> String? { + for pattern in self.headerPatterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue } + let range = NSRange(raw.startIndex..= 2, + let captureRange = Range(match.range(at: 1), in: raw) + else { + continue + } + let captured = String(raw[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) + if !captured.isEmpty { return captured } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift new file mode 100644 index 000000000..1c011b68d --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift @@ -0,0 +1,180 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum LongCatCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.longcatCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["longcat.chat", "www.longcat.chat"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.longcat]?.browserCookieOrder ?? Browser.defaultImportOrder + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + /// Full `Cookie:` header built from every longcat.chat cookie. LongCat's + /// console uses Meituan passport SSO; the exact auth cookie name is not + /// documented, so we forward the whole jar rather than keying on one name. + public var cookieHeader: String? { + guard !self.cookies.isEmpty else { return nil } + let header = HTTPCookie.requestHeaderFields(with: self.cookies)["Cookie"] + if let header, !header.isEmpty { return header } + return nil + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw LongCatCookieImportError.noCookies + } + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + log("Found \(httpCookies.count) longcat.chat cookie(s) in \(label)") + sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label)) + } + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { + throw LongCatCookieImportError.noCookies + } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty + } catch { + return false + } + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[longcat-cookie] \(message)") + self.log.debug(message) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { + return "Unknown" + } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): + rhs > lhs + case (nil, .some): + true + case (.some, nil): + false + case (nil, nil): + false + } + } +} + +enum LongCatCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No LongCat session cookies found in browsers." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift new file mode 100644 index 000000000..7cc1213ee --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift @@ -0,0 +1,81 @@ +import Foundation + +/// LongCat's web console wraps every response in a Meituan-style envelope: +/// `{ "code": 0, "message": "...", "data": { ... } }`. +/// +/// The exact `data` field names are not documented and cannot be derived from the +/// minified front-end bundle, so extraction is intentionally lenient: we walk the +/// decoded JSON trying a list of candidate keys and log the raw shape once so the +/// mapping can be tightened against a real response. See `LongCatUsageFetcher`. +enum LongCatEnvelope { + /// Returns the `data` payload if the envelope reports success, else throws. + static func unwrap(_ object: Any?) throws -> Any { + guard let dict = object as? [String: Any] else { + throw LongCatAPIError.parseFailed("response was not a JSON object") + } + // Meituan envelopes use code == 0 for success; some surfaces use 200. + if let code = LongCatJSON.int(dict["code"]), code != 0, code != 200 { + let message = LongCatJSON.string(dict["message"]) ?? LongCatJSON.string(dict["msg"]) ?? "code \(code)" + if code == 401 || code == 403 { throw LongCatAPIError.invalidSession } + throw LongCatAPIError.apiError(message) + } + return dict["data"] ?? dict + } +} + +/// Tiny dynamic-JSON helper for lenient extraction by candidate key names. +enum LongCatJSON { + static func int(_ value: Any?) -> Int? { + switch value { + case let v as Int: v + case let v as Double: Int(v) + case let v as String: Int(v) ?? Double(v).map(Int.init) + case let v as NSNumber: v.intValue + default: nil + } + } + + static func double(_ value: Any?) -> Double? { + switch value { + case let v as Double: v + case let v as Int: Double(v) + case let v as String: Double(v) + case let v as NSNumber: v.doubleValue + default: nil + } + } + + static func string(_ value: Any?) -> String? { + switch value { + case let v as String: v + case let v as NSNumber: v.stringValue + default: nil + } + } + + static func object(_ value: Any?) -> [String: Any]? { + value as? [String: Any] + } + + static func array(_ value: Any?) -> [[String: Any]]? { + if let arr = value as? [[String: Any]] { return arr } + if let arr = value as? [Any] { return arr.compactMap { $0 as? [String: Any] } } + return nil + } + + /// First numeric value found under any of `keys`, searched at the top level + /// and one level deep (LongCat nests some figures under `quota`/`detail`). + static func firstNumber(in object: [String: Any], keys: [String]) -> Double? { + for key in keys { + if let value = double(object[key]) { return value } + } + for value in object.values { + if let nested = value as? [String: Any] { + for key in keys { + if let found = double(nested[key]) { return found } + } + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift new file mode 100644 index 000000000..6f3068b53 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift @@ -0,0 +1,107 @@ +import Foundation + +public enum LongCatProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .longcat, + metadata: ProviderMetadata( + id: .longcat, + displayName: "LongCat", + sessionLabel: "Quota", + weeklyLabel: "Fuel Pack", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show LongCat usage", + cliName: "longcat", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.longcatCookieImportOrder, + dashboardURL: "https://longcat.chat/platform/", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .longcat, + iconResourceName: "ProviderIcon-longcat", + color: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "LongCat cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [LongCatWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "longcat", + aliases: ["long-cat", "lc"], + versionDetector: nil)) + } +} + +struct LongCatWebFetchStrategy: ProviderFetchStrategy { + let id: String = "longcat.web" + let kind: ProviderFetchKind = .web + private static let log = CodexBarLog.logger(LogCategories.longcatWeb) + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + if LongCatCookieHeader.resolveCookieOverride(context: context) != nil { + return true + } + + #if os(macOS) + if Self.allowsBrowserImport(context: context) { + return LongCatCookieImporter.hasSession() + } + #endif + + return false + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let cookieHeader = self.resolveCookieHeader(context: context) else { + throw LongCatAPIError.missingCookies + } + + let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: cookieHeader) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + if case LongCatAPIError.missingCookies = error { return false } + if case LongCatAPIError.invalidSession = error { return false } + return true + } + + private func resolveCookieHeader(context: ProviderFetchContext) -> String? { + if let override = LongCatCookieHeader.resolveCookieOverride(context: context) { + return override.cookieHeader + } + + #if os(macOS) + if Self.allowsBrowserImport(context: context) { + if let session = try? LongCatCookieImporter.importSession(), + let header = session.cookieHeader + { + return header + } + } + #endif + + return nil + } + + /// Browser cookie/keychain import is only used for user-initiated app + /// refreshes in the Auto source. Manual must use the pasted header and Off + /// disables web auth, so neither should silently fall back to a browser + /// session. + static func allowsBrowserImport(context: ProviderFetchContext) -> Bool { + let source = context.settings?.longcat?.cookieSource + return context.runtime == .app && + ProviderInteractionContext.current == .userInitiated && + (source == nil || source == .auto) + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift new file mode 100644 index 000000000..9fcc78227 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift @@ -0,0 +1,31 @@ +import Foundation + +public enum LongCatSettingsReader { + /// Manual cookie header for the LongCat web console (longcat.chat). + public static func cookieHeader(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let raw = environment["LONGCAT_MANUAL_COOKIE"] ?? environment["longcat_manual_cookie"] + return self.cleaned(raw) + } + + /// LongCat OpenAI/Anthropic-compatible API key. Not used for usage (the public + /// API exposes no usage endpoint) but kept for parity and future signals. + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let raw = environment["LONGCAT_API_KEY"] ?? environment["longcat_api_key"] + return self.cleaned(raw) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift new file mode 100644 index 000000000..91cce6ac2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift @@ -0,0 +1,158 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct LongCatUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.longcatAPI) + private static let host = "https://longcat.chat" + + private static let userCurrentPath = "/api/v1/user-current" + private static let tokenUsagePath = "/api/lc-platform/v1/tokenUsage" + private static let pendingFuelPath = "/api/lc-platform/v1/pending-fuel-packages" + + public static func fetchUsage(cookieHeader: String, now: Date = Date()) async throws -> LongCatUsageSnapshot { + // Account name. The user-current payload also carries a session token and + // phone number, so its body is never logged. This is the required probe: + // a Meituan envelope with HTTP 200 but code 401/403 surfaces as + // `.invalidSession` here (via unwrap) so expired cookies are reported + // rather than masked by an empty snapshot. + var account: [String: Any]? + if let data = try await self.get(self.userCurrentPath, cookieHeader: cookieHeader, required: true) { + account = try LongCatEnvelope.unwrap(self.json(data)) as? [String: Any] + } + + var usage: [String: Any]? + if let data = try? await self.get(self.tokenUsagePath, cookieHeader: cookieHeader, required: false) { + self.logRawShape(self.tokenUsagePath, data) + usage = (try? LongCatEnvelope.unwrap(self.json(data))) as? [String: Any] + } + + var fuel: [String: Any]? + if let data = try? await self.get(self.pendingFuelPath, cookieHeader: cookieHeader, required: false) { + self.logRawShape(self.pendingFuelPath, data) + fuel = (try? LongCatEnvelope.unwrap(self.json(data))) as? [String: Any] + } + + return self.buildSnapshot(account: account, tokenUsage: usage, pendingFuel: fuel, now: now) + } + + /// Pure extraction over the unwrapped `data` payloads. Field paths are locked + /// against captured live responses; see `LongCatProviderTests`. + static func buildSnapshot( + account: [String: Any]?, + tokenUsage: [String: Any]?, + pendingFuel: [String: Any]?, + now: Date = Date()) -> LongCatUsageSnapshot + { + var snapshot = LongCatUsageSnapshot(updatedAt: now) + + if let account { + snapshot.accountName = LongCatJSON.string(account["name"]) ?? LongCatJSON.string(account["nickName"]) + } + + // Token quota: data.usage is the canonical aggregate; extData holds the + // per-model breakdown (LongCat-Flash-Lite, LongCat-2.0-Preview, ...). + if let tokenUsage { + let usage = LongCatJSON.object(tokenUsage["usage"]) ?? tokenUsage + snapshot.totalQuota = LongCatJSON.double(usage["totalToken"]) + snapshot.usedQuota = LongCatJSON.double(usage["usedToken"]) + snapshot.remainingQuota = LongCatJSON.double(usage["availableToken"]) + } + + if let pendingFuel { + self.applyFuelPackages(pendingFuel, to: &snapshot) + } + + return snapshot + } + + private static func applyFuelPackages(_ dict: [String: Any], to snapshot: inout LongCatUsageSnapshot) { + let total = LongCatJSON.double(dict["totalQuota"]) + let packages = LongCatJSON.array(dict["list"]) ?? [] + + var remaining = 0.0 + var sawRemaining = false + var nearestExpiry: Date? + for package in packages { + if let value = LongCatJSON.firstNumber( + in: package, + keys: ["availableToken", "remainToken", "remainQuota", "remainingQuota", "remain", "availableQuota"]) + { + remaining += value + sawRemaining = true + } + if let expiry = self.parseDate( + package["expireTime"] ?? package["expiredTime"] ?? package["expireAt"] + ?? package["gmtExpire"] ?? package["expireDate"]) + { + if nearestExpiry == nil || expiry < nearestExpiry! { nearestExpiry = expiry } + } + } + + if let total, total > 0 { + snapshot.fuelPackTotal = total + snapshot.fuelPackRemaining = sawRemaining ? remaining : total + } + snapshot.nearestFuelExpiry = nearestExpiry + } + + // MARK: - HTTP + + private static func get(_ path: String, cookieHeader: String, required: Bool) async throws -> Data? { + guard let url = URL(string: self.host + path) else { + throw LongCatAPIError.invalidRequest("bad URL: \(path)") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(self.host, forHTTPHeaderField: "Origin") + request.setValue("\(self.host)/platform/usage", forHTTPHeaderField: "Referer") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + let userAgent = "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" + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw LongCatAPIError.invalidSession + } + if required { + throw LongCatAPIError.apiError("HTTP \(response.statusCode) for \(path)") + } + Self.log.error("LongCat \(path) returned \(response.statusCode)") + return nil + } + return response.data + } + + private static func json(_ data: Data) -> Any? { + try? JSONSerialization.jsonObject(with: data) + } + + /// Logs the (non-sensitive) response shape to help future debugging. Never + /// called for user-current, whose body carries a session token + phone. + private static func logRawShape(_ path: String, _ data: Data) { + guard let body = String(data: data, encoding: .utf8) else { return } + Self.log.debug("LongCat \(path) raw: \(body.prefix(1200))") + } + + private static func parseDate(_ value: Any?) -> Date? { + if let number = LongCatJSON.double(value) { + let seconds = number > 1_000_000_000_000 ? number / 1000 : number + if seconds > 1_000_000_000 { return Date(timeIntervalSince1970: seconds) } + } + if let string = LongCatJSON.string(value) { + let iso = ISO8601DateFormatter() + if let date = iso.date(from: string) { return date } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + if let date = formatter.date(from: string) { return date } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift new file mode 100644 index 000000000..e2452ed12 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Parsed, Sendable view of the LongCat console quota model: +/// 总额度 (total token quota) plus 加油包额度 (fuel packs, which expire). +public struct LongCatUsageSnapshot: Sendable { + public var totalQuota: Double? + public var usedQuota: Double? + public var remainingQuota: Double? + public var fuelPackTotal: Double? + public var fuelPackRemaining: Double? + public var nearestFuelExpiry: Date? + public var accountName: String? + public var updatedAt: Date + + public init( + totalQuota: Double? = nil, + usedQuota: Double? = nil, + remainingQuota: Double? = nil, + fuelPackTotal: Double? = nil, + fuelPackRemaining: Double? = nil, + nearestFuelExpiry: Date? = nil, + accountName: String? = nil, + updatedAt: Date = Date()) + { + self.totalQuota = totalQuota + self.usedQuota = usedQuota + self.remainingQuota = remainingQuota + self.fuelPackTotal = fuelPackTotal + self.fuelPackRemaining = fuelPackRemaining + self.nearestFuelExpiry = nearestFuelExpiry + self.accountName = accountName + self.updatedAt = updatedAt + } +} + +extension LongCatUsageSnapshot { + private func resolvedUsed(total: Double) -> Double { + if let used = usedQuota { return max(0, used) } + if let remaining = remainingQuota { return max(0, total - remaining) } + return 0 + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Primary: overall token quota consumption (总额度). + var primary: RateWindow? + if let total = totalQuota, total > 0 { + let used = self.resolvedUsed(total: total) + primary = RateWindow( + usedPercent: min(100, used / total * 100), + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(Int(used))/\(Int(total))") + } + + // Secondary: fuel-pack balance (加油包额度), with nearest expiry as reset. + var secondary: RateWindow? + if let total = fuelPackTotal, total > 0 { + let remaining = self.fuelPackRemaining ?? total + let used = max(0, total - remaining) + secondary = RateWindow( + usedPercent: min(100, used / total * 100), + windowMinutes: nil, + resetsAt: self.nearestFuelExpiry, + resetDescription: "Fuel pack: \(Int(remaining))/\(Int(total))") + } + + let identity = ProviderIdentitySnapshot( + providerID: .longcat, + accountEmail: nil, + accountOrganization: self.accountName, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 99ee47ccd..12f9a80c4 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -106,6 +106,7 @@ public enum ProviderDescriptorRegistry { .deepgram: DeepgramProviderDescriptor.descriptor, .poe: PoeProviderDescriptor.descriptor, .chutes: ChutesProviderDescriptor.descriptor, + .longcat: LongCatProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 88cd14f15..5dd7d9d79 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -25,6 +25,7 @@ public struct ProviderSettingsSnapshot: Sendable { copilot: CopilotProviderSettings? = nil, kilo: KiloProviderSettings? = nil, kimi: KimiProviderSettings? = nil, + longcat: LongCatProviderSettings? = nil, augment: AugmentProviderSettings? = nil, moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, @@ -57,6 +58,7 @@ public struct ProviderSettingsSnapshot: Sendable { copilot: copilot, kilo: kilo, kimi: kimi, + longcat: longcat, augment: augment, moonshot: moonshot, amp: amp, @@ -273,6 +275,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct LongCatProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct AugmentProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -442,6 +454,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let copilot: CopilotProviderSettings? public let kilo: KiloProviderSettings? public let kimi: KimiProviderSettings? + public let longcat: LongCatProviderSettings? public let augment: AugmentProviderSettings? public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? @@ -478,6 +491,7 @@ public struct ProviderSettingsSnapshot: Sendable { copilot: CopilotProviderSettings?, kilo: KiloProviderSettings?, kimi: KimiProviderSettings?, + longcat: LongCatProviderSettings? = nil, augment: AugmentProviderSettings?, moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, @@ -509,6 +523,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.copilot = copilot self.kilo = kilo self.kimi = kimi + self.longcat = longcat self.augment = augment self.moonshot = moonshot self.amp = amp @@ -541,6 +556,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case copilot(ProviderSettingsSnapshot.CopilotProviderSettings) case kilo(ProviderSettingsSnapshot.KiloProviderSettings) case kimi(ProviderSettingsSnapshot.KimiProviderSettings) + case longcat(ProviderSettingsSnapshot.LongCatProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) @@ -574,6 +590,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings? public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? + public var longcat: ProviderSettingsSnapshot.LongCatProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? @@ -611,6 +628,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .copilot(value): self.copilot = value case let .kilo(value): self.kilo = value case let .kimi(value): self.kimi = value + case let .longcat(value): self.longcat = value case let .augment(value): self.augment = value case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value @@ -646,6 +664,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { copilot: self.copilot, kilo: self.kilo, kimi: self.kimi, + longcat: self.longcat, augment: self.augment, moonshot: self.moonshot, amp: self.amp, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 005a6d2e4..2d8829d7e 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -56,6 +56,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case deepgram case poe case chutes + case longcat } // swiftformat:enable sortDeclarations @@ -112,6 +113,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case deepgram case poe case chutes + case longcat case combined } @@ -260,4 +262,13 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// LongCat Auto imports only from Chrome by default to avoid prompting unrelated browser keychains. + public static var longcatCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index aff14a6d9..4aeed7b54 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -452,7 +452,7 @@ enum CostUsageScanner { .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .stepfun, .bedrock, .grok, - .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes: + .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .longcat: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 923690bb1..5002780f4 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -106,6 +106,7 @@ enum ProviderChoice: String, AppEnum { case .deepgram: return nil // Deepgram not yet supported in widgets case .poe: return nil // Poe not yet supported in widgets case .chutes: return nil // Chutes not yet supported in widgets + case .longcat: return nil // LongCat not yet supported in widgets case .zed: return nil // Zed not yet supported in widgets } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 10fe0183f..428d4eb2d 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -315,6 +315,7 @@ private struct ProviderSwitchChip: View { case .deepgram: "Deepgram" case .poe: "Poe" case .chutes: "Chutes" + case .longcat: "LongCat" case .zed: "Zed" } } @@ -857,6 +858,8 @@ enum WidgetColors { Color(red: 0.15, green: 0.68, blue: 0.38) case .chutes: Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) + case .longcat: + Color(red: 255 / 255, green: 209 / 255, blue: 0 / 255) case .zed: Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index c8cf68d48..d43940465 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -69,5 +69,11 @@ struct BrowserCookieOrderStatusStringTests { #expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome]) #expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome]) } + + @Test + func `longcat cookie imports default to chrome only`() { + #expect(ProviderDefaults.metadata[.longcat]?.browserCookieOrder == [.chrome]) + #expect(ProviderBrowserCookieDefaults.longcatCookieImportOrder == [.chrome]) + } #endif } diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift new file mode 100644 index 000000000..0f0780e75 --- /dev/null +++ b/Tests/CodexBarTests/LongCatProviderTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct LongCatProviderTests { + // MARK: - Settings reader + + @Test + func `reads LONGCAT_MANUAL_COOKIE`() { + let env = ["LONGCAT_MANUAL_COOKIE": "passport_token=abc; uid=42"] + #expect(LongCatSettingsReader.cookieHeader(environment: env) == "passport_token=abc; uid=42") + } + + @Test + func `reads LONGCAT_API_KEY and trims quotes`() { + #expect(LongCatSettingsReader.apiKey(environment: ["LONGCAT_API_KEY": " \"ak_x\" "]) == "ak_x") + } + + @Test + func `missing env returns nil`() { + #expect(LongCatSettingsReader.cookieHeader(environment: [:]) == nil) + #expect(LongCatSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `cookieHeader reads lowercase alias and trims quotes`() { + // The env path routes through this reader, so the lower-case alias and + // quote-trimming must apply (regression for the env-bypass fix). + #expect(LongCatSettingsReader.cookieHeader(environment: ["longcat_manual_cookie": "'a=b; c=d'"]) == "a=b; c=d") + } + + // MARK: - Cookie header override + + @Test + func `override accepts bare cookie pair string`() { + let override = LongCatCookieHeader.override(from: "passport_token=abc; uid=42") + #expect(override?.cookieHeader == "passport_token=abc; uid=42") + } + + @Test + func `override extracts from a curl Cookie header`() { + let raw = "curl 'https://longcat.chat/api/v1/user-current' -H 'Cookie: passport_token=abc; uid=42'" + let override = LongCatCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "passport_token=abc; uid=42") + } + + @Test + func `override rejects a token-less string`() { + #expect(LongCatCookieHeader.override(from: "not a cookie") == nil) + #expect(LongCatCookieHeader.override(from: " ") == nil) + } + + // MARK: - Snapshot mapping + + @Test + func `total quota maps to primary used percent`() { + let snapshot = LongCatUsageSnapshot(totalQuota: 1000, usedQuota: 250) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .longcat) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.001) + } + + @Test + func `remaining quota infers used when used is absent`() { + let snapshot = LongCatUsageSnapshot(totalQuota: 1000, remainingQuota: 400) + #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 60) < 0.001) + } + + @Test + func `missing quota data omits primary window`() { + let usage = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary != nil) + } + + @Test + func `fuel pack populates secondary window`() { + let snapshot = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary != nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 60) < 0.001) + } + + // MARK: - buildSnapshot against captured live response shapes + + private func object(_ json: String) throws -> [String: Any] { + let parsed = try JSONSerialization.jsonObject(with: Data(json.utf8)) + return try #require(parsed as? [String: Any]) + } + + @Test + func `buildSnapshot maps live tokenUsage and account fields`() throws { + // Shapes captured from longcat.chat console (values neutralised). + let account = try self.object(#"{"userId":1,"name":"LongCat User","phone":"x","token":"secret"}"#) + let tokenUsage = try self.object(#""" + {"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000,"freeAvailableToken":380000}, + "extData":{"LongCat-Flash-Lite":{"totalToken":50000000,"usedToken":0}}} + """#) + let fuel = try self.object(#"{"totalQuota":0,"list":[]}"#) + + let snapshot = LongCatUsageFetcher.buildSnapshot(account: account, tokenUsage: tokenUsage, pendingFuel: fuel) + #expect(snapshot.accountName == "LongCat User") + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.remainingQuota == 380_000) + #expect(snapshot.fuelPackTotal == nil) // empty fuel list + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 24) < 0.001) + #expect(usage.secondary == nil) + } + + @Test + func `buildSnapshot sums active fuel packages`() throws { + let fuel = try self.object(#""" + {"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}, + {"availableToken":150,"expireTime":1760000000000}]} + """#) + let snapshot = LongCatUsageFetcher.buildSnapshot(account: nil, tokenUsage: nil, pendingFuel: fuel) + #expect(snapshot.fuelPackTotal == 1000) + #expect(snapshot.fuelPackRemaining == 750) + #expect(snapshot.nearestFuelExpiry != nil) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + // MARK: - Envelope + + @Test + func `envelope surfaces invalid session on auth code`() { + #expect(throws: LongCatAPIError.invalidSession) { + try LongCatEnvelope.unwrap(["code": 401, "message": "unauthorized"]) + } + } + + @Test + func `envelope unwraps data on success`() throws { + let data = try LongCatEnvelope.unwrap(["code": 0, "data": ["x": 1]]) as? [String: Any] + #expect(data?["x"] as? Int == 1) + } + + // MARK: - Cookie source semantics + + private func context( + env: [String: String], + cookieSource: ProviderCookieSource, + runtime: ProviderRuntime = .app) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make( + longcat: .init(cookieSource: cookieSource, manualCookieHeader: nil)), + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `off source disables env cookie override`() { + let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .off) + #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx) == nil) + } + + @Test + func `auto source allows env cookie override`() { + let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .auto) + #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx)?.cookieHeader == "a=b") + } + + @Test + func `browser import is user initiated app auto only`() { + let appAuto = self.context(env: [:], cookieSource: .auto) + let cliAuto = self.context(env: [:], cookieSource: .auto, runtime: .cli) + let appManual = self.context(env: [:], cookieSource: .manual) + let appOff = self.context(env: [:], cookieSource: .off) + + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto)) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appManual) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appOff) == false) + } + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 2d00a9ca4..dfd73d5ce 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -29,6 +29,7 @@ struct ProviderIconResourcesTests { "commandcode", "t3chat", "kimi", + "longcat", "bedrock", "elevenlabs", "groq", diff --git a/docs/configuration.md b/docs/configuration.md index 6a7577496..b05674520 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,7 +167,7 @@ and never paste real cookie values or readable DevTools screenshots into public ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`. +`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `longcat`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering.