From 76f6ef4406bea26d6950cc8c5641ee94b838ca69 Mon Sep 17 00:00:00 2001 From: waffensam Date: Thu, 26 Mar 2026 21:07:57 +0800 Subject: [PATCH] Scope iOS widgets to Codex and Claude MVP --- Package.swift | 1 + Sources/CodexBarCore/WidgetSnapshot.swift | 2 +- .../CodexBarWidgetProvider.swift | 27 ++++++++++- .../CodexBarWidgetProviderTests.swift | 47 ++++++++++++++++--- docs/IOS_MIGRATION.md | 24 ++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 docs/IOS_MIGRATION.md diff --git a/Package.swift b/Package.swift index ed0af2639..8ae3c8d5f 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,7 @@ let sweetCookieKitDependency: Package.Dependency = let package = Package( name: "CodexBar", platforms: [ + .iOS(.v17), .macOS(.v14), ], dependencies: [ diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 25a2f85b9..0511eb0d9 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -121,7 +121,7 @@ public enum WidgetSnapshotStore { private static func snapshotURL(bundleID: String?) -> URL? { let fm = FileManager.default let groupID = self.groupID(for: bundleID) - #if os(macOS) + #if canImport(Darwin) if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) { return container.appendingPathComponent(self.filename, isDirectory: false) } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c828a2695..465dc21f0 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -3,9 +3,24 @@ import CodexBarCore import SwiftUI import WidgetKit +enum WidgetProviderScope { + private static let iosMVPProviders: Set = [.codex, .claude] + + static func allows(_ provider: UsageProvider) -> Bool { + #if os(iOS) + return self.iosMVPProviders.contains(provider) + #else + return true + #endif + } + + static var fallbackProvider: UsageProvider { .codex } +} + enum ProviderChoice: String, AppEnum { case codex case claude + #if !os(iOS) case gemini case alibaba case antigravity @@ -14,12 +29,14 @@ enum ProviderChoice: String, AppEnum { case minimax case kilo case opencode + #endif static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [ .codex: DisplayRepresentation(title: "Codex"), .claude: DisplayRepresentation(title: "Claude"), + #if !os(iOS) .gemini: DisplayRepresentation(title: "Gemini"), .alibaba: DisplayRepresentation(title: "Alibaba"), .antigravity: DisplayRepresentation(title: "Antigravity"), @@ -28,12 +45,14 @@ enum ProviderChoice: String, AppEnum { .minimax: DisplayRepresentation(title: "MiniMax"), .kilo: DisplayRepresentation(title: "Kilo"), .opencode: DisplayRepresentation(title: "OpenCode"), + #endif ] var provider: UsageProvider { switch self { case .codex: .codex case .claude: .claude + #if !os(iOS) case .gemini: .gemini case .alibaba: .alibaba case .antigravity: .antigravity @@ -42,14 +61,17 @@ enum ProviderChoice: String, AppEnum { case .minimax: .minimax case .kilo: .kilo case .opencode: .opencode + #endif } } // swiftlint:disable:next cyclomatic_complexity init?(provider: UsageProvider) { + guard WidgetProviderScope.allows(provider) else { return nil } switch provider { case .codex: self = .codex case .claude: self = .claude + #if !os(iOS) case .gemini: self = .gemini case .alibaba: self = .alibaba case .antigravity: self = .antigravity @@ -71,6 +93,9 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + #else + default: return nil + #endif } } } @@ -229,7 +254,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { let enabled = snapshot.enabledProviders let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled let supported = providers.filter { ProviderChoice(provider: $0) != nil } - return supported.isEmpty ? [.codex] : supported + return supported.isEmpty ? [WidgetProviderScope.fallbackProvider] : supported } } diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 9f9527b2e..0c9b7addc 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -5,15 +5,46 @@ import Testing struct CodexBarWidgetProviderTests { @Test - func `provider choice supports alibaba`() { - #expect(ProviderChoice(provider: .alibaba) == .alibaba) - #expect(ProviderChoice.alibaba.provider == .alibaba) + func `provider choice supports codex and claude`() { + #expect(ProviderChoice(provider: .codex) == .codex) + #expect(ProviderChoice(provider: .claude) == .claude) } @Test - func `supported providers keep alibaba when it is the only enabled provider`() { + func `supported providers keep codex and claude when enabled`() { let now = Date(timeIntervalSince1970: 1_700_000_000) - let entry = WidgetSnapshot.ProviderEntry( + let codexEntry = WidgetSnapshot.ProviderEntry( + provider: .codex, + updatedAt: now, + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let claudeEntry = WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: now, + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot( + entries: [codexEntry, claudeEntry], + enabledProviders: [.codex, .claude], + generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.codex, .claude]) + } + + @Test + func `unsupported provider falls back to codex when needed`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let alibabaEntry = WidgetSnapshot.ProviderEntry( provider: .alibaba, updatedAt: now, primary: nil, @@ -23,8 +54,12 @@ struct CodexBarWidgetProviderTests { codeReviewRemainingPercent: nil, tokenUsage: nil, dailyUsage: []) - let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.alibaba], generatedAt: now) + let snapshot = WidgetSnapshot(entries: [alibabaEntry], enabledProviders: [.alibaba], generatedAt: now) + #if os(iOS) + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.codex]) + #else #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.alibaba]) + #endif } } diff --git a/docs/IOS_MIGRATION.md b/docs/IOS_MIGRATION.md new file mode 100644 index 000000000..3faed6386 --- /dev/null +++ b/docs/IOS_MIGRATION.md @@ -0,0 +1,24 @@ +# iOS Migration Plan (Phase 1) + +This repository started as a macOS menu bar app. The goal is to add an iOS companion app that powers Home Screen widgets for model usage. + +## Current status + +- SwiftPM now declares iOS 17 as a supported platform in addition to macOS 14. +- `WidgetSnapshotStore` now prefers App Group container URLs on all Darwin platforms, not just macOS. +- Widget provider scope is now iOS-MVP-first: iOS widgets expose Codex + Claude first. + +## Phase 1 scope (this change set) + +1. Prepare package metadata for iOS builds (`.iOS(.v17)`). +2. Ensure widget snapshot persistence can use shared App Group storage on iOS. +3. Keep macOS behavior unchanged. +4. Restrict iOS widget provider choices to Codex and Claude for MVP delivery. + +## Next steps + +1. Add an iOS app target (SwiftUI) that writes `WidgetSnapshot` to the shared container. +2. Reuse `Sources/CodexBarWidget` views/providers in an iOS widget extension target. +3. Isolate macOS-only UI/controller code (`NSStatusItem`, AppKit menu flow) behind `#if os(macOS)` boundaries where needed. +4. Implement iOS refresh strategy (BackgroundTasks + manual refresh in-app) to keep widget data fresh. +5. Add focused tests for App Group read/write behavior and iOS snapshot handoff.