Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
}

@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 @@ -66,6 +66,7 @@ enum ProviderImplementationRegistry {
case .deepgram: DeepgramProviderImplementation()
case .poe: PoeProviderImplementation()
case .chutes: ChutesProviderImplementation()
case .longcat: LongCatProviderImplementation()
}
}

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 @@ -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"
}
}
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 = "800a06dead603ea7"
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 @@ -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"
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
Comment thread
LeoLin990405 marked this conversation as resolved.
}

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