diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9a6e735..ae274ccc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). - Tests: add a Z.AI live test gate for smoke validation when keys are present. +- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. ### Fixes - Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases. diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 02aaf2ecd..be8946514 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -15,6 +15,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), .package(path: "../shared/ClawdisKit"), .package(path: "../../Swabble"), @@ -45,6 +46,7 @@ let package = Package( .product(name: "SwabbleKit", package: "swabble"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "Subprocess", package: "swift-subprocess"), + .product(name: "Logging", package: "swift-log"), .product(name: "Sparkle", package: "Sparkle"), .product(name: "PeekabooBridge", package: "PeekabooCore"), .product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"), diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index 30b0c8c41..e03ba2c42 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -1,5 +1,4 @@ import AppKit -import OSLog let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index 589091261..639d12afc 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -32,5 +32,6 @@ let modelCatalogReloadKey = "clawdis.modelCatalogReload" let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly" let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled" let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled" +let appLogLevelKey = "clawdis.debug.appLogLevel" let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"] diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 5312fa641..51c97a6c3 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -1,7 +1,6 @@ import ClawdisProtocol import Foundation import Observation -import OSLog import SwiftUI struct ControlHeartbeatEvent: Codable { diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index a30cf917a..037a17855 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -30,6 +30,7 @@ struct DebugSettings: View { @State private var pendingKill: DebugActions.PortListener? @AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false @AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false + @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue @State private var canvasSessionKey: String = "main" @State private var canvasStatus: String? @@ -232,13 +233,23 @@ struct DebugSettings: View { } GridRow { - self.gridLabel("Diagnostics") - VStack(alignment: .leading, spacing: 6) { + self.gridLabel("App logging") + VStack(alignment: .leading, spacing: 8) { + Picker("Verbosity", selection: self.$appLogLevelRaw) { + ForEach(AppLogLevel.allCases) { level in + Text(level.title).tag(level.rawValue) + } + } + .pickerStyle(.menu) + .labelsHidden() + .help("Controls the macOS app log verbosity.") + Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) .toggleStyle(.checkbox) .help( - "Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " + + "Writes a rotating, local-only log under ~/Library/Logs/Clawdis/. " + "Enable only while actively debugging.") + HStack(spacing: 8) { Button("Open folder") { NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) diff --git a/apps/macos/Sources/Clawdis/DockIconManager.swift b/apps/macos/Sources/Clawdis/DockIconManager.swift index 1e3a98f43..d0bee9543 100644 --- a/apps/macos/Sources/Clawdis/DockIconManager.swift +++ b/apps/macos/Sources/Clawdis/DockIconManager.swift @@ -1,5 +1,4 @@ import AppKit -import OSLog /// Central manager for Dock icon visibility. /// Shows the Dock icon while any windows are visible, regardless of user preference. diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index 262291824..721e8afd3 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -1,7 +1,6 @@ import Foundation import Network import Observation -import OSLog import SwiftUI struct HealthSnapshot: Codable, Sendable { diff --git a/apps/macos/Sources/Clawdis/Logging/ClawdisLogging.swift b/apps/macos/Sources/Clawdis/Logging/ClawdisLogging.swift new file mode 100644 index 000000000..b759828c1 --- /dev/null +++ b/apps/macos/Sources/Clawdis/Logging/ClawdisLogging.swift @@ -0,0 +1,229 @@ +@_exported import Logging +import Foundation +import OSLog + +typealias Logger = Logging.Logger + +enum AppLogSettings { + static let logLevelKey = appLogLevelKey + + static func logLevel() -> Logger.Level { + if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), + let level = Logger.Level(rawValue: raw) + { + return level + } + return .info + } + + static func setLogLevel(_ level: Logger.Level) { + UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) + } + + static func fileLoggingEnabled() -> Bool { + UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + } +} + +enum AppLogLevel: String, CaseIterable, Identifiable { + case trace + case debug + case info + case notice + case warning + case error + case critical + + static let `default`: AppLogLevel = .info + + var id: String { self.rawValue } + + var title: String { + switch self { + case .trace: "Trace" + case .debug: "Debug" + case .info: "Info" + case .notice: "Notice" + case .warning: "Warning" + case .error: "Error" + case .critical: "Critical" + } + } +} + +enum ClawdisLogging { + private static let labelSeparator = "::" + + private static let didBootstrap: Void = { + LoggingSystem.bootstrap { label in + let (subsystem, category) = Self.parseLabel(label) + let osHandler = ClawdisOSLogHandler(subsystem: subsystem, category: category) + let fileHandler = ClawdisFileLogHandler(label: label) + return MultiplexLogHandler([osHandler, fileHandler]) + } + }() + + static func bootstrapIfNeeded() { + _ = Self.didBootstrap + } + + static func makeLabel(subsystem: String, category: String) -> String { + "\(subsystem)\(Self.labelSeparator)\(category)" + } + + static func parseLabel(_ label: String) -> (String, String) { + guard let range = label.range(of: Self.labelSeparator) else { + return ("com.steipete.clawdis", label) + } + let subsystem = String(label[..(_ value: T, privacy: OSLogPrivacy) { + self.appendInterpolation(String(describing: value)) + } +} + +struct ClawdisOSLogHandler: LogHandler { + private let osLogger: OSLog.Logger + var metadata: Logger.Metadata = [:] + + var logLevel: Logger.Level { + get { AppLogSettings.logLevel() } + set { AppLogSettings.setLogLevel(newValue) } + } + + init(subsystem: String, category: String) { + self.osLogger = OSLog.Logger(subsystem: subsystem, category: category) + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt) + { + let merged = Self.mergeMetadata(self.metadata, metadata) + let rendered = Self.renderMessage(message, metadata: merged) + self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)") + } + + private static func osLogType(for level: Logger.Level) -> OSLogType { + switch level { + case .trace, .debug: + return .debug + case .info, .notice: + return .info + case .warning: + return .default + case .error: + return .error + case .critical: + return .fault + } + } + + private static func mergeMetadata( + _ base: Logger.Metadata, + _ extra: Logger.Metadata?) -> Logger.Metadata + { + guard let extra else { return base } + return base.merging(extra, uniquingKeysWith: { _, new in new }) + } + + private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String { + guard !metadata.isEmpty else { return message.description } + let meta = metadata + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\(stringify($0.value))" } + .joined(separator: " ") + return "\(message.description) [\(meta)]" + } + + private static func stringify(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { stringify($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}" + } + } +} + +struct ClawdisFileLogHandler: LogHandler { + let label: String + var metadata: Logger.Metadata = [:] + + var logLevel: Logger.Level { + get { AppLogSettings.logLevel() } + set { AppLogSettings.setLogLevel(newValue) } + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt) + { + guard AppLogSettings.fileLoggingEnabled() else { return } + let (subsystem, category) = ClawdisLogging.parseLabel(self.label) + var fields: [String: String] = [ + "subsystem": subsystem, + "category": category, + "level": level.rawValue, + "source": source, + "file": file, + "function": function, + "line": "\(line)", + ] + let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new }) + for (key, value) in merged { + fields["meta.\(key)"] = Self.stringify(value) + } + DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields) + } + + private static func stringify(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { stringify($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}" + } + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index c998cafea..770982311 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -3,7 +3,6 @@ import Darwin import Foundation import MenuBarExtraAccess import Observation -import OSLog import Security import SwiftUI @@ -30,6 +29,7 @@ struct ClawdisApp: App { } init() { + ClawdisLogging.bootstrapIfNeeded() _state = State(initialValue: AppStateStore.shared) } diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 7cb1d420b..d18aa6dbf 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -20,6 +20,8 @@ struct MenuContent: View { @State private var loadingMics = false @State private var browserControlEnabled = true @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false + @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue + @AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false init(state: AppState, updater: UpdaterProviding?) { self._state = Bindable(wrappedValue: state) @@ -182,6 +184,20 @@ struct MenuContent: View { : "Verbose Logging (Main): Off", systemImage: "text.alignleft") } + Menu("App Logging") { + Picker("Verbosity", selection: self.$appLogLevelRaw) { + ForEach(AppLogLevel.allCases) { level in + Text(level.title).tag(level.rawValue) + } + } + Toggle(isOn: self.$appFileLoggingEnabled) { + Label( + self.appFileLoggingEnabled + ? "File Logging: On" + : "File Logging: Off", + systemImage: "doc.text.magnifyingglass") + } + } Button { DebugActions.openSessionStore() } label: { diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift index d7d1ab4e0..e6de4e58f 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -5,7 +5,6 @@ import ClawdisIPC import CoreGraphics import Foundation import Observation -import OSLog import Speech import UserNotifications diff --git a/apps/macos/Sources/Clawdis/TalkModeController.swift b/apps/macos/Sources/Clawdis/TalkModeController.swift index 85e3405ec..c87dd92da 100644 --- a/apps/macos/Sources/Clawdis/TalkModeController.swift +++ b/apps/macos/Sources/Clawdis/TalkModeController.swift @@ -1,5 +1,4 @@ import Observation -import OSLog @MainActor @Observable diff --git a/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift b/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift index 070a066d4..5cd2f79f5 100644 --- a/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift +++ b/apps/macos/Sources/Clawdis/VoiceSessionCoordinator.swift @@ -1,7 +1,6 @@ import AppKit import Foundation import Observation -import OSLog @MainActor @Observable diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index f77ec4031..f2b818898 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -1,6 +1,5 @@ import AppKit import Observation -import OSLog import SwiftUI /// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar. diff --git a/apps/macos/Sources/Clawdis/VoiceWakeTester.swift b/apps/macos/Sources/Clawdis/VoiceWakeTester.swift index 3c6a81283..5d6d77852 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeTester.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeTester.swift @@ -1,6 +1,5 @@ import AVFoundation import Foundation -import OSLog import Speech import SwabbleKit diff --git a/docs/mac/logging.md b/docs/mac/logging.md index e0a5926b0..368da20e6 100644 --- a/docs/mac/logging.md +++ b/docs/mac/logging.md @@ -7,11 +7,12 @@ read_when: # Logging (macOS) ## Rolling diagnostics file log (Debug pane) -Clawdis can write a local, rotating diagnostics log to disk (useful when macOS unified logging is impractical during iterative repros). +Clawdis routes macOS app logs through swift-log (unified logging by default) and can write a local, rotating file log to disk when you need a durable capture. -- Enable: **Debug pane → Diagnostics log → “Write rolling diagnostics log (JSONL)”** +- Verbosity: **Debug pane → Logs → App logging → Verbosity** +- Enable: **Debug pane → Logs → App logging → “Write rolling diagnostics log (JSONL)”** - Location: `~/Library/Logs/Clawdis/diagnostics.jsonl` (rotates automatically; old files are suffixed with `.1`, `.2`, …) -- Clear: **Debug pane → Diagnostics log → “Clear”** +- Clear: **Debug pane → Logs → App logging → “Clear”** Notes: - This is **off by default**. Enable only while actively debugging.