498 lines
21 KiB
Swift
498 lines
21 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") }
|
|
}
|
|
/// When true, key views record each body evaluation into ViewBodyTracker.
|
|
/// The tracker reports per-second counts to the "ViewBody" category so you
|
|
/// can see exactly which views are re-evaluating and how often — without
|
|
/// needing Instruments. Toggle via the ⚡ Bodies button in the filter bar.
|
|
@Published var trackViewBodies: Bool = false {
|
|
didSet {
|
|
UserDefaults.standard.set(trackViewBodies, forKey: "debug_track_view_bodies")
|
|
if trackViewBodies {
|
|
ViewBodyTracker.shared.start()
|
|
} else {
|
|
ViewBodyTracker.shared.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
private let maxEntries = 500
|
|
|
|
private init() {
|
|
isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled")
|
|
trackViewBodies = UserDefaults.standard.bool(forKey: "debug_track_view_bodies")
|
|
if trackViewBodies { ViewBodyTracker.shared.start() }
|
|
// 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: - View Body Tracker
|
|
//
|
|
// Counts how many times each key view's body runs per second and logs the
|
|
// results to the "ViewBody" category in the debug console.
|
|
// Enable via the ⚡ Bodies toggle in the console filter bar.
|
|
//
|
|
// Usage in any view body:
|
|
// let _ = ViewBodyTracker.shared.record("MyView")
|
|
//
|
|
class ViewBodyTracker {
|
|
static let shared = ViewBodyTracker()
|
|
private var counts: [String: Int] = [:]
|
|
private var timer: Timer?
|
|
private let queue = DispatchQueue(label: "viewbodytracker", qos: .utility)
|
|
|
|
private init() {}
|
|
|
|
/// Call from within a view body to count that evaluation.
|
|
/// Returns Void so it can be used with `let _ = ...` inside ViewBuilder.
|
|
@discardableResult
|
|
func record(_ name: String) -> Bool {
|
|
queue.async { self.counts[name, default: 0] += 1 }
|
|
return true
|
|
}
|
|
|
|
func start() {
|
|
guard timer == nil else { return }
|
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
self.queue.async {
|
|
let snapshot = self.counts
|
|
self.counts = [:]
|
|
guard !snapshot.isEmpty else { return }
|
|
// Sort descending by count so the hottest views appear first
|
|
let parts = snapshot
|
|
.sorted { $0.value > $1.value }
|
|
.map { "\($0.key):\($0.value)" }
|
|
.joined(separator: " ")
|
|
DispatchQueue.main.async {
|
|
DebugLogger.shared.log(parts, category: "ViewBody", level: .debug)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
queue.async { self.counts = [:] }
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
Divider().frame(height: 16).background(Color.white.opacity(0.2))
|
|
viewBodyToggle()
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
/// Toggle button for the View Body tracker.
|
|
/// When active, key views call Self._printChanges() and route the output
|
|
/// into the console under the "ViewBody" category.
|
|
@ViewBuilder
|
|
private func viewBodyToggle() -> some View {
|
|
Button(action: { logger.trackViewBodies.toggle() }) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: logger.trackViewBodies ? "bolt.fill" : "bolt.slash")
|
|
.font(.system(size: 10))
|
|
Text("Bodies")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(logger.trackViewBodies ? .orange : .gray.opacity(0.4))
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(logger.trackViewBodies ? Color.orange.opacity(0.2) : Color.white.opacity(0.05))
|
|
.cornerRadius(10)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|