Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 29 additions & 6 deletions Sources/CodexBar/AppSettings.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CodexBarCore
import Foundation
import Observation
import OSLog

enum MenuBarPercentageSelection: Hashable {
case highest
Expand Down Expand Up @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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)
)
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBar/CodexBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
170 changes: 170 additions & 0 deletions Sources/CodexBarCore/TelemetryFolderMigration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 11 additions & 1 deletion Sources/CodexBarCore/TelemetryModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading
Loading