chore: add mac app logging coverage
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum ClawdisConfigFile {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "config")
|
||||
|
||||
static func url() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
@@ -15,8 +17,18 @@ enum ClawdisConfigFile {
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
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]) {
|
||||
@@ -27,7 +39,9 @@ enum ClawdisConfigFile {
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {}
|
||||
} catch {
|
||||
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
static func loadGatewayDict() -> [String: Any] {
|
||||
@@ -59,6 +73,7 @@ enum ClawdisConfigFile {
|
||||
browser["enabled"] = enabled
|
||||
root["browser"] = browser
|
||||
self.saveDict(root)
|
||||
self.logger.debug("browser control updated enabled=\(enabled)")
|
||||
}
|
||||
|
||||
static func agentWorkspace() -> String? {
|
||||
@@ -78,5 +93,6 @@ enum ClawdisConfigFile {
|
||||
}
|
||||
root["agent"] = agent
|
||||
self.saveDict(root)
|
||||
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
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 var plistURL: URL {
|
||||
@@ -26,12 +27,16 @@ enum GatewayLaunchAgentManager {
|
||||
if enabled {
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
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"
|
||||
}
|
||||
self.logger.info("launchd enable requested port=\(port)")
|
||||
self.writePlist(bundlePath: bundlePath, port: port)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
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
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -42,6 +47,7 @@ enum GatewayLaunchAgentManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested")
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
return nil
|
||||
@@ -103,7 +109,11 @@ enum GatewayLaunchAgentManager {
|
||||
</dict>
|
||||
</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? {
|
||||
|
||||
@@ -42,6 +42,7 @@ final class GatewayProcessManager {
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
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 environmentRefreshMinInterval: TimeInterval = 30
|
||||
@@ -53,8 +54,10 @@ final class GatewayProcessManager {
|
||||
self.stop()
|
||||
self.status = .stopped
|
||||
self.appendLog("[gateway] remote mode active; skipping local gateway\n")
|
||||
self.logger.info("gateway process skipped: remote mode active")
|
||||
return
|
||||
}
|
||||
self.logger.debug("gateway active requested active=\(active)")
|
||||
self.desiredActive = active
|
||||
self.refreshEnvironmentStatus()
|
||||
if active {
|
||||
@@ -86,6 +89,7 @@ final class GatewayProcessManager {
|
||||
return
|
||||
}
|
||||
self.status = .starting
|
||||
self.logger.debug("gateway start requested")
|
||||
|
||||
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
||||
Task { [weak self] in
|
||||
@@ -98,6 +102,7 @@ final class GatewayProcessManager {
|
||||
await MainActor.run {
|
||||
self.status = .failed("Attach-only enabled; no gateway to attach")
|
||||
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
|
||||
self.logger.warning("gateway attach-only enabled; not spawning")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -110,6 +115,7 @@ final class GatewayProcessManager {
|
||||
self.existingGatewayDetails = nil
|
||||
self.lastFailureReason = nil
|
||||
self.status = .stopped
|
||||
self.logger.info("gateway stop requested")
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
@@ -182,6 +188,7 @@ final class GatewayProcessManager {
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.logger.info("gateway using existing instance details=\(details)")
|
||||
self.refreshControlChannelIfNeeded(reason: "attach existing")
|
||||
self.refreshLog()
|
||||
return true
|
||||
@@ -197,6 +204,7 @@ final class GatewayProcessManager {
|
||||
self.status = .failed(reason)
|
||||
self.lastFailureReason = reason
|
||||
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
|
||||
self.logger.warning("gateway attach failed reason=\(reason)")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -268,16 +276,19 @@ final class GatewayProcessManager {
|
||||
await MainActor.run {
|
||||
self.status = .failed(resolution.status.message)
|
||||
}
|
||||
self.logger.error("gateway command resolve failed: \(resolution.status.message)")
|
||||
return
|
||||
}
|
||||
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
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)
|
||||
if let err {
|
||||
self.status = .failed(err)
|
||||
self.lastFailureReason = err
|
||||
self.logger.error("gateway launchd enable failed: \(err)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,6 +301,7 @@ final class GatewayProcessManager {
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
self.refreshLog()
|
||||
return
|
||||
@@ -300,6 +312,7 @@ final class GatewayProcessManager {
|
||||
|
||||
self.status = .failed("Gateway did not start in time")
|
||||
self.lastFailureReason = "launchd start timeout"
|
||||
self.logger.warning("gateway start timed out")
|
||||
}
|
||||
|
||||
private func appendLog(_ chunk: String) {
|
||||
@@ -317,6 +330,7 @@ final class GatewayProcessManager {
|
||||
break
|
||||
}
|
||||
self.appendLog("[gateway] refreshing control channel (\(reason))\n")
|
||||
self.logger.debug("gateway control channel refresh reason=\(reason)")
|
||||
Task { await ControlChannel.shared.configure() }
|
||||
}
|
||||
|
||||
@@ -332,12 +346,14 @@ final class GatewayProcessManager {
|
||||
}
|
||||
}
|
||||
self.appendLog("[gateway] readiness wait timed out\n")
|
||||
self.logger.warning("gateway readiness wait timed out")
|
||||
return false
|
||||
}
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||
self.logger.debug("gateway log cleared")
|
||||
}
|
||||
|
||||
func setProjectRoot(path: String) {
|
||||
|
||||
@@ -4,18 +4,23 @@ import JavaScriptCore
|
||||
enum ModelCatalogLoader {
|
||||
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
|
||||
.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] {
|
||||
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 sanitized = self.sanitize(source: source)
|
||||
|
||||
let ctx = JSContext()
|
||||
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)
|
||||
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
|
||||
self.logger.error("model catalog parse failed: MODELS missing")
|
||||
throw NSError(
|
||||
domain: "ModelCatalogLoader",
|
||||
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 {
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .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 {
|
||||
|
||||
@@ -5,6 +5,8 @@ import UserNotifications
|
||||
|
||||
@MainActor
|
||||
struct NotificationManager {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "notifications")
|
||||
|
||||
private static let hasTimeSensitiveEntitlement: Bool = {
|
||||
guard let task = SecTaskCreateFromSelf(nil) else { return false }
|
||||
let key = "com.apple.developer.usernotifications.time-sensitive" as CFString
|
||||
@@ -17,8 +19,12 @@ struct NotificationManager {
|
||||
let status = await center.notificationSettings()
|
||||
if status.authorizationStatus == .notDetermined {
|
||||
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 {
|
||||
self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -37,15 +43,22 @@ struct NotificationManager {
|
||||
case .active:
|
||||
content.interruptionLevel = .active
|
||||
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)
|
||||
do {
|
||||
try await center.add(req)
|
||||
self.logger.debug("notification queued")
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("notification send failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user