From fcae5f328ac178ce9df20506cf1112f9cb675b50 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Tue, 24 Mar 2026 17:47:23 +0100 Subject: [PATCH 01/11] Add Xiaomi MiMo balance support Introduce the Xiaomi MiMo provider across app, core, CLI, and widget surfaces.\n\nAdd MiMo-specific fetchers, settings, icons, and tests, and fix balance row formatting and cookie-source off handling discovered during review. --- Sources/CodexBar/MenuCardView.swift | 7 + Sources/CodexBar/MenuDescriptor.swift | 13 +- .../PreferencesProviderDetailView.swift | 2 +- .../MiMo/MiMoProviderImplementation.swift | 97 +++++++ .../Providers/MiMo/MiMoSettingsStore.swift | 35 +++ .../ProviderImplementationRegistry.swift | 1 + .../CodexBar/Resources/ProviderIcon-mimo.svg | 4 + Sources/CodexBar/UsageStore.swift | 3 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 13 +- .../Providers/MiMo/MiMoCookieImporter.swift | 131 +++++++++ .../MiMo/MiMoProviderDescriptor.swift | 154 ++++++++++ .../Providers/MiMo/MiMoUsageFetcher.swift | 140 +++++++++ .../Providers/MiMo/MiMoUsageSnapshot.swift | 33 +++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 +- .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + Tests/CodexBarTests/MiMoProviderTests.swift | 271 ++++++++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + 21 files changed, 931 insertions(+), 10 deletions(-) create mode 100644 Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-mimo.svg create mode 100644 Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/MiMoProviderTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5f685af23..d54d9940d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -785,6 +785,13 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .mimo, input.snapshot != nil { + return [ + "Balance updates in near-real time (up to 5 min lag)", + "Daily billing data finalizes at 07:00 UTC", + ] + } + guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..f926abfb0 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -247,7 +247,18 @@ struct MenuDescriptor { entries.append(.text("Activity: \(detail)", .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary)) + if provider == .openrouter || provider == .mimo { + let balanceValue = loginMethodText + .replacingOccurrences( + of: #"(?i)^\s*balance:\s*"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let value = balanceValue.isEmpty ? loginMethodText : balanceValue + entries.append(.text("Balance: \(AccountFormatter.plan(value))", .secondary)) + } else { + entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary)) + } } if metadata.usesAccountFallback { diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 58a55deb5..7b98b9649 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -27,7 +27,7 @@ struct ProviderDetailView: View { else { return nil } - guard provider == .openrouter else { + guard provider == .openrouter || provider == .mimo else { return (label: "Plan", value: rawPlan) } diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift new file mode 100644 index 000000000..ca540ec9b --- /dev/null +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -0,0 +1,97 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct MiMoProviderImplementation: ProviderImplementation { + let id: UsageProvider = .mimo + let supportsLoginFlow: Bool = true + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.miMoCookieSource + _ = settings.miMoCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .mimo(context.settings.miMoSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.miMoCookieSource.rawValue }, + set: { raw in + context.settings.miMoCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.miMoCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + manual: "Paste a Cookie header from platform.xiaomimimo.com.", + off: "Xiaomi MiMo cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "mimo-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .mimo) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "mimo-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.miMoCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "mimo-open-balance", + title: "Open MiMo Balance", + style: .link, + isVisible: nil, + perform: { + guard let url = URL(string: "https://platform.xiaomimimo.com/#/console/balance") else { + return + } + NSWorkspace.shared.open(url) + }), + ], + isVisible: { context.settings.miMoCookieSource == .manual }, + onActivate: { context.settings.ensureMiMoCookieLoaded() }), + ] + } + + @MainActor + func runLoginFlow(context _: ProviderLoginContext) async -> Bool { + let loginURL = "https://platform.xiaomimimo.com/api/v1/genLoginUrl?currentPath=%2F%23%2Fconsole%2Fbalance" + guard let url = URL(string: loginURL) else { + return false + } + NSWorkspace.shared.open(url) + return false + } +} diff --git a/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift new file mode 100644 index 000000000..3285a20b3 --- /dev/null +++ b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift @@ -0,0 +1,35 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var miMoCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .mimo)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .mimo) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .mimo, field: "cookieHeader", value: newValue) + } + } + + var miMoCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .mimo, fallback: .auto) } + set { + self.updateProviderConfig(provider: .mimo) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .mimo, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureMiMoCookieLoaded() {} +} + +extension SettingsStore { + func miMoSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.MiMoProviderSettings { + _ = tokenOverride + return ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: self.miMoCookieSource, + manualCookieHeader: self.miMoCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 9cb99850b..1da4852d5 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -36,6 +36,7 @@ enum ProviderImplementationRegistry { case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() + case .mimo: MiMoProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-mimo.svg b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg new file mode 100644 index 000000000..3b4e2a2a2 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 47efc5b63..51a55ed74 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1158,6 +1158,7 @@ extension UsageStore { .kimi: "Kimi debug log not yet implemented", .kimik2: "Kimi K2 debug log not yet implemented", .jetbrains: "JetBrains AI debug log not yet implemented", + .mimo: "Xiaomi MiMo debug log not yet implemented", ] let buildText = { switch provider { @@ -1231,7 +1232,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains: + .kimik2, .jetbrains, .mimo: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 937b37aa0..94b247002 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -165,6 +165,13 @@ struct TokenAccountCLIContext { kimi: ProviderSettingsSnapshot.KimiProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .mimo: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .zai: return self.makeSnapshot( zai: ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.resolveZaiRegion(config))) @@ -196,7 +203,8 @@ struct TokenAccountCLIContext { augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil, amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil, ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, - jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, + mimo: ProviderSettingsSnapshot.MiMoProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -212,7 +220,8 @@ struct TokenAccountCLIContext { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + mimo: mimo) } func environment( diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift new file mode 100644 index 000000000..39093ebbd --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -0,0 +1,131 @@ +import Foundation + +enum MiMoCookieHeader { + static let requiredCookieNames: Set = [ + "api-platform_serviceToken", + "userId", + ] + static let knownCookieNames: Set = requiredCookieNames.union([ + "api-platform_ph", + "api-platform_slh", + ]) + + static func normalizedHeader(from raw: String?) -> String? { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + let pairs = CookieHeaderNormalizer.pairs(from: normalized) + guard !pairs.isEmpty else { return nil } + + var byName: [String: String] = [:] + for pair in pairs { + let name = pair.name.trimmingCharacters(in: .whitespacesAndNewlines) + let value = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.knownCookieNames.contains(name), !value.isEmpty else { continue } + byName[name] = value + } + + guard self.requiredCookieNames.isSubset(of: Set(byName.keys)) else { return nil } + return byName.keys.sorted().compactMap { name in + guard let value = byName[name] else { return nil } + return "\(name)=\(value)" + }.joined(separator: "; ") + } + + static func header(from cookies: [HTTPCookie]) -> String? { + var byName: [String: HTTPCookie] = [:] + + for cookie in cookies { + guard self.knownCookieNames.contains(cookie.name) else { continue } + guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + if let expiry = cookie.expiresDate, expiry < Date() { continue } + + if let existing = byName[cookie.name] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + guard self.requiredCookieNames.isSubset(of: Set(byName.keys)) else { return nil } + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } +} + +#if os(macOS) +import SweetCookieKit + +private let miMoCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum MiMoCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = [ + "platform.xiaomimimo.com", + "xiaomimimo.com", + ] + + public struct SessionInfo: Sendable { + public let cookieHeader: String + public let sourceLabel: String + + public init(cookieHeader: String, sourceLabel: String) { + self.cookieHeader = cookieHeader + self.sourceLabel = sourceLabel + } + } + + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? + + public static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return try override(browserDetection, logger) + } + + let log: (String) -> Void = { msg in logger?("[mimo-cookie] \(msg)") } + var sessions: [SessionInfo] = [] + let installed = miMoCookieImportOrder.cookieImportCandidates(using: browserDetection) + let labels = installed.map(\.displayName).joined(separator: ", ") + log("Cookie import candidates: \(labels)") + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard let cookieHeader = MiMoCookieHeader.header(from: cookies) else { + continue + } + sessions.append(SessionInfo(cookieHeader: cookieHeader, sourceLabel: source.label)) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + return sessions + } + + public static func hasSession( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> Bool + { + (try? self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty == false) ?? false + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift new file mode 100644 index 000000000..f5658114c --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -0,0 +1,154 @@ +import CodexBarMacroSupport +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MiMoProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + #if os(macOS) + let browserOrder: BrowserCookieImportOrder = [ + .chrome, + .chromeBeta, + .chromeCanary, + ] + #else + let browserOrder: BrowserCookieImportOrder? = nil + #endif + + return ProviderDescriptor( + id: .mimo, + metadata: ProviderMetadata( + id: .mimo, + displayName: "Xiaomi MiMo", + sessionLabel: "Balance", + weeklyLabel: "Window", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Xiaomi MiMo balance", + cliName: "mimo", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: browserOrder, + dashboardURL: "https://platform.xiaomimimo.com/#/console/balance", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .mimo, + iconResourceName: "ProviderIcon-mimo", + color: ProviderColor(red: 1.0, green: 105 / 255, blue: 0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Xiaomi MiMo cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MiMoWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "mimo", + aliases: ["xiaomi-mimo"], + versionDetector: nil)) + } +} + +struct MiMoWebFetchStrategy: ProviderFetchStrategy { + let id: String = "mimo.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.mimo?.cookieSource != .off else { return false } + if Self.resolveManualCookieHeader(context: context) != nil { + return true + } + + #if os(macOS) + if let cached = CookieHeaderCache.load(provider: .mimo), + MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) != nil + { + return true + } + return MiMoCookieImporter.hasSession(browserDetection: context.browserDetection) + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard context.settings?.mimo?.cookieSource != .off else { + throw MiMoSettingsError.missingCookie + } + if let manualCookie = Self.resolveManualCookieHeader(context: context) { + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: manualCookie, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } + + #if os(macOS) + var lastError: Error? + + if let cached = CookieHeaderCache.load(provider: .mimo), + let cachedHeader = MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) + { + do { + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: cachedHeader, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } catch let error as MiMoUsageError where Self.shouldRetryNextSession(for: error) { + CookieHeaderCache.clear(provider: .mimo) + lastError = error + } + } + + let sessions = try MiMoCookieImporter.importSessions(browserDetection: context.browserDetection) + guard !sessions.isEmpty else { + if let lastError { throw lastError } + throw MiMoSettingsError.missingCookie + } + + for session in sessions { + do { + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: session.cookieHeader, + environment: context.env) + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } catch let error as MiMoUsageError where Self.shouldRetryNextSession(for: error) { + lastError = error + continue + } + } + + if let lastError { throw lastError } + throw MiMoSettingsError.missingCookie + #else + throw MiMoSettingsError.missingCookie + #endif + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveManualCookieHeader(context: ProviderFetchContext) -> String? { + guard context.settings?.mimo?.cookieSource == .manual else { return nil } + return MiMoCookieHeader.normalizedHeader(from: context.settings?.mimo?.manualCookieHeader) + } + + private static func shouldRetryNextSession(for error: MiMoUsageError) -> Bool { + switch error { + case .invalidCredentials, .loginRequired: + true + case .parseFailed, .networkError: + false + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift new file mode 100644 index 000000000..1a371ec5c --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift @@ -0,0 +1,140 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MiMoSettingsError: LocalizedError, Sendable { + case missingCookie + case invalidCookie + + public var errorDescription: String? { + switch self { + case .missingCookie: + "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first." + case .invalidCookie: + "Xiaomi MiMo requires the api-platform_serviceToken and userId cookies." + } + } +} + +public enum MiMoUsageError: LocalizedError, Sendable { + case invalidCredentials + case loginRequired + case parseFailed(String) + case networkError(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "Xiaomi MiMo browser session expired. Log in again." + case .loginRequired: + "Xiaomi MiMo login required." + case let .parseFailed(message): + "Could not parse Xiaomi MiMo balance: \(message)" + case let .networkError(message): + "Xiaomi MiMo request failed: \(message)" + } + } +} + +public enum MiMoSettingsReader { + public static let apiURLKey = "MIMO_API_URL" + + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment[self.apiURLKey], + let url = URL(string: override.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = url.scheme, !scheme.isEmpty + { + return url + } + return URL(string: "https://platform.xiaomimimo.com/api/v1")! + } +} + +public enum MiMoUsageFetcher { + private static let requestTimeout: TimeInterval = 15 + + public static func fetchUsage( + cookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) async throws -> MiMoUsageSnapshot + { + guard let normalizedCookie = MiMoCookieHeader.normalizedHeader(from: cookieHeader) else { + throw MiMoSettingsError.invalidCookie + } + + let url = MiMoSettingsReader.apiURL(environment: environment).appendingPathComponent("balance") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeout + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(normalizedCookie, forHTTPHeaderField: "Cookie") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue("UTC+01:00", forHTTPHeaderField: "x-timeZone") + request.setValue("https://platform.xiaomimimo.com", forHTTPHeaderField: "Origin") + request.setValue("https://platform.xiaomimimo.com/#/console/balance", forHTTPHeaderField: "Referer") + 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") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw MiMoUsageError.networkError("Invalid response") + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw MiMoUsageError.loginRequired + case 403: + throw MiMoUsageError.invalidCredentials + default: + throw MiMoUsageError.networkError("HTTP \(httpResponse.statusCode)") + } + + return try self.parseUsageSnapshot(from: data, now: now) + } + + static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> MiMoUsageSnapshot { + let decoder = JSONDecoder() + let response = try decoder.decode(Response.self, from: data) + + guard response.code == 0 else { + let message = response.message?.trimmingCharacters(in: .whitespacesAndNewlines) + if response.code == 401 { + throw MiMoUsageError.loginRequired + } + if response.code == 403 { + throw MiMoUsageError.invalidCredentials + } + throw MiMoUsageError.parseFailed(message?.isEmpty == false ? message! : "code \(response.code)") + } + + guard let data = response.data else { + throw MiMoUsageError.parseFailed("Missing balance payload") + } + guard let balance = Double(data.balance) else { + throw MiMoUsageError.parseFailed("Invalid balance value") + } + + let currency = data.currency.trimmingCharacters(in: .whitespacesAndNewlines) + guard !currency.isEmpty else { + throw MiMoUsageError.parseFailed("Missing currency") + } + + return MiMoUsageSnapshot(balance: balance, currency: currency, updatedAt: now) + } + + private struct Response: Decodable { + let code: Int + let message: String? + let data: Payload? + } + + private struct Payload: Decodable { + let balance: String + let currency: String + } +} diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift new file mode 100644 index 000000000..51b812e2d --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct MiMoUsageSnapshot: Sendable { + public let balance: Double + public let currency: String + public let updatedAt: Date + + public init(balance: Double, currency: String, updatedAt: Date) { + self.balance = balance + self.currency = currency + self.updatedAt = updatedAt + } +} + +extension MiMoUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let trimmedCurrency = self.currency.trimmingCharacters(in: .whitespacesAndNewlines) + let balanceText = UsageFormatter.currencyString(self.balance, currencyCode: trimmedCurrency) + let identity = ProviderIdentitySnapshot( + providerID: .mimo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(balanceText)") + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 0596617b7..b95bd5aab 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -76,6 +76,7 @@ public enum ProviderDescriptorRegistry { .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .mimo: MiMoProviderDescriptor.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 c5d9af4f7..41405df42 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -18,7 +18,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, - jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: JetBrainsProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -37,7 +38,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + mimo: mimo) } public struct CodexProviderSettings: Sendable { @@ -209,6 +211,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MiMoProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -226,6 +238,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let amp: AmpProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let mimo: MiMoProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -248,7 +261,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings?, amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, - jetbrains: JetBrainsProviderSettings? = nil) + jetbrains: JetBrainsProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -267,6 +281,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.amp = amp self.ollama = ollama self.jetbrains = jetbrains + self.mimo = mimo } } @@ -286,6 +301,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case amp(ProviderSettingsSnapshot.AmpProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case mimo(ProviderSettingsSnapshot.MiMoProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -306,6 +322,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -329,6 +346,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .amp(value): self.amp = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .mimo(value): self.mimo = value } } @@ -350,6 +368,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { augment: self.augment, amp: self.amp, ollama: self.ollama, - jetbrains: self.jetbrains) + jetbrains: self.jetbrains, + mimo: self.mimo) } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 039f7a3f3..fef0a4766 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -26,6 +26,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case synthetic case warp case openrouter + case mimo } // swiftformat:enable sortDeclarations @@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case mimo case combined } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 0e6767e15..4462af10a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -72,7 +72,7 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .mimo: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c828a2695..70a3e2b0a 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -71,6 +71,7 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .mimo: return nil // Xiaomi MiMo not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 3b0dd2d27..8173da770 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -280,6 +280,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" + case .mimo: "MiMo" } } } @@ -621,6 +622,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .mimo: + Color(red: 1.0, green: 105 / 255, blue: 0) } } } diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift new file mode 100644 index 000000000..6c582141f --- /dev/null +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -0,0 +1,271 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +struct MiMoProviderTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `cookie header normalizer keeps required mimo cookies`() { + let raw = """ + curl 'https://platform.xiaomimimo.com/api/v1/balance' \ + -H 'Cookie: userId=123; api-platform_serviceToken=svc-token; ignored=value; api-platform_ph=ph-token' + """ + + let normalized = MiMoCookieHeader.normalizedHeader(from: raw) + + #expect(normalized == "api-platform_ph=ph-token; api-platform_serviceToken=svc-token; userId=123") + } + + @Test + func `cookie header normalizer rejects missing auth cookies`() { + let normalized = MiMoCookieHeader.normalizedHeader(from: "Cookie: userId=123") + + #expect(normalized == nil) + } + + @Test + func `usage snapshot exposes balance through identity plan text`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .mimo) == "Balance: $25.51") + } + + @Test + func `parses balance payload`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "frozenBalance": null, + "currency": "USD", + "overdraftLimit": null + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(snapshot.updatedAt == now) + } + + @Test + func `fetch usage hits mimo balance endpoint with browser headers`() async throws { + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + } + + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/api/v1/balance") + #expect(request.value(forHTTPHeaderField: "Cookie") == "api-platform_serviceToken=svc-token; userId=123") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + #expect(request.value(forHTTPHeaderField: "x-timeZone") == "UTC+01:00") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://platform.xiaomimimo.com/#/console/balance") + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "Cookie: userId=123; api-platform_serviceToken=svc-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + now: Date(timeIntervalSince1970: 1_742_771_200)) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + } + + @Test + @MainActor + func `provider detail plan row formats mimo as balance`() { + let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + + #expect(row?.label == "Balance") + #expect(row?.value == "$25.51") + } + + @Test(arguments: [UsageProvider.openrouter, .mimo]) + @MainActor + func `menu descriptor renders balance providers without duplicate prefix`(provider: UsageProvider) throws { + let suite = "MiMoProviderTests-menu-balance-\(provider.rawValue)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting(self.makeBalanceSnapshot(provider: provider), provider: provider) + + let descriptor = MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Balance: $25.51")) + #expect(!lines.contains("Balance: Balance: $25.51")) + } + + @Test + func `mimo web strategy unavailable when cookie source is off`() async { + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .off, + manualCookieHeader: nil))) + + let available = await strategy.isAvailable(context) + + #expect(available == false) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private func makeBalanceSnapshot(provider: UsageProvider) -> UsageSnapshot { + let updatedAt = Date(timeIntervalSince1970: 1_742_771_200) + switch provider { + case .openrouter: + return OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 24.49, + balance: 25.51, + usedPercent: 49, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: updatedAt).toUsageSnapshot() + case .mimo: + return MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: updatedAt).toUsageSnapshot() + default: + Issue.record("Unexpected provider \(provider.rawValue)") + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: updatedAt) + } + } + + private func makeContext(settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } +} + +final class MiMoStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "mimo.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index a7cfb81b1..c0a1de3a0 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -767,6 +767,7 @@ struct SettingsStoreTests { .synthetic, .warp, .openrouter, + .mimo, ]) // Move one provider; ensure it's persisted across instances. From 7be116f30ed12a04422c1a310eac6cdf9a46ad9e Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Tue, 24 Mar 2026 17:53:52 +0100 Subject: [PATCH 02/11] Document Xiaomi MiMo provider Add Xiaomi MiMo to the provider reference and update the configuration guide's provider ID list so the docs match the current branch. --- docs/configuration.md | 2 +- docs/providers.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fecfd198a..7d5003827 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,7 +72,7 @@ All provider fields are optional unless noted. ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `ollama`, `synthetic`, `warp`, `openrouter`. +`codex`, `claude`, `cursor`, `opencode`, `alibaba`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `ollama`, `synthetic`, `warp`, `openrouter`, `mimo`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/providers.md b/docs/providers.md index 63f3aeaa0..d6219eb59 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Xiaomi MiMo)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -39,6 +39,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Warp | API token (config/env) → GraphQL request limits (`api`). | | Ollama | Web settings page via browser cookies (`web`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | +| Xiaomi MiMo | Balance endpoint via browser cookies or manual cookie header (`web`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -182,4 +183,13 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## Xiaomi MiMo +- Browser-cookie based provider for the Xiaomi MiMo balance console. +- Endpoint: `GET https://platform.xiaomimimo.com/api/v1/balance`. +- Required cookies: `api-platform_serviceToken` and `userId`; optional cookies like `api-platform_ph` and `api-platform_slh` are passed through when available. +- Cookie source can be `auto` (Chrome-family import) or `manual` (paste a `Cookie:` header from `platform.xiaomimimo.com`). +- Displays the current account balance as provider identity text (`Balance: ...`) rather than quota windows. +- Override the base API URL with `MIMO_API_URL` for testing. +- Status: none yet. + See also: `docs/provider.md` for architecture notes. From ec7499a219dbfb8787b8d33497cc10e16a212363 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Tue, 24 Mar 2026 22:08:16 +0100 Subject: [PATCH 03/11] Polish MiMo provider follow-up --- .../MiMo/MiMoProviderImplementation.swift | 5 ++ .../CodexBarWidget/CodexBarWidgetViews.swift | 6 +- docs/mimo.md | 55 +++++++++++++++++++ docs/providers.md | 4 +- 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 docs/mimo.md diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift index ca540ec9b..161947582 100644 --- a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -9,6 +9,11 @@ struct MiMoProviderImplementation: ProviderImplementation { let id: UsageProvider = .mimo let supportsLoginFlow: Bool = true + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "cookies" } + } + @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.miMoCookieSource diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 8173da770..b19f0e6c9 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -280,7 +280,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" - case .mimo: "MiMo" + default: self.longLabel } } } @@ -622,8 +622,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) - case .mimo: - Color(red: 1.0, green: 105 / 255, blue: 0) + default: + .secondary } } } diff --git a/docs/mimo.md b/docs/mimo.md new file mode 100644 index 000000000..be6fb0636 --- /dev/null +++ b/docs/mimo.md @@ -0,0 +1,55 @@ +--- +summary: "Xiaomi MiMo provider notes: cookie auth, balance endpoint, and setup." +read_when: + - Adding or modifying the Xiaomi MiMo provider + - Debugging MiMo cookie import or balance fetching + - Explaining MiMo setup and limitations to users +--- + +# Xiaomi MiMo Provider + +The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo console. + +## Features + +- **Balance display**: Shows the current MiMo balance as provider identity text. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Near-real-time updates**: Balance usually reflects within a few minutes. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Xiaomi MiMo** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://platform.xiaomimimo.com/#/console/balance` +2. Copy a `Cookie:` header from your browser’s Network tab +3. Paste it into **Xiaomi MiMo → Cookie source → Manual** + +## How it works + +- Fetches `GET https://platform.xiaomimimo.com/api/v1/balance` +- Requires the `api-platform_serviceToken` and `userId` cookies +- Accepts optional MiMo cookies like `api-platform_ph` and `api-platform_slh` when present +- Supports `MIMO_API_URL` to override the base API URL for testing + +## Limitations + +- MiMo currently exposes **balance only** +- Token cost, status polling, debug log output, and widgets are not supported yet + +## Troubleshooting + +### “No Xiaomi MiMo browser session found” + +Log in at `https://platform.xiaomimimo.com/#/console/balance` in Chrome, then refresh CodexBar. + +### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies” + +The pasted header or imported browser session is missing required cookies. Re-copy the request from the balance page after logging in again. + +### “Xiaomi MiMo browser session expired” + +Your MiMo login is stale. Sign out and back in on the MiMo site, then refresh CodexBar. diff --git a/docs/providers.md b/docs/providers.md index d6219eb59..7e3a559b2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -186,10 +186,8 @@ until the session is invalid, to avoid repeated Keychain prompts. ## Xiaomi MiMo - Browser-cookie based provider for the Xiaomi MiMo balance console. - Endpoint: `GET https://platform.xiaomimimo.com/api/v1/balance`. -- Required cookies: `api-platform_serviceToken` and `userId`; optional cookies like `api-platform_ph` and `api-platform_slh` are passed through when available. - Cookie source can be `auto` (Chrome-family import) or `manual` (paste a `Cookie:` header from `platform.xiaomimimo.com`). -- Displays the current account balance as provider identity text (`Balance: ...`) rather than quota windows. -- Override the base API URL with `MIMO_API_URL` for testing. - Status: none yet. +- Details: `docs/mimo.md`. See also: `docs/provider.md` for architecture notes. From 145127b6dba478747d6ccb6c9078a1bab5d94bd3 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 05:14:07 +0100 Subject: [PATCH 04/11] Align MiMo web labeling --- .../CodexBar/Providers/MiMo/MiMoProviderImplementation.swift | 2 +- docs/providers.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift index 161947582..bcb9b68cf 100644 --- a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -11,7 +11,7 @@ struct MiMoProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "cookies" } + ProviderPresentation { _ in "web" } } @MainActor diff --git a/docs/providers.md b/docs/providers.md index 7e3a559b2..7e639199d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -39,7 +39,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Warp | API token (config/env) → GraphQL request limits (`api`). | | Ollama | Web settings page via browser cookies (`web`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | -| Xiaomi MiMo | Balance endpoint via browser cookies or manual cookie header (`web`). | +| Xiaomi MiMo | Browser cookies → balance endpoint (`web`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. From ab3cf93b82cd2af5586d66b78d03b8a1e4ab7f0f Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 05:27:20 +0100 Subject: [PATCH 05/11] improve MiMo icon --- Sources/CodexBar/Resources/ProviderIcon-mimo.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Resources/ProviderIcon-mimo.svg b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg index 3b4e2a2a2..50b1b8e3e 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-mimo.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg @@ -1,4 +1,4 @@ - - - + + + From 3e41ab37913b773e9ec5daa1c372df40692936ad Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 05:45:59 +0100 Subject: [PATCH 06/11] reverting unintended default switch cases --- Sources/CodexBarWidget/CodexBarWidgetViews.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index b19f0e6c9..4dbac1464 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -280,7 +280,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" - default: self.longLabel + case .mimo: "MiMo" } } } @@ -622,8 +622,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) - default: - .secondary + case .mimo: + .primary } } } From b43d91b0d589c3e06f36259c3f29e7ccf6a89dec Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 09:57:51 +0100 Subject: [PATCH 07/11] Fix MiMo cookie matching --- .../Providers/MiMo/MiMoCookieImporter.swift | 26 ++- .../MiMo/MiMoProviderDescriptor.swift | 28 +++- Tests/CodexBarTests/MiMoProviderTests.swift | 151 +++++++++++++++++- 3 files changed, 191 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index 39093ebbd..2d97a3f80 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -31,17 +31,16 @@ enum MiMoCookieHeader { } static func header(from cookies: [HTTPCookie]) -> String? { + let requestURL = URL(string: "https://platform.xiaomimimo.com/api/v1/balance")! var byName: [String: HTTPCookie] = [:] - for cookie in cookies { guard self.knownCookieNames.contains(cookie.name) else { continue } guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } if let expiry = cookie.expiresDate, expiry < Date() { continue } + guard Self.matchesRequestURL(cookie: cookie, url: requestURL) else { continue } if let existing = byName[cookie.name] { - let existingExpiry = existing.expiresDate ?? .distantPast - let candidateExpiry = cookie.expiresDate ?? .distantPast - if candidateExpiry >= existingExpiry { + if Self.cookieSortKey(for: cookie) >= Self.cookieSortKey(for: existing) { byName[cookie.name] = cookie } } else { @@ -55,6 +54,25 @@ enum MiMoCookieHeader { return "\(cookie.name)=\(cookie.value)" }.joined(separator: "; ") } + + private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host else { return false } + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !normalizedDomain.isEmpty else { return false } + guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } + + let cookiePath = cookie.path.isEmpty ? "/" : cookie.path + let requestPath = url.path.isEmpty ? "/" : url.path + return requestPath.hasPrefix(cookiePath) + } + + private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { + let pathLength = cookie.path.count + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainLength = normalizedDomain.count + let expiry = cookie.expiresDate ?? .distantPast + return (pathLength, domainLength, expiry) + } } #if os(macOS) diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift index f5658114c..e02a56539 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -99,7 +99,10 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { cookieHeader: cachedHeader, environment: context.env) return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") - } catch let error as MiMoUsageError where Self.shouldRetryNextSession(for: error) { + } catch { + guard Self.shouldRetryNextSession(for: error) else { + throw error + } CookieHeaderCache.clear(provider: .mimo) lastError = error } @@ -121,7 +124,10 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") - } catch let error as MiMoUsageError where Self.shouldRetryNextSession(for: error) { + } catch { + guard Self.shouldRetryNextSession(for: error) else { + throw error + } lastError = error continue } @@ -143,12 +149,18 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { return MiMoCookieHeader.normalizedHeader(from: context.settings?.mimo?.manualCookieHeader) } - private static func shouldRetryNextSession(for error: MiMoUsageError) -> Bool { - switch error { - case .invalidCredentials, .loginRequired: - true - case .parseFailed, .networkError: - false + private static func shouldRetryNextSession(for error: Error) -> Bool { + if error is DecodingError { + return true + } + guard let mimoError = error as? MiMoUsageError else { + return false + } + switch mimoError { + case .invalidCredentials, .loginRequired, .parseFailed: + return true + case .networkError: + return false } } } diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 6c582141f..844de12be 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -38,6 +38,67 @@ struct MiMoProviderTests { #expect(normalized == nil) } + @Test + func `cookie header builder keeps mimo auth cookies from one scope`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "root-user", + domain: "xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "platform-token", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "platform-user", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_ph", + value: "platform-ph", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_ph=platform-ph; api-platform_serviceToken=platform-token; userId=platform-user") + } + + @Test + func `cookie header builder prefers more specific matching cookie`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "root-user", + domain: "xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "api-user", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "platform-token", + domain: ".xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "irrelevant", + value: "ignored", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=platform-token; userId=api-user") + } + @Test func `usage snapshot exposes balance through identity plan text`() { let snapshot = MiMoUsageSnapshot( @@ -181,6 +242,71 @@ struct MiMoProviderTests { #expect(available == false) } + @Test + func `mimo web strategy retries imported sessions after decode failure`() async throws { + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + MiMoCookieImporter.importSessionsOverrideForTesting = nil + CookieHeaderCache.clear(provider: .mimo) + } + + CookieHeaderCache.clear(provider: .mimo) + CookieHeaderCache.store(provider: .mimo, cookieHeader: "invalid", sourceLabel: "invalid") + + MiMoCookieImporter.importSessionsOverrideForTesting = { _, _ in + [ + .init( + cookieHeader: "api-platform_serviceToken=expired-token; userId=111", + sourceLabel: "Expired Chrome"), + .init( + cookieHeader: "api-platform_serviceToken=valid-token; userId=222", + sourceLabel: "Active Chrome"), + ] + } + + var requestedCookies: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" + requestedCookies.append(cookie) + + if cookie.contains("expired-token") { + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html"])! + return (response, Data("login".utf8)) + } + + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let strategy = MiMoWebFetchStrategy() + let result = try await strategy + .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + + #expect(requestedCookies.count == 2) + #expect(requestedCookies[0].contains("expired-token")) + #expect(requestedCookies[1].contains("valid-token")) + #expect(result.usage.loginMethod(for: .mimo) == "Balance: $25.51") + #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome") + } + private static func makeResponse( url: URL, body: String, @@ -223,7 +349,10 @@ struct MiMoProviderTests { } } - private func makeContext(settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext { + private func makeContext( + settings: ProviderSettingsSnapshot? = nil, + environment: [String: String] = [:]) -> ProviderFetchContext + { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( runtime: .app, @@ -232,12 +361,30 @@ struct MiMoProviderTests { webTimeout: 1, webDebugDumpHTML: false, verbose: false, - env: [:], + env: environment, settings: settings, fetcher: UsageFetcher(environment: [:]), claudeFetcher: StubClaudeFetcher(), browserDetection: browserDetection) } + + private func makeCookie( + name: String, + value: String, + domain: String, + path: String = "/", + expiresAt: Date) throws -> HTTPCookie + { + let properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .expires: expiresAt, + .secure: "TRUE", + ] + return try #require(HTTPCookie(properties: properties)) + } } final class MiMoStubURLProtocol: URLProtocol { From d6dfcdd5b5802e1585e4a6275ab911c2380c07e9 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 10:37:57 +0100 Subject: [PATCH 08/11] Fix MiMo cookie path matching --- .../Providers/MiMo/MiMoCookieImporter.swift | 12 ++++++- Tests/CodexBarTests/MiMoProviderTests.swift | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index 2d97a3f80..0a27486d6 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -63,7 +63,17 @@ enum MiMoCookieHeader { let cookiePath = cookie.path.isEmpty ? "/" : cookie.path let requestPath = url.path.isEmpty ? "/" : url.path - return requestPath.hasPrefix(cookiePath) + if requestPath == cookiePath { + return true + } + guard requestPath.hasPrefix(cookiePath) else { return false } + guard cookiePath != "/" else { return true } + guard let boundaryIndex = requestPath.index(cookiePath.startIndex, offsetBy: cookiePath.count, limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex else + { + return true + } + return requestPath[boundaryIndex] == "/" } private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 844de12be..29cdfd594 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -99,6 +99,40 @@ struct MiMoProviderTests { #expect(header == "api-platform_serviceToken=platform-token; userId=api-user") } + @Test + func `cookie header builder rejects partial path prefix matches`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "partial-path-user", + domain: "platform.xiaomimimo.com", + path: "/api/v1/bal", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "valid-user", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "partial-path-token", + domain: "platform.xiaomimimo.com", + path: "/api/v1/bal", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "valid-token", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=valid-token; userId=valid-user") + } + @Test func `usage snapshot exposes balance through identity plan text`() { let snapshot = MiMoUsageSnapshot( From d7ece0d5402b6c2c0ebe7254b6dd607fc03cfbe1 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 11:12:04 +0100 Subject: [PATCH 09/11] Merge MiMo cookie stores --- .../Providers/MiMo/MiMoCookieImporter.swift | 87 +++++++++++++++++-- Tests/CodexBarTests/MiMoProviderTests.swift | 50 +++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index 0a27486d6..bac411afd 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -132,14 +132,7 @@ public enum MiMoCookieImporter { matching: query, in: browserSource, logger: log) - - for source in sources where !source.records.isEmpty { - let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - guard let cookieHeader = MiMoCookieHeader.header(from: cookies) else { - continue - } - sessions.append(SessionInfo(cookieHeader: cookieHeader, sourceLabel: source.label)) - } + sessions.append(contentsOf: self.sessionInfos(from: sources, origin: query.origin)) } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") @@ -155,5 +148,83 @@ public enum MiMoCookieImporter { { (try? self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty == false) ?? false } + + static func sessionInfos( + from sources: [BrowserCookieStoreRecords], + origin: BrowserCookieOriginStrategy = .domainBased) -> [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) + } + + var sessions: [SessionInfo] = [] + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let cookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: origin) + guard let cookieHeader = MiMoCookieHeader.header(from: cookies) else { + continue + } + sessions.append(SessionInfo(cookieHeader: cookieHeader, sourceLabel: label)) + } + return sessions + } + + 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 + } + } } #endif diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 29cdfd594..8919f072b 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -2,6 +2,9 @@ import Foundation import Testing @testable import CodexBar @testable import CodexBarCore +#if os(macOS) +import SweetCookieKit +#endif @Suite(.serialized) struct MiMoProviderTests { @@ -341,6 +344,53 @@ struct MiMoProviderTests { #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome") } + #if os(macOS) + @Test + func `mimo importer merges profile stores before validating auth cookies`() { + let profile = BrowserProfile(id: "Default", name: "Default") + let primaryStore = BrowserCookieStore( + browser: .chrome, + profile: profile, + kind: .primary, + label: "Chrome Default", + databaseURL: nil) + let networkStore = BrowserCookieStore( + browser: .chrome, + profile: profile, + kind: .network, + label: "Chrome Default (Network)", + databaseURL: nil) + let expires = Date(timeIntervalSince1970: 1_900_000_000) + + let sessions = MiMoCookieImporter.sessionInfos(from: [ + BrowserCookieStoreRecords(store: primaryStore, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "userId", + path: "/", + value: "123", + expires: expires, + isSecure: true, + isHTTPOnly: false), + ]), + BrowserCookieStoreRecords(store: networkStore, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "api-platform_serviceToken", + path: "/", + value: "token", + expires: expires, + isSecure: true, + isHTTPOnly: true), + ]), + ]) + + #expect(sessions.count == 1) + #expect(sessions.first?.sourceLabel == "Chrome Default") + #expect(sessions.first?.cookieHeader == "api-platform_serviceToken=token; userId=123") + } + #endif + private static func makeResponse( url: URL, body: String, From 3f84020860b29fc2bf619c3ec192237a804a0241 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 11:56:18 +0100 Subject: [PATCH 10/11] Enforce MiMo manual cookies --- .../Providers/MiMo/MiMoCookieImporter.swift | 10 +++-- .../MiMo/MiMoProviderDescriptor.swift | 12 ++++++ Tests/CodexBarTests/MiMoProviderTests.swift | 38 +++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index bac411afd..62a172cc6 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -68,9 +68,13 @@ enum MiMoCookieHeader { } guard requestPath.hasPrefix(cookiePath) else { return false } guard cookiePath != "/" else { return true } - guard let boundaryIndex = requestPath.index(cookiePath.startIndex, offsetBy: cookiePath.count, limitedBy: requestPath.endIndex), - boundaryIndex < requestPath.endIndex else - { + guard + let boundaryIndex = requestPath.index( + cookiePath.startIndex, + offsetBy: cookiePath.count, + limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex + else { return true } return requestPath[boundaryIndex] == "/" diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift index e02a56539..4f0a6a042 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -61,6 +61,9 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { guard context.settings?.mimo?.cookieSource != .off else { return false } + if context.settings?.mimo?.cookieSource == .manual { + return Self.resolveManualCookieHeader(context: context) != nil + } if Self.resolveManualCookieHeader(context: context) != nil { return true } @@ -81,6 +84,15 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { guard context.settings?.mimo?.cookieSource != .off else { throw MiMoSettingsError.missingCookie } + if context.settings?.mimo?.cookieSource == .manual { + guard let manualCookie = Self.resolveManualCookieHeader(context: context) else { + throw MiMoSettingsError.invalidCookie + } + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: manualCookie, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } if let manualCookie = Self.resolveManualCookieHeader(context: context) { let snapshot = try await MiMoUsageFetcher.fetchUsage( cookieHeader: manualCookie, diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 8919f072b..bbd779305 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -279,6 +279,44 @@ struct MiMoProviderTests { #expect(available == false) } + @Test + func `mimo manual mode does not report available from cached browser session`() async { + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .manual, + manualCookieHeader: "Cookie: userId=123"))) + + let available = await strategy.isAvailable(context) + + #expect(available == false) + } + + @Test + func `mimo manual mode rejects invalid header instead of falling back to cached session`() async { + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .manual, + manualCookieHeader: "Cookie: userId=123"))) + + await #expect(throws: MiMoSettingsError.invalidCookie) { + _ = try await strategy.fetch(context) + } + } + @Test func `mimo web strategy retries imported sessions after decode failure`() async throws { let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) From 005f186e729c3adbbe9d5a045abde32c165f1341 Mon Sep 17 00:00:00 2001 From: Roland Kakonyi Date: Wed, 25 Mar 2026 13:44:09 +0100 Subject: [PATCH 11/11] Handle MiMo slash path cookies --- .../Providers/MiMo/MiMoCookieImporter.swift | 3 +++ Tests/CodexBarTests/MiMoProviderTests.swift | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index 62a172cc6..cf7e0260f 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -68,6 +68,9 @@ enum MiMoCookieHeader { } guard requestPath.hasPrefix(cookiePath) else { return false } guard cookiePath != "/" else { return true } + if cookiePath.hasSuffix("/") { + return true + } guard let boundaryIndex = requestPath.index( cookiePath.startIndex, diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index bbd779305..44da54155 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -136,6 +136,28 @@ struct MiMoProviderTests { #expect(header == "api-platform_serviceToken=valid-token; userId=valid-user") } + @Test + func `cookie header builder accepts slash terminated path prefixes`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "slash-user", + domain: "platform.xiaomimimo.com", + path: "/api/", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "slash-token", + domain: "platform.xiaomimimo.com", + path: "/api/", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=slash-token; userId=slash-user") + } + @Test func `usage snapshot exposes balance through identity plan text`() { let snapshot = MiMoUsageSnapshot(