99 lines
4.1 KiB
Swift
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: ", ")
|
|
}
|
|
}
|