diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 60e2093bef..4bd65c7cf4 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -1,6 +1,7 @@ package com.steipete.clawdis.node import android.content.Context +import android.os.Build import com.steipete.clawdis.node.bridge.BridgeDiscovery import com.steipete.clawdis.node.bridge.BridgeEndpoint import com.steipete.clawdis.node.bridge.BridgePairingClient @@ -182,6 +183,8 @@ class NodeRuntime(context: Context) { token = null, platform = "Android", version = "dev", + deviceFamily = "Android", + modelIdentifier = Build.MODEL, ), ) } else { @@ -204,6 +207,8 @@ class NodeRuntime(context: Context) { token = authToken, platform = "Android", version = "dev", + deviceFamily = "Android", + modelIdentifier = Build.MODEL, ), ) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt index a52aa5dbd2..021c918efd 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt @@ -24,6 +24,8 @@ class BridgePairingClient { val token: String?, val platform: String?, val version: String?, + val deviceFamily: String?, + val modelIdentifier: String?, ) data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) @@ -55,6 +57,8 @@ class BridgePairingClient { hello.token?.let { put("token", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) } + hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } }, ) @@ -76,6 +80,8 @@ class BridgePairingClient { hello.displayName?.let { put("displayName", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) } + hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } }, ) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt index 9f949f9d87..adb7b85ec9 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt @@ -39,6 +39,8 @@ class BridgeSession( val token: String?, val platform: String?, val version: String?, + val deviceFamily: String?, + val modelIdentifier: String?, ) data class InvokeRequest(val id: String, val command: String, val paramsJson: String?) @@ -191,6 +193,8 @@ class BridgeSession( hello.token?.let { put("token", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) } + hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } }, ) diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt index 9644bb7c91..aae427c882 100644 --- a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt @@ -46,6 +46,8 @@ class BridgePairingClientTest { token = "token-123", platform = "Android", version = "test", + deviceFamily = "Android", + modelIdentifier = "SM-X000", ), ) assertTrue(res.ok) @@ -91,6 +93,8 @@ class BridgePairingClientTest { token = null, platform = "Android", version = "test", + deviceFamily = "Android", + modelIdentifier = "SM-X000", ), ) assertTrue(res.ok) @@ -98,4 +102,3 @@ class BridgePairingClientTest { server.await() } } - diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index 8933a33bc8..5b2c6fc9d4 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -51,7 +51,9 @@ actor BridgeClient { nodeId: hello.nodeId, displayName: hello.displayName, platform: hello.platform, - version: hello.version), + version: hello.version, + deviceFamily: hello.deviceFamily, + modelIdentifier: hello.modelIdentifier), over: connection) onStatus?("Waiting for approval…") diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 2e6e4d01a4..b0cae15eb7 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -1,8 +1,10 @@ import ClawdisKit +import Darwin import Foundation import Network import Observation import SwiftUI +import UIKit @MainActor @Observable @@ -131,12 +133,43 @@ final class BridgeConnectionController { displayName: displayName, token: token, platform: self.platformString(), - version: self.appVersion()) + version: self.appVersion(), + deviceFamily: self.deviceFamily(), + modelIdentifier: self.modelIdentifier()) } private func platformString() -> String { let v = ProcessInfo.processInfo.operatingSystemVersion - return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + let name: String + switch UIDevice.current.userInterfaceIdiom { + case .pad: + name = "iPadOS" + case .phone: + name = "iOS" + default: + name = "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + private func deviceFamily() -> String { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + return "iPad" + case .phone: + return "iPhone" + default: + return "iOS" + } + } + + private func modelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(decoding: ptr.prefix { $0 != 0 }, as: UTF8.self) + } + return machine.isEmpty ? "unknown" : machine } private func appVersion() -> String { diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 4fe68858db..c2e155a53c 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -192,7 +192,7 @@ actor GatewayChannelActor { let clientName = InstanceIdentity.displayName let reqId = UUID().uuidString - let client: [String: ProtoAnyCodable] = [ + var client: [String: ProtoAnyCodable] = [ "name": ProtoAnyCodable(clientName), "version": ProtoAnyCodable( Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), @@ -200,6 +200,10 @@ actor GatewayChannelActor { "mode": ProtoAnyCodable("app"), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), ] + client["deviceFamily"] = ProtoAnyCodable("Mac") + if let model = InstanceIdentity.modelIdentifier { + client["modelIdentifier"] = ProtoAnyCodable(model) + } var params: [String: ProtoAnyCodable] = [ "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), diff --git a/apps/macos/Sources/Clawdis/InstanceIdentity.swift b/apps/macos/Sources/Clawdis/InstanceIdentity.swift index cf35b88fb8..35902ce00d 100644 --- a/apps/macos/Sources/Clawdis/InstanceIdentity.swift +++ b/apps/macos/Sources/Clawdis/InstanceIdentity.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation enum InstanceIdentity { @@ -30,4 +31,15 @@ enum InstanceIdentity { } return "clawdis-mac" }() + + static let modelIdentifier: String? = { + var size = 0 + guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } + + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } + + let s = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + return s.isEmpty ? nil : s + }() } diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index fb656e2252..94e110aa9a 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -70,6 +70,11 @@ struct InstancesSettings: View { if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) { self.label(icon: self.platformIcon(platform), text: prettyPlatform) } + if let deviceText = self.deviceDescription(inst), + let deviceIcon = self.deviceIcon(inst) + { + self.label(icon: deviceIcon, text: deviceText) + } 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 { @@ -115,6 +120,29 @@ struct InstancesSettings: View { } } + private func deviceIcon(_ inst: InstanceInfo) -> String? { + let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if family.isEmpty { return nil } + switch family.lowercased() { + case "ipad": + return "ipad" + case "iphone": + return "iphone" + case "mac": + return "laptopcomputer" + default: + return "cpu" + } + } + + private func deviceDescription(_ inst: InstanceInfo) -> String? { + let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !family.isEmpty, !model.isEmpty { return "\(family) (\(model))" } + if !model.isEmpty { return model } + return family.isEmpty ? nil : family + } + private func prettyPlatform(_ raw: String) -> String? { let (prefix, version) = self.parsePlatform(raw) if prefix.isEmpty { return nil } diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index e48f326fcc..517185504b 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -10,6 +10,8 @@ struct InstanceInfo: Identifiable, Codable { let ip: String? let version: String? let platform: String? + let deviceFamily: String? + let modelIdentifier: String? let lastInputSeconds: Int? let mode: String? let reason: String? @@ -284,6 +286,8 @@ final class InstancesStore { ip: entry.ip, version: entry.version, platform: entry.platform, + deviceFamily: entry.devicefamily, + modelIdentifier: entry.modelidentifier, lastInputSeconds: entry.lastinputseconds, mode: entry.mode, reason: entry.reason, @@ -308,6 +312,8 @@ extension InstancesStore { ip: "10.0.0.12", version: "1.2.3", platform: "macos 26.2.0", + deviceFamily: "Mac", + modelIdentifier: "Mac16,6", lastInputSeconds: 12, mode: "local", reason: "preview", @@ -319,6 +325,8 @@ extension InstancesStore { ip: "100.64.0.2", version: "1.2.3", platform: "linux 6.6.0", + deviceFamily: "Linux", + modelIdentifier: "x86_64", lastInputSeconds: 45, mode: "remote", reason: "preview", diff --git a/apps/macos/Sources/Clawdis/PresenceReporter.swift b/apps/macos/Sources/Clawdis/PresenceReporter.swift index dfe8d5efa9..05f7d3d4ed 100644 --- a/apps/macos/Sources/Clawdis/PresenceReporter.swift +++ b/apps/macos/Sources/Clawdis/PresenceReporter.swift @@ -45,6 +45,8 @@ final class PresenceReporter { "version": AnyHashable(version), "reason": AnyHashable(reason), ] + params["deviceFamily"] = AnyHashable("Mac") + if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) } if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } do { try await ControlChannel.shared.sendSystemEvent(text, params: params) diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 65e0dc4975..97ffa4cd8f 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -168,6 +168,8 @@ public struct PresenceEntry: Codable { public let ip: String? public let version: String? public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? public let mode: String? public let lastinputseconds: Int? public let reason: String? @@ -181,6 +183,8 @@ public struct PresenceEntry: Codable { ip: String?, version: String?, platform: String?, + devicefamily: String?, + modelidentifier: String?, mode: String?, lastinputseconds: Int?, reason: String?, @@ -193,6 +197,8 @@ public struct PresenceEntry: Codable { self.ip = ip self.version = version self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier self.mode = mode self.lastinputseconds = lastinputseconds self.reason = reason @@ -206,6 +212,8 @@ public struct PresenceEntry: Codable { case ip case version case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" case mode case lastinputseconds = "lastInputSeconds" case reason diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift index 74bf435e3e..666d2df257 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift @@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable { public let token: String? public let platform: String? public let version: String? + public let deviceFamily: String? + public let modelIdentifier: String? public init( type: String = "hello", @@ -70,7 +72,9 @@ public struct BridgeHello: Codable, Sendable { displayName: String?, token: String?, platform: String?, - version: String?) + version: String?, + deviceFamily: String? = nil, + modelIdentifier: String? = nil) { self.type = type self.nodeId = nodeId @@ -78,6 +82,8 @@ public struct BridgeHello: Codable, Sendable { self.token = token self.platform = platform self.version = version + self.deviceFamily = deviceFamily + self.modelIdentifier = modelIdentifier } } @@ -97,6 +103,8 @@ public struct BridgePairRequest: Codable, Sendable { public let displayName: String? public let platform: String? public let version: String? + public let deviceFamily: String? + public let modelIdentifier: String? public let remoteAddress: String? public init( @@ -105,6 +113,8 @@ public struct BridgePairRequest: Codable, Sendable { displayName: String?, platform: String?, version: String?, + deviceFamily: String? = nil, + modelIdentifier: String? = nil, remoteAddress: String? = nil) { self.type = type @@ -112,6 +122,8 @@ public struct BridgePairRequest: Codable, Sendable { self.displayName = displayName self.platform = platform self.version = version + self.deviceFamily = deviceFamily + self.modelIdentifier = modelIdentifier self.remoteAddress = remoteAddress } } diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index ffd1c025d5..767c00f920 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -8,6 +8,8 @@ export const PresenceEntrySchema = Type.Object( ip: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString), + deviceFamily: Type.Optional(NonEmptyString), + modelIdentifier: Type.Optional(NonEmptyString), mode: Type.Optional(NonEmptyString), lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })), reason: Type.Optional(NonEmptyString), @@ -65,6 +67,8 @@ export const ConnectParamsSchema = Type.Object( platform: NonEmptyString, mode: NonEmptyString, instanceId: Type.Optional(NonEmptyString), + deviceFamily: Type.Optional(NonEmptyString), + modelIdentifier: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index c3674f7666..8aaa01cc4b 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -2111,6 +2111,8 @@ describe("gateway server", () => { platform: "test", mode: "ui", instanceId: "abc", + deviceFamily: "Mac", + modelIdentifier: "Mac16,6", }, }); @@ -2133,6 +2135,8 @@ describe("gateway server", () => { expect(clientEntry?.host).toBe("fingerprint"); expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.mode).toBe("ui"); + expect(clientEntry?.deviceFamily).toBe("Mac"); + expect(clientEntry?.modelIdentifier).toBe("Mac16,6"); ws.close(); await server.close(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5c1405ba81..e2aa178ae7 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1300,12 +1300,16 @@ export async function startGatewayServer( const ip = node.remoteIp?.trim(); const version = node.version?.trim() || "unknown"; const platform = node.platform?.trim() || undefined; + const deviceFamily = node.deviceFamily?.trim() || undefined; + const modelIdentifier = node.modelIdentifier?.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, + deviceFamily, + modelIdentifier, mode: "remote", reason: "iris-connected", lastInputSeconds: 0, @@ -1342,12 +1346,16 @@ export async function startGatewayServer( const ip = node.remoteIp?.trim(); const version = node.version?.trim() || "unknown"; const platform = node.platform?.trim() || undefined; + const deviceFamily = node.deviceFamily?.trim() || undefined; + const modelIdentifier = node.modelIdentifier?.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, + deviceFamily, + modelIdentifier, mode: "remote", reason: "iris-disconnected", lastInputSeconds: 0, @@ -1743,6 +1751,8 @@ export async function startGatewayServer( ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, version: connectParams.client.version, platform: connectParams.client.platform, + deviceFamily: connectParams.client.deviceFamily, + modelIdentifier: connectParams.client.modelIdentifier, mode: connectParams.client.mode, instanceId: connectParams.client.instanceId, reason: "connect", @@ -2424,6 +2434,14 @@ export async function startGatewayServer( typeof params.version === "string" ? params.version : undefined; const platform = typeof params.platform === "string" ? params.platform : undefined; + const deviceFamily = + typeof params.deviceFamily === "string" + ? params.deviceFamily + : undefined; + const modelIdentifier = + typeof params.modelIdentifier === "string" + ? params.modelIdentifier + : undefined; const lastInputSeconds = typeof params.lastInputSeconds === "number" && Number.isFinite(params.lastInputSeconds) @@ -2444,6 +2462,8 @@ export async function startGatewayServer( mode, version, platform, + deviceFamily, + modelIdentifier, lastInputSeconds, reason, tags, diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 323e1d6e53..bdf16f58ba 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -17,6 +17,8 @@ type BridgeHelloFrame = { token?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; }; type BridgePairRequestFrame = { @@ -25,6 +27,8 @@ type BridgePairRequestFrame = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteAddress?: string; }; @@ -108,6 +112,8 @@ export type NodeBridgeClientInfo = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; }; @@ -263,6 +269,8 @@ export async function startNodeBridgeServer( displayName: verified.node.displayName ?? hello.displayName, platform: verified.node.platform ?? hello.platform, version: verified.node.version ?? hello.version, + deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily, + modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier, remoteIp: remoteAddress, }; connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); @@ -319,6 +327,8 @@ export async function startNodeBridgeServer( displayName: req.displayName, platform: req.platform, version: req.version, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, remoteIp: remoteAddress, }, opts.pairingBaseDir, diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index c7d727f3ca..1fba1ba130 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -9,6 +9,8 @@ export type NodePairingPendingRequest = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; isRepair?: boolean; ts: number; @@ -20,6 +22,8 @@ export type NodePairingPairedNode = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; createdAtMs: number; approvedAtMs: number; @@ -172,6 +176,8 @@ export async function requestNodePairing( displayName: req.displayName, platform: req.platform, version: req.version, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, remoteIp: req.remoteIp, isRepair, ts: Date.now(), @@ -199,6 +205,8 @@ export async function approveNodePairing( displayName: pending.displayName, platform: pending.platform, version: pending.version, + deviceFamily: pending.deviceFamily, + modelIdentifier: pending.modelIdentifier, remoteIp: pending.remoteIp, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index eb90417dcd..cb24ad5af3 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import os from "node:os"; export type SystemPresence = { @@ -5,6 +6,8 @@ export type SystemPresence = { ip?: string; version?: string; platform?: string; + deviceFamily?: string; + modelIdentifier?: string; lastInputSeconds?: number; mode?: string; reason?: string; @@ -47,6 +50,17 @@ function initSelfPresence() { const ip = resolvePrimaryIPv4() ?? undefined; const version = process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown"; + const modelIdentifier = (() => { + const p = os.platform(); + if (p === "darwin") { + const res = spawnSync("sysctl", ["-n", "hw.model"], { + encoding: "utf-8", + }); + const out = typeof res.stdout === "string" ? res.stdout.trim() : ""; + return out.length > 0 ? out : undefined; + } + return os.arch(); + })(); const platform = (() => { const p = os.platform(); const rel = os.release(); @@ -54,12 +68,21 @@ function initSelfPresence() { if (p === "win32") return `windows ${rel}`; return `${p} ${rel}`; })(); + const deviceFamily = (() => { + const p = os.platform(); + if (p === "darwin") return "Mac"; + if (p === "win32") return "Windows"; + if (p === "linux") return "Linux"; + return p; + })(); const text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`; const selfEntry: SystemPresence = { host, ip, version, platform, + deviceFamily, + modelIdentifier, mode: "gateway", reason: "self", text, @@ -123,6 +146,8 @@ type SystemPresencePayload = { ip?: string; version?: string; platform?: string; + deviceFamily?: string; + modelIdentifier?: string; lastInputSeconds?: number; mode?: string; reason?: string; @@ -147,6 +172,8 @@ export function updateSystemPresence(payload: SystemPresencePayload) { ip: payload.ip ?? parsed.ip ?? existing.ip, version: payload.version ?? parsed.version ?? existing.version, platform: payload.platform ?? existing.platform, + deviceFamily: payload.deviceFamily ?? existing.deviceFamily, + modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier, mode: payload.mode ?? parsed.mode ?? existing.mode, lastInputSeconds: payload.lastInputSeconds ??