import Foundation /// Append-only audit log for user-visible mutations. Entries intentionally do /// not contain prompt titles, contents, tags, complete source identifiers, or /// selected file paths. public final class ActivityLogger: @unchecked Sendable { public let logURL: URL private let lock = NSLock() public init(logURL: URL) throws { self.logURL = logURL let fileManager = FileManager.default try fileManager.createDirectory( at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true ) if !fileManager.fileExists(atPath: logURL.path) { guard fileManager.createFile(atPath: logURL.path, contents: nil) else { throw ActivityLoggerError.couldNotCreateLog(logURL) } } // The log is local operational metadata and should not be readable by // other accounts on the Mac. try fileManager.setAttributes( [.posixPermissions: 0o600], ofItemAtPath: logURL.path ) } public func logImport( statistics: ImportStatistics, sourceFamilies: [String] ) throws { try append(ActivityLogEntry( event: .importCompleted, promptID: nil, sourceFamily: nil, sourceFamilies: Array(Set(sourceFamilies)).sorted(), statistics: statistics )) } public func logImport(_ report: ImportReport) throws { // `sourceName` is deliberately omitted: even a last path component can // disclose personal project or export names. try append(ActivityLogEntry( event: .importCompleted, promptID: nil, sourceFamily: report.provider, sourceFamilies: [], statistics: report.statistics )) } public func logDeletion(promptID: Int64, sourceFamily: String?) throws { try append(ActivityLogEntry( event: .promptDeleted, promptID: promptID, sourceFamily: sourceFamily, sourceFamilies: [], statistics: nil )) } private func append(_ entry: ActivityLogEntry) throws { lock.lock() defer { lock.unlock() } let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] var line = try encoder.encode(entry) line.append(0x0A) let handle = try FileHandle(forWritingTo: logURL) defer { try? handle.close() } try handle.seekToEnd() try handle.write(contentsOf: line) try handle.synchronize() } } public enum ActivityLoggerError: LocalizedError, Sendable { case couldNotCreateLog(URL) public var errorDescription: String? { switch self { case let .couldNotCreateLog(url): "Das lokale Aktivitätsprotokoll konnte nicht angelegt werden: \(url.path)" } } } private struct ActivityLogEntry: Encodable { enum Event: String, Encodable { case importCompleted = "import_completed" case promptDeleted = "prompt_deleted" } let schemaVersion = 1 let timestamp = Date() let event: Event let promptID: Int64? let sourceFamily: String? let sourceFamilies: [String] let statistics: ImportStatistics? }