From 593358541de6257ab1f35f665d6f8855aa1cbdfa Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Tue, 13 Oct 2026 11:46:48 +1000 Subject: [PATCH 1/4] Move the default Telemetry Folder out of Documents macOS asks for permission to use Documents, so the default is now ~/Library/Application Support/CodexBar/Telemetry, as Telemetry Spec v1 now says. On launch, CodexBar moves its own Telemetry files out of the old Documents folder when no other folder was chosen, merges same-name files by appending complete lines, and removes the old folder only if it ends up empty. --- README.md | 2 +- Sources/CodexBar/AppSettings.swift | 30 +++- Sources/CodexBar/CodexBarApp.swift | 4 +- .../TelemetryFolderMigration.swift | 106 +++++++++++++ Sources/CodexBarCore/TelemetryModels.swift | 12 +- .../TelemetryFolderMigrationTests.swift | 139 ++++++++++++++++++ Tests/CodexBarCoreTests/TelemetryTests.swift | 7 +- Tests/CodexBarTests/AppSettingsTests.swift | 79 ++++++++++ docs/telemetry.md | 2 +- 9 files changed, 370 insertions(+), 11 deletions(-) create mode 100644 Sources/CodexBarCore/TelemetryFolderMigration.swift create mode 100644 Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift 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..07c51ba 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 @@ -61,6 +62,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 +175,24 @@ 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() { + guard usesDefaultTelemetryDirectory else { return } + for warning in TelemetryFolderMigration(home: home).run() { + logger.error("Telemetry folder migration: \(warning, privacy: .public)") + } + } + + 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 +203,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 +215,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..3fa94b4 --- /dev/null +++ b/Sources/CodexBarCore/TelemetryFolderMigration.swift @@ -0,0 +1,106 @@ +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] { + let legacy = TelemetryConfiguration.legacyDefaultDirectory(home: home) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: legacy.path, isDirectory: &isDirectory), isDirectory.boolValue 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 where Self.isTelemetryFile(entry) { + do { + let values = try entry.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { continue } + try fileManager.createDirectory(at: destination, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let target = destination.appending(path: entry.lastPathComponent) + 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 + } + + /// 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. + 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" + ]) + } + } + try handle.seekToEnd() + try handle.write(contentsOf: incoming[incoming.startIndex...lastNewline]) + try handle.synchronize() + try fileManager.removeItem(at: source) + } + + 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)"] } + } + + 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..e4ac42f --- /dev/null +++ b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import CodexBarCore + +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 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..cf077aa 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,55 @@ 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 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. From 58aeddcc7d5c569b52775e39b6f2e896f97429e3 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Tue, 13 Oct 2026 11:55:17 +1000 Subject: [PATCH 2/4] Stop checking Documents once moved, and make the move safe to retry Once none of CodexBar's Telemetry files are left in the old folder, a flag records the move as done and later launches no longer look in Documents. A symlinked old folder isn't followed, and a source file is renamed out of the matching pattern before its lines are appended, so a failed delete can't make a retry append them twice. --- Sources/CodexBar/AppSettings.swift | 9 +++- .../TelemetryFolderMigration.swift | 44 ++++++++++++++++--- .../TelemetryFolderMigrationTests.swift | 41 +++++++++++++++++ Tests/CodexBarTests/AppSettingsTests.swift | 29 ++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/AppSettings.swift b/Sources/CodexBar/AppSettings.swift index 07c51ba..23b7758 100644 --- a/Sources/CodexBar/AppSettings.swift +++ b/Sources/CodexBar/AppSettings.swift @@ -51,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" @@ -177,10 +178,14 @@ final class AppSettings { /// Moves files out of the old Documents default. Call once at launch, before telemetry starts writing. func migrateLegacyTelemetryFolder() { - guard usesDefaultTelemetryDirectory else { return } - for warning in TelemetryFolderMigration(home: home).run() { + // 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) { diff --git a/Sources/CodexBarCore/TelemetryFolderMigration.swift b/Sources/CodexBarCore/TelemetryFolderMigration.swift index 3fa94b4..1fa1824 100644 --- a/Sources/CodexBarCore/TelemetryFolderMigration.swift +++ b/Sources/CodexBarCore/TelemetryFolderMigration.swift @@ -12,9 +12,7 @@ public struct TelemetryFolderMigration { /// Returns a message per failure. A failure never throws: capture must still write to the new default. public func run() -> [String] { - let legacy = TelemetryConfiguration.legacyDefaultDirectory(home: home) - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: legacy.path, isDirectory: &isDirectory), isDirectory.boolValue else { return [] } + guard let legacy = legacyFolder() else { return [] } let entries: [URL] do { @@ -45,8 +43,31 @@ public struct TelemetryFolderMigration { return warnings } + /// True while a file the migration would still move is in the old folder, which means a previous attempt failed. + public func hasPendingFiles() -> Bool { + guard let legacy = legacyFolder(), + let entries = try? fileManager.contentsOfDirectory( + at: legacy, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [] + ) else { return false } + return entries.contains { entry in + guard Self.isTelemetryFile(entry), + 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 is renamed to a name the migration doesn't match before appending, so a delete that fails afterwards + /// can't cause the same lines to be appended again on the next launch. 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 { @@ -70,10 +91,19 @@ public struct TelemetryFolderMigration { ]) } } - try handle.seekToEnd() - try handle.write(contentsOf: incoming[incoming.startIndex...lastNewline]) - try handle.synchronize() - try fileManager.removeItem(at: source) + let claimed = source.appendingPathExtension("migrated") + try fileManager.moveItem(at: source, to: claimed) + do { + try handle.seekToEnd() + try handle.write(contentsOf: incoming[incoming.startIndex...lastNewline]) + try handle.synchronize() + } catch { + // Undo any partial append and hand the file back for the next launch. + try? handle.truncate(atOffset: size) + try? fileManager.moveItem(at: claimed, to: source) + throw error + } + try fileManager.removeItem(at: claimed) } private func removeLegacyFolderIfEmpty(_ legacy: URL) -> [String] { diff --git a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift index e4ac42f..8a909b9 100644 --- a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift +++ b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift @@ -1,6 +1,12 @@ 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]) + } +} + final class TelemetryFolderMigrationTests: XCTestCase { private var home: URL! private var legacy: URL! @@ -107,6 +113,41 @@ final class TelemetryFolderMigrationTests: XCTestCase { 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 testNoLegacyFolderIsANoOp() { XCTAssertEqual(TelemetryFolderMigration(home: home).run(), []) XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) diff --git a/Tests/CodexBarTests/AppSettingsTests.swift b/Tests/CodexBarTests/AppSettingsTests.swift index cf077aa..3421ac2 100644 --- a/Tests/CodexBarTests/AppSettingsTests.swift +++ b/Tests/CodexBarTests/AppSettingsTests.swift @@ -83,6 +83,35 @@ final class AppSettingsTests: XCTestCase { 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 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() From 05040f34d30b427338f85ace9922da3164527c2d Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Tue, 13 Oct 2026 12:02:27 +1000 Subject: [PATCH 3/4] Retry the move when the old folder can't be read An enumeration error, such as Documents access being denied, was read as nothing left to move, so the move was recorded as done and the files were never moved. It now counts as pending; a missing or symlinked old folder still counts as done. --- .../CodexBarCore/TelemetryFolderMigration.swift | 12 +++++++++--- .../TelemetryFolderMigrationTests.swift | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/TelemetryFolderMigration.swift b/Sources/CodexBarCore/TelemetryFolderMigration.swift index 1fa1824..be7d05c 100644 --- a/Sources/CodexBarCore/TelemetryFolderMigration.swift +++ b/Sources/CodexBarCore/TelemetryFolderMigration.swift @@ -45,10 +45,16 @@ public struct TelemetryFolderMigration { /// True while a file the migration would still move is in the old folder, which means a previous attempt failed. public func hasPendingFiles() -> Bool { - guard let legacy = legacyFolder(), - let entries = try? fileManager.contentsOfDirectory( + guard let legacy = legacyFolder() else { return false } + let entries: [URL] + do { + entries = try fileManager.contentsOfDirectory( at: legacy, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [] - ) else { return false } + ) + } 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 Self.isTelemetryFile(entry), let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { return false } diff --git a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift index 8a909b9..a841b7b 100644 --- a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift +++ b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift @@ -7,6 +7,14 @@ private final class UndeletableFileManager: FileManager, @unchecked Sendable { } } +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! @@ -148,6 +156,14 @@ final class TelemetryFolderMigrationTests: XCTestCase { XCTAssertEqual(try read(name, in: destination), "new\nold\n") } + 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)) From 79c498ded19a01d8bfaf92cb164b37bb03356989 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Tue, 13 Oct 2026 12:06:36 +1000 Subject: [PATCH 4/4] Recover a merge that was interrupted A crash after the source was renamed but before its lines were appended left the only copy under a name nothing looked at again. The source is now renamed to .merging while it is appended and to .migrated once done; a leftover .merging file is merged again on the next launch and counts as pending, and a leftover .migrated file is only deleted. --- .../TelemetryFolderMigration.swift | 50 +++++++++++++++---- .../TelemetryFolderMigrationTests.swift | 36 +++++++++++++ Tests/CodexBarTests/AppSettingsTests.swift | 15 ++++++ 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/TelemetryFolderMigration.swift b/Sources/CodexBarCore/TelemetryFolderMigration.swift index be7d05c..b5cca44 100644 --- a/Sources/CodexBarCore/TelemetryFolderMigration.swift +++ b/Sources/CodexBarCore/TelemetryFolderMigration.swift @@ -23,13 +23,18 @@ public struct TelemetryFolderMigration { let destination = TelemetryConfiguration.defaultDirectory(home: home) var warnings: [String] = [] - for entry in entries where Self.isTelemetryFile(entry) { + 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: entry.lastPathComponent) + let target = destination.appending(path: Self.originalName(of: entry)) if fileManager.fileExists(atPath: target.path) { try merge(entry, into: target) } else { @@ -43,7 +48,8 @@ public struct TelemetryFolderMigration { return warnings } - /// True while a file the migration would still move is in the old folder, which means a previous attempt failed. + /// 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] @@ -56,7 +62,7 @@ public struct TelemetryFolderMigration { return true } return entries.contains { entry in - guard Self.isTelemetryFile(entry), + 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 } @@ -72,8 +78,9 @@ public struct TelemetryFolderMigration { /// 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 is renamed to a name the migration doesn't match before appending, so a delete that fails afterwards - /// can't cause the same lines to be appended again on the next launch. + /// 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 { @@ -97,19 +104,21 @@ public struct TelemetryFolderMigration { ]) } } - let claimed = source.appendingPathExtension("migrated") - try fileManager.moveItem(at: source, to: claimed) + 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 and hand the file back for the next launch. + // Undo any partial append; the file keeps a name the next run picks up again. try? handle.truncate(atOffset: size) - try? fileManager.moveItem(at: claimed, to: source) throw error } - try fileManager.removeItem(at: claimed) + 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] { @@ -122,6 +131,25 @@ public struct TelemetryFolderMigration { } 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 } diff --git a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift index a841b7b..fc3f88c 100644 --- a/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift +++ b/Tests/CodexBarCoreTests/TelemetryFolderMigrationTests.swift @@ -156,6 +156,42 @@ final class TelemetryFolderMigrationTests: XCTestCase { 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()) diff --git a/Tests/CodexBarTests/AppSettingsTests.swift b/Tests/CodexBarTests/AppSettingsTests.swift index 3421ac2..596c23b 100644 --- a/Tests/CodexBarTests/AppSettingsTests.swift +++ b/Tests/CodexBarTests/AppSettingsTests.swift @@ -98,6 +98,21 @@ final class AppSettingsTests: XCTestCase { 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()