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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let sweetCookieKitDependency: Package.Dependency =
let package = Package(
name: "CodexBar",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
dependencies: [
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCore/WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
27 changes: 26 additions & 1 deletion Sources/CodexBarWidget/CodexBarWidgetProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@ import CodexBarCore
import SwiftUI
import WidgetKit

enum WidgetProviderScope {
private static let iosMVPProviders: Set<UsageProvider> = [.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
Expand All @@ -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"),
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
}
}
Expand Down Expand Up @@ -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
}
}

Expand Down
47 changes: 41 additions & 6 deletions Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
24 changes: 24 additions & 0 deletions docs/IOS_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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.