1// CrashLogManager.swift2// SophaxChatCore3//4// On-device, privacy-respecting error log.5// Never logs message content or peer identifiers — only error types and context strings.6// Users can view and voluntarily export the log from Settings when filing a bug report.78import Foundation910public final class CrashLogManager:@unchecked Sendable {1112 public static let shared = CrashLogManager()1314 private let logFile: URL15 private let queue = DispatchQueue(label:"com.sophax.crashlog", qos:.utility)1617 private static let maxLines = 20018 private static let maxFileSizeB = 512_000 // 512 KB safety cap1920 private static let iso8601: ISO8601DateFormatter ={21 let f = ISO8601DateFormatter()22 f.formatOptions =[.withInternetDateTime]23 return f24}()2526// MARK: - Init2728 private init(){29 let appSupport = FileManager.default.urls(for:.applicationSupportDirectory, in:.userDomainMask)[0]30 let dir = appSupport.appendingPathComponent("sophax_crash_logs", isDirectory: true)31 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)32 logFile = dir.appendingPathComponent("crash_log.txt")33}3435// MARK: - Public API3637/// Log an error with a context label (e.g. "X3DH", "BackupRestore").38/// Only the error *type* is recorded — message content is never logged.39 public func log(_ error: Error, context: String){40 let typeName = String(reflecting: type(of: error))41 let description = error.localizedDescription42// Strip anything that looks like a peer ID (hex strings ≥ 16 chars)43 let sanitized = sanitize(description)44 append("[ERROR] [\(context)] \(typeName): \(sanitized)")45}4647/// Log a plain diagnostic string (must never contain message content or peer IDs).48 public func log(_ message: String, context: String){49 append("[\(context)] \(message)")50}5152/// Returns all log entries as a single string for display or export.53 public func exportableText()-> String {54 queue.sync {55(try? String(contentsOf: logFile, encoding:.utf8))??"(no log entries)"56}57}5859/// Delete all log entries.60 public func clear(){61 queue.async {[logFile] in62 try? FileManager.default.removeItem(at: logFile)63}64}6566// MARK: - Private6768 private func append(_ body: String){69 let timestamp = Self.iso8601.string(from: Date())70 let line ="[\(timestamp)] \(body)\n"71 queue.async {[weak self] in72 self?.write(line)73}74}7576 private func write(_ line: String){77// Append to file78 if let data = line.data(using:.utf8){79 if FileManager.default.fileExists(atPath: logFile.path){80 if let handle = try? FileHandle(forWritingTo: logFile){81 handle.seekToEndOfFile()82 handle.write(data)83 try? handle.close()84}85} else {86 try? data.write(to: logFile, options:.atomic)87}88}89 rotate()90}9192/// Keep the file under maxLines and maxFileSizeB.93 private func rotate(){94 guard let text = try? String(contentsOf: logFile, encoding:.utf8) else { return }95 var lines = text.components(separatedBy:"\n")96// Remove empty trailing element from split97 if lines.last ==""{ lines.removeLast()}9899 let oversizedFile =(text.utf8.count > Self.maxFileSizeB)100 let tooManyLines =(lines.count > Self.maxLines)101102 guard oversizedFile || tooManyLines else { return }103104// Drop oldest half105 let keep = lines.suffix(Self.maxLines / 2)106 let trimmed = keep.joined(separator:"\n")+"\n"107 try? trimmed.write(to: logFile, atomically: true, encoding:.utf8)108}109110/// Remove long hex-looking substrings that could be peer IDs.111 private func sanitize(_ input: String)-> String {112// Replace hex sequences of 16+ chars with <redacted>113 let pattern ="[0-9a-fA-F]{16,}"114 return (try? NSRegularExpression(pattern: pattern))115.map { $0.stringByReplacingMatches(in: input, range: NSRange(input.startIndex..., in: input), withTemplate:"<redacted>")}116?? input117}118}