Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
e9502768af Presence: add device identity fields 2025-12-17 21:17:51 +01:00
19 changed files with 206 additions and 6 deletions

View File

@ -1,6 +1,7 @@
package com.steipete.clawdis.node package com.steipete.clawdis.node
import android.content.Context import android.content.Context
import android.os.Build
import com.steipete.clawdis.node.bridge.BridgeDiscovery import com.steipete.clawdis.node.bridge.BridgeDiscovery
import com.steipete.clawdis.node.bridge.BridgeEndpoint import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient import com.steipete.clawdis.node.bridge.BridgePairingClient
@ -182,6 +183,8 @@ class NodeRuntime(context: Context) {
token = null, token = null,
platform = "Android", platform = "Android",
version = "dev", version = "dev",
deviceFamily = "Android",
modelIdentifier = Build.MODEL,
), ),
) )
} else { } else {
@ -204,6 +207,8 @@ class NodeRuntime(context: Context) {
token = authToken, token = authToken,
platform = "Android", platform = "Android",
version = "dev", version = "dev",
deviceFamily = "Android",
modelIdentifier = Build.MODEL,
), ),
) )
} }

View File

@ -24,6 +24,8 @@ class BridgePairingClient {
val token: String?, val token: String?,
val platform: String?, val platform: String?,
val version: String?, val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
) )
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) 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.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", 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.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}, },
) )

View File

@ -39,6 +39,8 @@ class BridgeSession(
val token: String?, val token: String?,
val platform: String?, val platform: String?,
val version: String?, val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
) )
data class InvokeRequest(val id: String, val command: String, val paramsJson: 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.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}, },
) )

View File

@ -46,6 +46,8 @@ class BridgePairingClientTest {
token = "token-123", token = "token-123",
platform = "Android", platform = "Android",
version = "test", version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
), ),
) )
assertTrue(res.ok) assertTrue(res.ok)
@ -91,6 +93,8 @@ class BridgePairingClientTest {
token = null, token = null,
platform = "Android", platform = "Android",
version = "test", version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
), ),
) )
assertTrue(res.ok) assertTrue(res.ok)
@ -98,4 +102,3 @@ class BridgePairingClientTest {
server.await() server.await()
} }
} }

View File

@ -51,7 +51,9 @@ actor BridgeClient {
nodeId: hello.nodeId, nodeId: hello.nodeId,
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version), version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier),
over: connection) over: connection)
onStatus?("Waiting for approval…") onStatus?("Waiting for approval…")

View File

@ -1,8 +1,10 @@
import ClawdisKit import ClawdisKit
import Darwin
import Foundation import Foundation
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit
@MainActor @MainActor
@Observable @Observable
@ -131,12 +133,43 @@ final class BridgeConnectionController {
displayName: displayName, displayName: displayName,
token: token, token: token,
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion()) version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier())
} }
private func platformString() -> String { private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion 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 { private func appVersion() -> String {

View File

@ -192,7 +192,7 @@ actor GatewayChannelActor {
let clientName = InstanceIdentity.displayName let clientName = InstanceIdentity.displayName
let reqId = UUID().uuidString let reqId = UUID().uuidString
let client: [String: ProtoAnyCodable] = [ var client: [String: ProtoAnyCodable] = [
"name": ProtoAnyCodable(clientName), "name": ProtoAnyCodable(clientName),
"version": ProtoAnyCodable( "version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
@ -200,6 +200,10 @@ actor GatewayChannelActor {
"mode": ProtoAnyCodable("app"), "mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
] ]
client["deviceFamily"] = ProtoAnyCodable("Mac")
if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = ProtoAnyCodable(model)
}
var params: [String: ProtoAnyCodable] = [ var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),

View File

@ -1,3 +1,4 @@
import Darwin
import Foundation import Foundation
enum InstanceIdentity { enum InstanceIdentity {
@ -30,4 +31,15 @@ enum InstanceIdentity {
} }
return "clawdis-mac" 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
}()
} }

View File

@ -70,6 +70,11 @@ struct InstancesSettings: View {
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) { if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
self.label(icon: self.platformIcon(platform), text: prettyPlatform) 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) self.label(icon: "clock", text: inst.lastInputDescription)
if let mode = inst.mode { self.label(icon: "network", text: mode) } if let mode = inst.mode { self.label(icon: "network", text: mode) }
if let reason = inst.reason, !reason.isEmpty { 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? { private func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw) let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil } if prefix.isEmpty { return nil }

View File

@ -10,6 +10,8 @@ struct InstanceInfo: Identifiable, Codable {
let ip: String? let ip: String?
let version: String? let version: String?
let platform: String? let platform: String?
let deviceFamily: String?
let modelIdentifier: String?
let lastInputSeconds: Int? let lastInputSeconds: Int?
let mode: String? let mode: String?
let reason: String? let reason: String?
@ -284,6 +286,8 @@ final class InstancesStore {
ip: entry.ip, ip: entry.ip,
version: entry.version, version: entry.version,
platform: entry.platform, platform: entry.platform,
deviceFamily: entry.devicefamily,
modelIdentifier: entry.modelidentifier,
lastInputSeconds: entry.lastinputseconds, lastInputSeconds: entry.lastinputseconds,
mode: entry.mode, mode: entry.mode,
reason: entry.reason, reason: entry.reason,
@ -308,6 +312,8 @@ extension InstancesStore {
ip: "10.0.0.12", ip: "10.0.0.12",
version: "1.2.3", version: "1.2.3",
platform: "macos 26.2.0", platform: "macos 26.2.0",
deviceFamily: "Mac",
modelIdentifier: "Mac16,6",
lastInputSeconds: 12, lastInputSeconds: 12,
mode: "local", mode: "local",
reason: "preview", reason: "preview",
@ -319,6 +325,8 @@ extension InstancesStore {
ip: "100.64.0.2", ip: "100.64.0.2",
version: "1.2.3", version: "1.2.3",
platform: "linux 6.6.0", platform: "linux 6.6.0",
deviceFamily: "Linux",
modelIdentifier: "x86_64",
lastInputSeconds: 45, lastInputSeconds: 45,
mode: "remote", mode: "remote",
reason: "preview", reason: "preview",

View File

@ -45,6 +45,8 @@ final class PresenceReporter {
"version": AnyHashable(version), "version": AnyHashable(version),
"reason": AnyHashable(reason), "reason": AnyHashable(reason),
] ]
params["deviceFamily"] = AnyHashable("Mac")
if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) }
if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) }
do { do {
try await ControlChannel.shared.sendSystemEvent(text, params: params) try await ControlChannel.shared.sendSystemEvent(text, params: params)

View File

@ -168,6 +168,8 @@ public struct PresenceEntry: Codable {
public let ip: String? public let ip: String?
public let version: String? public let version: String?
public let platform: String? public let platform: String?
public let devicefamily: String?
public let modelidentifier: String?
public let mode: String? public let mode: String?
public let lastinputseconds: Int? public let lastinputseconds: Int?
public let reason: String? public let reason: String?
@ -181,6 +183,8 @@ public struct PresenceEntry: Codable {
ip: String?, ip: String?,
version: String?, version: String?,
platform: String?, platform: String?,
devicefamily: String?,
modelidentifier: String?,
mode: String?, mode: String?,
lastinputseconds: Int?, lastinputseconds: Int?,
reason: String?, reason: String?,
@ -193,6 +197,8 @@ public struct PresenceEntry: Codable {
self.ip = ip self.ip = ip
self.version = version self.version = version
self.platform = platform self.platform = platform
self.devicefamily = devicefamily
self.modelidentifier = modelidentifier
self.mode = mode self.mode = mode
self.lastinputseconds = lastinputseconds self.lastinputseconds = lastinputseconds
self.reason = reason self.reason = reason
@ -206,6 +212,8 @@ public struct PresenceEntry: Codable {
case ip case ip
case version case version
case platform case platform
case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier"
case mode case mode
case lastinputseconds = "lastInputSeconds" case lastinputseconds = "lastInputSeconds"
case reason case reason

View File

@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
public let token: String? public let token: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public init( public init(
type: String = "hello", type: String = "hello",
@ -70,7 +72,9 @@ public struct BridgeHello: Codable, Sendable {
displayName: String?, displayName: String?,
token: String?, token: String?,
platform: String?, platform: String?,
version: String?) version: String?,
deviceFamily: String? = nil,
modelIdentifier: String? = nil)
{ {
self.type = type self.type = type
self.nodeId = nodeId self.nodeId = nodeId
@ -78,6 +82,8 @@ public struct BridgeHello: Codable, Sendable {
self.token = token self.token = token
self.platform = platform self.platform = platform
self.version = version self.version = version
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
} }
} }
@ -97,6 +103,8 @@ public struct BridgePairRequest: Codable, Sendable {
public let displayName: String? public let displayName: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let remoteAddress: String? public let remoteAddress: String?
public init( public init(
@ -105,6 +113,8 @@ public struct BridgePairRequest: Codable, Sendable {
displayName: String?, displayName: String?,
platform: String?, platform: String?,
version: String?, version: String?,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
remoteAddress: String? = nil) remoteAddress: String? = nil)
{ {
self.type = type self.type = type
@ -112,6 +122,8 @@ public struct BridgePairRequest: Codable, Sendable {
self.displayName = displayName self.displayName = displayName
self.platform = platform self.platform = platform
self.version = version self.version = version
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.remoteAddress = remoteAddress self.remoteAddress = remoteAddress
} }
} }

View File

@ -8,6 +8,8 @@ export const PresenceEntrySchema = Type.Object(
ip: Type.Optional(NonEmptyString), ip: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
mode: Type.Optional(NonEmptyString), mode: Type.Optional(NonEmptyString),
lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })), lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
reason: Type.Optional(NonEmptyString), reason: Type.Optional(NonEmptyString),
@ -65,6 +67,8 @@ export const ConnectParamsSchema = Type.Object(
platform: NonEmptyString, platform: NonEmptyString,
mode: NonEmptyString, mode: NonEmptyString,
instanceId: Type.Optional(NonEmptyString), instanceId: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),

View File

@ -2111,6 +2111,8 @@ describe("gateway server", () => {
platform: "test", platform: "test",
mode: "ui", mode: "ui",
instanceId: "abc", instanceId: "abc",
deviceFamily: "Mac",
modelIdentifier: "Mac16,6",
}, },
}); });
@ -2133,6 +2135,8 @@ describe("gateway server", () => {
expect(clientEntry?.host).toBe("fingerprint"); expect(clientEntry?.host).toBe("fingerprint");
expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.version).toBe("9.9.9");
expect(clientEntry?.mode).toBe("ui"); expect(clientEntry?.mode).toBe("ui");
expect(clientEntry?.deviceFamily).toBe("Mac");
expect(clientEntry?.modelIdentifier).toBe("Mac16,6");
ws.close(); ws.close();
await server.close(); await server.close();

View File

@ -1300,12 +1300,16 @@ export async function startGatewayServer(
const ip = node.remoteIp?.trim(); const ip = node.remoteIp?.trim();
const version = node.version?.trim() || "unknown"; const version = node.version?.trim() || "unknown";
const platform = node.platform?.trim() || undefined; 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`; const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-connected`;
upsertPresence(node.nodeId, { upsertPresence(node.nodeId, {
host, host,
ip, ip,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
mode: "remote", mode: "remote",
reason: "iris-connected", reason: "iris-connected",
lastInputSeconds: 0, lastInputSeconds: 0,
@ -1342,12 +1346,16 @@ export async function startGatewayServer(
const ip = node.remoteIp?.trim(); const ip = node.remoteIp?.trim();
const version = node.version?.trim() || "unknown"; const version = node.version?.trim() || "unknown";
const platform = node.platform?.trim() || undefined; 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`; const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-disconnected`;
upsertPresence(node.nodeId, { upsertPresence(node.nodeId, {
host, host,
ip, ip,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
mode: "remote", mode: "remote",
reason: "iris-disconnected", reason: "iris-disconnected",
lastInputSeconds: 0, lastInputSeconds: 0,
@ -1743,6 +1751,8 @@ export async function startGatewayServer(
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
version: connectParams.client.version, version: connectParams.client.version,
platform: connectParams.client.platform, platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode, mode: connectParams.client.mode,
instanceId: connectParams.client.instanceId, instanceId: connectParams.client.instanceId,
reason: "connect", reason: "connect",
@ -2424,6 +2434,14 @@ export async function startGatewayServer(
typeof params.version === "string" ? params.version : undefined; typeof params.version === "string" ? params.version : undefined;
const platform = const platform =
typeof params.platform === "string" ? params.platform : undefined; 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 = const lastInputSeconds =
typeof params.lastInputSeconds === "number" && typeof params.lastInputSeconds === "number" &&
Number.isFinite(params.lastInputSeconds) Number.isFinite(params.lastInputSeconds)
@ -2444,6 +2462,8 @@ export async function startGatewayServer(
mode, mode,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
lastInputSeconds, lastInputSeconds,
reason, reason,
tags, tags,

View File

@ -17,6 +17,8 @@ type BridgeHelloFrame = {
token?: string; token?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
}; };
type BridgePairRequestFrame = { type BridgePairRequestFrame = {
@ -25,6 +27,8 @@ type BridgePairRequestFrame = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteAddress?: string; remoteAddress?: string;
}; };
@ -108,6 +112,8 @@ export type NodeBridgeClientInfo = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
}; };
@ -263,6 +269,8 @@ export async function startNodeBridgeServer(
displayName: verified.node.displayName ?? hello.displayName, displayName: verified.node.displayName ?? hello.displayName,
platform: verified.node.platform ?? hello.platform, platform: verified.node.platform ?? hello.platform,
version: verified.node.version ?? hello.version, version: verified.node.version ?? hello.version,
deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily,
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
remoteIp: remoteAddress, remoteIp: remoteAddress,
}; };
connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
@ -319,6 +327,8 @@ export async function startNodeBridgeServer(
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
remoteIp: remoteAddress, remoteIp: remoteAddress,
}, },
opts.pairingBaseDir, opts.pairingBaseDir,

View File

@ -9,6 +9,8 @@ export type NodePairingPendingRequest = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
isRepair?: boolean; isRepair?: boolean;
ts: number; ts: number;
@ -20,6 +22,8 @@ export type NodePairingPairedNode = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
createdAtMs: number; createdAtMs: number;
approvedAtMs: number; approvedAtMs: number;
@ -172,6 +176,8 @@ export async function requestNodePairing(
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
remoteIp: req.remoteIp, remoteIp: req.remoteIp,
isRepair, isRepair,
ts: Date.now(), ts: Date.now(),
@ -199,6 +205,8 @@ export async function approveNodePairing(
displayName: pending.displayName, displayName: pending.displayName,
platform: pending.platform, platform: pending.platform,
version: pending.version, version: pending.version,
deviceFamily: pending.deviceFamily,
modelIdentifier: pending.modelIdentifier,
remoteIp: pending.remoteIp, remoteIp: pending.remoteIp,
createdAtMs: existing?.createdAtMs ?? now, createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now, approvedAtMs: now,

View File

@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import os from "node:os"; import os from "node:os";
export type SystemPresence = { export type SystemPresence = {
@ -5,6 +6,8 @@ export type SystemPresence = {
ip?: string; ip?: string;
version?: string; version?: string;
platform?: string; platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
lastInputSeconds?: number; lastInputSeconds?: number;
mode?: string; mode?: string;
reason?: string; reason?: string;
@ -47,6 +50,17 @@ function initSelfPresence() {
const ip = resolvePrimaryIPv4() ?? undefined; const ip = resolvePrimaryIPv4() ?? undefined;
const version = const version =
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown"; 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 platform = (() => {
const p = os.platform(); const p = os.platform();
const rel = os.release(); const rel = os.release();
@ -54,12 +68,21 @@ function initSelfPresence() {
if (p === "win32") return `windows ${rel}`; if (p === "win32") return `windows ${rel}`;
return `${p} ${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 text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`;
const selfEntry: SystemPresence = { const selfEntry: SystemPresence = {
host, host,
ip, ip,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
mode: "gateway", mode: "gateway",
reason: "self", reason: "self",
text, text,
@ -123,6 +146,8 @@ type SystemPresencePayload = {
ip?: string; ip?: string;
version?: string; version?: string;
platform?: string; platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
lastInputSeconds?: number; lastInputSeconds?: number;
mode?: string; mode?: string;
reason?: string; reason?: string;
@ -147,6 +172,8 @@ export function updateSystemPresence(payload: SystemPresencePayload) {
ip: payload.ip ?? parsed.ip ?? existing.ip, ip: payload.ip ?? parsed.ip ?? existing.ip,
version: payload.version ?? parsed.version ?? existing.version, version: payload.version ?? parsed.version ?? existing.version,
platform: payload.platform ?? existing.platform, platform: payload.platform ?? existing.platform,
deviceFamily: payload.deviceFamily ?? existing.deviceFamily,
modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier,
mode: payload.mode ?? parsed.mode ?? existing.mode, mode: payload.mode ?? parsed.mode ?? existing.mode,
lastInputSeconds: lastInputSeconds:
payload.lastInputSeconds ?? payload.lastInputSeconds ??