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)
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
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 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
|
||||
|
||||
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") }
|
||||
.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"
|
||||
|
||||
Reference in New Issue
Block a user