import AppKit import SwiftUI import Combine @MainActor public class UsageStore: ObservableObject { @Published public var providers: [ProviderUsage] = [] @Published public var harmonized: HarmonizedUsage = .empty() @Published public var config: AppConfig = AppConfig() public var isLoading: Bool = false public var lastUpdated: Date? = nil public var statusItemTitle: String { if harmonized.isExhausted { return " 100%" } else if harmonized.usedPercent > 0.0 { return String(format: " %.0f%%", harmonized.usedPercent) } else { return "" } } private var refreshTimer: Timer? private var credsFileSource: DispatchSourceFileSystemObject? private var albedoConfigFileSource: DispatchSourceFileSystemObject? private var configFileSource: DispatchSourceFileSystemObject? private var lastFetchTime: Date? private var isScreenSleeping = false private var lastSelfAuthSave: Date? private var authDebounceTask: Task? private var configDebounceTask: Task? public init() { self.config = AppConfig.load() loadInitialCache() setupSleepObserver() setupAuthSaveHook() startFileWatchers() startTimer() Task(priority: .utility) { @MainActor [weak self] in await self?.refresh(force: true) } } isolated deinit { stopFileWatchers() refreshTimer?.invalidate() } private func setupSleepObserver() { let nc = NSWorkspace.shared.notificationCenter nc.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main) { [weak self] _ in Task { @MainActor [weak self] in self?.isScreenSleeping = true } } nc.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in Task { @MainActor [weak self] in guard let self = self else { return } self.isScreenSleeping = false await self.refresh(force: false) } } } private func setupAuthSaveHook() { AuthStorage.onAuthSaved = { [weak self] in Task { @MainActor [weak self] in self?.lastSelfAuthSave = Date() } } } private func loadInitialCache() { let cached = UsageCache.load() guard !cached.isEmpty else { return } // Show cached cards on launch; the first refresh replaces any legacy single-provider keys. let excluded = Set(config.excludedProviders) var list = cached.values.map { p -> ProviderUsage in var copy = p copy.isShared = Harmonizer.isExcluded(copy, excluded: excluded) copy.isCached = true return copy } list.sort(by: ProviderOrder.comesBefore) self.providers = list self.harmonized = Harmonizer.compute(providers: list, config: config) } public func refresh(force: Bool = false) async { if !force && isScreenSleeping { return } if !force, let last = lastFetchTime, Date().timeIntervalSince(last) < 45 { return } guard !isLoading else { return } isLoading = true guard let creds = AuthStorage.loadCreds() else { isLoading = false return } let albedoConfig = AuthStorage.loadAlbedoConfig() let newlyFetched = await UsageFetcher.fetchAll(creds: creds, albedoConfig: albedoConfig) let excluded = Set(config.excludedProviders) var merged: [ProviderUsage] = [] for newP in newlyFetched { var finalP = newP if let oldP = self.providers.first(where: { $0.id == newP.id }) { if newP.windows.isEmpty && !oldP.windows.isEmpty { finalP.windows = oldP.windows } if newP.isRateLimited || newP.isCached { finalP.windows = oldP.windows finalP.balance = oldP.balance ?? newP.balance } } if Harmonizer.isExcluded(finalP, excluded: excluded) { finalP.isShared = true } merged.append(finalP) } self.providers = merged self.harmonized = Harmonizer.compute(providers: merged, config: config) self.lastUpdated = Date() self.lastFetchTime = Date() self.isLoading = false } private func startTimer() { let timer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { [weak self] _ in Task(priority: .utility) { @MainActor [weak self] in await self?.refresh(force: false) } } timer.tolerance = 30.0 self.refreshTimer = timer } private func startFileWatchers() { watchCredsFile() watchAlbedoConfigFile() watchConfigFile() } private func stopFileWatchers() { authDebounceTask?.cancel() configDebounceTask?.cancel() credsFileSource?.cancel() albedoConfigFileSource?.cancel() configFileSource?.cancel() } private func scheduleAuthRefresh() { if let lastSave = self.lastSelfAuthSave, Date().timeIntervalSince(lastSave) < 2.0 { return } self.authDebounceTask?.cancel() self.authDebounceTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 400_000_000) guard !Task.isCancelled else { return } await self?.refresh(force: true) } } private func watchCredsFile() { credsFileSource?.cancel() credsFileSource = nil let path = AuthStorage.credsURL.path guard FileManager.default.fileExists(atPath: path) else { return } let fd = open(path, O_EVTONLY) guard fd >= 0 else { return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .extend, .attrib, .rename, .delete], queue: DispatchQueue.main ) source.setEventHandler { [weak self, weak source] in guard let self = self, let src = source else { return } let data = src.data self.scheduleAuthRefresh() if data.contains(.delete) || data.contains(.rename) { src.cancel() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.watchCredsFile() } } } let fdToClose = fd source.setCancelHandler { close(fdToClose) } source.resume() self.credsFileSource = source } private func watchAlbedoConfigFile() { albedoConfigFileSource?.cancel() albedoConfigFileSource = nil let path = AuthStorage.albedoConfigURL.path guard FileManager.default.fileExists(atPath: path) else { return } let fd = open(path, O_EVTONLY) guard fd >= 0 else { return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .extend, .attrib, .rename, .delete], queue: DispatchQueue.main ) source.setEventHandler { [weak self, weak source] in guard let self = self, let src = source else { return } let data = src.data self.scheduleAuthRefresh() if data.contains(.delete) || data.contains(.rename) { src.cancel() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.watchAlbedoConfigFile() } } } let fdToClose = fd source.setCancelHandler { close(fdToClose) } source.resume() self.albedoConfigFileSource = source } private func watchConfigFile() { configFileSource?.cancel() configFileSource = nil let path = AppConfig.configURL.path guard FileManager.default.fileExists(atPath: path) else { return } let fd = open(path, O_EVTONLY) guard fd >= 0 else { return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .extend, .attrib, .rename, .delete], queue: DispatchQueue.main ) source.setEventHandler { [weak self, weak source] in guard let self = self, let src = source else { return } let data = src.data self.configDebounceTask?.cancel() self.configDebounceTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 300_000_000) guard let self = self, !Task.isCancelled else { return } let loaded = AppConfig.load() self.config = loaded let excluded = Set(loaded.excludedProviders) for i in self.providers.indices { self.providers[i].isShared = Harmonizer.isExcluded(self.providers[i], excluded: excluded) } self.harmonized = Harmonizer.compute(providers: self.providers, config: loaded) } if data.contains(.delete) || data.contains(.rename) { src.cancel() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.watchConfigFile() } } } let fdToClose = fd source.setCancelHandler { close(fdToClose) } source.resume() self.configFileSource = source } }