Built a Jarvis-style scrolling log viewer that surfaces what each subsystem is doing in real time. The buffer is a thread-safe ring of the last 1,000 entries; the SwiftUI viewer polls at 10 Hz, only re-renders when the buffer version bumps, and supports level / source filters, pause, auto-scroll, and clear. Components - Sources/App/LogStream.swift — singleton with Level (DBG/INF/WRN/ERR) and Source (APP / CAM / DETECT / TRACK / FACE / RENDER / VPOC) taxonomies, an os_unfair_lock-guarded buffer, and stdout mirroring so `log stream --process VisionPOC` shows the same feed in Terminal. - Sources/App/LogStreamView.swift — SwiftUI viewer with toolbar (level menu, source menu, auto-scroll, pause, clear, filtered/total counts) and a monospaced row-per-entry list that auto-scrolls to the bottom when new entries arrive. - Sources/App/LogStreamWindowController.swift — separate dark-aqua floating window so the log doesn't compete with the main 4-pane HUD for real estate. - AppDelegate gains a ⌘L "Log Stream…" menu item and a held reference to the controller (lazy-instantiated on first open). Wiring - AppDelegate: launch + ready - CameraCapture: device selection, session start, permission denied, enumeration failures - ObjectDetector: model load (success or fallback), 1 Hz detector stats (yolo vs track mode, ms, track count + label list), per-track add and per-track lose - ObjectDetector face match: when a face is recognized via FaceRegistry the distance is logged at DBG level - FaceRegistry: load on init, enroll (+prints, total), forget Implementation notes - The viewer is not @Observable-bound to LogStream so background- thread log emit calls don't pay a MainActor hop. Instead the view polls .version every 100ms and reloads on bump — bursts up to ~100 entries per tick render fine and the buffer absorbs writes from any rate the os_unfair_lock can sustain (~10 M/s on M-series). - LogStream.shared.log() can be called from any thread/queue. Stdout is also written from the same call so terminal tail works. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
143 lines
5.2 KiB
Swift
143 lines
5.2 KiB
Swift
import SwiftUI
|
||
import Observation
|
||
|
||
/// SwiftUI viewer for the LogStream ring buffer. Polls every 100ms; only
|
||
/// re-renders when the LogStream version bumps. The viewer is intentionally
|
||
/// not @Observable-bound to the LogStream so background-thread log calls
|
||
/// don't pay MainActor hop cost — the polling design tolerates burst writes
|
||
/// at any rate the buffer can absorb (1000 entries × ~16 µs each).
|
||
struct LogStreamView: View {
|
||
@State private var entries: [LogStream.Entry] = []
|
||
@State private var lastVersion: UInt64 = 0
|
||
@State private var levelFilter: Set<LogStream.Level> = Set(LogStream.Level.allCases)
|
||
@State private var sourceFilter: Set<LogStream.Source> = Set(LogStream.Source.allCases)
|
||
@State private var autoScroll: Bool = true
|
||
@State private var paused: Bool = false
|
||
|
||
private let pollTimer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
||
|
||
private static let timeFormatter: DateFormatter = {
|
||
let df = DateFormatter()
|
||
df.dateFormat = "HH:mm:ss.SSS"
|
||
return df
|
||
}()
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
toolbar
|
||
Divider()
|
||
logList
|
||
}
|
||
.frame(minWidth: 640, idealWidth: 820, minHeight: 360, idealHeight: 540)
|
||
.background(Color(white: 0.07))
|
||
.onReceive(pollTimer) { _ in
|
||
guard !paused else { return }
|
||
let v = LogStream.shared.version
|
||
guard v != lastVersion else { return }
|
||
entries = LogStream.shared.snapshot()
|
||
lastVersion = v
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var toolbar: some View {
|
||
HStack(spacing: 12) {
|
||
Menu("Level") {
|
||
ForEach(LogStream.Level.allCases) { lvl in
|
||
Toggle(lvl.rawValue, isOn: Binding(
|
||
get: { levelFilter.contains(lvl) },
|
||
set: { isOn in
|
||
if isOn { levelFilter.insert(lvl) } else { levelFilter.remove(lvl) }
|
||
}
|
||
))
|
||
}
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
|
||
Menu("Source") {
|
||
ForEach(LogStream.Source.allCases) { src in
|
||
Toggle(src.rawValue, isOn: Binding(
|
||
get: { sourceFilter.contains(src) },
|
||
set: { isOn in
|
||
if isOn { sourceFilter.insert(src) } else { sourceFilter.remove(src) }
|
||
}
|
||
))
|
||
}
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
|
||
Toggle("Auto-scroll", isOn: $autoScroll)
|
||
.toggleStyle(.checkbox)
|
||
.controlSize(.small)
|
||
|
||
Toggle("Pause", isOn: $paused)
|
||
.toggleStyle(.checkbox)
|
||
.controlSize(.small)
|
||
|
||
Spacer()
|
||
|
||
Text("\(filteredCount) / \(entries.count)")
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
|
||
Button("Clear") { LogStream.shared.clear() }
|
||
.controlSize(.small)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(Color(white: 0.10))
|
||
}
|
||
|
||
private var filteredEntries: [LogStream.Entry] {
|
||
entries.filter { levelFilter.contains($0.level) && sourceFilter.contains($0.source) }
|
||
}
|
||
|
||
private var filteredCount: Int { filteredEntries.count }
|
||
|
||
@ViewBuilder
|
||
private var logList: some View {
|
||
ScrollViewReader { proxy in
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 0) {
|
||
ForEach(filteredEntries) { entry in
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Text(Self.timeFormatter.string(from: entry.timestamp))
|
||
.foregroundStyle(.secondary)
|
||
Text(entry.level.rawValue)
|
||
.foregroundStyle(color(for: entry.level))
|
||
.frame(width: 32, alignment: .leading)
|
||
Text(entry.source.rawValue)
|
||
.foregroundStyle(Color(red: 0.20, green: 0.85, blue: 0.95))
|
||
.frame(width: 60, alignment: .leading)
|
||
Text(entry.message)
|
||
.foregroundStyle(.primary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.font(.system(.caption, design: .monospaced))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 2)
|
||
.id(entry.id)
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: filteredEntries.last?.id) { _, newValue in
|
||
guard autoScroll, let last = newValue else { return }
|
||
withAnimation(.linear(duration: 0.06)) {
|
||
proxy.scrollTo(last, anchor: .bottom)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func color(for level: LogStream.Level) -> Color {
|
||
switch level {
|
||
case .debug: return .gray
|
||
case .info: return Color(white: 0.85)
|
||
case .warn: return .yellow
|
||
case .error: return .red
|
||
}
|
||
}
|
||
}
|