import AVFoundation import Foundation import SwiftUI import UIKit /// App log kept in a size-capped file so a tester can send it. Tokens are stripped before anything is written. final class Log: @unchecked Sendable { static let shared = Log() enum Level: String { case debug = "D", info = "I", error = "E" } static let verboseKey = "verboseLogging" static var verbose: Bool { get { UserDefaults.standard.bool(forKey: verboseKey) } set { UserDefaults.standard.set(newValue, forKey: verboseKey) } } private let lock = NSLock() private let url: URL private let maxBytes = 512 * 1024 private let stamp: DateFormatter = { let f = DateFormatter() f.dateFormat = "MM-dd HH:mm:ss.SSS" return f }() private init() { let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] url = dir.appendingPathComponent("stemify.log") } private static let secret = try! NSRegularExpression( pattern: #"(X-Plex-Token|X-Stem-Token|token|Authorization)([=:"' ]+)[^&\s"',}]+"#, options: [.caseInsensitive]) static func redact(_ s: String) -> String { secret.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1$2") } func write(_ level: Level, _ tag: String, _ msg: String) { if level == .debug && !Log.verbose { return } let line = "\(stamp.string(from: Date())) \(level.rawValue) [\(tag)] \(Log.redact(msg))\n" lock.lock(); defer { lock.unlock() } guard let data = line.data(using: .utf8) else { return } let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 if size > maxBytes { trim() } if FileManager.default.fileExists(atPath: url.path) { append(data) } else { try? data.write(to: url) } } /// Keeps the newest half of the file. private func trim() { guard let all = try? Data(contentsOf: url) else { return } let tail = all.suffix(maxBytes / 2) let cut = tail.firstIndex(of: 0x0A).map { tail.index(after: $0) } ?? tail.startIndex try? Data(tail[cut...]).write(to: url) } private func append(_ data: Data) { guard let h = try? FileHandle(forWritingTo: url) else { return } defer { try? h.close() } _ = try? h.seekToEnd() try? h.write(contentsOf: data) } func text() -> String { lock.lock(); defer { lock.unlock() } return (try? String(contentsOf: url, encoding: .utf8)) ?? "" } func clear() { lock.lock(); defer { lock.unlock() } try? FileManager.default.removeItem(at: url) } /// Device and app details followed by the log, written to a temp file for the share sheet. func export() -> URL { let b = Bundle.main let d = UIDevice.current var head = "Stemify \((b.object(forInfoDictionaryKey: "StemifyVersionLabel") ?? b.object(forInfoDictionaryKey: "CFBundleShortVersionString")) as? String ?? "?") " head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n" head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n" head += "Verbose logging: \(Log.verbose ? "on" : "off")\n" head += "Output: \(AirRoute.describe())\n\n" let out = FileManager.default.temporaryDirectory.appendingPathComponent("stemify-log.txt") try? (head + text()).write(to: out, atomically: true, encoding: .utf8) return out } func debug(_ tag: String, _ msg: @autoclosure () -> String) { if Log.verbose { write(.debug, tag, msg()) } } func info(_ tag: String, _ msg: String) { write(.info, tag, msg) } func error(_ tag: String, _ msg: String) { write(.error, tag, msg) } } /// Where audio is currently going (speaker, AirPlay, Bluetooth…). enum AirRoute { static func describe() -> String { let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { "\($0.portName) (\($0.portType.rawValue))" } return outs.isEmpty ? "none" : outs.joined(separator: ", ") } }