• Group toggles — iPhone / Watch / Companion / Audio, all persistent via @AppStorage so your filter state survives app restarts • Level toggles — ERROR / WARN / INFO / DEBUG in a single scrollable row next to the group toggles, color coded red/yellow/cyan/gray • Delta timestamps — each row shows +42ms between it and the previous entry, so you can see timing without mental math • Pause/Resume — bottom bar button snapshots the current log so you can read it without it scrolling, while still capturing in background • Auto background/foreground markers — ── Background ── / ── Foreground ── lines are auto-inserted by NotificationCenter, making crash correlation much easier • PiP button in toolbar sets logger.isPiP = true, which MainTabView’s .onChange picks up and activates the existing floating PiP window MainTabView.swift — minimal changes: • Added .onChange(of: debugLogger.isPiP) to sync the console’s PiP button to the existing debugPipMode state • Updated debugLogRow to show level dot + marker support, consistent with the full console
409 lines
17 KiB
Swift
409 lines
17 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
// MARK: - Log Level
|
|
enum LogLevel: String, CaseIterable {
|
|
case error = "ERROR"
|
|
case warning = "WARN"
|
|
case info = "INFO"
|
|
case debug = "DEBUG"
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .error: return .red
|
|
case .warning: return .yellow
|
|
case .info: return .cyan
|
|
case .debug: return .gray
|
|
}
|
|
}
|
|
var icon: String {
|
|
switch self {
|
|
case .error: return "exclamationmark.circle.fill"
|
|
case .warning: return "exclamationmark.triangle.fill"
|
|
case .info: return "info.circle.fill"
|
|
case .debug: return "ant.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Debug Logger
|
|
class DebugLogger: ObservableObject {
|
|
static let shared = DebugLogger()
|
|
|
|
struct LogEntry: Identifiable {
|
|
let id = UUID()
|
|
let timestamp: Date
|
|
let category: String
|
|
let message: String
|
|
let level: LogLevel
|
|
var isMarker: Bool = false // background/foreground separator
|
|
|
|
var formatted: String {
|
|
let df = DateFormatter(); df.dateFormat = "HH:mm:ss.SSS"
|
|
return "[\(df.string(from: timestamp))] [\(level.rawValue)] [\(category)] \(message)"
|
|
}
|
|
}
|
|
|
|
@Published var entries: [LogEntry] = []
|
|
@Published var isPiP: Bool = false
|
|
@Published var isEnabled: Bool {
|
|
didSet { UserDefaults.standard.set(isEnabled, forKey: "debug_console_enabled") }
|
|
}
|
|
|
|
private let maxEntries = 500
|
|
|
|
private init() {
|
|
isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled")
|
|
// Auto-insert app state markers
|
|
NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification,
|
|
object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Background ──") }
|
|
NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification,
|
|
object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Foreground ──") }
|
|
}
|
|
|
|
func log(_ message: String, category: String = "General", level: LogLevel = .debug) {
|
|
guard isEnabled else { return }
|
|
let entry = LogEntry(timestamp: Date(), category: category, message: message, level: level)
|
|
DispatchQueue.main.async {
|
|
self.entries.append(entry)
|
|
if self.entries.count > self.maxEntries {
|
|
self.entries.removeFirst(self.entries.count - self.maxEntries)
|
|
}
|
|
}
|
|
print("[\(level.rawValue)][\(category)] \(message)")
|
|
}
|
|
|
|
// Convenience level shortcuts
|
|
func error(_ message: String, category: String = "General") {
|
|
log(message, category: category, level: .error)
|
|
}
|
|
func warn(_ message: String, category: String = "General") {
|
|
log(message, category: category, level: .warning)
|
|
}
|
|
func info(_ message: String, category: String = "General") {
|
|
log(message, category: category, level: .info)
|
|
}
|
|
|
|
private func insertMarker(_ text: String) {
|
|
guard isEnabled else { return }
|
|
var e = LogEntry(timestamp: Date(), category: "System", message: text, level: .info)
|
|
e.isMarker = true
|
|
entries.append(e)
|
|
}
|
|
|
|
func clear() { entries.removeAll() }
|
|
}
|
|
|
|
// MARK: - Debug Console View
|
|
struct DebugConsoleView: View {
|
|
@ObservedObject private var logger = DebugLogger.shared
|
|
@State private var filterText = ""
|
|
@State private var selectedCategory: String? = nil
|
|
@State private var autoScroll = true
|
|
@State private var paused = false
|
|
|
|
// Group toggles — persisted
|
|
@AppStorage("dbg_show_iphone") private var showIPhone = true
|
|
@AppStorage("dbg_show_watch") private var showWatch = true
|
|
@AppStorage("dbg_show_companion") private var showCompanion = true
|
|
@AppStorage("dbg_show_audio") private var showAudio = true
|
|
|
|
// Level toggles
|
|
@State private var showError = true
|
|
@State private var showWarning = true
|
|
@State private var showInfo = true
|
|
@State private var showDebug = true
|
|
|
|
// Snapshot for pause mode
|
|
@State private var pausedSnapshot: [DebugLogger.LogEntry] = []
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
private let watchCategories: Set<String> = ["Watch", "WC", "WatchSync"]
|
|
private let companionCategories: Set<String> = ["Companion", "SmartDJ", "Crossfade", "Sync"]
|
|
private let audioCategories: Set<String> = ["Audio", "FFT", "Radio", "Prefetch"]
|
|
|
|
private var activeEntries: [DebugLogger.LogEntry] {
|
|
paused ? pausedSnapshot : logger.entries
|
|
}
|
|
|
|
private var categories: [String] {
|
|
Array(Set(activeEntries.map { $0.category })).sorted()
|
|
}
|
|
|
|
private var filteredEntries: [DebugLogger.LogEntry] {
|
|
activeEntries.filter { entry in
|
|
// Group filter
|
|
let isWatch = watchCategories.contains(entry.category)
|
|
let isCompanion = companionCategories.contains(entry.category)
|
|
let isAudio = audioCategories.contains(entry.category)
|
|
let isIPhone = !isWatch && !isCompanion && !isAudio
|
|
guard (isIPhone && showIPhone) || (isWatch && showWatch) ||
|
|
(isCompanion && showCompanion) || (isAudio && showAudio) || entry.isMarker
|
|
else { return false }
|
|
|
|
// Level filter
|
|
let level = entry.level
|
|
guard (level == .error && showError) || (level == .warning && showWarning) ||
|
|
(level == .info && showInfo) || (level == .debug && showDebug) || entry.isMarker
|
|
else { return false }
|
|
|
|
// Category pill
|
|
if let cat = selectedCategory, entry.category != cat { return false }
|
|
|
|
// Text search
|
|
if !filterText.isEmpty {
|
|
return entry.message.localizedCaseInsensitiveContains(filterText) ||
|
|
entry.category.localizedCaseInsensitiveContains(filterText)
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// ── Search bar ───────────────────────────────────────────────
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 13))
|
|
TextField("Filter logs...", text: $filterText)
|
|
.font(.system(size: 13)).foregroundColor(.white)
|
|
.keyboardDoneButton()
|
|
if !filterText.isEmpty {
|
|
Button(action: { filterText = "" }) {
|
|
Image(systemName: "xmark.circle.fill").foregroundColor(.gray).font(.system(size: 13))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
|
.background(Color.white.opacity(0.08)).cornerRadius(8)
|
|
.padding(.horizontal, 12).padding(.top, 8)
|
|
|
|
// ── Group toggles ─────────────────────────────────────────────
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
groupToggle("iPhone", icon: "iphone", isOn: $showIPhone)
|
|
groupToggle("Watch", icon: "applewatch", isOn: $showWatch)
|
|
groupToggle("Companion", icon: "server.rack", isOn: $showCompanion)
|
|
groupToggle("Audio", icon: "waveform", isOn: $showAudio)
|
|
Divider().frame(height: 16).background(Color.white.opacity(0.2))
|
|
levelToggle(.error, isOn: $showError)
|
|
levelToggle(.warning, isOn: $showWarning)
|
|
levelToggle(.info, isOn: $showInfo)
|
|
levelToggle(.debug, isOn: $showDebug)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.padding(.vertical, 4)
|
|
|
|
// ── Category pills ────────────────────────────────────────────
|
|
if !categories.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
categoryPill("All", isSelected: selectedCategory == nil) { selectedCategory = nil }
|
|
ForEach(categories, id: \.self) { cat in
|
|
categoryPill(cat, isSelected: selectedCategory == cat) { selectedCategory = cat }
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
Divider().background(Color.white.opacity(0.1))
|
|
|
|
// ── Log entries ───────────────────────────────────────────────
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 1) {
|
|
ForEach(Array(filteredEntries.enumerated()), id: \.element.id) { idx, entry in
|
|
let prev = idx > 0 ? filteredEntries[idx - 1] : nil
|
|
logRow(entry, prev: prev)
|
|
.id(entry.id)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 4)
|
|
}
|
|
.onChange(of: logger.entries.count) { _, _ in
|
|
guard !paused, autoScroll, let last = filteredEntries.last else { return }
|
|
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) }
|
|
}
|
|
}
|
|
|
|
// ── Bottom bar ────────────────────────────────────────────────
|
|
HStack(spacing: 12) {
|
|
Text("\(filteredEntries.count) / \(logger.entries.count)")
|
|
.font(.system(size: 11)).foregroundColor(.gray)
|
|
|
|
Spacer()
|
|
|
|
// Pause / Resume
|
|
Button(action: togglePause) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: paused ? "play.fill" : "pause.fill").font(.system(size: 10))
|
|
Text(paused ? "Resume" : "Pause").font(.system(size: 11))
|
|
}
|
|
.foregroundColor(paused ? accentPink : .gray)
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(paused ? accentPink.opacity(0.15) : Color.white.opacity(0.06))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
Toggle("", isOn: $autoScroll)
|
|
.labelsHidden()
|
|
.toggleStyle(.switch)
|
|
.tint(accentPink)
|
|
.fixedSize()
|
|
|
|
Text("Scroll").font(.system(size: 11)).foregroundColor(.gray)
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
|
.background(Color.black.opacity(0.3))
|
|
}
|
|
.background(Color(white: 0.06))
|
|
.navigationTitle("Debug Console")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(action: { logger.clear() }) {
|
|
Text("Clear").font(.system(size: 14))
|
|
}.foregroundColor(.red)
|
|
}
|
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
|
// PiP toggle → uses existing MainTabView floating window
|
|
Button(action: { logger.isPiP = true }) {
|
|
Image(systemName: "pip.enter").font(.system(size: 14))
|
|
}.foregroundColor(accentPink)
|
|
|
|
ShareLink(item: exportLogs()) {
|
|
Image(systemName: "square.and.arrow.up").font(.system(size: 14))
|
|
}.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func togglePause() {
|
|
if paused {
|
|
paused = false
|
|
pausedSnapshot = []
|
|
} else {
|
|
pausedSnapshot = logger.entries
|
|
paused = true
|
|
}
|
|
}
|
|
|
|
private func categoryPill(_ title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(title)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(isSelected ? .white : .gray)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(isSelected ? accentPink.opacity(0.6) : Color.white.opacity(0.08))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
private func groupToggle(_ title: String, icon: String, isOn: Binding<Bool>) -> some View {
|
|
Button(action: { isOn.wrappedValue.toggle() }) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon).font(.system(size: 10))
|
|
Text(title).font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(isOn.wrappedValue ? .white : .gray.opacity(0.4))
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(isOn.wrappedValue ? accentPink.opacity(0.4) : Color.white.opacity(0.05))
|
|
.cornerRadius(10)
|
|
}
|
|
}
|
|
|
|
private func levelToggle(_ level: LogLevel, isOn: Binding<Bool>) -> some View {
|
|
Button(action: { isOn.wrappedValue.toggle() }) {
|
|
Text(level.rawValue)
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(isOn.wrappedValue ? level.color : .gray.opacity(0.3))
|
|
.padding(.horizontal, 6).padding(.vertical, 4)
|
|
.background(isOn.wrappedValue ? level.color.opacity(0.15) : Color.white.opacity(0.04))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
private func logRow(_ entry: DebugLogger.LogEntry, prev: DebugLogger.LogEntry?) -> some View {
|
|
// Marker (background/foreground separator)
|
|
if entry.isMarker {
|
|
return AnyView(
|
|
Text(entry.message)
|
|
.font(.system(size: 10, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.25))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
)
|
|
}
|
|
|
|
// Delta time since previous entry
|
|
let delta: String? = {
|
|
guard let p = prev, !p.isMarker else { return nil }
|
|
let d = entry.timestamp.timeIntervalSince(p.timestamp)
|
|
if d < 0.001 { return nil }
|
|
return d < 1 ? String(format: "+%.0fms", d * 1000) : String(format: "+%.1fs", d)
|
|
}()
|
|
|
|
let catColor: Color = {
|
|
switch entry.category {
|
|
case "Watch", "WC", "WatchSync": return .cyan
|
|
case "Audio": return .green
|
|
case "FFT": return .orange
|
|
case "Network", "Radio": return .yellow
|
|
case "Error": return .red
|
|
case "Companion", "SmartDJ": return .purple
|
|
default: return .gray
|
|
}
|
|
}()
|
|
|
|
return AnyView(
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 5) {
|
|
// Level indicator
|
|
Circle()
|
|
.fill(entry.level.color)
|
|
.frame(width: 5, height: 5)
|
|
.opacity(entry.level == .debug ? 0.4 : 1.0)
|
|
|
|
// Category badge
|
|
Text(entry.category)
|
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
|
.foregroundColor(catColor)
|
|
.padding(.horizontal, 4).padding(.vertical, 1)
|
|
.background(catColor.opacity(0.12))
|
|
.cornerRadius(3)
|
|
|
|
// Timestamp
|
|
let df = DateFormatter(); let _ = df.dateFormat = "HH:mm:ss.SSS"
|
|
Text(df.string(from: entry.timestamp))
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.gray.opacity(0.6))
|
|
|
|
// Delta
|
|
if let d = delta {
|
|
Text(d)
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.25))
|
|
}
|
|
}
|
|
|
|
Text(entry.message)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(entry.level == .error ? .red.opacity(0.9) :
|
|
entry.level == .warning ? .yellow.opacity(0.9) : .white.opacity(0.85))
|
|
.textSelection(.enabled)
|
|
}
|
|
.padding(.vertical, 4)
|
|
)
|
|
}
|
|
|
|
private func exportLogs() -> String {
|
|
filteredEntries.map { $0.formatted }.joined(separator: "\n")
|
|
}
|
|
}
|