diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index e4d2fb296..bf4d94cfc 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -67,6 +67,9 @@ struct InstancesSettings: View { if let version = inst.version { self.label(icon: "shippingbox", text: version) } + if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) { + self.label(icon: self.platformIcon(platform), text: prettyPlatform) + } self.label(icon: "clock", text: inst.lastInputDescription) if let mode = inst.mode { self.label(icon: "network", text: mode) } if let reason = inst.reason, !reason.isEmpty { @@ -94,6 +97,52 @@ struct InstancesSettings: View { .font(.footnote) } + private func platformIcon(_ raw: String) -> String { + let (prefix, _) = self.parsePlatform(raw) + switch prefix { + case "macos": + return "laptopcomputer" + case "ios": + return "iphone" + case "ipados": + return "ipad" + case "tvos": + return "appletv" + case "watchos": + return "applewatch" + default: + return "cpu" + } + } + + private func prettyPlatform(_ raw: String) -> String? { + let (prefix, version) = self.parsePlatform(raw) + if prefix.isEmpty { return nil } + let name: String = switch prefix { + case "macos": "macOS" + case "ios": "iOS" + case "ipados": "iPadOS" + case "tvos": "tvOS" + case "watchos": "watchOS" + default: prefix.prefix(1).uppercased() + prefix.dropFirst() + } + guard let version, !version.isEmpty else { return name } + let parts = version.split(separator: ".").map(String.init) + if parts.count >= 2 { + return "\(name) \(parts[0]).\(parts[1])" + } + return "\(name) \(version)" + } + + private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return ("", nil) } + let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + let prefix = parts.first?.lowercased() ?? "" + let versionToken = parts.dropFirst().first + return (prefix, versionToken) + } + private func presenceUpdateSourceText(_ reason: String) -> String { let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) switch trimmed { diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index a7f82b9f2..7dad9d3b5 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -8,6 +8,7 @@ struct InstanceInfo: Identifiable, Codable { let host: String? let ip: String? let version: String? + let platform: String? let lastInputSeconds: Int? let mode: String? let reason: String? @@ -145,6 +146,8 @@ final class InstancesStore: ObservableObject { let host = Host.current().localizedName ?? "this-mac" let ip = Self.primaryIPv4Address() let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")" let ts = Date().timeIntervalSince1970 * 1000 return InstanceInfo( @@ -152,6 +155,7 @@ final class InstancesStore: ObservableObject { host: host, ip: ip, version: version, + platform: platform, lastInputSeconds: Self.lastInputSeconds(), mode: "local", reason: reason, @@ -228,6 +232,7 @@ final class InstancesStore: ObservableObject { host: "gateway (health)", ip: nil, version: nil, + platform: nil, lastInputSeconds: nil, mode: "health", reason: "health probe", @@ -276,6 +281,7 @@ final class InstancesStore: ObservableObject { host: entry.host, ip: entry.ip, version: entry.version, + platform: entry.platform, lastInputSeconds: entry.lastinputseconds, mode: entry.mode, reason: entry.reason, @@ -299,6 +305,7 @@ extension InstancesStore { host: "steipete-mac", ip: "10.0.0.12", version: "1.2.3", + platform: "macos 26.2.0", lastInputSeconds: 12, mode: "local", reason: "preview", @@ -309,6 +316,7 @@ extension InstancesStore { host: "gateway", ip: "100.64.0.2", version: "1.2.3", + platform: "linux 6.6.0", lastInputSeconds: 45, mode: "remote", reason: "preview", diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 9d66643c5..c4b4def2b 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -167,6 +167,7 @@ public struct PresenceEntry: Codable { public let host: String? public let ip: String? public let version: String? + public let platform: String? public let mode: String? public let lastinputseconds: Int? public let reason: String? @@ -179,6 +180,7 @@ public struct PresenceEntry: Codable { host: String?, ip: String?, version: String?, + platform: String?, mode: String?, lastinputseconds: Int?, reason: String?, @@ -190,6 +192,7 @@ public struct PresenceEntry: Codable { self.host = host self.ip = ip self.version = version + self.platform = platform self.mode = mode self.lastinputseconds = lastinputseconds self.reason = reason @@ -202,6 +205,7 @@ public struct PresenceEntry: Codable { case host case ip case version + case platform case mode case lastinputseconds = "lastInputSeconds" case reason diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 3fa266ded..34dae46c7 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -7,6 +7,7 @@ export const PresenceEntrySchema = Type.Object( host: Type.Optional(NonEmptyString), ip: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString), + platform: Type.Optional(NonEmptyString), mode: Type.Optional(NonEmptyString), lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })), reason: Type.Optional(NonEmptyString), diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5b20e5ee0..5789de48f 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -819,11 +819,13 @@ export async function startGatewayServer( const host = node.displayName?.trim() || node.nodeId; const ip = node.remoteIp?.trim(); const version = node.version?.trim() || "unknown"; + const platform = node.platform?.trim() || undefined; const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-connected`; upsertPresence(node.nodeId, { host, ip, version, + platform, mode: "remote", reason: "iris-connected", lastInputSeconds: 0, @@ -847,11 +849,13 @@ export async function startGatewayServer( const host = node.displayName?.trim() || node.nodeId; const ip = node.remoteIp?.trim(); const version = node.version?.trim() || "unknown"; + const platform = node.platform?.trim() || undefined; const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-disconnected`; upsertPresence(node.nodeId, { host, ip, version, + platform, mode: "remote", reason: "iris-disconnected", lastInputSeconds: 0, @@ -1206,6 +1210,7 @@ export async function startGatewayServer( host: connectParams.client.name || os.hostname(), ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, version: connectParams.client.version, + platform: connectParams.client.platform, mode: connectParams.client.mode, instanceId: connectParams.client.instanceId, reason: "connect", @@ -1824,6 +1829,8 @@ export async function startGatewayServer( typeof params.mode === "string" ? params.mode : undefined; const version = typeof params.version === "string" ? params.version : undefined; + const platform = + typeof params.platform === "string" ? params.platform : undefined; const lastInputSeconds = typeof params.lastInputSeconds === "number" && Number.isFinite(params.lastInputSeconds) @@ -1843,6 +1850,7 @@ export async function startGatewayServer( ip, mode, version, + platform, lastInputSeconds, reason, tags, diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index ae2e0a9d6..eb90417dc 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -4,6 +4,7 @@ export type SystemPresence = { host?: string; ip?: string; version?: string; + platform?: string; lastInputSeconds?: number; mode?: string; reason?: string; @@ -46,11 +47,19 @@ function initSelfPresence() { const ip = resolvePrimaryIPv4() ?? undefined; const version = process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown"; + const platform = (() => { + const p = os.platform(); + const rel = os.release(); + if (p === "darwin") return `macos ${rel}`; + if (p === "win32") return `windows ${rel}`; + return `${p} ${rel}`; + })(); const text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`; const selfEntry: SystemPresence = { host, ip, version, + platform, mode: "gateway", reason: "self", text, @@ -113,6 +122,7 @@ type SystemPresencePayload = { host?: string; ip?: string; version?: string; + platform?: string; lastInputSeconds?: number; mode?: string; reason?: string; @@ -136,6 +146,7 @@ export function updateSystemPresence(payload: SystemPresencePayload) { host: payload.host ?? parsed.host ?? existing.host, ip: payload.ip ?? parsed.ip ?? existing.ip, version: payload.version ?? parsed.version ?? existing.version, + platform: payload.platform ?? existing.platform, mode: payload.mode ?? parsed.mode ?? existing.mode, lastInputSeconds: payload.lastInputSeconds ??