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>
94 lines
2.9 KiB
Swift
94 lines
2.9 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
/// In-memory ring buffer of recent system events from every subsystem, plus
|
|
/// a single sink that mirrors entries to stdout for `xcrun log stream`. The
|
|
/// log viewer window polls `snapshot()` to render entries; it never holds a
|
|
/// long-lived reference, so the buffer can be safely mutated from background
|
|
/// queues.
|
|
final class LogStream: @unchecked Sendable {
|
|
static let shared = LogStream()
|
|
|
|
enum Level: String, Sendable, CaseIterable, Identifiable {
|
|
case debug = "DBG"
|
|
case info = "INF"
|
|
case warn = "WRN"
|
|
case error = "ERR"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
enum Source: String, Sendable, CaseIterable, Identifiable {
|
|
case app = "APP"
|
|
case camera = "CAM"
|
|
case detect = "DETECT"
|
|
case track = "TRACK"
|
|
case face = "FACE"
|
|
case render = "RENDER"
|
|
case other = "VPOC"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
struct Entry: Identifiable, Sendable {
|
|
let id = UUID()
|
|
let timestamp: Date
|
|
let level: Level
|
|
let source: Source
|
|
let message: String
|
|
}
|
|
|
|
private var lock = os_unfair_lock_s()
|
|
private var buffer: [Entry] = []
|
|
private let maxEntries: Int = 1000
|
|
|
|
/// Monotonic counter bumped on every append. The viewer reads this to
|
|
/// short-circuit polling when nothing has changed.
|
|
private var _version: UInt64 = 0
|
|
var version: UInt64 {
|
|
os_unfair_lock_lock(&lock)
|
|
defer { os_unfair_lock_unlock(&lock) }
|
|
return _version
|
|
}
|
|
|
|
func log(_ message: String, level: Level = .info, source: Source = .other) {
|
|
let entry = Entry(timestamp: Date(), level: level, source: source, message: message)
|
|
os_unfair_lock_lock(&lock)
|
|
buffer.append(entry)
|
|
if buffer.count > maxEntries {
|
|
buffer.removeFirst(buffer.count - maxEntries)
|
|
}
|
|
_version &+= 1
|
|
os_unfair_lock_unlock(&lock)
|
|
|
|
// Mirror to stdout for tail-from-terminal scenarios.
|
|
// Keep it terse: it's the running stream, not a permanent archive.
|
|
let stamp = LogStream.iso8601.string(from: entry.timestamp)
|
|
print("[\(stamp)] \(level.rawValue) \(source.rawValue.padding(toLength: 6, withPad: " ", startingAt: 0)) \(message)")
|
|
}
|
|
|
|
func snapshot() -> [Entry] {
|
|
os_unfair_lock_lock(&lock)
|
|
defer { os_unfair_lock_unlock(&lock) }
|
|
return buffer
|
|
}
|
|
|
|
func clear() {
|
|
os_unfair_lock_lock(&lock)
|
|
buffer.removeAll()
|
|
_version &+= 1
|
|
os_unfair_lock_unlock(&lock)
|
|
}
|
|
|
|
/// Drop-in replacement for NSLog that also surfaces in the log viewer.
|
|
func bridgeNSLog(_ message: String, source: Source = .other) {
|
|
NSLog("%@", message)
|
|
log(message, level: .info, source: source)
|
|
}
|
|
|
|
// Helpers
|
|
private static let iso8601: DateFormatter = {
|
|
let df = DateFormatter()
|
|
df.dateFormat = "HH:mm:ss.SSS"
|
|
return df
|
|
}()
|
|
}
|