Skip to content

Commit ac15351

Browse files
authored
Merge pull request #3 from linuxlewis/feat/multi-account
feat: Multi-account support
2 parents 5cbd52c + d457f69 commit ac15351

11 files changed

Lines changed: 732 additions & 196 deletions

File tree

ClaudeUsage.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
AA000008 /* UsageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100011 /* UsageViewModel.swift */; };
1818
AA000009 /* UsageBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100012 /* UsageBar.swift */; };
1919
AA000010 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100013 /* SettingsView.swift */; };
20+
AA000011 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100014 /* Account.swift */; };
21+
AA000012 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100015 /* AccountStore.swift */; };
2022
AA000030 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA100030 /* Assets.xcassets */; };
2123
/* End PBXBuildFile section */
2224

@@ -44,6 +46,8 @@
4446
AA100011 /* UsageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageViewModel.swift; sourceTree = "<group>"; };
4547
AA100012 /* UsageBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageBar.swift; sourceTree = "<group>"; };
4648
AA100013 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
49+
AA100014 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; };
50+
AA100015 /* AccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStore.swift; sourceTree = "<group>"; };
4751
AA100030 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4852
/* End PBXFileReference section */
4953

@@ -120,6 +124,7 @@
120124
children = (
121125
AA100007 /* UsageData.swift */,
122126
AA100011 /* UsageViewModel.swift */,
127+
AA100014 /* Account.swift */,
123128
);
124129
path = Models;
125130
sourceTree = "<group>";
@@ -129,6 +134,7 @@
129134
children = (
130135
AA100008 /* KeychainService.swift */,
131136
AA100009 /* UsageService.swift */,
137+
AA100015 /* AccountStore.swift */,
132138
);
133139
path = Services;
134140
sourceTree = "<group>";
@@ -224,6 +230,8 @@
224230
AA000008 /* UsageViewModel.swift in Sources */,
225231
AA000009 /* UsageBar.swift in Sources */,
226232
AA000010 /* SettingsView.swift in Sources */,
233+
AA000011 /* Account.swift in Sources */,
234+
AA000012 /* AccountStore.swift in Sources */,
227235
);
228236
runOnlyForDeploymentPostprocessing = 0;
229237
};

ClaudeUsage/ClaudeUsageApp.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import SwiftUI
22

33
@main
44
struct ClaudeUsageApp: App {
5-
@StateObject private var viewModel = UsageViewModel()
5+
@StateObject private var accountStore = AccountStore()
6+
@StateObject private var viewModel: UsageViewModel
7+
8+
init() {
9+
let store = AccountStore()
10+
_accountStore = StateObject(wrappedValue: store)
11+
_viewModel = StateObject(wrappedValue: UsageViewModel(accountStore: store))
12+
}
13+
614
private var menuBarText: String {
715
let pct = Int(viewModel.usageData?.fiveHour.utilization ?? 0)
816
let resetDate: Date? = viewModel.usageData?.fiveHour.resetsAt
@@ -15,7 +23,7 @@ struct ClaudeUsageApp: App {
1523

1624
var body: some Scene {
1725
MenuBarExtra {
18-
ContentView(viewModel: viewModel)
26+
ContentView(viewModel: viewModel, accountStore: accountStore)
1927
} label: {
2028
HStack(spacing: 3) {
2129
Image("MenuBarIcon")

ClaudeUsage/Models/Account.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
3+
struct Account: Identifiable, Codable, Equatable {
4+
let id: UUID
5+
var email: String
6+
var sessionKey: String?
7+
var orgId: String?
8+
9+
var isConfigured: Bool {
10+
guard let key = sessionKey, let org = orgId else { return false }
11+
return !key.isEmpty && !org.isEmpty
12+
}
13+
14+
init(id: UUID = UUID(), email: String, sessionKey: String? = nil, orgId: String? = nil) {
15+
self.id = id
16+
self.email = email
17+
self.sessionKey = sessionKey
18+
self.orgId = orgId
19+
}
20+
}

ClaudeUsage/Models/UsageViewModel.swift

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,19 @@ class UsageViewModel: ObservableObject {
3434
case notConfigured
3535
}
3636

37+
let accountStore: AccountStore
3738
private let pollingInterval: TimeInterval = 300 // 5 minutes
3839
private var pollingTask: Task<Void, Never>?
3940
private var cancellables = Set<AnyCancellable>()
4041

41-
init() {
42+
/// The email of the currently active account.
43+
var activeEmail: String? {
44+
accountStore.activeAccount?.email
45+
}
46+
47+
init(accountStore: AccountStore) {
48+
self.accountStore = accountStore
49+
4250
// Initialize time display preference from UserDefaults
4351
let savedFormat = UserDefaults.standard.string(forKey: "claude_time_display_format") ?? TimeDisplayFormat.resetTime.rawValue
4452
timeDisplayFormat = TimeDisplayFormat(rawValue: savedFormat) ?? .resetTime
@@ -65,25 +73,52 @@ class UsageViewModel: ObservableObject {
6573
}
6674
.store(in: &cancellables)
6775

68-
// Start polling if credentials exist
69-
startPollingIfConfigured()
76+
// Watch for active account changes and restart polling
77+
// Note: @Published fires on willSet, so we receive on next runloop tick
78+
// to ensure activeAccountId is already updated when we read it.
79+
accountStore.$activeAccountId
80+
.removeDuplicates()
81+
.receive(on: RunLoop.main)
82+
.sink { [weak self] _ in
83+
guard let self = self else { return }
84+
// Cancel existing polling FIRST to prevent stale fetches
85+
self.pollingTask?.cancel()
86+
self.pollingTask = nil
87+
self.usageData = nil
88+
self.lastUpdated = nil
89+
self.errorState = nil
90+
self.updateAuthStatus()
91+
self.startPollingIfConfigured()
92+
}
93+
.store(in: &cancellables)
7094
}
7195

7296
deinit {
7397
pollingTask?.cancel()
7498
}
7599

76100
func updateAuthStatus() {
77-
let hasKey = KeychainService.read(key: .sessionKey) != nil
78-
let hasOrg = KeychainService.read(key: .orgId) != nil
101+
guard let account = accountStore.activeAccount else {
102+
authStatus = .notConfigured
103+
return
104+
}
105+
// Check both in-memory model and Keychain for credentials
106+
let hasKey: Bool = {
107+
if let key = account.sessionKey, !key.isEmpty { return true }
108+
if let key = accountStore.sessionKey(for: account.id), !key.isEmpty { return true }
109+
return false
110+
}()
111+
let hasOrg: Bool = {
112+
if let org = account.orgId, !org.isEmpty { return true }
113+
if let org = accountStore.orgId(for: account.id), !org.isEmpty { return true }
114+
return false
115+
}()
79116
if hasKey && hasOrg {
80117
if errorState == .authExpired {
81118
authStatus = .expired
82-
} else if usageData != nil {
83-
authStatus = .connected
84119
} else {
85-
// Credentials exist but haven't verified yet
86-
authStatus = .notConfigured
120+
// Credentials exist — treat as connected (data fetch in progress or complete)
121+
authStatus = .connected
87122
}
88123
} else {
89124
authStatus = .notConfigured
@@ -94,53 +129,58 @@ class UsageViewModel: ObservableObject {
94129
func startPollingIfConfigured() {
95130
pollingTask?.cancel()
96131

97-
guard let sessionKey = KeychainService.read(key: .sessionKey),
98-
let orgId = KeychainService.read(key: .orgId),
132+
guard let accountId = accountStore.activeAccountId,
133+
let sessionKey = accountStore.sessionKey(for: accountId),
134+
let orgId = accountStore.orgId(for: accountId),
99135
!sessionKey.isEmpty, !orgId.isEmpty else {
100136
return
101137
}
102138

103139
pollingTask = Task { [weak self] in
104140
guard let self = self else { return }
105141
// Initial fetch
106-
await self.performFetch(sessionKey: sessionKey, orgId: orgId)
142+
guard !Task.isCancelled, self.accountStore.activeAccountId == accountId else { return }
143+
await self.performFetch(sessionKey: sessionKey, orgId: orgId, accountId: accountId)
107144

108145
// Polling loop
109146
while !Task.isCancelled {
110147
try? await Task.sleep(nanoseconds: UInt64(self.pollingInterval * 1_000_000_000))
111148
if Task.isCancelled { break }
149+
// Verify this is still the active account
150+
guard self.accountStore.activeAccountId == accountId else { break }
112151
// Re-read credentials in case they were updated
113-
guard let currentKey = KeychainService.read(key: .sessionKey),
114-
let currentOrg = KeychainService.read(key: .orgId) else {
152+
guard let currentKey = self.accountStore.sessionKey(for: accountId),
153+
let currentOrg = self.accountStore.orgId(for: accountId) else {
115154
break
116155
}
117-
await self.performFetch(sessionKey: currentKey, orgId: currentOrg)
156+
await self.performFetch(sessionKey: currentKey, orgId: currentOrg, accountId: accountId)
118157
}
119158
}
120159
}
121160

122161
/// Triggers an immediate fetch outside the polling cycle.
123162
func fetchNow() {
124-
guard let sessionKey = KeychainService.read(key: .sessionKey),
125-
let orgId = KeychainService.read(key: .orgId),
163+
guard let accountId = accountStore.activeAccountId,
164+
let sessionKey = accountStore.sessionKey(for: accountId),
165+
let orgId = accountStore.orgId(for: accountId),
126166
!sessionKey.isEmpty, !orgId.isEmpty else {
127167
return
128168
}
129169

130170
Task { [weak self] in
131-
await self?.performFetch(sessionKey: sessionKey, orgId: orgId)
171+
await self?.performFetch(sessionKey: sessionKey, orgId: orgId, accountId: accountId)
132172
}
133173
}
134174

135175
@MainActor
136-
private func performFetch(sessionKey: String, orgId: String) async {
176+
private func performFetch(sessionKey: String, orgId: String, accountId: UUID) async {
137177
let service = UsageService(sessionKey: sessionKey, orgId: orgId)
138178
do {
139179
let (data, newKey) = try await service.fetchUsage()
140180

141181
// Update Keychain if a new session key was returned via Set-Cookie
142182
if let newKey = newKey {
143-
KeychainService.save(key: .sessionKey, value: newKey)
183+
accountStore.saveSessionKey(newKey, for: accountId)
144184
}
145185

146186
usageData = data

0 commit comments

Comments
 (0)