feat(macos): add instances tab and presence beacons
This commit is contained in:
@@ -253,7 +253,13 @@ final class ControlChannel: ObservableObject {
|
|||||||
return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data)
|
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()
|
try await self.ensureConnected()
|
||||||
let id = UUID().uuidString
|
let id = UUID().uuidString
|
||||||
var frame: [String: Any] = ["type": "request", "id": id, "method": method]
|
var frame: [String: Any] = ["type": "request", "id": id, "method": method]
|
||||||
|
|||||||
80
apps/macos/Sources/Clawdis/InstancesSettings.swift
Normal file
80
apps/macos/Sources/Clawdis/InstancesSettings.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
apps/macos/Sources/Clawdis/InstancesStore.swift
Normal file
82
apps/macos/Sources/Clawdis/InstancesStore.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
try? await ControlChannel.shared.configure(mode: controlMode)
|
try? await ControlChannel.shared.configure(mode: controlMode)
|
||||||
try? await AgentRPC.shared.start()
|
try? await AgentRPC.shared.start()
|
||||||
_ = await AgentRPC.shared.setHeartbeatsEnabled(AppStateStore.shared.heartbeatsEnabled)
|
_ = await AgentRPC.shared.setHeartbeatsEnabled(AppStateStore.shared.heartbeatsEnabled)
|
||||||
|
PresenceReporter.shared.start()
|
||||||
}
|
}
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
self.startListener()
|
self.startListener()
|
||||||
@@ -109,6 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
RelayProcessManager.shared.stop()
|
RelayProcessManager.shared.stop()
|
||||||
|
PresenceReporter.shared.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
95
apps/macos/Sources/Clawdis/PresenceReporter.swift
Normal file
95
apps/macos/Sources/Clawdis/PresenceReporter.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
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<ifaddrs>? = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ struct SettingsRootView: View {
|
|||||||
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
|
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
|
||||||
.tag(SettingsTab.config)
|
.tag(SettingsTab.config)
|
||||||
|
|
||||||
|
InstancesSettings()
|
||||||
|
.tabItem { Label("Instances", systemImage: "network") }
|
||||||
|
.tag(SettingsTab.instances)
|
||||||
|
|
||||||
PermissionsSettings(
|
PermissionsSettings(
|
||||||
status: self.permissionMonitor.status,
|
status: self.permissionMonitor.status,
|
||||||
refresh: self.refreshPerms,
|
refresh: self.refreshPerms,
|
||||||
@@ -104,15 +108,16 @@ struct SettingsRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SettingsTab: CaseIterable {
|
enum SettingsTab: CaseIterable {
|
||||||
case general, tools, sessions, config, voiceWake, permissions, debug, about
|
case general, tools, sessions, config, instances, voiceWake, permissions, debug, about
|
||||||
static let windowWidth: CGFloat = 520
|
static let windowWidth: CGFloat = 598 // +15%
|
||||||
static let windowHeight: CGFloat = 624
|
static let windowHeight: CGFloat = 718 // +15%
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "General"
|
case .general: "General"
|
||||||
case .tools: "Tools"
|
case .tools: "Tools"
|
||||||
case .sessions: "Sessions"
|
case .sessions: "Sessions"
|
||||||
case .config: "Config"
|
case .config: "Config"
|
||||||
|
case .instances: "Instances"
|
||||||
case .voiceWake: "Voice Wake"
|
case .voiceWake: "Voice Wake"
|
||||||
case .permissions: "Permissions"
|
case .permissions: "Permissions"
|
||||||
case .debug: "Debug"
|
case .debug: "Debug"
|
||||||
|
|||||||
Reference in New Issue
Block a user