chore: add mac app logging coverage

This commit is contained in:
Peter Steinberger
2025-12-31 16:28:51 +01:00
parent 6517b05abe
commit 0babf08926
5 changed files with 70 additions and 8 deletions

View File

@@ -1,6 +1,8 @@
import Foundation import Foundation
enum ClawdisConfigFile { enum ClawdisConfigFile {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "config")
static func url() -> URL { static func url() -> URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis") .appendingPathComponent(".clawdis")
@@ -15,8 +17,18 @@ enum ClawdisConfigFile {
static func loadDict() -> [String: Any] { static func loadDict() -> [String: Any] {
let url = self.url() let url = self.url()
guard let data = try? Data(contentsOf: url) else { return [:] } guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("config JSON root invalid")
return [:]
}
return root
} catch {
self.logger.warning("config read failed: \(error.localizedDescription)")
return [:]
}
} }
static func saveDict(_ dict: [String: Any]) { static func saveDict(_ dict: [String: Any]) {
@@ -27,7 +39,9 @@ enum ClawdisConfigFile {
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
} catch {} } catch {
self.logger.error("config save failed: \(error.localizedDescription)")
}
} }
static func loadGatewayDict() -> [String: Any] { static func loadGatewayDict() -> [String: Any] {
@@ -59,6 +73,7 @@ enum ClawdisConfigFile {
browser["enabled"] = enabled browser["enabled"] = enabled
root["browser"] = browser root["browser"] = browser
self.saveDict(root) self.saveDict(root)
self.logger.debug("browser control updated enabled=\(enabled)")
} }
static func agentWorkspace() -> String? { static func agentWorkspace() -> String? {
@@ -78,5 +93,6 @@ enum ClawdisConfigFile {
} }
root["agent"] = agent root["agent"] = agent
self.saveDict(root) self.saveDict(root)
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
} }
} }

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
enum GatewayLaunchAgentManager { enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static var plistURL: URL { private static var plistURL: URL {
@@ -26,12 +27,16 @@ enum GatewayLaunchAgentManager {
if enabled { if enabled {
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else { guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh" return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
} }
self.logger.info("launchd enable requested port=\(port)")
self.writePlist(bundlePath: bundlePath, port: port) self.writePlist(bundlePath: bundlePath, port: port)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 { if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Failed to bootstrap gateway launchd job" ? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) : bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -42,6 +47,7 @@ enum GatewayLaunchAgentManager {
return nil return nil
} }
self.logger.info("launchd disable requested")
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.plistURL) try? FileManager.default.removeItem(at: self.plistURL)
return nil return nil
@@ -103,7 +109,11 @@ enum GatewayLaunchAgentManager {
</dict> </dict>
</plist> </plist>
""" """
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) do {
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
} catch {
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
}
} }
private static func preferredGatewayBind() -> String? { private static func preferredGatewayBind() -> String? {

View File

@@ -42,6 +42,7 @@ final class GatewayProcessManager {
private var environmentRefreshTask: Task<Void, Never>? private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date? private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>? private var logRefreshTask: Task<Void, Never>?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.process")
private let logLimit = 20000 // characters to keep in-memory private let logLimit = 20000 // characters to keep in-memory
private let environmentRefreshMinInterval: TimeInterval = 30 private let environmentRefreshMinInterval: TimeInterval = 30
@@ -53,8 +54,10 @@ final class GatewayProcessManager {
self.stop() self.stop()
self.status = .stopped self.status = .stopped
self.appendLog("[gateway] remote mode active; skipping local gateway\n") self.appendLog("[gateway] remote mode active; skipping local gateway\n")
self.logger.info("gateway process skipped: remote mode active")
return return
} }
self.logger.debug("gateway active requested active=\(active)")
self.desiredActive = active self.desiredActive = active
self.refreshEnvironmentStatus() self.refreshEnvironmentStatus()
if active { if active {
@@ -86,6 +89,7 @@ final class GatewayProcessManager {
return return
} }
self.status = .starting self.status = .starting
self.logger.debug("gateway start requested")
// First try to latch onto an already-running gateway to avoid spawning a duplicate. // First try to latch onto an already-running gateway to avoid spawning a duplicate.
Task { [weak self] in Task { [weak self] in
@@ -98,6 +102,7 @@ final class GatewayProcessManager {
await MainActor.run { await MainActor.run {
self.status = .failed("Attach-only enabled; no gateway to attach") self.status = .failed("Attach-only enabled; no gateway to attach")
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n") self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
self.logger.warning("gateway attach-only enabled; not spawning")
} }
return return
} }
@@ -110,6 +115,7 @@ final class GatewayProcessManager {
self.existingGatewayDetails = nil self.existingGatewayDetails = nil
self.lastFailureReason = nil self.lastFailureReason = nil
self.status = .stopped self.status = .stopped
self.logger.info("gateway stop requested")
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
Task { Task {
_ = await GatewayLaunchAgentManager.set( _ = await GatewayLaunchAgentManager.set(
@@ -182,6 +188,7 @@ final class GatewayProcessManager {
self.existingGatewayDetails = details self.existingGatewayDetails = details
self.status = .attachedExisting(details: details) self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n") self.appendLog("[gateway] using existing instance: \(details)\n")
self.logger.info("gateway using existing instance details=\(details)")
self.refreshControlChannelIfNeeded(reason: "attach existing") self.refreshControlChannelIfNeeded(reason: "attach existing")
self.refreshLog() self.refreshLog()
return true return true
@@ -197,6 +204,7 @@ final class GatewayProcessManager {
self.status = .failed(reason) self.status = .failed(reason)
self.lastFailureReason = reason self.lastFailureReason = reason
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
self.logger.warning("gateway attach failed reason=\(reason)")
return true return true
} }
@@ -268,16 +276,19 @@ final class GatewayProcessManager {
await MainActor.run { await MainActor.run {
self.status = .failed(resolution.status.message) self.status = .failed(resolution.status.message)
} }
self.logger.error("gateway command resolve failed: \(resolution.status.message)")
return return
} }
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
self.logger.info("gateway enabling launchd port=\(port)")
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
if let err { if let err {
self.status = .failed(err) self.status = .failed(err)
self.lastFailureReason = err self.lastFailureReason = err
self.logger.error("gateway launchd enable failed: \(err)")
return return
} }
@@ -290,6 +301,7 @@ final class GatewayProcessManager {
let instance = await PortGuardian.shared.describe(port: port) let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" } let details = instance.map { "pid \($0.pid)" }
self.status = .running(details: details) self.status = .running(details: details)
self.logger.info("gateway started details=\(details ?? "ok")")
self.refreshControlChannelIfNeeded(reason: "gateway started") self.refreshControlChannelIfNeeded(reason: "gateway started")
self.refreshLog() self.refreshLog()
return return
@@ -300,6 +312,7 @@ final class GatewayProcessManager {
self.status = .failed("Gateway did not start in time") self.status = .failed("Gateway did not start in time")
self.lastFailureReason = "launchd start timeout" self.lastFailureReason = "launchd start timeout"
self.logger.warning("gateway start timed out")
} }
private func appendLog(_ chunk: String) { private func appendLog(_ chunk: String) {
@@ -317,6 +330,7 @@ final class GatewayProcessManager {
break break
} }
self.appendLog("[gateway] refreshing control channel (\(reason))\n") self.appendLog("[gateway] refreshing control channel (\(reason))\n")
self.logger.debug("gateway control channel refresh reason=\(reason)")
Task { await ControlChannel.shared.configure() } Task { await ControlChannel.shared.configure() }
} }
@@ -332,12 +346,14 @@ final class GatewayProcessManager {
} }
} }
self.appendLog("[gateway] readiness wait timed out\n") self.appendLog("[gateway] readiness wait timed out\n")
self.logger.warning("gateway readiness wait timed out")
return false return false
} }
func clearLog() { func clearLog() {
self.log = "" self.log = ""
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath) try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
self.logger.debug("gateway log cleared")
} }
func setProjectRoot(path: String) { func setProjectRoot(path: String) {

View File

@@ -4,18 +4,23 @@ import JavaScriptCore
enum ModelCatalogLoader { enum ModelCatalogLoader {
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "models")
static func load(from path: String) async throws -> [ModelChoice] { static func load(from path: String) async throws -> [ModelChoice] {
let expanded = (path as NSString).expandingTildeInPath let expanded = (path as NSString).expandingTildeInPath
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
let source = try String(contentsOfFile: expanded, encoding: .utf8) let source = try String(contentsOfFile: expanded, encoding: .utf8)
let sanitized = self.sanitize(source: source) let sanitized = self.sanitize(source: source)
let ctx = JSContext() let ctx = JSContext()
ctx?.exceptionHandler = { _, exception in ctx?.exceptionHandler = { _, exception in
if let exception { print("JS exception: \(exception)") } if let exception {
self.logger.warning("model catalog JS exception: \(exception)")
}
} }
ctx?.evaluateScript(sanitized) ctx?.evaluateScript(sanitized)
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
self.logger.error("model catalog parse failed: MODELS missing")
throw NSError( throw NSError(
domain: "ModelCatalogLoader", domain: "ModelCatalogLoader",
code: 1, code: 1,
@@ -33,12 +38,14 @@ enum ModelCatalogLoader {
} }
} }
return choices.sorted { lhs, rhs in let sorted = choices.sorted { lhs, rhs in
if lhs.provider == rhs.provider { if lhs.provider == rhs.provider {
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
} }
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
} }
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
return sorted
} }
private static func sanitize(source: String) -> String { private static func sanitize(source: String) -> String {

View File

@@ -5,6 +5,8 @@ import UserNotifications
@MainActor @MainActor
struct NotificationManager { struct NotificationManager {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "notifications")
private static let hasTimeSensitiveEntitlement: Bool = { private static let hasTimeSensitiveEntitlement: Bool = {
guard let task = SecTaskCreateFromSelf(nil) else { return false } guard let task = SecTaskCreateFromSelf(nil) else { return false }
let key = "com.apple.developer.usernotifications.time-sensitive" as CFString let key = "com.apple.developer.usernotifications.time-sensitive" as CFString
@@ -17,8 +19,12 @@ struct NotificationManager {
let status = await center.notificationSettings() let status = await center.notificationSettings()
if status.authorizationStatus == .notDetermined { if status.authorizationStatus == .notDetermined {
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted != true { return false } if granted != true {
self.logger.warning("notification permission denied (request)")
return false
}
} else if status.authorizationStatus != .authorized { } else if status.authorizationStatus != .authorized {
self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)")
return false return false
} }
@@ -37,15 +43,22 @@ struct NotificationManager {
case .active: case .active:
content.interruptionLevel = .active content.interruptionLevel = .active
case .timeSensitive: case .timeSensitive:
content.interruptionLevel = Self.hasTimeSensitiveEntitlement ? .timeSensitive : .active if Self.hasTimeSensitiveEntitlement {
content.interruptionLevel = .timeSensitive
} else {
self.logger.debug("time-sensitive notification requested without entitlement; falling back to active")
content.interruptionLevel = .active
}
} }
} }
let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
do { do {
try await center.add(req) try await center.add(req)
self.logger.debug("notification queued")
return true return true
} catch { } catch {
self.logger.error("notification send failed: \(error.localizedDescription)")
return false return false
} }
} }