From a37725a3d5eaaf1780b6f09500f2c23ae99efe6d Mon Sep 17 00:00:00 2001 From: HemSoft Date: Mon, 26 Oct 2026 20:15:45 -0400 Subject: [PATCH 1/3] Show Muse Code as unavailable on iPhone with a schema-1 allowance reader (#522) Meta documents no sign-in or usage service an iPhone app can use for Muse Code subscriptions, so Add Account explains the limit and Muse Code can't be added, refreshed, scored or hold an account slot. A native reader for `muse usage --json` schema 1 maps fixtures to distinct, truthful states for any future supported source. Contract and limits are in MUSE-CODE.md. Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + CHANGELOG.md | 12 + CodexBarIOS.xcodeproj/project.pbxproj | 12 + .../Models/GoogleUsageMetricCatalog.swift | 4 + .../Models/ProviderAccountConfiguration.swift | 2 +- CodexBarIOS/Models/ProviderID.swift | 15 +- .../Models/SubscriptionUtilizationScore.swift | 9 +- CodexBarIOS/Services/AccountPlanAccess.swift | 8 +- .../Services/FeedbackSupportDestination.swift | 2 + CodexBarIOS/Services/MuseCodeAllowance.swift | 324 ++++++++++++++++ .../Services/ProviderConfigurationStore.swift | 25 +- CodexBarIOS/Services/UITestFixtures.swift | 4 +- .../Services/UITestMuseCodeFixture.swift | 90 +++++ .../Services/UsageRefreshService.swift | 5 +- .../Views/ProviderSettingsViewModel.swift | 2 + CodexBarIOS/Views/ProviderUsageCard.swift | 6 +- CodexBarIOS/Views/SettingsView.swift | 34 +- .../Views/SubscriptionUtilizationBadge.swift | 3 +- CodexBarIOSTests/AppAndWidgetTests.swift | 6 +- .../DashboardAndSettingsTests.swift | 2 +- CodexBarIOSTests/MuseCodeAllowanceTests.swift | 345 ++++++++++++++++++ MUSE-CODE.md | 140 +++++++ README.md | 5 + SmokeTests/main.swift | 2 +- 24 files changed, 1029 insertions(+), 29 deletions(-) create mode 100644 CodexBarIOS/Services/MuseCodeAllowance.swift create mode 100644 CodexBarIOS/Services/UITestMuseCodeFixture.swift create mode 100644 CodexBarIOSTests/MuseCodeAllowanceTests.swift create mode 100644 MUSE-CODE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e301e488..ef6b0c5c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -57,6 +57,7 @@ body: - Greptile - Google Gemini - Antigravity + - Muse Code - Not provider-specific - Other diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cccb70b..577230c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ building, testing, or releasing the app. to full use at reset. Fresh windows and missing quota show n/a. ([#518](https://github.com/hemsoft-dev/codexbar-ios/issues/518)) +- Add Account now explains that Muse Code isn't available on iPhone. Meta + doesn't offer a sign-in or usage service that iPhone apps can use for Muse + Code subscriptions, and CodexBar won't estimate an allowance from token + activity. ([#522](https://github.com/hemsoft-dev/codexbar-ios/issues/522)) + ### Fixed - Keep missing Grok and X Subscription usage unavailable instead of inferring @@ -23,6 +28,13 @@ building, testing, or releasing the app. ### Developer Experience +- Add a Muse Code allowance reader for schema 1 of `muse usage --json`, with + sanitized fixtures and distinct unavailable states, for use if Meta ships a + supported iPhone source. Muse Code can't be added, refreshed or published, + and gets no Sub Utilization Score. The contract and its limits are in + [MUSE-CODE.md](MUSE-CODE.md). + ([#522](https://github.com/hemsoft-dev/codexbar-ios/issues/522)) + - Include both subscription-utilization journeys in the release manifest so manual iOS validation can start and verify all 53 journeys per device family. Retain the last verified-renewal predicate state if native expiration diff --git a/CodexBarIOS.xcodeproj/project.pbxproj b/CodexBarIOS.xcodeproj/project.pbxproj index fae86887..af674b79 100644 --- a/CodexBarIOS.xcodeproj/project.pbxproj +++ b/CodexBarIOS.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ A46700000000000000043001 /* WatchAccessReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46700000000000000040004 /* WatchAccessReaderTests.swift */; }; A45100000000000000000201 /* UITestOpenCodeRenewalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45100000000000000000202 /* UITestOpenCodeRenewalProvider.swift */; }; + A52200000000000000000105 /* UITestMuseCodeFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52200000000000000000106 /* UITestMuseCodeFixture.swift */; }; A45100000000000000000101 /* OpenCodeSubscriptionBilling.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45100000000000000000102 /* OpenCodeSubscriptionBilling.swift */; }; A44600000000000000000601 /* UITestSubscriptionBillingProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A44600000000000000000602 /* UITestSubscriptionBillingProtocol.swift */; }; A44600000000000000000501 /* SubscriptionBillingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A44600000000000000000502 /* SubscriptionBillingTests.swift */; }; @@ -68,6 +69,7 @@ 10000000000000000000004C /* ProviderParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000004C /* ProviderParsingTests.swift */; }; A29600000000000000000003 /* GeminiUsageProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29600000000000000000004 /* GeminiUsageProviderTests.swift */; }; A31400000000000000000401 /* AntigravityUsageProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31400000000000000000402 /* AntigravityUsageProviderTests.swift */; }; + A52200000000000000000107 /* MuseCodeAllowanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52200000000000000000108 /* MuseCodeAllowanceTests.swift */; }; A29900000000000000000201 /* GeminiBrowserSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29900000000000000000202 /* GeminiBrowserSignInTests.swift */; }; 10000000000000000000004D /* UsageHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000004D /* UsageHistoryTests.swift */; }; 10000000000000000000004E /* ProviderNetworkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000004E /* ProviderNetworkTests.swift */; }; @@ -112,6 +114,7 @@ A29600000000000000000001 /* GeminiUsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29600000000000000000002 /* GeminiUsageProvider.swift */; }; A31400000000000000000301 /* AntigravityUsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31400000000000000000302 /* AntigravityUsageProvider.swift */; }; A31400000000000000000201 /* AntigravityQuotaParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31400000000000000000202 /* AntigravityQuotaParser.swift */; }; + A52200000000000000000101 /* MuseCodeAllowance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52200000000000000000102 /* MuseCodeAllowance.swift */; }; A31400000000000000000101 /* AntigravityCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31400000000000000000102 /* AntigravityCredentials.swift */; }; A35300000000000000000301 /* OpenCodeDeviceAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35300000000000000000302 /* OpenCodeDeviceAuthService.swift */; }; A35300000000000000000401 /* OpenCodeConsoleUsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35300000000000000000402 /* OpenCodeConsoleUsageProvider.swift */; }; @@ -379,6 +382,7 @@ /* Begin PBXFileReference section */ A46700000000000000040004 /* WatchAccessReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchAccessReaderTests.swift; sourceTree = ""; }; A45100000000000000000202 /* UITestOpenCodeRenewalProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestOpenCodeRenewalProvider.swift; sourceTree = ""; }; + A52200000000000000000106 /* UITestMuseCodeFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestMuseCodeFixture.swift; sourceTree = ""; }; A45100000000000000000102 /* OpenCodeSubscriptionBilling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeSubscriptionBilling.swift; sourceTree = ""; }; A44600000000000000000602 /* UITestSubscriptionBillingProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestSubscriptionBillingProtocol.swift; sourceTree = ""; }; A44600000000000000000502 /* SubscriptionBillingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarIOSTests/SubscriptionBillingTests.swift; sourceTree = SOURCE_ROOT; }; @@ -431,6 +435,7 @@ 20000000000000000000004C /* ProviderParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderParsingTests.swift; sourceTree = ""; }; A29600000000000000000004 /* GeminiUsageProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiUsageProviderTests.swift; sourceTree = ""; }; A31400000000000000000402 /* AntigravityUsageProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityUsageProviderTests.swift; sourceTree = ""; }; + A52200000000000000000108 /* MuseCodeAllowanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuseCodeAllowanceTests.swift; sourceTree = ""; }; A29900000000000000000202 /* GeminiBrowserSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiBrowserSignInTests.swift; sourceTree = ""; }; 20000000000000000000004D /* UsageHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageHistoryTests.swift; sourceTree = ""; }; 20000000000000000000004E /* ProviderNetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderNetworkTests.swift; sourceTree = ""; }; @@ -470,6 +475,7 @@ A29600000000000000000002 /* GeminiUsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiUsageProvider.swift; sourceTree = ""; }; A31400000000000000000302 /* AntigravityUsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityUsageProvider.swift; sourceTree = ""; }; A31400000000000000000202 /* AntigravityQuotaParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityQuotaParser.swift; sourceTree = ""; }; + A52200000000000000000102 /* MuseCodeAllowance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuseCodeAllowance.swift; sourceTree = ""; }; A31400000000000000000102 /* AntigravityCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityCredentials.swift; sourceTree = ""; }; A35300000000000000000302 /* OpenCodeDeviceAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeDeviceAuthService.swift; sourceTree = ""; }; A35300000000000000000402 /* OpenCodeConsoleUsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeConsoleUsageProvider.swift; sourceTree = ""; }; @@ -828,6 +834,7 @@ A29600000000000000000002 /* GeminiUsageProvider.swift */, A31400000000000000000302 /* AntigravityUsageProvider.swift */, A31400000000000000000202 /* AntigravityQuotaParser.swift */, + A52200000000000000000102 /* MuseCodeAllowance.swift */, A31400000000000000000102 /* AntigravityCredentials.swift */, A29900000000000000000002 /* GeminiBrowserSessionPolicy.swift */, A40600000000000000000202 /* GreptileDashboardClient.swift */, @@ -838,6 +845,7 @@ A35300000000000000000402 /* OpenCodeConsoleUsageProvider.swift */, A45100000000000000000102 /* OpenCodeSubscriptionBilling.swift */, A45100000000000000000202 /* UITestOpenCodeRenewalProvider.swift */, + A52200000000000000000106 /* UITestMuseCodeFixture.swift */, A35500000000000000000102 /* GrokDeviceAuthService.swift */, A35500000000000000000202 /* GrokUsageProvider.swift */, A45800000000000000000102 /* XSubscriptionUsageParser.swift */, @@ -938,6 +946,7 @@ 20000000000000000000004C /* ProviderParsingTests.swift */, A29600000000000000000004 /* GeminiUsageProviderTests.swift */, A31400000000000000000402 /* AntigravityUsageProviderTests.swift */, + A52200000000000000000108 /* MuseCodeAllowanceTests.swift */, A29900000000000000000202 /* GeminiBrowserSignInTests.swift */, 200000000000000000000050 /* TestSupport.swift */, A42000000000000000000502 /* ClaudeUsageResetInventoryTests.swift */, @@ -1453,6 +1462,7 @@ A29600000000000000000001 /* GeminiUsageProvider.swift in Sources */, A31400000000000000000301 /* AntigravityUsageProvider.swift in Sources */, A31400000000000000000201 /* AntigravityQuotaParser.swift in Sources */, + A52200000000000000000101 /* MuseCodeAllowance.swift in Sources */, A31400000000000000000101 /* AntigravityCredentials.swift in Sources */, A29900000000000000000001 /* GeminiBrowserSessionPolicy.swift in Sources */, A37400000000000000000001 /* GeminiBrowserInspectionState.swift in Sources */, @@ -1461,6 +1471,7 @@ A35300000000000000000401 /* OpenCodeConsoleUsageProvider.swift in Sources */, A45100000000000000000101 /* OpenCodeSubscriptionBilling.swift in Sources */, A45100000000000000000201 /* UITestOpenCodeRenewalProvider.swift in Sources */, + A52200000000000000000105 /* UITestMuseCodeFixture.swift in Sources */, A35500000000000000000101 /* GrokDeviceAuthService.swift in Sources */, A35500000000000000000201 /* GrokUsageProvider.swift in Sources */, A45800000000000000000101 /* XSubscriptionUsageParser.swift in Sources */, @@ -1545,6 +1556,7 @@ 10000000000000000000004C /* ProviderParsingTests.swift in Sources */, A29600000000000000000003 /* GeminiUsageProviderTests.swift in Sources */, A31400000000000000000401 /* AntigravityUsageProviderTests.swift in Sources */, + A52200000000000000000107 /* MuseCodeAllowanceTests.swift in Sources */, A29900000000000000000201 /* GeminiBrowserSignInTests.swift in Sources */, 100000000000000000000050 /* TestSupport.swift in Sources */, A42000000000000000000501 /* ClaudeUsageResetInventoryTests.swift in Sources */, diff --git a/CodexBarIOS/Models/GoogleUsageMetricCatalog.swift b/CodexBarIOS/Models/GoogleUsageMetricCatalog.swift index b71279d0..140a4696 100644 --- a/CodexBarIOS/Models/GoogleUsageMetricCatalog.swift +++ b/CodexBarIOS/Models/GoogleUsageMetricCatalog.swift @@ -34,6 +34,10 @@ public enum GoogleUsageMetricCatalog { codingDefinitions case .codex: [Definition(sourceProviderID: .codex, key: CodexUsageParser.creditsPoolStableKey, label: "Credits pool", window: "balance")] + case .museCode: + MuseCodeAllowance.Window.allCases.map { + Definition(sourceProviderID: .museCode, key: $0.rawValue, label: $0.label, window: $0 == .window ? "5h" : "weekly") + } default: [] } diff --git a/CodexBarIOS/Models/ProviderAccountConfiguration.swift b/CodexBarIOS/Models/ProviderAccountConfiguration.swift index 7a08de67..98725887 100644 --- a/CodexBarIOS/Models/ProviderAccountConfiguration.swift +++ b/CodexBarIOS/Models/ProviderAccountConfiguration.swift @@ -323,7 +323,7 @@ public extension ProviderAccountConfiguration { ProviderAccountConfiguration(providerID: providerID, authMethod: .apiKey) case .cursor: ProviderAccountConfiguration(providerID: providerID, authMethod: .browserSession) - case .greptile: + case .greptile, .museCode: ProviderAccountConfiguration(providerID: providerID, authMethod: .browserSession) } } diff --git a/CodexBarIOS/Models/ProviderID.swift b/CodexBarIOS/Models/ProviderID.swift index ed87d529..d1d025fe 100644 --- a/CodexBarIOS/Models/ProviderID.swift +++ b/CodexBarIOS/Models/ProviderID.swift @@ -14,6 +14,7 @@ public enum ProviderID: String, CaseIterable, Codable, Identifiable, Sendable { case greptile case gemini case antigravity + case museCode public var id: String { rawValue @@ -47,18 +48,30 @@ public enum ProviderID: String, CaseIterable, Codable, Identifiable, Sendable { "Google Gemini" case .antigravity: "Antigravity" + case .museCode: + "Muse Code" } } public var supportsPlanBadge: Bool { switch self { - case .codex, .copilot, .githubBilling, .claude, .grok, .xSubscription: + case .codex, .copilot, .githubBilling, .claude, .grok, .xSubscription, .museCode: true case .openRouter, .openCodeZen, .moonshot, .cursor, .greptile, .gemini, .antigravity: false } } + /// Why this provider can't be connected on iPhone, or nil when it has a supported sign-in. + /// Add Account names it with this reason; it can't be added, refreshed or hold an account slot (MUSE-CODE.md). + public var phoneUnavailableReason: String? { + self == .museCode ? MuseCodeAllowance.phoneAcquisitionUnavailableReason : nil + } + + public var isAvailableOnIPhone: Bool { + phoneUnavailableReason == nil + } + /// Providers signed in through xAI's Grok CLI device authorization. They share the credential /// format, Keychain lock, subject verification and generated-label rules, but not billing reads. public var usesXAIConsumerAuth: Bool { diff --git a/CodexBarIOS/Models/SubscriptionUtilizationScore.swift b/CodexBarIOS/Models/SubscriptionUtilizationScore.swift index 88f4bbde..e599f217 100644 --- a/CodexBarIOS/Models/SubscriptionUtilizationScore.swift +++ b/CodexBarIOS/Models/SubscriptionUtilizationScore.swift @@ -33,6 +33,13 @@ public struct SubscriptionUtilizationScore: Equatable, Sendable { return duration.isFinite && abs(duration - 7 * 86_400) <= 60 } + /// Providers whose weekly windows never get a score: Muse Code has no verified price or scoring scope (MUSE-CODE.md). + public static let unscoredProviders: Set = [.museCode] + + public static func applies(to bar: UsageBar, providerID: ProviderID) -> Bool { + !unscoredProviders.contains(providerID) && applies(to: bar) + } + public static func applies(to bar: UsageBar) -> Bool { if let start = bar.projectionPeriodStart, let end = bar.projectionPeriodEnd { return isWeekly(start: start, end: end) @@ -68,7 +75,7 @@ public enum SubscriptionUtilizationFleet { /// Average weekly pools once per account, then apply its subscription price once. public static func entry(for result: ProviderUsageResult, at now: Date = Date()) -> Entry? { - guard result.hasCurrentBars else { return nil } + guard result.hasCurrentBars, !SubscriptionUtilizationScore.unscoredProviders.contains(result.providerID) else { return nil } let scores = result.enabledBarIndices.compactMap { SubscriptionUtilizationScore(bar: result.bars[$0], at: now)?.value } guard !scores.isEmpty else { return nil } let mean = Double(scores.reduce(0, +)) / Double(scores.count) diff --git a/CodexBarIOS/Services/AccountPlanAccess.swift b/CodexBarIOS/Services/AccountPlanAccess.swift index 5c5390c8..61c526b0 100644 --- a/CodexBarIOS/Services/AccountPlanAccess.swift +++ b/CodexBarIOS/Services/AccountPlanAccess.swift @@ -36,7 +36,8 @@ public struct AccountPlanAccess: Equatable, Sendable { } public func permitsRefresh(_ configuration: ProviderAccountConfiguration) -> Bool { - guard configuration.isEnabled, configuration.providerID != .antigravity else { return false } + guard configuration.isEnabled, configuration.providerID != .antigravity, + configuration.providerID.isAvailableOnIPhone else { return false } return hasProAccess || (isResolved && activeAccountID == configuration.id) } @@ -44,7 +45,8 @@ public struct AccountPlanAccess: Equatable, Sendable { for providerID: ProviderID, configurations: [ProviderAccountConfiguration] ) -> Bool { - providerID == .antigravity || hasProAccess - || !configurations.contains { $0.isEnabled && $0.providerID != .antigravity } + guard providerID.isAvailableOnIPhone else { return false } + return providerID == .antigravity || hasProAccess + || !configurations.contains { $0.isEnabled && $0.providerID != .antigravity && $0.providerID.isAvailableOnIPhone } } } diff --git a/CodexBarIOS/Services/FeedbackSupportDestination.swift b/CodexBarIOS/Services/FeedbackSupportDestination.swift index 241588a8..3a87444b 100644 --- a/CodexBarIOS/Services/FeedbackSupportDestination.swift +++ b/CodexBarIOS/Services/FeedbackSupportDestination.swift @@ -406,6 +406,8 @@ extension ProviderID { "Google Gemini" case .antigravity: "Antigravity" + case .museCode: + "Muse Code" } } } diff --git a/CodexBarIOS/Services/MuseCodeAllowance.swift b/CodexBarIOS/Services/MuseCodeAllowance.swift new file mode 100644 index 00000000..6e811137 --- /dev/null +++ b/CodexBarIOS/Services/MuseCodeAllowance.swift @@ -0,0 +1,324 @@ +import Foundation + +/// Muse Code subscription allowance from schema 1 of `muse usage --json` (Muse Code 1.4.13-R6996.1). +/// +/// Meta publishes no sign-in or usage endpoint that an iPhone app can use, so nothing in the app calls +/// this yet and Muse Code can't be added as an account. The parser and states exist so a future supported +/// source maps through tested, truthful results. Field mapping, limits and fixtures: MUSE-CODE.md. +enum MuseCodeAllowance { + static let supportedSchemaVersion = 1 + /// An observation older than this is shown as last known, never as current. + static let freshnessInterval: TimeInterval = 7 * 60 + static let futureObservationTolerance: TimeInterval = 60 + static let phoneAcquisitionUnavailableReason = + "Meta doesn't offer a sign-in or usage service that iPhone apps can use for Muse Code subscriptions. " + + "CodexBar won't estimate your allowance from token activity." + + enum Window: String, CaseIterable, Equatable, Sendable { + case window + case weekly + + var duration: TimeInterval { + switch self { + case .window: 5 * 60 * 60 + case .weekly: 7 * 24 * 60 * 60 + } + } + + var label: String { + switch self { + case .window: "5-hour" + case .weekly: "Weekly" + } + } + + var metricID: String { + "\(ProviderID.museCode.rawValue).\(rawValue)" + } + } + + struct WindowReading: Equatable, Sendable { + let window: Window + /// Percentage of the allowance used, as reported. May exceed 100. + let usedPercent: Double + let resetsAt: Date + } + + struct Reading: Equatable, Sendable { + let observedAt: Date + let tier: String? + let source: String? + let windows: [WindowReading] + let invalidWindows: [Window] + let undocumentedWindowNames: [String] + let unnamedUndocumentedWindowCount: Int + } + + /// Every reason the app cannot show a current allowance. None of them is zero, unlimited or healthy. + enum Unavailable: String, CaseIterable, Equatable, Sendable { + case phoneAcquisitionUnsupported + case allowanceNotReported + case unsupportedSchema + case malformed + case observationMissing + case observationInFuture + case noValidWindows + case authorizationExpired + case entitlementDenied + case payAsYouGoBilling + case rateLimited + case timedOut + case transportFailure + + var message: String { + switch self { + case .phoneAcquisitionUnsupported: + "Muse Code isn't available on iPhone. " + MuseCodeAllowance.phoneAcquisitionUnavailableReason + case .allowanceNotReported: + "Muse Code didn't report a subscription allowance." + case .unsupportedSchema: + "Muse Code sent an allowance format this version of CodexBar doesn't support." + case .malformed: + "Muse Code sent an allowance CodexBar couldn't read." + case .observationMissing: + "Muse Code didn't say when the allowance was measured." + case .observationInFuture: + "Muse Code reported an allowance measured in the future." + case .noValidWindows: + "Muse Code reported no valid allowance windows." + case .authorizationExpired: + "Muse Code sign-in expired. Sign in again to read the allowance." + case .entitlementDenied: + "This Meta account has no Muse Code subscription allowance." + case .payAsYouGoBilling: + "This connection uses pay-as-you-go billing, which has no subscription allowance." + case .rateLimited: + "Muse Code is limiting requests. Try again later." + case .timedOut: + "Muse Code didn't respond in time." + case .transportFailure: + "Couldn't reach Muse Code." + } + } + + var recoveryAction: ProviderUsageRecoveryAction { + self == .authorizationExpired ? .reauthenticate : .retryRefresh + } + } + + enum Outcome: Equatable, Sendable { + case reading(Reading) + case unavailable(Unavailable) + } + + // MARK: - Parsing + + /// Parses one payload. `reason` and `hint` are free-form and may carry identity; they are never read. + static func parse(_ data: Data, now: Date) -> Outcome { + guard case let .object(root)? = try? JSONDecoder().decode(JSONValue.self, from: data) else { + return .unavailable(.malformed) + } + guard case let .number(version)? = root["schemaVersion"], version == Double(supportedSchemaVersion) else { + return .unavailable(.unsupportedSchema) + } + let allowance: [String: JSONValue] + switch root["allowance"] { + case .null?: + return .unavailable(.allowanceNotReported) + case let .object(object)?: + allowance = object + default: + return .unavailable(.malformed) + } + guard let observedAt = allowance["asOfMs"].flatMap(date) else { + return .unavailable(.observationMissing) + } + guard observedAt <= now.addingTimeInterval(futureObservationTolerance) else { + return .unavailable(.observationInFuture) + } + + var windows: [WindowReading] = [] + var invalid: [Window] = [] + for window in Window.allCases { + guard let value = allowance[window.rawValue] else { continue } + if let reading = windowReading(window, value: value, observedAt: observedAt, now: now) { + windows.append(reading) + } else { + invalid.append(window) + } + } + guard !windows.isEmpty else { + return .unavailable(.noValidWindows) + } + + let documented = Set(Window.allCases.map(\.rawValue) + ["asOfMs", "tier", "source"]) + let undocumented = allowance.keys.sorted().filter { key in + guard !documented.contains(key), case let .object(object)? = allowance[key] else { return false } + return object["usedPercent"]?.numberValue != nil && object["resetsAtMs"]?.numberValue != nil + } + let named = undocumented.filter { matches($0, pattern: "^[A-Za-z0-9_-]{1,40}$") } + return .reading(Reading( + observedAt: observedAt, + tier: allowance["tier"]?.stringValue.flatMap { matches($0, pattern: "^[A-Za-z0-9 ._()+-]{1,64}$") ? $0 : nil }, + source: allowance["source"]?.stringValue.flatMap { matches($0, pattern: "^[a-z0-9_-]{1,32}$") ? $0 : nil }, + windows: windows, + invalidWindows: invalid, + undocumentedWindowNames: named, + unnamedUndocumentedWindowCount: undocumented.count - named.count + )) + } + + private static func windowReading(_ window: Window, value: JSONValue, observedAt: Date, now: Date) -> WindowReading? { + guard case let .object(object) = value, + let used = object["usedPercent"]?.numberValue, used.isFinite, used >= 0, + let resetsAt = object["resetsAtMs"].flatMap(date), + resetsAt > observedAt, resetsAt > now, + resetsAt.timeIntervalSince(observedAt) <= window.duration + else { + return nil + } + return WindowReading(window: window, usedPercent: used, resetsAt: resetsAt) + } + + private static func date(_ value: JSONValue) -> Date? { + guard let milliseconds = value.numberValue, milliseconds.isFinite, milliseconds > 0 else { return nil } + return Date(timeIntervalSince1970: milliseconds / 1000) + } + + private static func matches(_ value: String, pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } + + // MARK: - Results + + static func result( + for outcome: Outcome, + configuration: ProviderAccountConfiguration, + fetchedAt: Date, + dateTimeFormatter: UserFacingDateTimeFormatter = .current + ) -> ProviderUsageResult { + switch outcome { + case let .unavailable(reason): + ProviderUsageResult( + accountID: configuration.id, + providerID: .museCode, + title: configuration.displayName, + subtitle: "Muse Code subscription allowance", + bars: [], + unavailableUsageMetrics: Dictionary(uniqueKeysWithValues: Window.allCases.map { ($0.metricID, "Unavailable") }), + failureMessage: reason.message, + recoveryAction: reason.recoveryAction, + preserveCachedBarsOnFailure: reason != .phoneAcquisitionUnsupported, + fetchedAt: fetchedAt + ) + case let .reading(reading): + readingResult(reading, configuration: configuration, fetchedAt: fetchedAt, dateTimeFormatter: dateTimeFormatter) + } + } + + private static func readingResult( + _ reading: Reading, + configuration: ProviderAccountConfiguration, + fetchedAt: Date, + dateTimeFormatter: UserFacingDateTimeFormatter + ) -> ProviderUsageResult { + let age = fetchedAt.timeIntervalSince(reading.observedAt) + let isStale = age > freshnessInterval + let bars = reading.windows.map { window in + UsageBar( + stableKey: window.window.rawValue, + label: window.window.label, + used: min(window.usedPercent, 100), + limit: 100, + resetsAt: window.resetsAt, + resetDisplayStyle: .relativeWithLocalTime, + // A stale observation cannot anchor a current forecast. + projectionCurrent: isStale ? nil : window.usedPercent / 100, + projectionLimit: isStale ? nil : 1, + projectionPeriodStart: isStale ? nil : window.resetsAt.addingTimeInterval(-window.window.duration), + projectionPeriodEnd: isStale ? nil : window.resetsAt, + showProjectionOnCurrentBar: !isStale + ) + } + var messages = reading.invalidWindows.map { "\($0.label) allowance unavailable: Muse Code sent an invalid value." } + let undocumentedCount = reading.undocumentedWindowNames.count + reading.unnamedUndocumentedWindowCount + if undocumentedCount > 0 { + let names = reading.undocumentedWindowNames.joined(separator: ", ") + let unnamed = reading.unnamedUndocumentedWindowCount + let detail = names.isEmpty ? "\(unnamed)" : (unnamed > 0 ? "\(names) and \(unnamed) more" : names) + messages.append("Muse Code reported other allowance windows that CodexBar doesn't track (\(detail)).") + } + if isStale { + messages.append("Last known value, measured \(Int(age / 60)) minutes before this refresh.") + } + var items = [ + ProviderCardInformationItem( + id: "museCode.observed-at", + label: "Measured", + detail: dateTimeFormatter.dateAndTime(reading.observedAt) + ), + ] + if let source = reading.source { + items.append(ProviderCardInformationItem(id: "museCode.source", label: "Source", detail: source)) + } + return ProviderUsageResult( + accountID: configuration.id, + providerID: .museCode, + title: configuration.displayName, + plan: reading.tier.map { + ProviderPlanDescriptor.make( + providerPrefix: ProviderID.museCode.rawValue, + identifier: $0.lowercased().replacingOccurrences(of: " ", with: "-"), + label: $0, + displayLabel: $0 + ) + }, + subtitle: "Muse Code subscription allowance", + bars: bars, + barsFetchedAt: isStale ? reading.observedAt : fetchedAt, + unavailableUsageMetrics: Dictionary(uniqueKeysWithValues: reading.invalidWindows.map { ($0.metricID, "Unavailable") }), + usageMessages: messages, + cardInformationSections: [ + ProviderCardInformationSection(id: "museCode.observation", title: "Allowance reading", items: items), + ], + fetchedAt: fetchedAt + ) + } +} + +/// Untyped JSON that keeps numbers, strings and booleans distinct, so `"15"` or `true` never becomes a number. +private enum JSONValue: Decodable, Equatable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + var numberValue: Double? { + if case let .number(value) = self { return value } + return nil + } + + var stringValue: String? { + if case let .string(value) = self { return value } + return nil + } +} diff --git a/CodexBarIOS/Services/ProviderConfigurationStore.swift b/CodexBarIOS/Services/ProviderConfigurationStore.swift index c98ec76c..73904800 100644 --- a/CodexBarIOS/Services/ProviderConfigurationStore.swift +++ b/CodexBarIOS/Services/ProviderConfigurationStore.swift @@ -2419,7 +2419,7 @@ public final class ProviderConfigurationStore: ObservableObject { normalized.authMethod = .browserSession case .antigravity: normalized.authMethod = .cliToken - case .copilot, .claude, .openRouter, .openCodeZen, .moonshot, .greptile: + case .copilot, .claude, .openRouter, .openCodeZen, .moonshot, .greptile, .museCode: break } return normalized @@ -2987,6 +2987,11 @@ public extension ProviderConfigurationStore { } private func allowAccountInsertion(for providerID: ProviderID) -> Bool { + // A provider without a supported iPhone source never becomes an account, Pro or not. + guard providerID.isAvailableOnIPhone else { + lastError = "\(providerID.displayName) isn't available on iPhone." + return false + } guard canAddAccount(for: providerID) else { lastError = "CodexBar Pro is required to add another account." requestProPresentation() @@ -3298,3 +3303,21 @@ extension ProviderConfigurationStore { } } } + +#if DEBUG +extension ProviderConfigurationStore { + /// UI evidence and tests only. Accounts for a provider that can't be added on iPhone, so its fixture states render + /// through the real dashboard. Production has no path to such an account (MUSE-CODE.md). + func insertUnavailableProviderFixtureAccounts(_ fixtures: [ProviderAccountConfiguration]) { + precondition(fixtures.allSatisfy { !$0.providerID.isAvailableOnIPhone }) + configurations += fixtures.filter { fixture in !configurations.contains { $0.id == fixture.id } } + sortConfigurations() + precondition(saveConfigurations()) + for fixture in fixtures where metricLayouts[fixture.id] == nil { + metricLayouts[fixture.id] = AccountMetricLayout() + } + saveMetricLayouts() + refreshSecretAvailability() + } +} +#endif diff --git a/CodexBarIOS/Services/UITestFixtures.swift b/CodexBarIOS/Services/UITestFixtures.swift index 34754381..5e7ec6e6 100644 --- a/CodexBarIOS/Services/UITestFixtures.swift +++ b/CodexBarIOS/Services/UITestFixtures.swift @@ -133,7 +133,9 @@ final class UITestFixtures { } } Self.seedXSubscriptionAccounts(in: configurationStore, scenario: scenario) - let results = PlanTierFixture.initialResults(in: configurationStore, scenario: scenario) + UITestMuseCodeFixture.seed(in: configurationStore, scenario: scenario) + let results = UITestMuseCodeFixture.results(in: configurationStore, scenario: scenario) + ?? PlanTierFixture.initialResults(in: configurationStore, scenario: scenario) ?? Self.initialResults(in: configurationStore, scenario: scenario, googleSources: googleSources, defaults: defaults) let providers: [any UsageProvider] if google { diff --git a/CodexBarIOS/Services/UITestMuseCodeFixture.swift b/CodexBarIOS/Services/UITestMuseCodeFixture.swift new file mode 100644 index 00000000..d10d6f23 --- /dev/null +++ b/CodexBarIOS/Services/UITestMuseCodeFixture.swift @@ -0,0 +1,90 @@ +#if DEBUG +import Foundation + +/// Simulator-only Muse Code states for UI evidence. Every value is synthetic and the shipping app cannot +/// reach these cards: Muse Code can't be added on iPhone (MUSE-CODE.md). +@MainActor +enum UITestMuseCodeFixture { + static let scenario = "muse-code-states" + + private enum State: String, CaseIterable { + case measured + case partial + case exhausted + case stale + case notReported = "not-reported" + case signInExpired = "sign-in-expired" + case notOnIPhone = "not-on-iphone" + + var label: String { + switch self { + case .measured: "Fixture: measured" + case .partial: "Fixture: partial" + case .exhausted: "Fixture: exhausted" + case .stale: "Fixture: stale" + case .notReported: "Fixture: allowance null" + case .signInExpired: "Fixture: sign-in expired" + case .notOnIPhone: "Fixture: not on iPhone" + } + } + + var accountID: String { + "ui-muse-code-\(rawValue)" + } + } + + static func seed(in store: ProviderConfigurationStore, scenario: String?) { + guard scenario == Self.scenario else { return } + let accounts = State.allCases.map { + ProviderAccountConfiguration(id: $0.accountID, providerID: .museCode, accountLabel: $0.label, authMethod: .browserSession) + } + store.insertUnavailableProviderFixtureAccounts(accounts) + for account in accounts { + _ = store.saveSecret("ui-test-credential", for: account) + } + } + + static func results(in store: ProviderConfigurationStore, scenario: String?, now: Date = Date()) -> [ProviderUsageResult]? { + guard scenario == Self.scenario else { return nil } + return State.allCases.compactMap { state in + store.configuration(accountID: state.accountID).map { result(for: state, configuration: $0, now: now) } + } + } + + private static func result(for state: State, configuration: ProviderAccountConfiguration, now: Date) -> ProviderUsageResult { + let outcome: MuseCodeAllowance.Outcome + switch state { + case .measured: + outcome = parse(observedAgo: 60, window: #""usedPercent":15"#, weekly: #""usedPercent":5"#, now: now) + case .partial: + outcome = parse(observedAgo: 60, window: nil, weekly: #""usedPercent":5"#, now: now) + case .exhausted: + outcome = parse(observedAgo: 60, window: #""usedPercent":104"#, weekly: #""usedPercent":62"#, now: now) + case .stale: + outcome = parse(observedAgo: 40 * 60, window: #""usedPercent":15"#, weekly: #""usedPercent":5"#, now: now) + case .notReported: + let payload = #"{"schemaVersion":1,"allowance":null,"reason":"synthetic reason for fixture.user@example.com","hint":"x"}"# + outcome = MuseCodeAllowance.parse(Data(payload.utf8), now: now) + case .signInExpired: + outcome = .unavailable(.authorizationExpired) + case .notOnIPhone: + outcome = .unavailable(.phoneAcquisitionUnsupported) + } + return MuseCodeAllowance.result(for: outcome, configuration: configuration, fetchedAt: now) + } + + /// `window: nil` keeps the five-hour key but drops its percentage, the partial reading in the producer fixtures. + private static func parse(observedAgo: TimeInterval, window: String?, weekly: String, now: Date) -> MuseCodeAllowance.Outcome { + let asOf = now.addingTimeInterval(-observedAgo) + func milliseconds(_ date: Date) -> Int { Int(date.timeIntervalSince1970 * 1000) } + let windowReset = milliseconds(asOf.addingTimeInterval(3 * 60 * 60)) + let weeklyReset = milliseconds(asOf.addingTimeInterval(4 * 24 * 60 * 60)) + let windowFields = window.map { "\($0)," } ?? "" + let payload = """ + {"schemaVersion":1,"allowance":{"asOfMs":\(milliseconds(asOf)),"source":"mint","tier":"Muse Code Power Usage",\ + "window":{\(windowFields)"resetsAtMs":\(windowReset)},"weekly":{\(weekly),"resetsAtMs":\(weeklyReset)}}} + """ + return MuseCodeAllowance.parse(Data(payload.utf8), now: now) + } +} +#endif diff --git a/CodexBarIOS/Services/UsageRefreshService.swift b/CodexBarIOS/Services/UsageRefreshService.swift index b6fd7fc4..1f65fcd0 100644 --- a/CodexBarIOS/Services/UsageRefreshService.swift +++ b/CodexBarIOS/Services/UsageRefreshService.swift @@ -327,7 +327,8 @@ public final class UsageRefreshService: ObservableObject { } public func refresh() async { - await refresh(configurations: ProviderID.allCases.map(ProviderAccountConfiguration.defaultConfiguration)) + await refresh(configurations: ProviderID.allCases.filter(\.isAvailableOnIPhone) + .map(ProviderAccountConfiguration.defaultConfiguration)) } public func consumeClaudeReset( @@ -625,7 +626,7 @@ public final class UsageRefreshService: ObservableObject { /// The same predicate protects batch work, individual requests and reconciliation. func canRefresh(_ configuration: ProviderAccountConfiguration) -> Bool { - configuration.isEnabled && configuration.providerID != .antigravity + configuration.isEnabled && configuration.providerID != .antigravity && configuration.providerID.isAvailableOnIPhone && (accountAccess?.permitsRefresh(configuration) ?? true) } diff --git a/CodexBarIOS/Views/ProviderSettingsViewModel.swift b/CodexBarIOS/Views/ProviderSettingsViewModel.swift index 8b0551a4..66fa2919 100644 --- a/CodexBarIOS/Views/ProviderSettingsViewModel.swift +++ b/CodexBarIOS/Views/ProviderSettingsViewModel.swift @@ -254,6 +254,8 @@ final class ProviderSettingsViewModel: ObservableObject { [.browserSession, .cliToken] case .openRouter, .moonshot: [.apiKey] + case .museCode: + [] } } diff --git a/CodexBarIOS/Views/ProviderUsageCard.swift b/CodexBarIOS/Views/ProviderUsageCard.swift index 266e78de..f66145af 100644 --- a/CodexBarIOS/Views/ProviderUsageCard.swift +++ b/CodexBarIOS/Views/ProviderUsageCard.swift @@ -1278,7 +1278,7 @@ struct ProviderUsageCard: View { .monospacedDigit() } MetricVisualizationView(bar: bar, style: visualizationStyle, showsSeverity: result.hasCurrentBars) - SubscriptionUtilizationMetricView(bar: bar, isCurrent: result.hasCurrentBars) + SubscriptionUtilizationMetricView(bar: bar, providerID: result.providerID, isCurrent: result.hasCurrentBars) let githubAllowanceSummary = result.providerID == .githubBilling ? Self.githubAllowanceSummary(bar) : nil @@ -1452,7 +1452,7 @@ struct ProviderUsageCard: View { result.hasCurrentBars ? "fresh" : "stale", bar.localizedResetDescription(), result.hasCurrentBars ? bar.dashboardProjectionDescription() : nil, - SubscriptionUtilizationScore.applies(to: bar) + SubscriptionUtilizationScore.applies(to: bar, providerID: result.providerID) ? (result.hasCurrentBars ? SubscriptionUtilizationScore(bar: bar)?.accessibilityText : nil) ?? "SUS unavailable" : nil, ] .compactMap { $0 } @@ -2634,7 +2634,7 @@ private struct ProviderMetricTileDetailView: View { : "Unavailable" ) } - SubscriptionUtilizationMetricView(bar: bar, isCurrent: result.hasCurrentBars, showsExplanation: true) + SubscriptionUtilizationMetricView(bar: bar, providerID: result.providerID, isCurrent: result.hasCurrentBars, showsExplanation: true) detailRow("Freshness", result.hasCurrentBars ? "Current" : "Last known value") if let resetDescription = bar.localizedResetDescription() { detailRow("Reset", resetDescription) diff --git a/CodexBarIOS/Views/SettingsView.swift b/CodexBarIOS/Views/SettingsView.swift index 84409e4b..1697acd1 100644 --- a/CodexBarIOS/Views/SettingsView.swift +++ b/CodexBarIOS/Views/SettingsView.swift @@ -1487,7 +1487,8 @@ enum SettingsAccountsSectionRow: Identifiable, Equatable { } struct AddAccountFlowState: Equatable { - static let providerOptions = ProviderID.allCases.filter { $0 != .antigravity } + static let providerOptions = ProviderID.allCases.filter { $0 != .antigravity && $0.isAvailableOnIPhone } + static let unavailableProviders = ProviderID.allCases.filter { !$0.isAvailableOnIPhone } private(set) var accountID: String? @@ -1566,18 +1567,29 @@ struct AddAccountSetupFlow: View { } } } else { - List(AddAccountFlowState.providerOptions) { providerID in - Button { - select(providerID) - } label: { - VStack(alignment: .leading, spacing: 4) { - Label(providerID.displayName, systemImage: providerID.addAccountIconName) - if let description = GoogleUsageMetricCatalog.setupDescription(for: providerID) { - Text(description).font(.caption).foregroundStyle(.secondary) + List { + Section { + ForEach(AddAccountFlowState.providerOptions) { providerID in + Button { + select(providerID) + } label: { + VStack(alignment: .leading, spacing: 4) { + Label(providerID.displayName, systemImage: providerID.addAccountIconName) + if let description = GoogleUsageMetricCatalog.setupDescription(for: providerID) { + Text(description).font(.caption).foregroundStyle(.secondary) + } + } + } + .accessibilityHint("Creates this account and opens its setup screen.") + } + } footer: { + ForEach(AddAccountFlowState.unavailableProviders) { providerID in + if let reason = providerID.phoneUnavailableReason { + Text("\(providerID.displayName) isn't available on iPhone. \(reason)") + .accessibilityIdentifier("add-account.unavailable.\(providerID.rawValue)") } } } - .accessibilityHint("Creates this account and opens its setup screen.") } .navigationTitle("Choose a Provider") .navigationBarTitleDisplayMode(.inline) @@ -1642,6 +1654,8 @@ extension ProviderID { "diamond.fill" case .antigravity: "arrow.up.circle" + case .museCode: + "music.note" } } } diff --git a/CodexBarIOS/Views/SubscriptionUtilizationBadge.swift b/CodexBarIOS/Views/SubscriptionUtilizationBadge.swift index f0e5f7ee..655aa610 100644 --- a/CodexBarIOS/Views/SubscriptionUtilizationBadge.swift +++ b/CodexBarIOS/Views/SubscriptionUtilizationBadge.swift @@ -69,11 +69,12 @@ struct UITestSUSProvider: UsageProvider { struct SubscriptionUtilizationMetricView: View { let bar: UsageBar + let providerID: ProviderID let isCurrent: Bool var showsExplanation = false var body: some View { - if SubscriptionUtilizationScore.applies(to: bar) { + if SubscriptionUtilizationScore.applies(to: bar, providerID: providerID) { TimelineView(.periodic(from: .now, by: 60)) { context in SubscriptionUtilizationBadge(score: isCurrent ? SubscriptionUtilizationScore(bar: bar, at: context.date) : nil) } diff --git a/CodexBarIOSTests/AppAndWidgetTests.swift b/CodexBarIOSTests/AppAndWidgetTests.swift index af94d96b..23b79737 100644 --- a/CodexBarIOSTests/AppAndWidgetTests.swift +++ b/CodexBarIOSTests/AppAndWidgetTests.swift @@ -272,7 +272,7 @@ final class AppAndWidgetTests: XCTestCase { let options = AddAccountFlowState.providerOptions XCTAssertEqual(options.count, Set(options).count) - XCTAssertEqual(Set(options), Set(ProviderID.allCases.filter { $0 != .antigravity })) + XCTAssertEqual(Set(options), Set(ProviderID.allCases.filter { $0 != .antigravity && $0.isAvailableOnIPhone })) XCTAssertTrue(options.allSatisfy { !$0.displayName.isEmpty }) XCTAssertTrue(options.allSatisfy { !$0.addAccountIconName.isEmpty }) } @@ -898,7 +898,7 @@ final class AppAndWidgetTests: XCTestCase { let configurationStore = ProviderConfigurationStore.appStoreScreenshotDemo() let results = AppStoreScreenshotFixtures.results(for: configurationStore) - XCTAssertEqual(Set(results.map(\.providerID)), Set(ProviderID.allCases.filter { $0 != .antigravity })) + XCTAssertEqual(Set(results.map(\.providerID)), Set(ProviderID.allCases.filter { $0 != .antigravity && $0.isAvailableOnIPhone })) XCTAssertTrue(results.allSatisfy { $0.accountID.hasPrefix("app-store-screenshots.") }) XCTAssertGreaterThanOrEqual( configurationStore.configurations.filter { @@ -1512,7 +1512,7 @@ final class AppAndWidgetTests: XCTestCase { .moonshot: "Moonshot (Kimi)", .greptile: "Greptile", .gemini: "Google Gemini", - .antigravity: "Antigravity", + .antigravity: "Antigravity", .museCode: "Muse Code", ] ) } diff --git a/CodexBarIOSTests/DashboardAndSettingsTests.swift b/CodexBarIOSTests/DashboardAndSettingsTests.swift index 9882aa5b..71277a46 100644 --- a/CodexBarIOSTests/DashboardAndSettingsTests.swift +++ b/CodexBarIOSTests/DashboardAndSettingsTests.swift @@ -175,7 +175,7 @@ final class DashboardAndSettingsTests: XCTestCase { XCTAssertEqual( Set(service.results.map(\.providerID)), - Set(ProviderID.allCases.filter { $0 != .antigravity }) + Set(ProviderID.allCases.filter { $0 != .antigravity && $0.isAvailableOnIPhone }) ) XCTAssertFalse(service.isRefreshing) XCTAssertNil(service.lastRefreshError) diff --git a/CodexBarIOSTests/MuseCodeAllowanceTests.swift b/CodexBarIOSTests/MuseCodeAllowanceTests.swift new file mode 100644 index 00000000..42cd7d84 --- /dev/null +++ b/CodexBarIOSTests/MuseCodeAllowanceTests.swift @@ -0,0 +1,345 @@ +import XCTest +@testable import CodexBarIOS + +/// Schema-1 `muse usage --json` fixtures. `measured` is the producer's real reading without identity +/// (hs-pi-dashboard 588be88 internal/usage/testdata/muse); every other payload is synthetic. Wire shapes for +/// expired sign-in, entitlement denial, PAYG, rate limits and transport failures are unknown, so those states +/// are constructed directly rather than parsed (MUSE-CODE.md). +final class MuseCodeAllowanceTests: XCTestCase { + private static let asOf = Date(timeIntervalSince1970: 1_793_037_245.064) + private static let now = asOf.addingTimeInterval(30) + private static let windowReset = Date(timeIntervalSince1970: 1_793_053_104) + private static let weeklyReset = Date(timeIntervalSince1970: 1_793_577_600) + private static let configuration = ProviderAccountConfiguration( + id: "muse-fixture", providerID: .museCode, accountLabel: "Muse Code", authMethod: .browserSession + ) + + private static let measured = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","tier":"Muse Code Power Usage","# + + #""weekly":{"resetsAtMs":1793577600000,"usedPercent":5},"window":{"resetsAtMs":1793053104000,"# + + #""usedPercent":15}}}"# + private static let zero = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","tier":"Muse Code Power Usage","# + + #""weekly":{"resetsAtMs":1793577600000,"usedPercent":0},"window":{"resetsAtMs":1793053104000,"# + + #""usedPercent":0}}}"# + private static let exhausted = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","tier":"Muse Code Power Usage","# + + #""weekly":{"resetsAtMs":1793577600000,"usedPercent":100},"window":{"resetsAtMs":1793053104000,"# + + #""usedPercent":130}}}"# + private static let partial = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","# + + #""weekly":{"resetsAtMs":1793577600000,"usedPercent":5},"window":{"resetsAtMs":1793053104000}}}"# + private static let malformedValues = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","# + + #""weekly":{"resetsAtMs":1793577600000,"usedPercent":"5%"},"window":{"resetsAtMs":"soon","# + + #""usedPercent":15}}}"# + private static let resetBeyondWindow = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","# + + #""window":{"resetsAtMs":1793053104000,"usedPercent":15},"weekly":{"resetsAtMs":1794182400000,"# + + #""usedPercent":5}}}"# + private static let unsupportedSchema = #"{"schemaVersion":2,"allowance":{"asOfMs":1793037245064,"weekly":{"resetsAtMs":1793577600000,"# + + #""usedPercent":5}}}"# + private static let truncated = #"{"schemaVersion":1,"allowance":"# + private static let nullAllowance = #"{"schemaVersion":1,"allowance":null,"reason":"synthetic reason for fixture.user@example.com","# + + #""hint":"synthetic hint: run muse login as Fixture User"}"# + private static let payAsYouGo = #"{"schemaVersion":1,"allowance":null,"reason":"META_API_KEY sk-fixture-secret is pay-as-you-go","# + + #""hint":"unset the key"}"# + private static let extraWindow = #"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","# + + #""tier":"Muse Code Power Usage