diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 8d4aa0ac1..e60ad8669 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -253,7 +253,13 @@ final class ControlChannel: ObservableObject { return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) } - private func request(method: String, params: [String: Any]? = nil, timeout: TimeInterval? = nil) async throws -> Data { + func sendSystemEvent(_ text: String) async throws { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + _ = try await self.request(method: "system-event", params: ["text": trimmed], timeout: 5) + } + + func request(method: String, params: [String: Any]? = nil, timeout: TimeInterval? = nil) async throws -> Data { try await self.ensureConnected() let id = UUID().uuidString var frame: [String: Any] = ["type": "request", "id": id, "method": method] diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift new file mode 100644 index 000000000..569206fbb --- /dev/null +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct InstancesSettings: View { + @StateObject private var store = InstancesStore.shared + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if let err = store.lastError { + Text("Error: \(err)") + .foregroundStyle(.red) + } + if store.instances.isEmpty { + Text("No instances reported yet.") + .foregroundStyle(.secondary) + } else { + List(store.instances) { inst in + instanceRow(inst) + } + .listStyle(.inset) + } + Spacer() + } + .onAppear { store.start() } + .onDisappear { store.stop() } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Connected Instances") + .font(.headline) + Text("Latest presence beacons from Clawdis nodes. Updated periodically.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + if store.isLoading { + ProgressView() + } else { + Button("Refresh") { Task { await store.refresh() } } + } + } + } + + @ViewBuilder + private func instanceRow(_ inst: InstanceInfo) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(inst.host ?? "unknown host").font(.subheadline.bold()) + if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } + } + HStack(spacing: 8) { + if let version = inst.version { + label(icon: "shippingbox", text: version) + } + label(icon: "clock", text: inst.lastInputDescription) + if let mode = inst.mode { label(icon: "network", text: mode) } + if let reason = inst.reason, !reason.isEmpty { + label(icon: "info.circle", text: reason) + } + } + Text(inst.text) + .font(.footnote) + .foregroundStyle(.secondary) + Text(inst.ageDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + } + + private func label(icon: String, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon).foregroundStyle(.secondary).font(.caption) + Text(text) + } + .font(.footnote) + } +} diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift new file mode 100644 index 000000000..22832a80f --- /dev/null +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -0,0 +1,82 @@ +import Foundation +import OSLog + +struct InstanceInfo: Identifiable, Codable { + let id: String + let host: String? + let ip: String? + let version: String? + let lastInputSeconds: Int? + let mode: String? + let reason: String? + let text: String + let ts: Double + + var ageDescription: String { + let date = Date(timeIntervalSince1970: ts / 1000) + return age(from: date) + } + + var lastInputDescription: String { + guard let secs = lastInputSeconds else { return "unknown" } + return "\(secs)s ago" + } +} + +@MainActor +final class InstancesStore: ObservableObject { + static let shared = InstancesStore() + + @Published var instances: [InstanceInfo] = [] + @Published var lastError: String? + @Published var isLoading = false + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances") + private var task: Task? + private let interval: TimeInterval = 30 + + func start() { + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh() + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + func refresh() async { + if self.isLoading { return } + self.isLoading = true + defer { self.isLoading = false } + do { + let data = try await ControlChannel.shared.request(method: "system-presence") + let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data) + let withIDs = decoded.map { entry -> InstanceInfo in + let key = entry.host ?? entry.ip ?? entry.text + return InstanceInfo( + id: key, + host: entry.host, + ip: entry.ip, + version: entry.version, + lastInputSeconds: entry.lastInputSeconds, + mode: entry.mode, + reason: entry.reason, + text: entry.text, + ts: entry.ts) + } + self.instances = withIDs + self.lastError = nil + } catch { + self.logger.error("instances fetch failed: \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 366d45eed..5d536e7ff 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -95,6 +95,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate try? await ControlChannel.shared.configure(mode: controlMode) try? await AgentRPC.shared.start() _ = await AgentRPC.shared.setHeartbeatsEnabled(AppStateStore.shared.heartbeatsEnabled) + PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } self.startListener() @@ -109,6 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate func applicationWillTerminate(_ notification: Notification) { RelayProcessManager.shared.stop() + PresenceReporter.shared.stop() } @MainActor diff --git a/apps/macos/Sources/Clawdis/PresenceReporter.swift b/apps/macos/Sources/Clawdis/PresenceReporter.swift new file mode 100644 index 000000000..b0041f74c --- /dev/null +++ b/apps/macos/Sources/Clawdis/PresenceReporter.swift @@ -0,0 +1,95 @@ +import Cocoa +import Darwin +import Foundation +import OSLog + +@MainActor +final class PresenceReporter { + static let shared = PresenceReporter() + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "presence") + private var task: Task? + private let interval: TimeInterval = 180 // a few minutes + + func start() { + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.push(reason: "launch") + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.push(reason: "periodic") + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + @Sendable + private func push(reason: String) async { + let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } + let text = Self.composePresenceSummary(mode: mode, reason: reason) + do { + try await ControlChannel.shared.sendSystemEvent(text) + } catch { + self.logger.error("presence send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private static func composePresenceSummary(mode: String, reason: String) -> String { + let host = Host.current().localizedName ?? "unknown-host" + let ip = Self.primaryIPv4Address() ?? "ip-unknown" + let version = Self.appVersionString() + let lastInput = Self.lastInputSeconds() + let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown" + return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)" + } + + private static func appVersionString() -> String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { + return "\(version) (\(build))" + } + return version + } + + private static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } + + private static func primaryIPv4Address() -> String? { + var addrList: UnsafeMutablePointer? = nil + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + var fallback: String? + var en0: String? + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let name = String(cString: ptr.pointee.ifa_name) + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo(&addr, socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let ip = String(decoding: len.map { UInt8(bitPattern: $0) }, as: UTF8.self) + + if name == "en0" { en0 = ip; break } + if fallback == nil { fallback = ip } + } + + return en0 ?? fallback + } +} diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index ad527bbfe..6a0f0fed7 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -21,6 +21,10 @@ struct SettingsRootView: View { .tabItem { Label("Config", systemImage: "slider.horizontal.3") } .tag(SettingsTab.config) + InstancesSettings() + .tabItem { Label("Instances", systemImage: "network") } + .tag(SettingsTab.instances) + PermissionsSettings( status: self.permissionMonitor.status, refresh: self.refreshPerms, @@ -104,15 +108,16 @@ struct SettingsRootView: View { } enum SettingsTab: CaseIterable { - case general, tools, sessions, config, voiceWake, permissions, debug, about - static let windowWidth: CGFloat = 520 - static let windowHeight: CGFloat = 624 + case general, tools, sessions, config, instances, voiceWake, permissions, debug, about + static let windowWidth: CGFloat = 598 // +15% + static let windowHeight: CGFloat = 718 // +15% var title: String { switch self { case .general: "General" case .tools: "Tools" case .sessions: "Sessions" case .config: "Config" + case .instances: "Instances" case .voiceWake: "Voice Wake" case .permissions: "Permissions" case .debug: "Debug"