Files

99 lines
4.1 KiB
Swift

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<redacted>")
}
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: ", ")
}
}