Skip to content
100 changes: 100 additions & 0 deletions Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire LongCat cookies into CLI snapshots

This app-side snapshot wiring does not cover the CLI path: TokenAccountCLIContext.makeCookieBackedSnapshot/makeSnapshot has no LongCat case, and the environment resolver does not project a config cookieHeader into LONGCAT_MANUAL_COOKIE. In the scenario where a user runs codexbar usage --provider longcat with LongCat credentials saved in the config, the fetch context is built with settings == nil, so LongCatCookieHeader only sees process env and reports missing cookies even though the config contains them.

Useful? React with 👍 / 👎.

}

@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() }),
]
}
}
54 changes: 54 additions & 0 deletions Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ enum ProviderImplementationRegistry {
case .deepgram: DeepgramProviderImplementation()
case .poe: PoeProviderImplementation()
case .chutes: ChutesProviderImplementation()
case .longcat: LongCatProviderImplementation()
case .crossmodel: CrossModelProviderImplementation()
case .clawrouter: ClawRouterProviderImplementation()
case .sub2api: Sub2APIProviderImplementation()
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-longcat.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ extension UsageStore {
.sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder,
.stepfun,
.bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes,
.clawrouter, .wayfinder, .sub2api, .zenmux:
.longcat, .clawrouter, .wayfinder, .sub2api, .zenmux:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "9b26fd821cf090dc"
static let value = "910b475f0fded3e5"
}
3 changes: 3 additions & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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"
Expand Down
27 changes: 27 additions & 0 deletions Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
77 changes: 77 additions & 0 deletions Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift
Original file line number Diff line number Diff line change
@@ -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..<raw.endIndex, in: raw)
guard let match = regex.firstMatch(in: raw, options: [], range: range),
match.numberOfRanges >= 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
}
}
Loading
Loading