From 28d4e952c7fca9e883d3d26fc791ae43a45779e0 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Sun, 12 Apr 2026 19:11:51 +0800 Subject: [PATCH 1/3] Add Manus credits provider --- README.md | 4 +- Sources/CodexBar/MenuCardView.swift | 16 + .../Manus/ManusProviderImplementation.swift | 95 ++++++ .../Providers/Manus/ManusSettingsStore.swift | 33 ++ .../ProviderImplementationRegistry.swift | 1 + .../CodexBar/Resources/ProviderIcon-manus.svg | 6 + Sources/CodexBar/UsageStore.swift | 4 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 9 + .../CodexBarCore/Logging/LogCategories.swift | 3 + .../Providers/Manus/ManusCookieHeader.swift | 39 +++ .../Providers/Manus/ManusCookieImporter.swift | 188 ++++++++++++ .../Manus/ManusProviderDescriptor.swift | 182 +++++++++++ .../Providers/Manus/ManusSettingsReader.swift | 32 ++ .../Providers/Manus/ManusUsageFetcher.swift | 278 +++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 19 ++ .../CodexBarCore/Providers/Providers.swift | 2 + .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../ManusCookieHeaderTests.swift | 48 +++ Tests/CodexBarTests/ManusProviderTests.swift | 284 ++++++++++++++++++ docs/providers.md | 9 +- 24 files changed, 1261 insertions(+), 5 deletions(-) create mode 100644 Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-manus.svg create mode 100644 Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift create mode 100644 Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift create mode 100644 Tests/CodexBarTests/ManusCookieHeaderTests.swift create mode 100644 Tests/CodexBarTests/ManusProviderTests.swift diff --git a/README.md b/README.md index 78d34d3e0..93969cb35 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Perplexity limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Manus, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Perplexity limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -71,7 +71,7 @@ The menu bar icon is a tiny two-bar meter: Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, local JSONL logs) when the related features are enabled. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12). ## macOS permissions (why they’re needed) -- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead. +- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory, Manus). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead. - **Keychain access (prompted by macOS)**: - Chrome cookie import needs the “Chrome Safe Storage” key to decrypt cookies. - Claude OAuth credentials (written by the Claude CLI) are read from Keychain when present. diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d211a9d96..6c04750e8 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1054,6 +1054,13 @@ extension UsageMenuCardView.Model { { primaryDetailText = detail } + if input.provider == .manus, + let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + primaryDetailText = detail + primaryResetText = nil + } if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { primaryResetText = nil } @@ -1106,6 +1113,12 @@ extension UsageMenuCardView.Model { { weeklyDetailText = detail } + if input.provider == .manus, + let detail = weekly.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + weeklyDetailText = detail + } // Perplexity bonus credits don't reset; show balance without "Resets" prefix. if input.provider == .perplexity, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1348,6 +1361,9 @@ extension UsageMenuCardView.Model { provider: UsageProvider, cost: ProviderCostSnapshot?) -> ProviderCostSection? { + if provider == .manus { + return nil + } guard let cost else { return nil } guard cost.limit > 0 else { return nil } diff --git a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift new file mode 100644 index 000000000..7caef954f --- /dev/null +++ b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift @@ -0,0 +1,95 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct ManusProviderImplementation: ProviderImplementation { + let id: UsageProvider = .manus + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func runLoginFlow(context _: ProviderLoginContext) async -> Bool { + if let url = URL(string: "https://manus.im") { + NSWorkspace.shared.open(url) + } + return false + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.manusCookieSource + _ = settings.manusManualCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .manus(context.settings.manusSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.manusCookieSource.rawValue }, + set: { raw in + context.settings.manusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.manusCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports browser session cookies.", + manual: "Paste the session_id value or a full Cookie header.", + off: "Manus cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "manus-cookie-source", + title: "Cookie source", + subtitle: "Automatically imports browser session cookies.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "manus-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "session_id=...\n\nor paste just the session_id value", + binding: context.stringBinding(\.manusManualCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "manus-open-dashboard", + title: "Open Manus", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://manus.im") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.manusCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift new file mode 100644 index 000000000..8816d765c --- /dev/null +++ b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift @@ -0,0 +1,33 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var manusManualCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .manus)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .manus) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .manus, field: "cookieHeader", value: newValue) + } + } + + var manusCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .manus, fallback: .auto) } + set { + self.updateProviderConfig(provider: .manus) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .manus, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func manusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ManusProviderSettings { + _ = tokenOverride + return ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: self.manusCookieSource, + manualCookieHeader: self.manusManualCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index c2b008592..8e3baf7ec 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -25,6 +25,7 @@ enum ProviderImplementationRegistry { case .copilot: CopilotProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() + case .manus: ManusProviderImplementation() case .kimi: KimiProviderImplementation() case .kilo: KiloProviderImplementation() case .kiro: KiroProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-manus.svg b/Sources/CodexBar/Resources/ProviderIcon-manus.svg new file mode 100644 index 000000000..fcfd81daf --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-manus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0ecbc1820..0daaf76ed 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -772,6 +772,7 @@ extension UsageStore { .alibaba: "Alibaba Coding Plan debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", + .manus: "Manus debug log not yet implemented", .vertexai: "Vertex AI debug log not yet implemented", .kilo: "Kilo debug log not yet implemented", .kiro: "Kiro debug log not yet implemented", @@ -850,7 +851,8 @@ extension UsageStore { let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, + case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .manus, .vertexai, .kilo, .kiro, + .kimi, .kimik2, .jetbrains, .perplexity: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index d639019cf..5de243aa6 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -139,6 +139,13 @@ struct TokenAccountCLIContext { cookieSource: cookieSource, manualCookieHeader: cookieHeader, apiRegion: self.resolveMiniMaxRegion(config))) + case .manus: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .augment: let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) let cookieSource = self.cookieSource(provider: provider, account: account, config: config) @@ -200,6 +207,7 @@ struct TokenAccountCLIContext { alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil, factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil, minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil, + manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil, zai: ProviderSettingsSnapshot.ZaiProviderSettings? = nil, kilo: ProviderSettingsSnapshot.KiloProviderSettings? = nil, kimi: ProviderSettingsSnapshot.KimiProviderSettings? = nil, @@ -218,6 +226,7 @@ struct TokenAccountCLIContext { alibaba: alibaba, factory: factory, minimax: minimax, + manus: manus, zai: zai, kilo: kilo, kimi: kimi, diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 5f6cf9217..6c799d7b4 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -32,6 +32,9 @@ public enum LogCategories { public static let launchAtLogin = "launch-at-login" public static let login = "login" public static let logging = "logging" + public static let manusAPI = "manus-api" + public static let manusCookie = "manus-cookie" + public static let manusWeb = "manus-web" public static let minimaxAPITokenStore = "minimax-api-token-store" public static let minimaxCookie = "minimax-cookie" public static let minimaxCookieStore = "minimax-cookie-store" diff --git a/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift b/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift new file mode 100644 index 000000000..e4c7fa878 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift @@ -0,0 +1,39 @@ +import Foundation + +public enum ManusCookieHeader { + public static let sessionCookieName = "session_id" + + public static func resolveToken(context: ProviderFetchContext) -> String? { + guard let settings = context.settings?.manus, settings.cookieSource == .manual else { return nil } + return self.token(from: settings.manualCookieHeader) + } + + public static func token(from raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + if !raw.contains("="), !raw.contains(";") { + return raw + } + + let pairs = CookieHeaderNormalizer.pairs(from: raw) + for pair in pairs where pair.name.caseInsensitiveCompare(self.sessionCookieName) == .orderedSame { + let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + return token + } + } + return nil + } + + public static func sessionToken(from cookies: [HTTPCookie]) -> String? { + for cookie in cookies where cookie.name.caseInsensitiveCompare(self.sessionCookieName) == .orderedSame { + let token = cookie.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + return token + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift b/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift new file mode 100644 index 000000000..e8affb76e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift @@ -0,0 +1,188 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum ManusCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.manusCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["manus.im", "www.manus.im"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.manus]?.browserCookieOrder ?? Browser.defaultImportOrder + nonisolated(unsafe) static var importSessionOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> SessionInfo)? + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var sessionToken: String? { + ManusCookieHeader.sessionToken(from: self.cookies) + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return try override(browserDetection, logger) + } + if let override = self.importSessionOverrideForTesting { + return try [override(browserDetection, logger)] + } + + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw ManusCookieImportError.noCookies + } + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { message in self.emit(message, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + let session = SessionInfo(cookies: httpCookies, sourceLabel: label) + guard let token = session.sessionToken else { + continue + } + + log("Found \(ManusCookieHeader.sessionCookieName) cookie in \(label)") + if !token.isEmpty { + sessions.append(session) + } + } + + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { + throw ManusCookieImportError.noCookies + } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession(browserDetection: browserDetection, logger: logger) + return true + } catch { + return false + } + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[manus-cookie] \(message)") + self.log.debug(message) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { return "Unknown" } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): rhs > lhs + case (nil, .some): true + case (.some, nil): false + case (nil, nil): false + } + } +} + +enum ManusCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Manus session cookies found in browsers. Please log into manus.im." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift new file mode 100644 index 000000000..838c6e5eb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift @@ -0,0 +1,182 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum ManusProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .manus, + metadata: ProviderMetadata( + id: .manus, + displayName: "Manus", + sessionLabel: "Monthly credits", + weeklyLabel: "Daily refresh", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Manus usage", + cliName: "manus", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://manus.im", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .manus, + iconResourceName: "ProviderIcon-manus", + color: ProviderColor(red: 52 / 255, green: 50 / 255, blue: 45 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Manus cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ManusWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "manus", + aliases: [], + versionDetector: nil)) + } +} + +struct ManusWebFetchStrategy: ProviderFetchStrategy { + private enum SessionTokenSource { + case manual + case cache + case browser + case environment + + var shouldCacheAfterFetch: Bool { + self == .browser + } + } + + private struct ResolvedSessionToken { + let value: String + let source: SessionTokenSource + } + + let id: String = "manus.web" + let kind: ProviderFetchKind = .web + private static let log = CodexBarLog.logger(LogCategories.manusWeb) + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.manus?.cookieSource != .off else { return false } + if context.settings?.manus?.cookieSource == .manual { return true } + + if let cached = CookieHeaderCache.load(provider: .manus), + ManusCookieHeader.token(from: cached.cookieHeader) != nil + { + return true + } + + #if os(macOS) + if ManusCookieImporter.hasSession(browserDetection: context.browserDetection) { + return true + } + #endif + + return ManusSettingsReader.sessionToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let resolvedTokens = try self.resolveSessionTokens(context: context) + guard !resolvedTokens.isEmpty else { + throw ManusAPIError.missingToken + } + + var sawInvalidToken = false + for resolved in resolvedTokens { + do { + let response = try await ManusUsageFetcher.fetchCredits(sessionToken: resolved.value) + self.cacheTokenIfNeeded(resolved, sourceLabel: "web") + return self.makeResult( + usage: response.toUsageSnapshot(), + sourceLabel: "web") + } catch ManusAPIError.invalidToken { + sawInvalidToken = true + if resolved.source == .cache { + CookieHeaderCache.clear(provider: .manus) + } + continue + } + } + + if sawInvalidToken { + throw ManusAPIError.invalidToken + } + throw ManusAPIError.missingToken + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + if case ManusAPIError.missingToken = error { return false } + if case ManusAPIError.invalidCookie = error { return false } + if case ManusAPIError.invalidToken = error { return false } + return true + } + + private func resolveSessionTokens(context: ProviderFetchContext) throws -> [ResolvedSessionToken] { + guard context.settings?.manus?.cookieSource != .off else { return [] } + + if context.settings?.manus?.cookieSource == .manual { + guard let token = ManusCookieHeader.resolveToken(context: context) else { + throw ManusAPIError.invalidCookie + } + return [ResolvedSessionToken(value: token, source: .manual)] + } + + var tokens: [ResolvedSessionToken] = [] + + if let cached = CookieHeaderCache.load(provider: .manus), + let token = ManusCookieHeader.token(from: cached.cookieHeader) + { + tokens.append(ResolvedSessionToken(value: token, source: .cache)) + } + + tokens.append(contentsOf: self.resolveBrowserOrEnvironmentTokens(context: context)) + return self.deduplicated(tokens) + } + + private func resolveBrowserOrEnvironmentTokens(context: ProviderFetchContext) -> [ResolvedSessionToken] { + guard context.settings?.manus?.cookieSource != .off else { return [] } + var tokens: [ResolvedSessionToken] = [] + + #if os(macOS) + do { + let sessions = try ManusCookieImporter.importSessions(browserDetection: context.browserDetection) + tokens.append(contentsOf: sessions.compactMap { session in + guard let token = session.sessionToken else { return nil } + return ResolvedSessionToken(value: token, source: .browser) + }) + } catch { + Self.log.debug("No Manus browser session available: \(error.localizedDescription)") + } + #endif + + if let token = ManusSettingsReader.sessionToken(environment: context.env) { + tokens.append(ResolvedSessionToken(value: token, source: .environment)) + } + return self.deduplicated(tokens) + } + + private func deduplicated(_ tokens: [ResolvedSessionToken]) -> [ResolvedSessionToken] { + var seen: Set = [] + var deduplicated: [ResolvedSessionToken] = [] + for token in tokens where !token.value.isEmpty { + if seen.insert(token.value).inserted { + deduplicated.append(token) + } + } + return deduplicated + } + + private func cacheTokenIfNeeded(_ token: ResolvedSessionToken, sourceLabel: String) { + guard token.source.shouldCacheAfterFetch else { return } + CookieHeaderCache.store( + provider: .manus, + cookieHeader: "\(ManusCookieHeader.sessionCookieName)=\(token.value)", + sourceLabel: sourceLabel) + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift b/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift new file mode 100644 index 000000000..fa5874c12 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift @@ -0,0 +1,32 @@ +import Foundation + +public enum ManusSettingsReader { + public static func sessionToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let rawToken = environment["MANUS_SESSION_TOKEN"] + ?? environment["manus_session_token"] + ?? environment["MANUS_SESSION_ID"] + ?? environment["manus_session_id"] + if let token = ManusCookieHeader.token(from: self.cleaned(rawToken)) { + return token + } + + let rawCookie = environment["MANUS_COOKIE"] ?? environment["manus_cookie"] + return ManusCookieHeader.token(from: self.cleaned(rawCookie)) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift new file mode 100644 index 000000000..61c3cd5ac --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift @@ -0,0 +1,278 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ManusCreditsResponse: Decodable, Sendable { + public let totalCredits: Double + public let freeCredits: Double + public let periodicCredits: Double + public let addonCredits: Double + public let refreshCredits: Double + public let maxRefreshCredits: Double + public let proMonthlyCredits: Double + public let eventCredits: Double + public let nextRefreshTime: Date? + public let refreshInterval: String? + + public init( + totalCredits: Double, + freeCredits: Double, + periodicCredits: Double, + addonCredits: Double, + refreshCredits: Double, + maxRefreshCredits: Double, + proMonthlyCredits: Double, + eventCredits: Double, + nextRefreshTime: Date? = nil, + refreshInterval: String? = nil) + { + self.totalCredits = totalCredits + self.freeCredits = freeCredits + self.periodicCredits = periodicCredits + self.addonCredits = addonCredits + self.refreshCredits = refreshCredits + self.maxRefreshCredits = maxRefreshCredits + self.proMonthlyCredits = proMonthlyCredits + self.eventCredits = eventCredits + self.nextRefreshTime = nextRefreshTime + self.refreshInterval = refreshInterval + } + + private enum CodingKeys: String, CodingKey { + case totalCredits + case freeCredits + case periodicCredits + case addonCredits + case refreshCredits + case maxRefreshCredits + case proMonthlyCredits + case eventCredits + case nextRefreshTime + case refreshInterval + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.totalCredits = container.decodeLossyDoubleIfPresent(forKey: .totalCredits) ?? 0 + self.freeCredits = container.decodeLossyDoubleIfPresent(forKey: .freeCredits) ?? 0 + self.periodicCredits = container.decodeLossyDoubleIfPresent(forKey: .periodicCredits) ?? 0 + self.addonCredits = container.decodeLossyDoubleIfPresent(forKey: .addonCredits) ?? 0 + self.refreshCredits = container.decodeLossyDoubleIfPresent(forKey: .refreshCredits) ?? 0 + self.maxRefreshCredits = container.decodeLossyDoubleIfPresent(forKey: .maxRefreshCredits) ?? 0 + self.proMonthlyCredits = container.decodeLossyDoubleIfPresent(forKey: .proMonthlyCredits) ?? 0 + self.eventCredits = container.decodeLossyDoubleIfPresent(forKey: .eventCredits) ?? 0 + self.nextRefreshTime = container.decodeIfPresentFlexibleDate(forKey: .nextRefreshTime) + self.refreshInterval = try? container.decodeIfPresent(String.self, forKey: .refreshInterval) + } +} + +public enum ManusUsageFetcher { + private static let log = CodexBarLog.logger(LogCategories.manusAPI) + private static let creditsURL = + URL(string: "https://api.manus.im/user.v1.UserService/GetAvailableCredits")! + @TaskLocal static var fetchCreditsOverride: + (@Sendable (String, Date) async throws -> ManusCreditsResponse)? + + public static func fetchCredits( + sessionToken: String, + now: Date = Date()) async throws -> ManusCreditsResponse + { + if let override = self.fetchCreditsOverride { + return try await override(sessionToken, now) + } + + guard !sessionToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ManusAPIError.missingToken + } + + var request = URLRequest(url: self.creditsURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.httpBody = Data("{}".utf8) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(sessionToken)", forHTTPHeaderField: "Authorization") + request.setValue("https://manus.im", forHTTPHeaderField: "Origin") + request.setValue("https://manus.im/", forHTTPHeaderField: "Referer") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + request.setValue( + userAgent, + forHTTPHeaderField: "User-Agent") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ManusAPIError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body + Self.log.error("Manus API returned \(httpResponse.statusCode): \(truncated)") + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw ManusAPIError.invalidToken + } + throw ManusAPIError.apiError("HTTP \(httpResponse.statusCode)") + } + + do { + return try self.parseResponse(data) + } catch let error as ManusAPIError { + throw error + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + Self.log.error("Manus parse failed: \(error) — response: \(preview)") + throw ManusAPIError.parseFailed(error.localizedDescription) + } + } + + public static func parseResponse(_ data: Data) throws -> ManusCreditsResponse { + let decoder = JSONDecoder() + if let direct = try? decoder.decode(ManusCreditsResponse.self, from: data) { + return direct + } + + let envelope = try decoder.decode(ManusCreditsEnvelope.self, from: data) + if let response = envelope.data ?? envelope.result ?? envelope.response ?? envelope.availableCredits { + return response + } + throw ManusAPIError.parseFailed("Missing credits payload") + } +} + +extension ManusCreditsResponse { + public func toUsageSnapshot(now: Date = Date()) -> UsageSnapshot { + let primary: RateWindow? = if self.proMonthlyCredits > 0 { + RateWindow( + usedPercent: min( + 100, + max(0, (self.proMonthlyCredits - self.periodicCredits) / self.proMonthlyCredits * 100)), + windowMinutes: nil, + resetsAt: nil, + resetDescription: Self.monthlyDetail(totalCredits: self.totalCredits, freeCredits: self.freeCredits)) + } else { + nil + } + + let secondary: RateWindow? = if self.maxRefreshCredits > 0 { + RateWindow( + usedPercent: min( + 100, + max(0, (self.maxRefreshCredits - self.refreshCredits) / self.maxRefreshCredits * 100)), + windowMinutes: nil, + resetsAt: self.nextRefreshTime, + resetDescription: Self.refreshDetail( + refreshCredits: self.refreshCredits, + maxRefreshCredits: self.maxRefreshCredits, + refreshInterval: self.refreshInterval)) + } else { + nil + } + + let balance = Self.creditCountString(self.totalCredits) + let identity = ProviderIdentitySnapshot( + providerID: .manus, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(balance) credits") + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: now, + identity: identity) + } + + private static func creditCountString(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.maximumFractionDigits = 0 + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value.rounded())) ?? String(Int(value.rounded())) + } + + private static func monthlyDetail(totalCredits: Double, freeCredits: Double) -> String? { + let total = self.creditCountString(totalCredits) + let free = self.creditCountString(freeCredits) + return "Total \(total) • Free \(free)" + } + + private static func refreshDetail( + refreshCredits: Double, + maxRefreshCredits: Double, + refreshInterval: String?) -> String? + { + let refresh = self.creditCountString(refreshCredits) + let maxRefresh = self.creditCountString(maxRefreshCredits) + if let refreshInterval, !refreshInterval.isEmpty { + return "\(refreshInterval.capitalized): \(refresh) / \(maxRefresh)" + } + return "\(refresh) / \(maxRefresh)" + } +} + +public enum ManusAPIError: LocalizedError, Equatable, Sendable { + case missingToken + case invalidCookie + case invalidToken + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingToken: + "No Manus session token provided." + case .invalidCookie: + "Manus session cookie is invalid." + case .invalidToken: + "Invalid Manus session token." + case let .networkError(message): + "Manus network error: \(message)" + case let .apiError(message): + "Manus API error: \(message)" + case let .parseFailed(message): + "Failed to parse Manus response: \(message)" + } + } +} + +private struct ManusCreditsEnvelope: Decodable { + let data: ManusCreditsResponse? + let result: ManusCreditsResponse? + let response: ManusCreditsResponse? + let availableCredits: ManusCreditsResponse? +} + +extension KeyedDecodingContainer where K: CodingKey { + fileprivate func decodeLossyDoubleIfPresent(forKey key: K) -> Double? { + if let value = try? self.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let intValue = try? self.decodeIfPresent(Int.self, forKey: key) { + return Double(intValue) + } + if let stringValue = try? self.decodeIfPresent(String.self, forKey: key) { + return Double(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + fileprivate func decodeIfPresentFlexibleDate(forKey key: K) -> Date? { + if let value = try? self.decodeIfPresent(Date.self, forKey: key) { + return value + } + guard let stringValue = try? self.decodeIfPresent(String.self, forKey: key), + !stringValue.isEmpty + else { + return nil + } + return ISO8601DateFormatter().date(from: stringValue) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index c55d0a194..2f2894b71 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -65,6 +65,7 @@ public enum ProviderDescriptorRegistry { .copilot: CopilotProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, + .manus: ManusProviderDescriptor.descriptor, .kimi: KimiProviderDescriptor.descriptor, .kilo: KiloProviderDescriptor.descriptor, .kiro: KiroProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index d0e6be940..cdff0b556 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -12,6 +12,7 @@ public struct ProviderSettingsSnapshot: Sendable { alibaba: AlibabaCodingPlanProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, + manus: ManusProviderSettings? = nil, zai: ZaiProviderSettings? = nil, copilot: CopilotProviderSettings? = nil, kilo: KiloProviderSettings? = nil, @@ -33,6 +34,7 @@ public struct ProviderSettingsSnapshot: Sendable { alibaba: alibaba, factory: factory, minimax: minimax, + manus: manus, zai: zai, copilot: copilot, kilo: kilo, @@ -152,6 +154,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct ManusProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct ZaiProviderSettings: Sendable { public let apiRegion: ZaiAPIRegion @@ -242,6 +254,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let alibaba: AlibabaCodingPlanProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? + public let manus: ManusProviderSettings? public let zai: ZaiProviderSettings? public let copilot: CopilotProviderSettings? public let kilo: KiloProviderSettings? @@ -267,6 +280,7 @@ public struct ProviderSettingsSnapshot: Sendable { alibaba: AlibabaCodingPlanProviderSettings?, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, + manus: ManusProviderSettings?, zai: ZaiProviderSettings?, copilot: CopilotProviderSettings?, kilo: KiloProviderSettings?, @@ -287,6 +301,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.alibaba = alibaba self.factory = factory self.minimax = minimax + self.manus = manus self.zai = zai self.copilot = copilot self.kilo = kilo @@ -308,6 +323,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) + case manus(ProviderSettingsSnapshot.ManusProviderSettings) case zai(ProviderSettingsSnapshot.ZaiProviderSettings) case copilot(ProviderSettingsSnapshot.CopilotProviderSettings) case kilo(ProviderSettingsSnapshot.KiloProviderSettings) @@ -330,6 +346,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? + public var manus: ProviderSettingsSnapshot.ManusProviderSettings? public var zai: ProviderSettingsSnapshot.ZaiProviderSettings? public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings? public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? @@ -355,6 +372,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .alibaba(value): self.alibaba = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value + case let .manus(value): self.manus = value case let .zai(value): self.zai = value case let .copilot(value): self.copilot = value case let .kilo(value): self.kilo = value @@ -379,6 +397,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { alibaba: self.alibaba, factory: self.factory, minimax: self.minimax, + manus: self.manus, zai: self.zai, copilot: self.copilot, kilo: self.kilo, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index eb9f08e92..1d6c1940d 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -15,6 +15,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case copilot case zai case minimax + case manus case kimi case kilo case kiro @@ -37,6 +38,7 @@ public enum IconStyle: Sendable, CaseIterable { case claude case zai case minimax + case manus case gemini case antigravity case cursor diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 36308cdf0..a62af6677 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -51,6 +51,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .manus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Manus session_id cookies.", + placeholder: "session_id=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: "session_id"), .augment: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple Augment Cookie headers.", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a982934ae..e0bd88739 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -92,7 +92,7 @@ enum CostUsageScanner { } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, - .minimax, .kilo, .kiro, .kimi, + .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity: return emptyReport } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 7e4a7ddb0..1c2880d5b 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -63,6 +63,7 @@ enum ProviderChoice: String, AppEnum { case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot case .minimax: self = .minimax + case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets case .kilo: self = .kilo case .kiro: return nil // Kiro not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 4ccc2b57e..2b91dd130 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -269,6 +269,7 @@ private struct ProviderSwitchChip: View { case .factory: "Droid" case .copilot: "Copilot" case .minimax: "MiniMax" + case .manus: "Manus" case .vertexai: "Vertex" case .kilo: "Kilo" case .kiro: "Kiro" @@ -615,6 +616,8 @@ enum WidgetColors { Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) + case .manus: + Color(red: 24 / 255, green: 24 / 255, blue: 24 / 255) case .vertexai: Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue case .kilo: diff --git a/Tests/CodexBarTests/ManusCookieHeaderTests.swift b/Tests/CodexBarTests/ManusCookieHeaderTests.swift new file mode 100644 index 000000000..6f760d3d9 --- /dev/null +++ b/Tests/CodexBarTests/ManusCookieHeaderTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ManusCookieHeaderTests { + @Test + func `bare token resolves directly`() { + #expect(ManusCookieHeader.token(from: "abc123") == "abc123") + } + + @Test + func `extracts session_id from cookie header`() { + let header = "foo=bar; session_id=token-a; baz=qux" + #expect(ManusCookieHeader.token(from: header) == "token-a") + } + + @Test + func `extracts mixed case session id from cookie header`() { + let header = "foo=bar; Session_ID=token-b; baz=qux" + #expect(ManusCookieHeader.token(from: header) == "token-b") + } + + @Test + func `unsupported cookie header returns nil`() { + #expect(ManusCookieHeader.token(from: "foo=bar; hello=world") == nil) + } + + #if os(macOS) + @Test + func `importer session info extracts session token`() throws { + let cookies = try [ + #require(self.makeCookie(name: "session_id", value: "cookie-token")), + ] + let session = ManusCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome") + #expect(session.sessionToken == "cookie-token") + } + + private func makeCookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: name, + .value: value, + .secure: "TRUE", + ]) + } + #endif +} diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift new file mode 100644 index 000000000..26d1b4b80 --- /dev/null +++ b/Tests/CodexBarTests/ManusProviderTests.swift @@ -0,0 +1,284 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ManusProviderTests { + private static let now = Date(timeIntervalSince1970: 1_744_000_000) + + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + 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 + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:]) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func stubResponse() -> ManusCreditsResponse { + ManusCreditsResponse( + totalCredits: 120, + freeCredits: 20, + periodicCredits: 80, + addonCredits: 10, + refreshCredits: 30, + maxRefreshCredits: 300, + proMonthlyCredits: 100, + eventCredits: 10, + nextRefreshTime: Date(timeIntervalSince1970: 1_744_003_600), + refreshInterval: "daily") + } + + private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T { + let service = "manus-provider-tests-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await operation() + } + } + + @Test + func `off mode ignores environment session token`() async { + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + + #expect(await strategy.isAvailable(context) == false) + } + + @Test + func `manual mode invalid cookie does not fall back to cache or environment`() async { + await self.withIsolatedCacheStore { + CookieHeaderCache.store( + provider: .manus, + cookieHeader: "session_id=cached-token", + sourceLabel: "web") + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .manual, + manualCookieHeader: "foo=bar")) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + + do { + _ = try await strategy.fetch(context) + Issue.record("Expected invalid manual cookie instead of falling back to cache/environment") + } catch let error as ManusAPIError { + #expect(error == .invalidCookie) + } catch { + Issue.record("Expected ManusAPIError.invalidCookie, got \(error)") + } + } + } + + @Test + func `environment token does not populate browser cache`() async throws { + try await self.withIsolatedCacheStore { + #if os(macOS) + ManusCookieImporter.importSessionsOverrideForTesting = { _, _ in + throw ManusCookieImportError.noCookies + } + ManusCookieImporter.importSessionOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionsOverrideForTesting = nil + ManusCookieImporter.importSessionOverrideForTesting = nil + } + #endif + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } + } + + @Test + func `invalid browser token falls back to environment token`() async throws { + try await self.withIsolatedCacheStore { + #if os(macOS) + let browserCookie = try #require(HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: "session_id", + .value: "browser-token", + .secure: "TRUE", + ])) + ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") + } + ManusCookieImporter.importSessionsOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionOverrideForTesting = nil + ManusCookieImporter.importSessionsOverrideForTesting = nil + } + #endif + + let attempts = LockedArray() + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + attempts.append(token) + if token == "browser-token" { + throw ManusAPIError.invalidToken + } + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attempts.snapshot() == ["browser-token", "env-token"]) + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } + } + + @Test + func `browser token populates cache after successful fetch`() async throws { + try await self.withIsolatedCacheStore { + #if os(macOS) + let browserCookie = try #require(HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: "session_id", + .value: "browser-token", + .secure: "TRUE", + ])) + ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") + } + ManusCookieImporter.importSessionsOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionOverrideForTesting = nil + ManusCookieImporter.importSessionsOverrideForTesting = nil + } + #endif + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "browser-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + let cached = CookieHeaderCache.load(provider: .manus) + #expect(cached?.cookieHeader == "session_id=browser-token") + } + } + + @Test + func `settings reader accepts full cookie header from environment`() { + let env = ["MANUS_COOKIE": "foo=bar; session_id=env-cookie-token; baz=qux"] + #expect(ManusSettingsReader.sessionToken(environment: env) == "env-cookie-token") + } + + @Test + func `parse response tolerates sparse live payload`() throws { + let data = Data(""" + { + "totalCredits": 2869, + "freeCredits": 1500, + "periodicCredits": 1369, + "proMonthlyCredits": 4000, + "maxRefreshCredits": 300, + "nextRefreshTime": "2026-04-13T00:00:00Z", + "refreshInterval": "daily", + "userFlag": { "drc16": true } + } + """.utf8) + + let response = try ManusUsageFetcher.parseResponse(data) + #expect(response.totalCredits == 2869) + #expect(response.periodicCredits == 1369) + #expect(response.proMonthlyCredits == 4000) + #expect(response.refreshCredits == 0) + #expect(response.addonCredits == 0) + #expect(response.maxRefreshCredits == 300) + #expect(response.nextRefreshTime != nil) + + let snapshot = response.toUsageSnapshot(now: Self.now) + #expect(snapshot.providerCost == nil) + #expect(snapshot.primary?.usedPercent ?? 0 > 65) + #expect(snapshot.primary?.resetDescription == "Total 2,869 • Free 1,500") + #expect(snapshot.secondary?.usedPercent == 100) + #expect(snapshot.secondary?.resetDescription == "Daily: 0 / 300") + } +} diff --git a/docs/providers.md b/docs/providers.md index e6f0516bc..f20eff02c 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, Manus, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -27,6 +27,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | | z.ai | API token (Keychain/env) → quota API (`api`). | +| Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | | MiniMax | Manual cookie header (Keychain/env) → browser cookies (+ local storage access token) → coding plan page (HTML) with remains API fallback (`web`). | | Kimi | API token (JWT from `kimi-auth` cookie) → usage API (`api`). | | Kilo | API token (`KILO_API_KEY`) → usage API (`api`); auto falls back to CLI session auth (`cli`). | @@ -62,6 +63,12 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: none yet. - Details: `docs/zai.md`. +## Manus +- Session token via browser `session_id` cookie, manual Settings entry, `MANUS_SESSION_TOKEN`, or `MANUS_COOKIE`. +- Credits endpoint: `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits`. +- Auto mode prefers cached/browser cookies before env fallback; manual mode accepts either a bare `session_id` value or a full Cookie header. +- Status: none yet. + ## MiniMax - Session cookie header from Keychain or `MINIMAX_COOKIE`/`MINIMAX_COOKIE_HEADER` env var. - Hosts: `platform.minimax.io` (global) or `platform.minimaxi.com` (China mainland) via region picker or `MINIMAX_HOST`; full overrides via `MINIMAX_CODING_PLAN_URL` / `MINIMAX_REMAINS_URL`. From 21cb6c0e5c5537c82da9b998e9d413d7186deb50 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Tue, 14 Apr 2026 14:16:23 +0800 Subject: [PATCH 2/3] fix: address Copilot and Codex review feedback for Manus provider - Swap parseResponse decode order: try envelope first to avoid permissive direct decoder silently returning zero credits for wrapped payloads - Honor tokenOverride in ManusSettingsStore snapshot, mirroring Cursor pattern - Add tokenAccountsVisibility/applyTokenAccountCookieSource to wire up token account UI properly - Wrap macOS-only browser cookie tests in #if os(macOS) for Linux CI compat Co-Authored-By: Claude Opus 4.6 --- .../Manus/ManusProviderImplementation.swift | 14 ++++++++ .../Providers/Manus/ManusSettingsStore.swift | 35 ++++++++++++++++--- .../Providers/Manus/ManusUsageFetcher.swift | 13 +++---- Tests/CodexBarTests/ManusProviderTests.swift | 6 ++-- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift index 7caef954f..873ffca56 100644 --- a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift @@ -33,6 +33,20 @@ struct ManusProviderImplementation: ProviderImplementation { .manus(context.settings.manusSettingsSnapshot(tokenOverride: context.tokenOverride)) } + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.manusCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.manusCookieSource != .manual { + settings.manusCookieSource = .manual + } + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( diff --git a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift index 8816d765c..1b4573a1b 100644 --- a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift +++ b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift @@ -25,9 +25,36 @@ extension SettingsStore { extension SettingsStore { func manusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ManusProviderSettings { - _ = tokenOverride - return ProviderSettingsSnapshot.ManusProviderSettings( - cookieSource: self.manusCookieSource, - manualCookieHeader: self.manusManualCookieHeader) + ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: self.manusSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.manusSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func manusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.manusManualCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .manus), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .manus, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func manusSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.manusCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .manus), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .manus).isEmpty { return fallback } + return .manual } } diff --git a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift index 61c3cd5ac..f3bc41052 100644 --- a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift @@ -131,15 +131,16 @@ public enum ManusUsageFetcher { public static func parseResponse(_ data: Data) throws -> ManusCreditsResponse { let decoder = JSONDecoder() - if let direct = try? decoder.decode(ManusCreditsResponse.self, from: data) { - return direct - } - let envelope = try decoder.decode(ManusCreditsEnvelope.self, from: data) - if let response = envelope.data ?? envelope.result ?? envelope.response ?? envelope.availableCredits { + // Try envelope first — the direct decoder defaults missing fields to 0, + // so it would "succeed" on wrapped payloads and silently return zero credits. + if let envelope = try? decoder.decode(ManusCreditsEnvelope.self, from: data), + let response = envelope.data ?? envelope.result ?? envelope.response ?? envelope.availableCredits + { return response } - throw ManusAPIError.parseFailed("Missing credits payload") + + return try decoder.decode(ManusCreditsResponse.self, from: data) } } diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift index 26d1b4b80..6d0a19756 100644 --- a/Tests/CodexBarTests/ManusProviderTests.swift +++ b/Tests/CodexBarTests/ManusProviderTests.swift @@ -155,10 +155,10 @@ struct ManusProviderTests { } } + #if os(macOS) @Test func `invalid browser token falls back to environment token`() async throws { try await self.withIsolatedCacheStore { - #if os(macOS) let browserCookie = try #require(HTTPCookie(properties: [ .domain: "manus.im", .path: "/", @@ -174,7 +174,6 @@ struct ManusProviderTests { ManusCookieImporter.importSessionOverrideForTesting = nil ManusCookieImporter.importSessionsOverrideForTesting = nil } - #endif let attempts = LockedArray() let strategy = ManusWebFetchStrategy() @@ -206,7 +205,6 @@ struct ManusProviderTests { @Test func `browser token populates cache after successful fetch`() async throws { try await self.withIsolatedCacheStore { - #if os(macOS) let browserCookie = try #require(HTTPCookie(properties: [ .domain: "manus.im", .path: "/", @@ -222,7 +220,6 @@ struct ManusProviderTests { ManusCookieImporter.importSessionOverrideForTesting = nil ManusCookieImporter.importSessionsOverrideForTesting = nil } - #endif let strategy = ManusWebFetchStrategy() let settings = ProviderSettingsSnapshot.make( @@ -243,6 +240,7 @@ struct ManusProviderTests { #expect(cached?.cookieHeader == "session_id=browser-token") } } + #endif @Test func `settings reader accepts full cookie header from environment`() { From 03d7547ade260ef933a3c2dd1eb1888efff7b115 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 17 Apr 2026 16:44:30 +0800 Subject: [PATCH 3/3] Address Codex review on Manus provider - Reject Manus API payloads lacking expected credits fields so unrelated 200 responses (e.g. error objects) no longer decode into a bogus zero-credit snapshot via the permissive defaults. - Add docs/manus.md so the README link resolves, covering setup, cookie sources, API endpoint, and troubleshooting. - Extend parseResponse tests for the rejection path and the envelope wrapper shape. --- .../Providers/Manus/ManusUsageFetcher.swift | 21 +++++- Tests/CodexBarTests/ManusProviderTests.swift | 28 +++++++ docs/manus.md | 73 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 docs/manus.md diff --git a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift index f3bc41052..9de2fa8cd 100644 --- a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift @@ -140,7 +140,26 @@ public enum ManusUsageFetcher { return response } - return try decoder.decode(ManusCreditsResponse.self, from: data) + let response = try decoder.decode(ManusCreditsResponse.self, from: data) + // The custom decoder defaults every numeric field to 0, so an unrelated JSON + // object (e.g. an error payload) would otherwise surface as a bogus zero-credit + // snapshot. Require at least one known credits key in the raw payload. + guard Self.payloadContainsCreditsField(data: data) else { + throw ManusAPIError.parseFailed("response missing expected credits fields") + } + return response + } + + private static let expectedCreditsKeys: Set = [ + "totalCredits", "freeCredits", "periodicCredits", "addonCredits", + "refreshCredits", "maxRefreshCredits", "proMonthlyCredits", "eventCredits", + ] + + private static func payloadContainsCreditsField(data: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return !Self.expectedCreditsKeys.isDisjoint(with: object.keys) } } diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift index 6d0a19756..be69fdf51 100644 --- a/Tests/CodexBarTests/ManusProviderTests.swift +++ b/Tests/CodexBarTests/ManusProviderTests.swift @@ -279,4 +279,32 @@ struct ManusProviderTests { #expect(snapshot.secondary?.usedPercent == 100) #expect(snapshot.secondary?.resetDescription == "Daily: 0 / 300") } + + @Test + func `parse response rejects payload without credits fields`() { + let data = Data(#"{"error":"unauthorized","message":"session expired"}"#.utf8) + + #expect(throws: ManusAPIError.self) { + try ManusUsageFetcher.parseResponse(data) + } + } + + @Test + func `parse response accepts wrapped envelope`() throws { + let data = Data(""" + { + "data": { + "totalCredits": 100, + "proMonthlyCredits": 200, + "periodicCredits": 50, + "maxRefreshCredits": 10, + "refreshCredits": 5 + } + } + """.utf8) + + let response = try ManusUsageFetcher.parseResponse(data) + #expect(response.totalCredits == 100) + #expect(response.periodicCredits == 50) + } } diff --git a/docs/manus.md b/docs/manus.md new file mode 100644 index 000000000..7ee919846 --- /dev/null +++ b/docs/manus.md @@ -0,0 +1,73 @@ +--- +summary: "Manus provider: browser session_id cookie auth for credit balance, monthly credits, and daily refresh tracking." +read_when: + - Adding or modifying the Manus provider + - Debugging Manus cookie imports or API responses + - Adjusting Manus usage display or credit formatting +--- + +# Manus Provider + +The Manus provider tracks credit usage on [manus.im](https://manus.im) via browser `session_id` cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows Pro monthly credits used vs. plan total (`proMonthlyCredits` − `periodicCredits`). +- **Daily refresh gauge**: Shows daily refresh credits used vs. max refresh allotment, with reset timing. +- **Balance display**: Total credits available, shown in the menu identity line. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. +- **Env var support**: `MANUS_SESSION_TOKEN` (raw token) or `MANUS_COOKIE` (full cookie header) for CLI/headless usage. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Manus** +3. Log in to [manus.im](https://manus.im) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Manus**, set Cookie source to **Manual** +2. Open your browser DevTools on `manus.im`, copy the `Cookie:` header from any API request (must contain `session_id=...`) +3. Paste the header into the cookie field in CodexBar + +### Environment variables (CLI / headless) + +- `MANUS_SESSION_TOKEN`: the raw `session_id` value. +- `MANUS_COOKIE`: a full cookie header; the provider extracts `session_id` from it. + +Either works; raw-token form is preferred when only one value is needed. + +## How it works + +A single API endpoint is fetched with a bearer token derived from the `session_id` cookie value: + +- `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits` — returns credit fields including `totalCredits`, `freeCredits`, `periodicCredits`, `proMonthlyCredits`, `refreshCredits`, `maxRefreshCredits`, `nextRefreshTime`, and `refreshInterval`. + +Cookie domain: `manus.im`. Valid `session_id` cookies are cached in Keychain and reused until the session expires. + +The response parser tolerates both a direct object and common envelope shapes (`data` / `result` / `response` / `availableCredits`). Payloads missing all expected credit fields are rejected as a parse error rather than surfacing a misleading zero-credit snapshot. + +## Token accounts + +Manus supports multiple accounts via the standard token-account mechanism. Add entries to `~/.codexbar/config.json` (`tokenAccounts`) with the full `Cookie:` header (containing `session_id=...`), then switch between accounts from the menu. + +## CLI + +```bash +codexbar usage --provider manus --verbose +``` + +## Troubleshooting + +### "No Manus session token provided" + +Log in to [manus.im](https://manus.im) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. Alternatively, set `MANUS_SESSION_TOKEN` or `MANUS_COOKIE`, or paste a cookie header in manual mode. + +### "Invalid Manus session token" + +Your session has expired or been revoked. Log out and back in to Manus, or paste a fresh `Cookie:` header in manual mode. + +### "Response missing expected credits fields" + +The API returned a 200 response that doesn't look like a credits payload (often an error object). Re-login to Manus; if it persists, the upstream response schema may have changed.