From 7adf9cb9fea977c5b2d25a15e89a4b84846c7760 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:46:43 +0200 Subject: [PATCH 1/2] Add shared usage history sync and observed timestamps --- CONTEXT.md | 12 + Sources/CodexLimits/ForecastEngine.swift | 14 +- Sources/CodexLimits/MenuContentView.swift | 54 +- Sources/CodexLimits/UsageHistory.swift | 484 ++++++++++++++++++ Sources/CodexLimits/UsageModels.swift | 32 +- Sources/CodexLimits/UsageMonitor.swift | 192 +++++-- .../ForecastEngineTests.swift | 12 +- .../CodexLimitsTests/UsageHistoryTests.swift | 431 ++++++++++++++++ ...elected-folder-for-shared-usage-history.md | 5 + docs/usage-history-sync.md | 51 ++ 10 files changed, 1238 insertions(+), 49 deletions(-) create mode 100644 Sources/CodexLimits/UsageHistory.swift create mode 100644 Tests/CodexLimitsTests/UsageHistoryTests.swift create mode 100644 docs/adr/0006-user-selected-folder-for-shared-usage-history.md create mode 100644 docs/usage-history-sync.md diff --git a/CONTEXT.md b/CONTEXT.md index fc4904b..059cbd6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,3 +51,15 @@ _Avoid_: Worst case, guaranteed minimum **Burn-down chart**: A chart for the current usage window with time and remaining-budget axes. It shows the observed usage curve, a straight path from 100% to the safety buffer, the current projection, the historical projection, and the current point; earlier windows inform the historical projection but are not drawn individually. _Avoid_: Activity chart, task history + +**Usage sample**: +A timestamped observation of the main limit's remaining budget and window reset. Its observation time is recorded locally; its remaining budget and window reset come from Codex. +_Avoid_: Token bucket, activity event + +**Shared usage history**: +The combined usage samples recorded by Macs connected through the same optional sync folder. It excludes preferences, launch settings, credentials, and raw Codex responses. +_Avoid_: Shared app state, cloud backup + +**Sync folder**: +An optional user-selected folder that connects the shared usage history of Macs signed in to the same Codex account. One sync folder represents one Codex account; the app does not identify or verify that account. +_Avoid_: Account store, settings sync diff --git a/Sources/CodexLimits/ForecastEngine.swift b/Sources/CodexLimits/ForecastEngine.swift index 037ab5f..69ac8ff 100644 --- a/Sources/CodexLimits/ForecastEngine.swift +++ b/Sources/CodexLimits/ForecastEngine.swift @@ -11,16 +11,16 @@ enum ForecastEngine { ) -> Forecast { let daysLeft = max(window.resetsAt.timeIntervalSince(now) / 86_400, 0) let currentSamples = samples - .filter { $0.resetsAt == window.resetsAt && $0.date <= now } - .sorted { $0.date < $1.date } + .filter { $0.resetsAt == window.resetsAt && $0.observedAt <= now } + .sorted { $0.observedAt < $1.observedAt } let elapsedDays = max(now.timeIntervalSince(window.startsAt) / 86_400, 1 / 24) let windowRate = max((100 - window.remainingPercent) / elapsedDays, 0) let recentRate: Double if let first = currentSamples.first, let last = currentSamples.last, - last.date > first.date { - let days = last.date.timeIntervalSince(first.date) / 86_400 + last.observedAt > first.observedAt { + let days = last.observedAt.timeIntervalSince(first.observedAt) / 86_400 recentRate = max((first.remainingPercent - last.remainingPercent) / days, 0) } else { recentRate = windowRate @@ -32,11 +32,11 @@ enum ForecastEngine { let historicalRates = Dictionary(grouping: samples.filter { $0.resetsAt != window.resetsAt }) { $0.resetsAt }.values.compactMap { windowSamples -> Double? in - let ordered = windowSamples.sorted { $0.date < $1.date } + let ordered = windowSamples.sorted { $0.observedAt < $1.observedAt } guard let first = ordered.first, let last = ordered.last, - last.date > first.date else { return nil } - let days = last.date.timeIntervalSince(first.date) / 86_400 + last.observedAt > first.observedAt else { return nil } + let days = last.observedAt.timeIntervalSince(first.observedAt) / 86_400 return max((first.remainingPercent - last.remainingPercent) / days, 0) } let historicalRate: Double diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 40e9369..8635129 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -6,6 +6,7 @@ import SwiftUI struct MenuContentView: View { @ObservedObject var monitor: UsageMonitor @AppStorage(UsageMonitor.safetyBufferKey) private var safetyBuffer = 3.0 + @Environment(\.openSettings) private var openSettings var body: some View { Group { @@ -111,7 +112,14 @@ struct MenuContentView: View { .font(.caption) .foregroundStyle(.secondary) Spacer() - SettingsLink { + Button { + openSettings() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NSApp.windows.first { + $0.isVisible && $0.styleMask.contains(.titled) + }?.orderFrontRegardless() + } + } label: { Image(systemName: "gearshape") } .buttonStyle(.borderless) @@ -227,8 +235,8 @@ private struct BurnDownChart: View { private var observed: [BurnPoint] { let current = BurnPoint(date: fetchedAt, remaining: window.remainingPercent) let local = samples - .filter { $0.date > window.startsAt && $0.date < fetchedAt } - .map { BurnPoint(date: $0.date, remaining: $0.remainingPercent) } + .filter { $0.observedAt > window.startsAt && $0.observedAt < fetchedAt } + .map { BurnPoint(date: $0.observedAt, remaining: $0.remainingPercent) } .sorted { $0.date < $1.date } let firstKnown = local.first ?? current let buckets = tokenHistory @@ -492,10 +500,37 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + Section("History sync") { + Text("Keep usage history in a folder available on your other Macs.") + .foregroundStyle(.secondary) + + if let folderName = monitor.syncFolderName { + LabeledContent("Folder", value: folderName) + Button("Stop Syncing") { + Task { await monitor.stopHistorySync() } + } + } else { + Button("Choose Folder…", action: chooseHistoryFolder) + } + + Text("Use this folder only on Macs signed in to the same Codex account.") + .font(.caption) + .foregroundStyle(.secondary) + Text("Choose a private folder that isn’t shared with other people.") + .font(.caption) + .foregroundStyle(.secondary) + + if let syncErrorMessage = monitor.syncErrorMessage { + Label(syncErrorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + } + } } .formStyle(.grouped) .padding() - .frame(width: 320) + .frame(width: 380) } private func updateLaunchAtLogin(_ enabled: Bool) { @@ -512,6 +547,17 @@ struct SettingsView: View { loginItemError = "Couldn’t update the login setting." } } + + private func chooseHistoryFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + guard panel.runModal() == .OK, let directory = panel.url else { return } + Task { await monitor.connectHistoryFolder(directory) } + } } enum LoginItem { diff --git a/Sources/CodexLimits/UsageHistory.swift b/Sources/CodexLimits/UsageHistory.swift new file mode 100644 index 0000000..80137c2 --- /dev/null +++ b/Sources/CodexLimits/UsageHistory.swift @@ -0,0 +1,484 @@ +import Foundation + +actor UsageHistory { + struct State: Equatable, Sendable { + let samples: [UsageSample] + let folderName: String? + let errorMessage: String? + } + + private struct Marker: Codable { + let version: Int + } + + private struct DailyFile: Codable { + let version: Int + let samples: [UsageSample] + } + + private enum HistoryError: Error { + case invalidFolder + case invalidFile + case unsupportedFileVersion + case unsupportedFolderVersion + case unavailableFolder + } + + private static let formatVersion = 1 + private static let markerName = ".codex-limits-history.json" + private static let installationsName = "installations" + private static let retention: TimeInterval = 90 * 86_400 + private static let maximumFileSize = 1_000_000 + + private let localDirectory: URL + private let installationID: String + private let now: @Sendable () -> Date + private var syncDirectory: URL? + private var errorMessage: String? + private var knownSamples: [UsageSample] = [] + + init( + localDirectory: URL, + installationID: String, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.localDirectory = localDirectory + self.installationID = installationID + self.now = now + } + + func load(legacySamples: [UsageSample] = []) -> State { + do { + try prepareRoot(localDirectory, createIfMissing: true, coordinated: false) + let hadCleanupErrors = try removeExpiredOwnFiles(from: localDirectory, coordinated: false) + try add(legacySamples, to: localDirectory, installationID: installationID, coordinated: false) + errorMessage = hadCleanupErrors + ? "Some usage history couldn’t be read." + : nil + } catch { + errorMessage = "Usage history couldn’t be saved." + } + return state(fallback: legacySamples) + } + + func record(_ sample: UsageSample) -> State { + do { + try prepareRoot(localDirectory, createIfMissing: true, coordinated: false) + _ = try removeExpiredOwnFiles(from: localDirectory, coordinated: false) + try add([sample], to: localDirectory, installationID: installationID, coordinated: false) + if let syncDirectory { + try prepareRoot(syncDirectory, createIfMissing: false, coordinated: true) + _ = try removeExpiredOwnFiles(from: syncDirectory, coordinated: true) + try add([sample], to: syncDirectory, installationID: installationID, coordinated: true) + } + errorMessage = nil + } catch { + errorMessage = syncDirectory == nil + ? "Usage history couldn’t be saved." + : message(for: error) + } + return state() + } + + func connect(to directory: URL) -> State { + do { + try prepareRoot(directory, createIfMissing: false, coordinated: true) + let existing = readAll(from: localDirectory).samples + try add(existing, to: localDirectory, installationID: installationID, coordinated: false) + syncDirectory = directory + errorMessage = nil + return synchronize() + } catch { + errorMessage = message(for: error) + return state() + } + } + + func disconnect() -> State { + syncDirectory = nil + errorMessage = nil + return state() + } + + func synchronize() -> State { + guard let syncDirectory else { return state() } + do { + try prepareRoot(syncDirectory, createIfMissing: false, coordinated: true) + _ = try removeExpiredOwnFiles(from: localDirectory, coordinated: false) + _ = try removeExpiredOwnFiles(from: syncDirectory, coordinated: true) + let hadImportErrors = try importHistory(from: syncDirectory) + try publishOwnHistory(to: syncDirectory) + errorMessage = hadImportErrors + ? "Some synced history couldn’t be read." + : nil + } catch { + errorMessage = message(for: error) + } + return state() + } + + private func state(fallback: [UsageSample] = []) -> State { + let local = readAll(from: localDirectory) + if local.hadError && errorMessage == nil { + errorMessage = "Some usage history couldn’t be read." + } + let samples = local.hadError || errorMessage != nil + ? normalized(local.samples + knownSamples + fallback) + : local.samples + knownSamples = samples + return State( + samples: samples, + folderName: syncDirectory?.lastPathComponent, + errorMessage: errorMessage + ) + } + + private func prepareRoot(_ root: URL, createIfMissing: Bool, coordinated: Bool) throws { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: root.path, isDirectory: &isDirectory) + if !exists { + guard createIfMissing else { throw HistoryError.unavailableFolder } + try createDirectory(at: root, coordinated: coordinated) + } else if !isDirectory.boolValue { + throw HistoryError.invalidFolder + } + + let marker = root.appendingPathComponent(Self.markerName) + if FileManager.default.fileExists(atPath: marker.path) { + let value = try JSONDecoder().decode( + Marker.self, + from: readData(at: marker, coordinated: coordinated) + ) + guard value.version == Self.formatVersion else { + throw HistoryError.unsupportedFolderVersion + } + } else { + let entries = try FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + guard entries.isEmpty else { throw HistoryError.invalidFolder } + let data = try JSONEncoder().encode(Marker(version: Self.formatVersion)) + try writeData(data, to: marker, coordinated: coordinated) + } + + try createDirectory( + at: installationsDirectory(in: root), + coordinated: coordinated + ) + } + + private func add( + _ samples: [UsageSample], + to root: URL, + installationID: String, + coordinated: Bool + ) throws { + let valid = normalized(samples) + guard !valid.isEmpty else { return } + let grouped = Dictionary(grouping: valid, by: { dayName(for: $0.observedAt) }) + let writerDirectory = installationsDirectory(in: root) + .appendingPathComponent(installationID, isDirectory: true) + try createDirectory(at: writerDirectory, coordinated: coordinated) + + for (day, newSamples) in grouped { + let url = writerDirectory.appendingPathComponent("\(day).json") + let existing = try readDailyFileIfPresent(at: url, coordinated: coordinated) + try write(normalized(existing + newSamples), to: url, coordinated: coordinated) + } + } + + private func importHistory(from remoteRoot: URL) throws -> Bool { + let remoteInstallations = installationsDirectory(in: remoteRoot) + let localInstallations = installationsDirectory(in: localDirectory) + var hadError = false + for remoteWriter in try directoryContents(of: remoteInstallations) { + let localWriter = localInstallations.appendingPathComponent( + remoteWriter.lastPathComponent, + isDirectory: true + ) + try createDirectory(at: localWriter, coordinated: false) + for remoteFile in try jsonFiles(in: remoteWriter) { + do { + let localFile = localWriter.appendingPathComponent(remoteFile.lastPathComponent) + let remoteSamples = try readDailyFileIfPresent(at: remoteFile, coordinated: true) + let localSamples = try readDailyFileIfPresent(at: localFile, coordinated: false) + try write( + normalized(localSamples + remoteSamples), + to: localFile, + coordinated: false + ) + } catch { + hadError = true + } + } + } + return hadError + } + + private func publishOwnHistory(to remoteRoot: URL) throws { + let localWriter = installationsDirectory(in: localDirectory) + .appendingPathComponent(installationID, isDirectory: true) + guard FileManager.default.fileExists(atPath: localWriter.path) else { return } + let remoteWriter = installationsDirectory(in: remoteRoot) + .appendingPathComponent(installationID, isDirectory: true) + try createDirectory(at: remoteWriter, coordinated: true) + for localFile in try jsonFiles(in: localWriter) { + let remoteFile = remoteWriter.appendingPathComponent(localFile.lastPathComponent) + let localSamples = try readDailyFileIfPresent(at: localFile, coordinated: false) + let remoteSamples = try readDailyFileIfPresent(at: remoteFile, coordinated: true) + let merged = normalized(localSamples + remoteSamples) + try write(merged, to: localFile, coordinated: false) + try write(merged, to: remoteFile, coordinated: true) + } + } + + private func removeExpiredOwnFiles(from root: URL, coordinated: Bool) throws -> Bool { + let writer = installationsDirectory(in: root) + .appendingPathComponent(installationID, isDirectory: true) + guard FileManager.default.fileExists(atPath: writer.path) else { return false } + var hadReadError = false + for file in try jsonFiles(in: writer) { + let existing: [UsageSample] + do { + existing = try readDailyFileIfPresent(at: file, coordinated: coordinated) + } catch { + hadReadError = true + continue + } + let retained = normalized(existing) + if retained.isEmpty { + try removeItem(at: file, coordinated: coordinated) + } else if retained != existing { + try write(retained, to: file, coordinated: coordinated) + } + } + return hadReadError + } + + private func readAll(from root: URL) -> (samples: [UsageSample], hadError: Bool) { + var samples: [UsageSample] = [] + var hadError = false + let directory = installationsDirectory(in: root) + do { + for writer in try directoryContents(of: directory) { + do { + for file in try jsonFiles(in: writer) { + do { + samples += try readDailyFileIfPresent(at: file, coordinated: false) + } catch { + hadError = true + } + } + } catch { + hadError = true + } + } + } catch { + hadError = true + } + return (normalized(samples), hadError) + } + + private func readDailyFileIfPresent(at url: URL, coordinated: Bool) throws -> [UsageSample] { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let value = try JSONDecoder().decode( + DailyFile.self, + from: readData(at: url, coordinated: coordinated) + ) + guard value.version == Self.formatVersion else { + throw HistoryError.unsupportedFileVersion + } + return value.samples + } + + private func write(_ samples: [UsageSample], to url: URL, coordinated: Bool) throws { + let value = DailyFile(version: Self.formatVersion, samples: samples) + let data = try JSONEncoder().encode(value) + try writeData(data, to: url, coordinated: coordinated) + } + + private func readData(at url: URL, coordinated: Bool) throws -> Data { + guard coordinated, isUbiquitousItem(url) else { return try checkedData(at: url) } + var result: Result? + var coordinationError: NSError? + NSFileCoordinator(filePresenter: nil).coordinate( + readingItemAt: url, + options: [], + error: &coordinationError + ) { coordinatedURL in + result = Result { try checkedData(at: coordinatedURL) } + } + if let coordinationError { throw coordinationError } + guard let result else { throw HistoryError.unavailableFolder } + return try result.get() + } + + private func writeData(_ data: Data, to url: URL, coordinated: Bool) throws { + guard coordinated, isUbiquitousItem(url) else { + try data.write(to: url, options: .atomic) + return + } + let fileExists = FileManager.default.fileExists(atPath: url.path) + let coordinationURL = fileExists ? url : url.deletingLastPathComponent() + var writeError: Error? + var coordinationError: NSError? + var didWrite = false + NSFileCoordinator(filePresenter: nil).coordinate( + writingItemAt: coordinationURL, + options: [], + error: &coordinationError + ) { coordinatedURL in + do { + let destination = fileExists + ? coordinatedURL + : coordinatedURL.appendingPathComponent(url.lastPathComponent) + try data.write(to: destination, options: .atomic) + didWrite = true + } catch { + writeError = error + } + } + if let coordinationError { throw coordinationError } + if let writeError { throw writeError } + guard didWrite else { throw HistoryError.unavailableFolder } + } + + private func removeItem(at url: URL, coordinated: Bool) throws { + guard coordinated, isUbiquitousItem(url) else { + try FileManager.default.removeItem(at: url) + return + } + var removeError: Error? + var coordinationError: NSError? + var didRemove = false + NSFileCoordinator(filePresenter: nil).coordinate( + writingItemAt: url, + options: .forDeleting, + error: &coordinationError + ) { coordinatedURL in + do { + try FileManager.default.removeItem(at: coordinatedURL) + didRemove = true + } catch { + removeError = error + } + } + if let coordinationError { throw coordinationError } + if let removeError { throw removeError } + guard didRemove else { throw HistoryError.unavailableFolder } + } + + private func createDirectory(at url: URL, coordinated: Bool) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { return } + guard coordinated, isUbiquitousItem(url) else { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return + } + var createError: Error? + var coordinationError: NSError? + var didCreate = false + NSFileCoordinator(filePresenter: nil).coordinate( + writingItemAt: url.deletingLastPathComponent(), + options: [], + error: &coordinationError + ) { coordinatedParent in + do { + let directory = coordinatedParent.appendingPathComponent(url.lastPathComponent) + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: false + ) + } + didCreate = true + } catch { + createError = error + } + } + if let coordinationError { throw coordinationError } + if let createError { throw createError } + guard didCreate else { throw HistoryError.unavailableFolder } + } + + private func checkedData(at url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize + guard (size ?? 0) <= Self.maximumFileSize else { + throw HistoryError.invalidFile + } + return try Data(contentsOf: url) + } + + private func isUbiquitousItem(_ url: URL) -> Bool { + var candidate = url + while !FileManager.default.fileExists(atPath: candidate.path), + candidate.pathComponents.count > 1 { + candidate.deleteLastPathComponent() + } + return (try? candidate.resourceValues( + forKeys: [.isUbiquitousItemKey] + ).isUbiquitousItem) == true + } + + private func normalized(_ samples: [UsageSample]) -> [UsageSample] { + let cutoff = now().addingTimeInterval(-Self.retention) + return Array(Set(samples.filter { + $0.observedAt >= cutoff + && $0.observedAt <= $0.resetsAt + && $0.remainingPercent.isFinite + && (0 ... 100).contains($0.remainingPercent) + })).sorted { + if $0.observedAt != $1.observedAt { return $0.observedAt < $1.observedAt } + if $0.remainingPercent != $1.remainingPercent { + return $0.remainingPercent > $1.remainingPercent + } + return $0.resetsAt < $1.resetsAt + } + } + + private func installationsDirectory(in root: URL) -> URL { + root.appendingPathComponent(Self.installationsName, isDirectory: true) + } + + private func directoryContents(of directory: URL) throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ).filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + } + + private func jsonFiles(in directory: URL) throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ).filter { + $0.pathExtension == "json" + && (try? $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + } + } + + private func dayName(for date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!) + } + + private func message(for error: Error) -> String { + switch error { + case HistoryError.invalidFolder: + "Choose an empty folder or an existing Codex Limits history folder." + case HistoryError.unsupportedFolderVersion: + "This history folder was created by a newer version of Codex Limits." + case HistoryError.unavailableFolder: + "Sync paused — folder unavailable." + default: + "Some synced history couldn’t be read." + } + } +} diff --git a/Sources/CodexLimits/UsageModels.swift b/Sources/CodexLimits/UsageModels.swift index 3107f8e..c274502 100644 --- a/Sources/CodexLimits/UsageModels.swift +++ b/Sources/CodexLimits/UsageModels.swift @@ -10,10 +10,38 @@ struct UsageWindow: Codable, Equatable, Sendable { } } -struct UsageSample: Codable, Equatable, Sendable { - let date: Date +struct UsageSample: Codable, Equatable, Hashable, Sendable { + let observedAt: Date let remainingPercent: Double let resetsAt: Date + + private enum CodingKeys: String, CodingKey { + case observedAt + case date + case remainingPercent + case resetsAt + } + + init(observedAt: Date, remainingPercent: Double, resetsAt: Date) { + self.observedAt = observedAt + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + observedAt = try container.decodeIfPresent(Date.self, forKey: .observedAt) + ?? container.decode(Date.self, forKey: .date) + remainingPercent = try container.decode(Double.self, forKey: .remainingPercent) + resetsAt = try container.decode(Date.self, forKey: .resetsAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(observedAt, forKey: .observedAt) + try container.encode(remainingPercent, forKey: .remainingPercent) + try container.encode(resetsAt, forKey: .resetsAt) + } } struct TokenDay: Codable, Equatable, Sendable { diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index b1ae583..2f5f8fd 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -11,21 +11,44 @@ final class UsageMonitor: ObservableObject { @Published private(set) var samples: [UsageSample] = [] @Published private(set) var isRefreshing = false @Published private(set) var errorMessage: String? + @Published private(set) var syncFolderName: String? + @Published private(set) var syncErrorMessage: String? private static let stateKey = "usageState" + private static let historyInstallationIDKey = "historyInstallationID" + private static let historySyncBookmarkKey = "historySyncBookmark" + private let history: UsageHistory private var previousStatus: PaceStatus? private var cancellables: Set = [] private var started = false + private var historyPrepared = false + private var historyUsesFiles = false + private var configuredSyncDirectory: URL? + private var historyConnectionActive = false init() { - if let data = UserDefaults.standard.data(forKey: Self.stateKey), + let defaults = UserDefaults.standard + if let data = defaults.data(forKey: Self.stateKey), let state = try? JSONDecoder().decode(StoredState.self, from: data) { snapshot = state.snapshot samples = state.samples previousStatus = state.previousStatus - recalculate() } + let installationID: String + if let existing = defaults.string(forKey: Self.historyInstallationIDKey), + let uuid = UUID(uuidString: existing) { + installationID = uuid.uuidString.lowercased() + } else { + installationID = UUID().uuidString.lowercased() + defaults.set(installationID, forKey: Self.historyInstallationIDKey) + } + history = UsageHistory( + localDirectory: Self.historyDirectory(), + installationID: installationID + ) + recalculate() + Task { [weak self] in await self?.start() } @@ -38,13 +61,15 @@ final class UsageMonitor: ObservableObject { var currentWindowSamples: [UsageSample] { guard let reset = snapshot?.mainLimit.window.resetsAt else { return [] } - return samples.filter { $0.resetsAt == reset }.sorted { $0.date < $1.date } + return samples.filter { $0.resetsAt == reset }.sorted { $0.observedAt < $1.observedAt } } func start() async { guard !started else { return } started = true + await prepareHistory() + Timer.publish(every: 600, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in @@ -67,9 +92,33 @@ final class UsageMonitor: ObservableObject { isRefreshing = true defer { isRefreshing = false } + await prepareHistory() + if !historyUsesFiles { + let historyState = await history.load(legacySamples: samples) + apply(historyState) + historyUsesFiles = historyState.errorMessage == nil + } + + let fetchTask = Task { try await CodexClient.fetch() } + let historyState = await exchangeHistory() + apply(historyState, configuredFolderName: configuredSyncDirectory?.lastPathComponent) + let exchangeErrorMessage = historyState.errorMessage + recalculate() + persist() + do { - let newSnapshot = try await CodexClient.fetch() - record(newSnapshot) + let newSnapshot = try await fetchTask.value + let window = newSnapshot.mainLimit.window + let sample = UsageSample( + observedAt: newSnapshot.fetchedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt + ) + let recordedState = await history.record(sample) + apply(recordedState, configuredFolderName: configuredSyncDirectory?.lastPathComponent) + if recordedState.errorMessage == nil { + syncErrorMessage = exchangeErrorMessage + } snapshot = newSnapshot errorMessage = nil recalculate() @@ -86,6 +135,38 @@ final class UsageMonitor: ObservableObject { persist() } + func connectHistoryFolder(_ directory: URL) async { + await prepareHistory() + let state = await history.connect(to: directory) + apply(state) + historyConnectionActive = state.folderName != nil + guard historyConnectionActive else { return } + + do { + let bookmark = try directory.bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmark, forKey: Self.historySyncBookmarkKey) + configuredSyncDirectory = directory + syncFolderName = directory.lastPathComponent + } catch { + _ = await history.disconnect() + configuredSyncDirectory = nil + historyConnectionActive = false + syncFolderName = nil + syncErrorMessage = "Couldn’t remember the history folder. Choose it again." + } + } + + func stopHistorySync() async { + UserDefaults.standard.removeObject(forKey: Self.historySyncBookmarkKey) + configuredSyncDirectory = nil + historyConnectionActive = false + apply(await history.disconnect()) + } + private func recalculate(safetyBuffer: Double? = nil) { guard let snapshot else { return } let storedBuffer = UserDefaults.standard.object(forKey: Self.safetyBufferKey) as? Double @@ -102,40 +183,91 @@ final class UsageMonitor: ObservableObject { previousStatus = result.status } - private func record(_ snapshot: UsageSnapshot) { - let window = snapshot.mainLimit.window - let sample = UsageSample( - date: snapshot.fetchedAt, - remainingPercent: window.remainingPercent, - resetsAt: window.resetsAt - ) - samples.removeAll { $0.date < snapshot.fetchedAt.addingTimeInterval(-90 * 86_400) } - - if let last = samples.last, - last.resetsAt == sample.resetsAt, - last.remainingPercent == sample.remainingPercent { - if samples.count > 1, - samples[samples.count - 2].resetsAt == sample.resetsAt, - samples[samples.count - 2].remainingPercent == sample.remainingPercent { - samples[samples.count - 1] = sample - } else { - samples.append(sample) - } - } else { - samples.append(sample) - } - } - private func persist() { let state = StoredState( snapshot: snapshot, - samples: samples, + samples: historyUsesFiles ? [] : samples, previousStatus: previousStatus ) if let data = try? JSONEncoder().encode(state) { UserDefaults.standard.set(data, forKey: Self.stateKey) } } + + private func prepareHistory() async { + guard !historyPrepared else { return } + historyPrepared = true + + let state = await history.load(legacySamples: samples) + apply(state) + historyUsesFiles = state.errorMessage == nil + if historyUsesFiles { + persist() + } + + guard let bookmark = UserDefaults.standard.data(forKey: Self.historySyncBookmarkKey) else { + return + } + let directory: URL + var isStale = false + do { + directory = try URL( + resolvingBookmarkData: bookmark, + options: [.withoutUI], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + UserDefaults.standard.removeObject(forKey: Self.historySyncBookmarkKey) + syncErrorMessage = "Couldn’t reopen the history folder. Choose it again." + return + } + + configuredSyncDirectory = directory + let connectedState = await history.connect(to: directory) + historyConnectionActive = connectedState.folderName != nil + apply(connectedState, configuredFolderName: directory.lastPathComponent) + if isStale, historyConnectionActive { + do { + let refreshed = try directory.bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(refreshed, forKey: Self.historySyncBookmarkKey) + } catch { + syncErrorMessage = "Couldn’t update the saved history folder." + } + } + } + + private func exchangeHistory() async -> UsageHistory.State { + if let configuredSyncDirectory, !historyConnectionActive { + let state = await history.connect(to: configuredSyncDirectory) + historyConnectionActive = state.folderName != nil + return state + } + return await history.synchronize() + } + + private func apply( + _ state: UsageHistory.State, + configuredFolderName: String? = nil + ) { + samples = state.samples + syncFolderName = state.folderName ?? configuredFolderName + syncErrorMessage = state.errorMessage + } + + private static func historyDirectory() -> URL { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return base + .appendingPathComponent("com.github.thrr87.CodexLimits", isDirectory: true) + .appendingPathComponent("History", isDirectory: true) + } } private struct StoredState: Codable { diff --git a/Tests/CodexLimitsTests/ForecastEngineTests.swift b/Tests/CodexLimitsTests/ForecastEngineTests.swift index 5426f5b..61a0148 100644 --- a/Tests/CodexLimitsTests/ForecastEngineTests.swift +++ b/Tests/CodexLimitsTests/ForecastEngineTests.swift @@ -11,8 +11,8 @@ final class ForecastEngineTests: XCTestCase { durationMinutes: 7 * 24 * 60 ) let samples = [ - UsageSample(date: now.addingTimeInterval(-86_400), remainingPercent: 60, resetsAt: reset), - UsageSample(date: now, remainingPercent: 20, resetsAt: reset) + UsageSample(observedAt: now.addingTimeInterval(-86_400), remainingPercent: 60, resetsAt: reset), + UsageSample(observedAt: now, remainingPercent: 20, resetsAt: reset) ] let result = ForecastEngine.evaluate( @@ -40,10 +40,10 @@ final class ForecastEngineTests: XCTestCase { durationMinutes: 7 * 24 * 60 ) let samples = [ - UsageSample(date: earlierReset.addingTimeInterval(-5 * day), remainingPercent: 100, resetsAt: earlierReset), - UsageSample(date: earlierReset, remainingPercent: 75, resetsAt: earlierReset), - UsageSample(date: now.addingTimeInterval(-day), remainingPercent: 42, resetsAt: reset), - UsageSample(date: now, remainingPercent: 40, resetsAt: reset) + UsageSample(observedAt: earlierReset.addingTimeInterval(-5 * day), remainingPercent: 100, resetsAt: earlierReset), + UsageSample(observedAt: earlierReset, remainingPercent: 75, resetsAt: earlierReset), + UsageSample(observedAt: now.addingTimeInterval(-day), remainingPercent: 42, resetsAt: reset), + UsageSample(observedAt: now, remainingPercent: 40, resetsAt: reset) ] let result = ForecastEngine.evaluate( diff --git a/Tests/CodexLimitsTests/UsageHistoryTests.swift b/Tests/CodexLimitsTests/UsageHistoryTests.swift new file mode 100644 index 0000000..fc2dd5d --- /dev/null +++ b/Tests/CodexLimitsTests/UsageHistoryTests.swift @@ -0,0 +1,431 @@ +import Foundation +import XCTest +@testable import CodexLimits + +final class UsageHistoryTests: XCTestCase { + func testUnavailableFolderKeepsLocalHistoryAndRemainsSelected() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let now = Date(timeIntervalSince1970: 1_900_000) + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "macbook", + now: { now } + ) + _ = await history.load() + _ = await history.connect(to: shared) + _ = await history.record(sample) + try FileManager.default.removeItem(at: shared) + + let state = await history.synchronize() + + XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.folderName, "shared") + XCTAssertEqual(state.errorMessage, "Sync paused — folder unavailable.") + XCTAssertFalse(FileManager.default.fileExists(atPath: shared.path)) + } + + func testLegacyDateKeyDecodesAsObservationTime() throws { + let data = Data(#"{"date":0,"remainingPercent":80,"resetsAt":86400}"#.utf8) + + let sample = try JSONDecoder().decode(UsageSample.self, from: data) + + XCTAssertEqual(sample.observedAt, Date(timeIntervalSinceReferenceDate: 0)) + XCTAssertEqual(sample.remainingPercent, 80) + XCTAssertEqual(sample.resetsAt, Date(timeIntervalSinceReferenceDate: 86_400)) + } + + func testCorruptionDoesNotReplaceHistoryAlreadyLoadedInMemory() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_900_000) + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let history = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { now } + ) + _ = await history.load() + _ = await history.record(sample) + let file = try XCTUnwrap(jsonFiles(for: "macbook", in: root).first) + try Data("broken".utf8).write(to: file) + + let state = await history.synchronize() + + XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.errorMessage, "Some usage history couldn’t be read.") + } + + func testOversizedDailyFileIsSkipped() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_900_000) + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let history = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { now } + ) + _ = await history.load() + _ = await history.record(sample) + let file = try XCTUnwrap(jsonFiles(for: "macbook", in: root).first) + let original = try XCTUnwrap(String(data: Data(contentsOf: file), encoding: .utf8)) + let oversized = original.replacingOccurrences( + of: "{", + with: #"{"padding":""# + String(repeating: "x", count: 1_000_001) + #"","#, + options: [.anchored] + ) + try Data(oversized.utf8).write(to: file) + + let reloaded = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { now } + ) + let state = await reloaded.load() + + XCTAssertTrue(state.samples.isEmpty) + XCTAssertEqual(state.errorMessage, "Some usage history couldn’t be read.") + } + + func testRepeatedLegacyMigrationAndSyncAreIdempotent() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let now = Date(timeIntervalSince1970: 1_900_000) + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "macbook", + now: { now } + ) + + _ = await history.load(legacySamples: [sample]) + _ = await history.load(legacySamples: [sample]) + _ = await history.connect(to: shared) + _ = await history.synchronize() + let state = await history.synchronize() + + XCTAssertEqual(state.samples, [sample]) + XCTAssertNil(state.errorMessage) + } + + func testUnsupportedFolderVersionIsNotModified() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let marker = shared.appendingPathComponent(".codex-limits-history.json") + let unsupportedMarker = Data(#"{"version":2}"#.utf8) + try unsupportedMarker.write(to: marker) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "macbook" + ) + _ = await history.load() + + let state = await history.connect(to: shared) + + XCTAssertEqual( + state.errorMessage, + "This history folder was created by a newer version of Codex Limits." + ) + XCTAssertEqual(try Data(contentsOf: marker), unsupportedMarker) + XCTAssertFalse(FileManager.default.fileExists( + atPath: shared.appendingPathComponent("installations").path + )) + } + + func testDisconnectKeepsLocalHistoryAndStopsPublishing() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let now = Date(timeIntervalSince1970: 1_900_060) + let reset = Date(timeIntervalSince1970: 2_000_000) + let first = UsageSample( + observedAt: Date(timeIntervalSince1970: 1_900_000), + remainingPercent: 82, + resetsAt: reset + ) + let second = UsageSample( + observedAt: now, + remainingPercent: 81, + resetsAt: reset + ) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "macbook", + now: { now } + ) + _ = await history.load() + _ = await history.connect(to: shared) + _ = await history.record(first) + + _ = await history.disconnect() + let localState = await history.record(second) + + let reader = UsageHistory( + localDirectory: root.appendingPathComponent("reader", isDirectory: true), + installationID: "reader", + now: { now } + ) + _ = await reader.load() + let sharedState = await reader.connect(to: shared) + + XCTAssertEqual(localState.samples, [first, second]) + XCTAssertNil(localState.folderName) + XCTAssertEqual(sharedState.samples, [first]) + } + + func testMissingSyncFolderIsNotRecreated() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let missing = root.appendingPathComponent("missing", isDirectory: true) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "macbook" + ) + _ = await history.load() + + let state = await history.connect(to: missing) + + XCTAssertFalse(FileManager.default.fileExists(atPath: missing.path)) + XCTAssertNil(state.folderName) + XCTAssertEqual(state.errorMessage, "Sync paused — folder unavailable.") + } + + func testMalformedSyncedFileDoesNotBlockValidRemoteHistory() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let now = Date(timeIntervalSince1970: 1_900_000) + let receiver = UsageHistory( + localDirectory: root.appendingPathComponent("receiver", isDirectory: true), + installationID: "receiver", + now: { now } + ) + _ = await receiver.load() + _ = await receiver.connect(to: shared) + + let corruptWriter = shared + .appendingPathComponent("installations", isDirectory: true) + .appendingPathComponent("a-corrupt", isDirectory: true) + try FileManager.default.createDirectory(at: corruptWriter, withIntermediateDirectories: true) + try Data("broken".utf8).write( + to: corruptWriter.appendingPathComponent("0000-broken.json") + ) + + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let sender = UsageHistory( + localDirectory: root.appendingPathComponent("sender", isDirectory: true), + installationID: "z-sender", + now: { now } + ) + _ = await sender.load() + _ = await sender.connect(to: shared) + let senderState = await sender.record(sample) + XCTAssertNil(senderState.errorMessage) + + let state = await receiver.synchronize() + + XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.errorMessage, "Some synced history couldn’t be read.") + } + + func testRetentionDeletesOnlyThisInstallationsExpiredFiles() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let currentDate = Date(timeIntervalSince1970: 10_000_000) + let oldDate = currentDate.addingTimeInterval(-91 * 86_400) + let oldReset = oldDate.addingTimeInterval(7 * 86_400) + let macBook = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { oldDate } + ) + let macStudio = UsageHistory( + localDirectory: root, + installationID: "mac-studio", + now: { oldDate } + ) + + _ = await macBook.load() + _ = await macBook.record(UsageSample( + observedAt: oldDate, + remainingPercent: 80, + resetsAt: oldReset + )) + _ = await macStudio.record(UsageSample( + observedAt: oldDate, + remainingPercent: 79, + resetsAt: oldReset + )) + + let currentMacBook = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { currentDate } + ) + _ = await currentMacBook.load() + + XCTAssertTrue(jsonFiles(for: "macbook", in: root).isEmpty) + XCTAssertEqual(jsonFiles(for: "mac-studio", in: root).count, 1) + } + + func testMalformedFileKeepsValidHistoryAndReportsWarning() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_900_000) + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let history = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { now } + ) + _ = await history.load() + _ = await history.record(sample) + let writerDirectory = root + .appendingPathComponent("installations", isDirectory: true) + .appendingPathComponent("macbook", isDirectory: true) + try Data("broken".utf8).write( + to: writerDirectory.appendingPathComponent("broken.json") + ) + + let reloaded = UsageHistory( + localDirectory: root, + installationID: "macbook", + now: { now } + ) + let state = await reloaded.load() + + XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.errorMessage, "Some usage history couldn’t be read.") + } + + func testFailedMigrationKeepsLegacyHistoryAvailable() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let unusableDirectory = root.appendingPathComponent("history") + try Data("not a directory".utf8).write(to: unusableDirectory) + let now = Date(timeIntervalSince1970: 1_900_000) + let sample = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: now.addingTimeInterval(86_400) + ) + let history = UsageHistory( + localDirectory: unusableDirectory, + installationID: "macbook", + now: { now } + ) + + let state = await history.load(legacySamples: [sample]) + + XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.errorMessage, "Usage history couldn’t be saved.") + } + + func testTwoInstallationsMergeWithoutLosingSamples() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let now = Date(timeIntervalSince1970: 1_900_060) + let macBook = UsageHistory( + localDirectory: root.appendingPathComponent("macbook", isDirectory: true), + installationID: "macbook", + now: { now } + ) + let macStudio = UsageHistory( + localDirectory: root.appendingPathComponent("mac-studio", isDirectory: true), + installationID: "mac-studio", + now: { now } + ) + let reset = Date(timeIntervalSince1970: 2_000_000) + let macBookSample = UsageSample( + observedAt: Date(timeIntervalSince1970: 1_900_000), + remainingPercent: 82, + resetsAt: reset + ) + let macStudioSample = UsageSample( + observedAt: Date(timeIntervalSince1970: 1_900_060), + remainingPercent: 81, + resetsAt: reset + ) + + _ = await macBook.load() + _ = await macStudio.load() + _ = await macBook.connect(to: shared) + _ = await macStudio.connect(to: shared) + + async let macBookWrite = macBook.record(macBookSample) + async let macStudioWrite = macStudio.record(macStudioSample) + let writeStates = await (macBookWrite, macStudioWrite) + XCTAssertNil(writeStates.0.errorMessage) + XCTAssertNil(writeStates.1.errorMessage) + XCTAssertEqual(jsonFiles(for: "macbook", in: shared).count, 1) + XCTAssertEqual(jsonFiles(for: "mac-studio", in: shared).count, 1) + + let macBookState = await macBook.synchronize() + let macStudioState = await macStudio.synchronize() + + XCTAssertEqual(macBookState.samples, [macBookSample, macStudioSample]) + XCTAssertEqual(macStudioState.samples, [macBookSample, macStudioSample]) + XCTAssertNil(macBookState.errorMessage) + XCTAssertNil(macStudioState.errorMessage) + } + + private func jsonFiles(for installationID: String, in root: URL) -> [URL] { + let directory = root + .appendingPathComponent("installations", isDirectory: true) + .appendingPathComponent(installationID, isDirectory: true) + return (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ))?.filter { $0.pathExtension == "json" } ?? [] + } +} diff --git a/docs/adr/0006-user-selected-folder-for-shared-usage-history.md b/docs/adr/0006-user-selected-folder-for-shared-usage-history.md new file mode 100644 index 0000000..c01385e --- /dev/null +++ b/docs/adr/0006-user-selected-folder-for-shared-usage-history.md @@ -0,0 +1,5 @@ +# User-selected folder for shared usage history + +The app stores usage history locally as versioned daily JSON files and optionally replicates those files across Macs through a dedicated user-selected folder, such as one in iCloud Drive. This keeps the same lightweight format on both sides; a shared SQLite database is rejected because file synchronization cannot coordinate its transactions and auxiliary files safely, while a separate local database would add a second persistence model without benefiting the small 90-day dataset. CloudKit is unavailable to the app's ad-hoc local builds. + +The app initializes only an empty folder or joins a folder carrying its supported format marker, so it never treats an arbitrary nonempty directory as sync data. Each sync folder represents one Codex account and contains usage history only; preferences, credentials, and device state remain local. The folder must be private and not shared with other people. The JSON remains readable and has no app-level encryption because it contains no credentials or content, while synchronizing and recovering an encryption key would add a new data-loss risk. When a Mac joins, its existing 90-day history is merged with the history already in the folder without replacing either side. An exact tuple of observation time, remaining percentage, and window reset identifies a usage sample; exact copies are deduplicated, while readings made at different times remain distinct. Each installation has a random local identifier and writes only files belonging to that identifier, preventing concurrent Macs from overwriting one another without exposing hardware identity. The app ignores samples older than 90 days; an installation removes only its own expired daily files and never deletes another installation's files. Stopping sync only disconnects the folder: the merged local history and the folder's contents remain intact. Folder failures never replace valid local history or interrupt the main usage display; the app keeps the folder selected, retries later, and reports sync status only in settings. Valid files continue to merge when one history file is malformed, while that file is left untouched and reported in settings. An unsupported folder-format marker prevents all writes so an older app cannot damage newer data. Sync runs only during the app's existing refresh events and may therefore lag by up to the ten-minute background refresh interval. Each Mac imports independently; the app neither tracks delivery to another Mac nor claims that both devices are up to date. Reading current usage from Codex and exchanging history produce independent results, so either can succeed when the other fails. diff --git a/docs/usage-history-sync.md b/docs/usage-history-sync.md new file mode 100644 index 0000000..fc0e48c --- /dev/null +++ b/docs/usage-history-sync.md @@ -0,0 +1,51 @@ +# Shared usage history + +## Scope + +Shared usage history is optional and contains only main-limit usage samples. A usage sample consists of its locally recorded observation time, remaining percentage, and the window reset returned by Codex. Preferences, launch settings, credentials, raw Codex responses, and other device state never sync. + +One sync folder represents one Codex account. The app does not identify or verify the account, so the settings interface tells the user to use the folder only on Macs signed in to the same Codex account. The folder must be private and not shared with other people. Files remain readable JSON without app-level encryption and contain no credentials or Codex content. + +## Behavior + +- Choosing a sync folder enables synchronization. The app accepts an empty folder that it can initialize or a folder with a supported Codex Limits format marker; it rejects other nonempty folders. +- On first connection, the app merges the existing local 90-day history with the history already in the folder without replacing either side. +- Sync runs on launch, wake, panel open, manual refresh, and the existing ten-minute background refresh. +- Each Mac imports available history independently during those refreshes. The app does not track whether another Mac has downloaded a change. +- Reading current usage from Codex and exchanging folder history are independent operations. Failure of either one does not prevent the other result from updating the app. +- Stopping sync disconnects the folder without deleting local or folder history. +- A missing folder or malformed history file never replaces valid local history or interrupts the main usage display. The app retries later and reports the problem only in settings. +- Valid files continue to merge when one history file is malformed. An unsupported folder version prevents all writes. + +## Storage and merge rules + +- Local history and its optional folder replica use the same versioned daily JSON format. +- Each installation has a random local identifier and writes only files belonging to that identifier. +- The tuple of observation time, remaining percentage, and window reset identifies a usage sample. Exact copies are deduplicated; readings made at different times remain distinct. +- A daily file larger than 1 MB is skipped and reported like any other unreadable history file. +- Samples older than 90 days are ignored. An installation removes only its own expired daily files and never deletes another installation's files. +- iCloud Drive access uses coordinated file operations. No SQLite database file is synchronized. + +Existing usage samples are migrated automatically from `UserDefaults` on the first launch that uses file-backed history. Migration is idempotent and keeps the legacy state available until the new daily files have been written and read successfully. After successful migration, usage history is removed from `UserDefaults`, which retains only small preferences and identifiers. + +## Settings + +The settings interface uses a `History sync` section with this description: + +> Keep usage history in a folder available on your other Macs. + +`Choose Folder…` selects and connects a folder. When connected, the section shows the folder name and `Stop Syncing`; it shows a short status only when there is a problem. There is no separate toggle, folder-opening action, sync-now action, last-sync timestamp, or claim that another Mac is up to date. The existing `Refresh` action also triggers sync. + +The account invariant is presented as: + +> Use this folder only on Macs signed in to the same Codex account. + +Folder privacy is presented as: + +> Choose a private folder that isn’t shared with other people. + +## TDD seam + +Critical behavior is tested through the concrete `UsageHistory` module interface, using real temporary directories instead of filesystem mocks. The interface covers loading local history, recording a usage sample, connecting or disconnecting a sync folder, merging the replica during refresh, and returning history with nonblocking sync status. + +Tests cover lossless legacy migration, simultaneous writes by two installations, initial merge, idempotent repeated merge, malformed files, unsupported folder versions, nondestructive disconnect, and writer-owned retention. Tests do not target private functions, call order, or JSON implementation details. From e91f146c1b1d1d8d7c14e878df450fae3f8a853b Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:01:46 +0200 Subject: [PATCH 2/2] Run Swift CI on macOS 15 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c45ddb3..f622721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: test: name: Swift tests - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Check out repository