diff --git a/README.md b/README.md index 7008b33..a99c071 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Open **Settings…** from the dropdown: - **Pace flame** — show a flame beside the menu-bar percentage when usage is ahead of a steady pace through a window. - **⛽ Fuel tank mode** — flip every percentage and bar from *used* to *remaining*. Instead of a limit filling from 0 → 100% as you burn through it, it starts full and drains toward empty like a fuel gauge. It's purely cosmetic: colours, the pace flame, and the steady-pace marker still track real usage, so a nearly-empty tank still turns red. - **Celebrations** — choose full-screen reactions for session resets, weekly resets, and crossing the weekly pace line, or turn them off individually. -- **Telemetry** — off by default. Record OTLP JSON to `~/Documents/CodexBar Telemetry`, choose another folder, or open it in Finder. Capture every five minutes and retain 90 days by default; both are configurable. `codexbar://telemetry` links can change these after you confirm. See the [telemetry format and coverage](docs/telemetry.md). +- **Telemetry** — off by default. Record OTLP JSON to `~/Library/Application Support/CodexBar/Telemetry`, choose another folder, or open it in Finder. Capture every five minutes and retain 90 days by default; both are configurable. `codexbar://telemetry` links can change these after you confirm. See the [telemetry format and coverage](docs/telemetry.md). - **Launch at login** — on by default for an installed app. ## How it works diff --git a/Sources/CodexBar/AppSettings.swift b/Sources/CodexBar/AppSettings.swift index 6e785a2..23b7758 100644 --- a/Sources/CodexBar/AppSettings.swift +++ b/Sources/CodexBar/AppSettings.swift @@ -1,6 +1,7 @@ import CodexBarCore import Foundation import Observation +import OSLog enum MenuBarPercentageSelection: Hashable { case highest @@ -50,6 +51,7 @@ final class AppSettings { static let telemetryIntervalMinutes = "telemetryIntervalMinutes" static let telemetryRetentionDays = "telemetryRetentionDays" static let telemetryDirectory = "telemetryDirectory" + static let telemetryFolderMigrated = "telemetryFolderMigrated" static func celebrationEnabled(_ trigger: CelebrationTrigger) -> String { "celebration.\(trigger.rawValue).enabled" @@ -61,6 +63,9 @@ final class AppSettings { } private let defaults: UserDefaults + private let home: URL + private let usesDefaultTelemetryDirectory: Bool + private let logger = Logger(subsystem: "com.gordonbeeming.CodexBar", category: "Telemetry") private var storedTelemetryIntervalMinutes: Int private var storedTelemetryRetentionDays: Int @@ -171,8 +176,28 @@ final class AppSettings { ) } - init(defaults: UserDefaults = .standard) { + /// Moves files out of the old Documents default. Call once at launch, before telemetry starts writing. + func migrateLegacyTelemetryFolder() { + // The flag keeps later launches from touching Documents at all, even when the old folder stays because it + // holds files that aren't ours. + guard usesDefaultTelemetryDirectory, !defaults.bool(forKey: Keys.telemetryFolderMigrated) else { return } + let migration = TelemetryFolderMigration(home: home) + for warning in migration.run() { + logger.error("Telemetry folder migration: \(warning, privacy: .public)") + } + if !migration.hasPendingFiles() { defaults.set(true, forKey: Keys.telemetryFolderMigrated) } + } + + init(defaults: UserDefaults = .standard, home: URL = FileManager.default.homeDirectoryForCurrentUser) { self.defaults = defaults + self.home = home + // Read before anything registers a default: only a stored value means the user chose a folder, and a stored + // copy of the old default means they picked "Use default" while it pointed at Documents. + let storedDirectory = defaults.string(forKey: Keys.telemetryDirectory).flatMap { $0.hasPrefix("/") ? $0 : nil } + let legacyPath = TelemetryConfiguration.legacyDefaultDirectory(home: home).standardizedFileURL.path + let storedIsLegacy = storedDirectory.map { URL(fileURLWithPath: $0).standardizedFileURL.path == legacyPath } ?? false + usesDefaultTelemetryDirectory = storedDirectory == nil || storedIsLegacy + if storedIsLegacy { defaults.removeObject(forKey: Keys.telemetryDirectory) } var registrations: [String: Any] = [ Keys.useDefaultSeverity: true, Keys.warningThresholdPercent: 75.0, @@ -183,8 +208,7 @@ final class AppSettings { Keys.celebrationsEnabled: false, Keys.telemetryEnabled: false, Keys.telemetryIntervalMinutes: 5, - Keys.telemetryRetentionDays: 90, - Keys.telemetryDirectory: TelemetryConfiguration.defaultDirectory.path + Keys.telemetryRetentionDays: 90 ] for trigger in CelebrationTrigger.allCases { registrations[Keys.celebrationEnabled(trigger)] = true @@ -196,9 +220,8 @@ final class AppSettings { telemetryEnabled = defaults.bool(forKey: Keys.telemetryEnabled) storedTelemetryIntervalMinutes = min(max(defaults.integer(forKey: Keys.telemetryIntervalMinutes), 1), 1_440) storedTelemetryRetentionDays = TelemetryConfiguration.normalizedRetentionDays(defaults.integer(forKey: Keys.telemetryRetentionDays)) - telemetryDirectory = defaults.string(forKey: Keys.telemetryDirectory).flatMap { - $0.hasPrefix("/") ? $0 : nil - } ?? TelemetryConfiguration.defaultDirectory.path + telemetryDirectory = (storedIsLegacy ? nil : storedDirectory) + ?? TelemetryConfiguration.defaultDirectory(home: home).path menuBarPercentageSelection = MenuBarPercentageSelection( rawValue: defaults.string(forKey: Keys.menuBarPercentageSelection) ) diff --git a/Sources/CodexBar/CodexBarApp.swift b/Sources/CodexBar/CodexBarApp.swift index 9f04755..b01abd3 100644 --- a/Sources/CodexBar/CodexBarApp.swift +++ b/Sources/CodexBar/CodexBarApp.swift @@ -9,7 +9,9 @@ struct CodexBarApp: App { @State private var celebrations: CelebrationController init() { - self.init(model: UsageViewModel(), settings: AppSettings()) + let settings = AppSettings() + settings.migrateLegacyTelemetryFolder() + self.init(model: UsageViewModel(), settings: settings) } init( diff --git a/Sources/CodexBarCore/TelemetryFolderMigration.swift b/Sources/CodexBarCore/TelemetryFolderMigration.swift new file mode 100644 index 0000000..b5cca44 --- /dev/null +++ b/Sources/CodexBarCore/TelemetryFolderMigration.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Moves telemetry written by earlier versions out of `~/Documents`, so macOS stops asking for Documents access. +public struct TelemetryFolderMigration { + private let home: URL + private let fileManager: FileManager + + public init(home: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default) { + self.home = home + self.fileManager = fileManager + } + + /// Returns a message per failure. A failure never throws: capture must still write to the new default. + public func run() -> [String] { + guard let legacy = legacyFolder() else { return [] } + + let entries: [URL] + do { + entries = try fileManager.contentsOfDirectory( + at: legacy, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [] + ) + } catch { return ["Couldn't read the old telemetry folder: \(error.localizedDescription)"] } + + let destination = TelemetryConfiguration.defaultDirectory(home: home) + var warnings: [String] = [] + for entry in entries { + guard let stage = Self.stage(of: entry) else { continue } + do { + let values = try entry.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { continue } + if stage == .migrated { + try fileManager.removeItem(at: entry) + continue + } + try fileManager.createDirectory(at: destination, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let target = destination.appending(path: Self.originalName(of: entry)) + if fileManager.fileExists(atPath: target.path) { + try merge(entry, into: target) + } else { + try fileManager.moveItem(at: entry, to: target) + } + } catch { + warnings.append("Couldn't move \(entry.lastPathComponent) out of the old telemetry folder: \(error.localizedDescription)") + } + } + warnings.append(contentsOf: removeLegacyFolderIfEmpty(legacy)) + return warnings + } + + /// True while a file the migration would still move or finish merging is in the old folder, which means a previous + /// attempt failed. A `.migrated` leftover is already merged and only needs deleting, so it doesn't count. + public func hasPendingFiles() -> Bool { + guard let legacy = legacyFolder() else { return false } + let entries: [URL] + do { + entries = try fileManager.contentsOfDirectory( + at: legacy, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [] + ) + } catch { + // Unreadable (for example Documents access was denied) isn't empty: files may still be there to move. + return true + } + return entries.contains { entry in + guard let stage = Self.stage(of: entry), stage != .migrated, + let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { return false } + return values.isRegularFile == true && values.isSymbolicLink != true + } + } + + /// The old folder, unless it's missing or a symbolic link: a link could point anywhere, and its target isn't ours to move out of. + private func legacyFolder() -> URL? { + let legacy = TelemetryConfiguration.legacyDefaultDirectory(home: home) + guard let type = try? fileManager.attributesOfItem(atPath: legacy.path)[.type] as? FileAttributeType, + type == .typeDirectory else { return nil } + return legacy + } + + /// Appends the old file's complete lines, then deletes it. A trailing unterminated fragment is an unfinished + /// record that the sink would discard itself, so it isn't carried over. + /// The source goes through `.merging` (append in progress) and `.migrated` (appended, only needs deleting). A crash + /// leaves a name the next run recognizes: a `.merging` file is merged again, which can repeat a line that was + /// already appended, and a `.migrated` file is just deleted. + private func merge(_ source: URL, into target: URL) throws { + let values = try target.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw CocoaError(.fileWriteNoPermission, userInfo: [NSFilePathErrorKey: target.path]) + } + let incoming = try Data(contentsOf: source) + guard let lastNewline = incoming.lastIndex(of: 0x0A) else { + try fileManager.removeItem(at: source) + return + } + let handle = try FileHandle(forUpdating: target) + defer { try? handle.close() } + let size = try handle.seekToEnd() + if size > 0 { + try handle.seek(toOffset: size - 1) + // Appending to an unfinished record would corrupt both lines; the sink repairs it on its next write. + guard try handle.read(upToCount: 1)?.first == 0x0A else { + throw CocoaError(.fileWriteUnknown, userInfo: [ + NSFilePathErrorKey: target.path, + NSLocalizedDescriptionKey: "the existing file ends in an unfinished record; it will be merged on a later launch" + ]) + } + } + let merging = Self.stage(of: source) == .merging + ? source : source.appendingPathExtension(Self.mergingExtension) + if merging != source { try fileManager.moveItem(at: source, to: merging) } + do { + try handle.seekToEnd() + try handle.write(contentsOf: incoming[incoming.startIndex...lastNewline]) + try handle.synchronize() + } catch { + // Undo any partial append; the file keeps a name the next run picks up again. + try? handle.truncate(atOffset: size) + throw error + } + let migrated = merging.deletingPathExtension().appendingPathExtension(Self.migratedExtension) + try fileManager.moveItem(at: merging, to: migrated) + try fileManager.removeItem(at: migrated) + } + + private func removeLegacyFolderIfEmpty(_ legacy: URL) -> [String] { + do { + let remaining = try fileManager.contentsOfDirectory(atPath: legacy.path) + guard remaining.allSatisfy({ $0 == ".DS_Store" }) else { return [] } + for name in remaining { try fileManager.removeItem(at: legacy.appending(path: name)) } + try fileManager.removeItem(at: legacy) + return [] + } catch { return ["Couldn't remove the old telemetry folder: \(error.localizedDescription)"] } + } + + private static let mergingExtension = "merging" + private static let migratedExtension = "migrated" + + private enum Stage { case live, merging, migrated } + + private static func stage(of url: URL) -> Stage? { + if isTelemetryFile(url) { return .live } + guard isTelemetryFile(url.deletingPathExtension()) else { return nil } + switch url.pathExtension { + case mergingExtension: return .merging + case migratedExtension: return .migrated + default: return nil + } + } + + private static func originalName(of url: URL) -> String { + stage(of: url) == .live ? url.lastPathComponent : url.deletingPathExtension().lastPathComponent + } + + static func isTelemetryFile(_ url: URL) -> Bool { + let name = url.lastPathComponent + guard name.hasSuffix(".jsonl") else { return false } + for signal in TelemetrySignal.allCases { + let prefix = "codexbar-\(signal.rawValue)-" + guard name.hasPrefix(prefix) else { continue } + let day = String(name.dropFirst(prefix.count).dropLast(".jsonl".count)) + guard day.count == 10 else { return false } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + formatter.isLenient = false + return formatter.date(from: day).map { formatter.string(from: $0) == day } ?? false + } + return false + } +} diff --git a/Sources/CodexBarCore/TelemetryModels.swift b/Sources/CodexBarCore/TelemetryModels.swift index de703bf..4455adf 100644 --- a/Sources/CodexBarCore/TelemetryModels.swift +++ b/Sources/CodexBarCore/TelemetryModels.swift @@ -64,7 +64,17 @@ public struct TelemetryConfiguration: Equatable, Sendable { } public static var defaultDirectory: URL { - FileManager.default.homeDirectoryForCurrentUser.appending(path: "Documents/CodexBar Telemetry") + defaultDirectory(home: FileManager.default.homeDirectoryForCurrentUser) + } + + /// Application Support isn't a TCC-protected folder, so macOS doesn't prompt for access the way it does for Documents. + public static func defaultDirectory(home: URL) -> URL { + home.appending(path: "Library/Application Support/CodexBar/Telemetry", directoryHint: .isDirectory) + } + + /// Where earlier versions wrote by default; only used to migrate existing files. + public static func legacyDefaultDirectory(home: URL) -> URL { + home.appending(path: "Documents/CodexBar Telemetry", directoryHint: .isDirectory) } } diff --git a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift new file mode 100644 index 0000000..fc3f88c --- /dev/null +++ b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift @@ -0,0 +1,232 @@ +import XCTest +@testable import CodexBarCore + +private final class UndeletableFileManager: FileManager, @unchecked Sendable { + override func removeItem(at URL: URL) throws { + throw CocoaError(.fileWriteNoPermission, userInfo: [NSFilePathErrorKey: URL.path]) + } +} + +private final class UnreadableFileManager: FileManager, @unchecked Sendable { + override func contentsOfDirectory( + at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions = [] + ) throws -> [URL] { + throw CocoaError(.fileReadNoPermission, userInfo: [NSFilePathErrorKey: url.path]) + } +} + +final class TelemetryFolderMigrationTests: XCTestCase { + private var home: URL! + private var legacy: URL! + private var destination: URL! + + override func setUpWithError() throws { + home = FileManager.default.temporaryDirectory.appending(path: "codexbar-home-\(UUID())", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + legacy = TelemetryConfiguration.legacyDefaultDirectory(home: home) + destination = TelemetryConfiguration.defaultDirectory(home: home) + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: home) + } + + func testMovesMatchingFilesAndRemovesEmptyLegacyFolder() throws { + try write("codexbar-metrics-2026-10-11.jsonl", "m\n", in: legacy) + try write("codexbar-logs-2026-10-11.jsonl", "l\n", in: legacy) + + let warnings = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(warnings, []) + XCTAssertEqual(try read("codexbar-metrics-2026-10-11.jsonl", in: destination), "m\n") + XCTAssertEqual(try read("codexbar-logs-2026-10-11.jsonl", in: destination), "l\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testCreatesDestinationWithPrivatePermissions() throws { + try write("codexbar-metrics-2026-10-11.jsonl", "m\n", in: legacy) + + _ = TelemetryFolderMigration(home: home).run() + + let attributes = try FileManager.default.attributesOfItem(atPath: destination.path) + XCTAssertEqual(attributes[.posixPermissions] as? Int, 0o700) + } + + func testLeavesOtherFilesAndKeepsFolderWhenNotEmpty() throws { + try write("codexbar-metrics-2026-10-11.jsonl", "m\n", in: legacy) + try write("notes.txt", "mine", in: legacy) + try write("codexbar-metrics-2026-10-11.jsonl.bak", "bak", in: legacy) + try write("codexbar-metrics-2026-13-45.jsonl", "bad date", in: legacy) + + _ = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(try read("notes.txt", in: legacy), "mine") + XCTAssertEqual(try read("codexbar-metrics-2026-10-11.jsonl.bak", in: legacy), "bak") + XCTAssertEqual(try read("codexbar-metrics-2026-13-45.jsonl", in: legacy), "bad date") + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.appending(path: "notes.txt").path)) + XCTAssertEqual(try read("codexbar-metrics-2026-10-11.jsonl", in: destination), "m\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.appending(path: "codexbar-metrics-2026-10-11.jsonl").path)) + } + + func testIgnoresDSStoreWhenDecidingIfLegacyFolderIsEmpty() throws { + try write("codexbar-logs-2026-10-11.jsonl", "l\n", in: legacy) + try write(".DS_Store", "x", in: legacy) + + _ = TelemetryFolderMigration(home: home).run() + + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testKeepsDSStoreWhenOtherFilesRemain() throws { + try write("notes.txt", "mine", in: legacy) + try write(".DS_Store", "x", in: legacy) + + _ = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(try read(".DS_Store", in: legacy), "x") + XCTAssertEqual(try read("notes.txt", in: legacy), "mine") + } + + func testSameNameFileIsAppendedNotOverwritten() throws { + let name = "codexbar-metrics-2026-10-11.jsonl" + try write(name, "new1\nnew2\n", in: destination) + try write(name, "old1\nold2\n", in: legacy) + + let warnings = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(warnings, []) + XCTAssertEqual(try read(name, in: destination), "new1\nnew2\nold1\nold2\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testMergeAppendsOnlyCompleteLines() throws { + let name = "codexbar-logs-2026-10-11.jsonl" + try write(name, "new\n", in: destination) + try write(name, "old1\nunfinished", in: legacy) + + _ = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(try read(name, in: destination), "new\nold1\n") + } + + func testMergeIntoUnterminatedDestinationLeavesLegacyFileInPlace() throws { + let name = "codexbar-logs-2026-10-11.jsonl" + try write(name, "new-without-newline", in: destination) + try write(name, "old\n", in: legacy) + + let warnings = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(warnings.count, 1) + XCTAssertEqual(try read(name, in: destination), "new-without-newline") + XCTAssertEqual(try read(name, in: legacy), "old\n") + } + + func testSymbolicLinkedLegacyRootIsNotFollowed() throws { + let elsewhere = home.appending(path: "elsewhere", directoryHint: .isDirectory) + try write("codexbar-metrics-2026-10-11.jsonl", "m\n", in: elsewhere) + try FileManager.default.createDirectory(at: legacy.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: legacy, withDestinationURL: elsewhere) + let migration = TelemetryFolderMigration(home: home) + + XCTAssertEqual(migration.run(), []) + XCTAssertFalse(migration.hasPendingFiles()) + XCTAssertEqual(try read("codexbar-metrics-2026-10-11.jsonl", in: elsewhere), "m\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) + } + + func testPendingFilesOnlyCountEligibleTelemetryFiles() throws { + let migration = TelemetryFolderMigration(home: home) + XCTAssertFalse(migration.hasPendingFiles()) + try write("notes.txt", "mine", in: legacy) + XCTAssertFalse(migration.hasPendingFiles()) + try write("codexbar-logs-2026-10-11.jsonl", "l\n", in: legacy) + XCTAssertTrue(migration.hasPendingFiles()) + } + + func testFailedDeleteAfterAppendDoesNotAppendAgainOnRetry() throws { + let name = "codexbar-metrics-2026-10-11.jsonl" + try write(name, "new\n", in: destination) + try write(name, "old\n", in: legacy) + + let failing = TelemetryFolderMigration(home: home, fileManager: UndeletableFileManager()) + XCTAssertEqual(failing.run().count, 1) + XCTAssertFalse(failing.hasPendingFiles()) + _ = TelemetryFolderMigration(home: home).run() + + XCTAssertEqual(try read(name, in: destination), "new\nold\n") + } + + func testLeftoverMergingFileIsMergedAndCleanedUp() throws { + let name = "codexbar-metrics-2026-10-11.jsonl" + try write(name, "new\n", in: destination) + try write("\(name).merging", "old\n", in: legacy) + let migration = TelemetryFolderMigration(home: home) + XCTAssertTrue(migration.hasPendingFiles()) + + XCTAssertEqual(migration.run(), []) + + XCTAssertEqual(try read(name, in: destination), "new\nold\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testLeftoverMergingFileWithoutDestinationIsMovedIntoPlace() throws { + let name = "codexbar-logs-2026-10-11.jsonl" + try write("\(name).merging", "old\n", in: legacy) + + XCTAssertEqual(TelemetryFolderMigration(home: home).run(), []) + + XCTAssertEqual(try read(name, in: destination), "old\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testLeftoverMigratedFileIsOnlyDeleted() throws { + let name = "codexbar-metrics-2026-10-11.jsonl" + try write(name, "new\n", in: destination) + try write("\(name).migrated", "old\n", in: legacy) + let migration = TelemetryFolderMigration(home: home) + XCTAssertFalse(migration.hasPendingFiles()) + + XCTAssertEqual(migration.run(), []) + + XCTAssertEqual(try read(name, in: destination), "new\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testUnreadableLegacyFolderCountsAsPendingSoItIsRetried() throws { + try write("codexbar-metrics-2026-10-11.jsonl", "m\n", in: legacy) + let migration = TelemetryFolderMigration(home: home, fileManager: UnreadableFileManager()) + + XCTAssertEqual(migration.run().count, 1) + XCTAssertTrue(migration.hasPendingFiles()) + } + + func testNoLegacyFolderIsANoOp() { + XCTAssertEqual(TelemetryFolderMigration(home: home).run(), []) + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) + } + + func testDoesNotMoveSymbolicLinks() throws { + let outside = home.appending(path: "outside.jsonl") + try Data("secret\n".utf8).write(to: outside) + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: legacy.appending(path: "codexbar-metrics-2026-10-11.jsonl"), withDestinationURL: outside + ) + + _ = TelemetryFolderMigration(home: home).run() + + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.appending(path: "codexbar-metrics-2026-10-11.jsonl").path)) + XCTAssertNotNil(try? FileManager.default.destinationOfSymbolicLink( + atPath: legacy.appending(path: "codexbar-metrics-2026-10-11.jsonl").path + )) + } + + private func write(_ name: String, _ content: String, in directory: URL) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(content.utf8).write(to: directory.appending(path: name)) + } + + private func read(_ name: String, in directory: URL) throws -> String { + try String(contentsOf: directory.appending(path: name), encoding: .utf8) + } +} diff --git a/Tests/CodexBarCoreTests/TelemetryTests.swift b/Tests/CodexBarCoreTests/TelemetryTests.swift index c34afc7..62c7050 100644 --- a/Tests/CodexBarCoreTests/TelemetryTests.swift +++ b/Tests/CodexBarCoreTests/TelemetryTests.swift @@ -176,7 +176,12 @@ final class TelemetryTests: XCTestCase { XCTAssertEqual(bounded.intervalMinutes, 1) XCTAssertEqual(bounded.retentionDays, 1) XCTAssertEqual(TelemetryConfiguration(retentionDays: 0, directory: temporaryDirectory()).retentionDays, 0) - XCTAssertTrue(TelemetryConfiguration.defaultDirectory.path.hasSuffix("Documents/CodexBar Telemetry")) + let home = URL(fileURLWithPath: "/Users/example", isDirectory: true) + XCTAssertEqual(TelemetryConfiguration.defaultDirectory(home: home).path, + "/Users/example/Library/Application Support/CodexBar/Telemetry") + XCTAssertEqual(TelemetryConfiguration.legacyDefaultDirectory(home: home).path, + "/Users/example/Documents/CodexBar Telemetry") + XCTAssertTrue(TelemetryConfiguration.defaultDirectory.path.hasSuffix("Library/Application Support/CodexBar/Telemetry")) } func testUnlimitedRetentionKeepsOldMetricsAndLogsUntilLimitIsRestored() async throws { diff --git a/Tests/CodexBarTests/AppSettingsTests.swift b/Tests/CodexBarTests/AppSettingsTests.swift index 1f57d7d..596c23b 100644 --- a/Tests/CodexBarTests/AppSettingsTests.swift +++ b/Tests/CodexBarTests/AppSettingsTests.swift @@ -2,8 +2,38 @@ import Foundation import Observation import Synchronization import XCTest +import CodexBarCore @testable import CodexBar +private struct TelemetryFolderFixture { + let suite = "com.gordonbeeming.CodexBar.Tests.\(UUID())" + let defaults: UserDefaults + let home: URL + + init() throws { + defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + home = FileManager.default.temporaryDirectory.appending(path: "codexbar-home-\(UUID())", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + } + + var legacy: URL { TelemetryConfiguration.legacyDefaultDirectory(home: home) } + var newDefault: URL { TelemetryConfiguration.defaultDirectory(home: home) } + + func writeLegacy(_ name: String, _ content: String) throws { + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + try Data(content.utf8).write(to: legacy.appending(path: name)) + } + + func read(_ directory: URL, _ name: String) throws -> String { + try String(contentsOf: directory.appending(path: name), encoding: .utf8) + } + + func cleanUp() { + defaults.removePersistentDomain(forName: suite) + try? FileManager.default.removeItem(at: home) + } +} + final class AppSettingsTests: XCTestCase { @MainActor func testUnlimitedRetentionPersistsAcrossReload() async throws { @@ -20,6 +50,99 @@ final class AppSettingsTests: XCTestCase { XCTAssertEqual(AppSettings(defaults: defaults).telemetryRetentionDays, 45) } + @MainActor + func testUnsetTelemetryDirectoryUsesApplicationSupportAndMigratesLegacyFiles() throws { + let fixture = try TelemetryFolderFixture() + defer { fixture.cleanUp() } + try fixture.writeLegacy("codexbar-metrics-2026-10-11.jsonl", "m\n") + + let settings = AppSettings(defaults: fixture.defaults, home: fixture.home) + settings.migrateLegacyTelemetryFolder() + + XCTAssertEqual(settings.telemetryDirectory, fixture.newDefault.path) + XCTAssertEqual(try fixture.read(fixture.newDefault, "codexbar-metrics-2026-10-11.jsonl"), "m\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.legacy.path)) + } + + @MainActor + func testStoredLegacyDefaultIsClearedAndMigrated() throws { + let fixture = try TelemetryFolderFixture() + defer { fixture.cleanUp() } + fixture.defaults.set(fixture.legacy.path, forKey: "telemetryDirectory") + try fixture.writeLegacy("codexbar-logs-2026-10-11.jsonl", "l\n") + + let settings = AppSettings(defaults: fixture.defaults, home: fixture.home) + settings.migrateLegacyTelemetryFolder() + + XCTAssertEqual(settings.telemetryDirectory, fixture.newDefault.path) + XCTAssertNil(fixture.defaults.object(forKey: "telemetryDirectory")) + XCTAssertEqual(try fixture.read(fixture.newDefault, "codexbar-logs-2026-10-11.jsonl"), "l\n") + // A second launch finds nothing stored and nothing to move. + let reloaded = AppSettings(defaults: fixture.defaults, home: fixture.home) + reloaded.migrateLegacyTelemetryFolder() + XCTAssertEqual(reloaded.telemetryDirectory, fixture.newDefault.path) + } + + @MainActor + func testMigrationStopsProbingOnceNoTelemetryFilesRemainEvenIfFolderStays() throws { + let fixture = try TelemetryFolderFixture() + defer { fixture.cleanUp() } + try fixture.writeLegacy("codexbar-metrics-2026-10-11.jsonl", "m\n") + try fixture.writeLegacy("notes.txt", "mine") + + AppSettings(defaults: fixture.defaults, home: fixture.home).migrateLegacyTelemetryFolder() + XCTAssertTrue(fixture.defaults.bool(forKey: "telemetryFolderMigrated")) + + try fixture.writeLegacy("codexbar-logs-2026-10-12.jsonl", "l\n") + AppSettings(defaults: fixture.defaults, home: fixture.home).migrateLegacyTelemetryFolder() + XCTAssertEqual(try fixture.read(fixture.legacy, "codexbar-logs-2026-10-12.jsonl"), "l\n") + } + + @MainActor + func testMigrationKeepsRetryingWhileAnInterruptedMergeRemains() throws { + let fixture = try TelemetryFolderFixture() + defer { fixture.cleanUp() } + let name = "codexbar-metrics-2026-10-11.jsonl" + try FileManager.default.createDirectory(at: fixture.newDefault, withIntermediateDirectories: true) + try Data("unfinished".utf8).write(to: fixture.newDefault.appending(path: name)) + try fixture.writeLegacy("\(name).merging", "old\n") + + AppSettings(defaults: fixture.defaults, home: fixture.home).migrateLegacyTelemetryFolder() + + XCTAssertFalse(fixture.defaults.bool(forKey: "telemetryFolderMigrated")) + XCTAssertEqual(try fixture.read(fixture.legacy, "\(name).merging"), "old\n") + } + + @MainActor + func testMigrationKeepsRetryingWhileAnEligibleFileRemains() throws { + let fixture = try TelemetryFolderFixture() + defer { fixture.cleanUp() } + let name = "codexbar-metrics-2026-10-11.jsonl" + try FileManager.default.createDirectory(at: fixture.newDefault, withIntermediateDirectories: true) + try Data("unfinished".utf8).write(to: fixture.newDefault.appending(path: name)) + try fixture.writeLegacy(name, "old\n") + + AppSettings(defaults: fixture.defaults, home: fixture.home).migrateLegacyTelemetryFolder() + + XCTAssertFalse(fixture.defaults.bool(forKey: "telemetryFolderMigrated")) + } + + @MainActor + func testCustomTelemetryDirectoryMeansNoMigration() throws { + let fixture = try TelemetryFolderFixture() + defer { fixture.cleanUp() } + let custom = fixture.home.appending(path: "Elsewhere", directoryHint: .isDirectory) + fixture.defaults.set(custom.path, forKey: "telemetryDirectory") + try fixture.writeLegacy("codexbar-metrics-2026-10-11.jsonl", "m\n") + + let settings = AppSettings(defaults: fixture.defaults, home: fixture.home) + settings.migrateLegacyTelemetryFolder() + + XCTAssertEqual(settings.telemetryDirectory, custom.path) + XCTAssertEqual(try fixture.read(fixture.legacy, "codexbar-metrics-2026-10-11.jsonl"), "m\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.newDefault.path)) + } + @MainActor func testNumericUpdatesNotifyObserversAndPersist() async throws { let suite = "com.gordonbeeming.CodexBar.Tests.\(UUID())" diff --git a/docs/telemetry.md b/docs/telemetry.md index 1b18707..88ef359 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -4,7 +4,7 @@ CodexBar's telemetry follows the [Bar Telemetry Spec v1](https://github.com/Gord ## Turning it on -Enable **Record telemetry to disk** in Settings. Files go to `~/Documents/CodexBar Telemetry` unless you choose another folder. CodexBar captures when recording starts and then every five minutes by default (1 to 1,440). Retention is 90 days by default (1 to 3,650, or unlimited). Reset-credit changes are recorded as soon as a usage poll sees them. Capture runs in the background from launch, including launch at login. It doesn't run during sleep or while CodexBar is closed, and missed captures aren't backfilled. +Enable **Record telemetry to disk** in Settings. Files go to `~/Library/Application Support/CodexBar/Telemetry` unless you choose another folder. Recordings from earlier versions that used `~/Documents/CodexBar Telemetry` move over on first launch. CodexBar captures when recording starts and then every five minutes by default (1 to 1,440). Retention is 90 days by default (1 to 3,650, or unlimited). Reset-credit changes are recorded as soon as a usage poll sees them. Capture runs in the background from launch, including launch at login. It doesn't run during sleep or while CodexBar is closed, and missed captures aren't backfilled. In Settings, **Apply telemetry settings** saves the interval, retention and folder.