chore: add mac app logging coverage
This commit is contained in:
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user