Discovery: wide-area bridge DNS-SD
# Conflicts: # apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift # src/cli/dns-cli.tsmain
parent
e9bfe34850
commit
557ffdbe35
2
Peekaboo
2
Peekaboo
|
|
@ -1 +1 @@
|
||||||
Subproject commit 74a1c75702760f3865f89d1193e9c76f7f38275f
|
Subproject commit 4ff4d3db244f9794a7af1b728a6104929e95250b
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
object BonjourEscapes {
|
||||||
|
fun decode(input: String): String {
|
||||||
|
if (input.isEmpty()) return input
|
||||||
|
|
||||||
|
val out = StringBuilder(input.length)
|
||||||
|
var i = 0
|
||||||
|
while (i < input.length) {
|
||||||
|
if (input[i] == '\\' && i + 3 < input.length) {
|
||||||
|
val d0 = input[i + 1]
|
||||||
|
val d1 = input[i + 2]
|
||||||
|
val d2 = input[i + 3]
|
||||||
|
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
|
||||||
|
val value =
|
||||||
|
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
||||||
|
if (value in 0..0x10FFFF) {
|
||||||
|
out.appendCodePoint(value)
|
||||||
|
i += 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.append(input[i])
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package com.steipete.clawdis.node.bridge
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.net.nsd.NsdManager
|
import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
import java.time.Duration
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.xbill.DNS.ExtendedResolver
|
||||||
import org.xbill.DNS.Lookup
|
import org.xbill.DNS.Lookup
|
||||||
import org.xbill.DNS.PTRRecord
|
import org.xbill.DNS.PTRRecord
|
||||||
import org.xbill.DNS.SRVRecord
|
import org.xbill.DNS.SRVRecord
|
||||||
|
|
@ -25,6 +28,7 @@ class BridgeDiscovery(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
private val serviceType = "_clawdis-bridge._tcp."
|
private val serviceType = "_clawdis-bridge._tcp."
|
||||||
private val wideAreaDomain = "clawdis.internal."
|
private val wideAreaDomain = "clawdis.internal."
|
||||||
|
|
||||||
|
|
@ -100,8 +104,10 @@ class BridgeDiscovery(
|
||||||
val port = resolved.port
|
val port = resolved.port
|
||||||
if (port <= 0) return
|
if (port <= 0) return
|
||||||
|
|
||||||
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
|
val rawServiceName = resolved.serviceName
|
||||||
val id = stableId(resolved.serviceName, "local.")
|
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||||
|
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||||
|
val id = stableId(serviceName, "local.")
|
||||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
@ -133,13 +139,15 @@ class BridgeDiscovery(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
|
val resolver = createUnicastResolver()
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrRecords = lookup(ptrName, Type.PTR).mapNotNull { it as? PTRRecord }
|
val ptrRecords = lookup(ptrName, Type.PTR, resolver).mapNotNull { it as? PTRRecord }
|
||||||
|
|
||||||
val next = LinkedHashMap<String, BridgeEndpoint>()
|
val next = LinkedHashMap<String, BridgeEndpoint>()
|
||||||
for (ptr in ptrRecords) {
|
for (ptr in ptrRecords) {
|
||||||
val instanceFqdn = ptr.target.toString()
|
val instanceFqdn = ptr.target.toString()
|
||||||
val srv = lookup(instanceFqdn, Type.SRV).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
|
val srv =
|
||||||
|
lookup(instanceFqdn, Type.SRV, resolver).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
|
||||||
val port = srv.port
|
val port = srv.port
|
||||||
if (port <= 0) continue
|
if (port <= 0) continue
|
||||||
|
|
||||||
|
|
@ -152,9 +160,9 @@ class BridgeDiscovery(
|
||||||
null
|
null
|
||||||
} ?: continue
|
} ?: continue
|
||||||
|
|
||||||
val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord }
|
val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord }
|
||||||
val instanceName = decodeInstanceName(instanceFqdn, domain)
|
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||||
val displayName = txtValue(txt, "displayName") ?: instanceName
|
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||||
val id = stableId(instanceName, domain)
|
val id = stableId(instanceName, domain)
|
||||||
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
}
|
}
|
||||||
|
|
@ -179,15 +187,41 @@ class BridgeDiscovery(
|
||||||
return raw.removeSuffix(".")
|
return raw.removeSuffix(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lookup(name: String, type: Int): List<org.xbill.DNS.Record> {
|
private fun lookup(name: String, type: Int, resolver: org.xbill.DNS.Resolver?): List<org.xbill.DNS.Record> {
|
||||||
return try {
|
return try {
|
||||||
val out = Lookup(name, type).run() ?: return emptyList()
|
val lookup = Lookup(name, type)
|
||||||
|
if (resolver != null) {
|
||||||
|
lookup.setResolver(resolver)
|
||||||
|
lookup.setCache(null)
|
||||||
|
}
|
||||||
|
val out = lookup.run() ?: return emptyList()
|
||||||
out.toList()
|
out.toList()
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createUnicastResolver(): org.xbill.DNS.Resolver? {
|
||||||
|
val cm = connectivity ?: return null
|
||||||
|
val net = cm.activeNetwork ?: return null
|
||||||
|
val dnsServers = cm.getLinkProperties(net)?.dnsServers ?: return null
|
||||||
|
val addrs =
|
||||||
|
dnsServers
|
||||||
|
.mapNotNull { it.hostAddress }
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
if (addrs.isEmpty()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
ExtendedResolver(addrs.toTypedArray()).apply {
|
||||||
|
setTimeout(Duration.ofMillis(1500))
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||||
val prefix = "$key="
|
val prefix = "$key="
|
||||||
for (r in records) {
|
for (r in records) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BonjourEscapesTest {
|
||||||
|
@Test
|
||||||
|
fun decodeNoop() {
|
||||||
|
assertEquals("", BonjourEscapes.decode(""))
|
||||||
|
assertEquals("hello", BonjourEscapes.decode("hello"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decodeDecodesDecimalEscapes() {
|
||||||
|
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
|
||||||
|
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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…")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
|
|
@ -131,12 +132,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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,28 @@ 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 model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !model.isEmpty { return model }
|
||||||
|
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ final class PresenceReporter {
|
||||||
let host = InstanceIdentity.displayName
|
let host = InstanceIdentity.displayName
|
||||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||||
let version = Self.appVersionString()
|
let version = Self.appVersionString()
|
||||||
|
let platform = Self.platformString()
|
||||||
let lastInput = Self.lastInputSeconds()
|
let lastInput = Self.lastInputSeconds()
|
||||||
let text = Self.composePresenceSummary(mode: mode, reason: reason)
|
let text = Self.composePresenceSummary(mode: mode, reason: reason)
|
||||||
var params: [String: AnyHashable] = [
|
var params: [String: AnyHashable] = [
|
||||||
|
|
@ -43,8 +44,11 @@ final class PresenceReporter {
|
||||||
"ip": AnyHashable(ip),
|
"ip": AnyHashable(ip),
|
||||||
"mode": AnyHashable(mode),
|
"mode": AnyHashable(mode),
|
||||||
"version": AnyHashable(version),
|
"version": AnyHashable(version),
|
||||||
|
"platform": AnyHashable(platform),
|
||||||
|
"deviceFamily": AnyHashable("Mac"),
|
||||||
"reason": AnyHashable(reason),
|
"reason": AnyHashable(reason),
|
||||||
]
|
]
|
||||||
|
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)
|
||||||
|
|
@ -78,6 +82,11 @@ final class PresenceReporter {
|
||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func platformString() -> String {
|
||||||
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
private static func lastInputSeconds() -> Int? {
|
private static func lastInputSeconds() -> Int? {
|
||||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,12 @@ pnpm clawdis gateway --force
|
||||||
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
|
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
|
||||||
|
|
||||||
## Protocol (operator view)
|
## Protocol (operator view)
|
||||||
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
|
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
|
||||||
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
|
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
|
||||||
- After handshake:
|
- After handshake:
|
||||||
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
||||||
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
|
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
|
||||||
- Structured presence entries: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
- Structured presence entries: `{host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
||||||
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
|
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
|
||||||
|
|
||||||
## Methods (initial set)
|
## Methods (initial set)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ Presence entries are structured objects with (some) fields:
|
||||||
- `host`: a human-readable name (often the machine name)
|
- `host`: a human-readable name (often the machine name)
|
||||||
- `ip`: best-effort IP address (may be missing or stale)
|
- `ip`: best-effort IP address (may be missing or stale)
|
||||||
- `version`: client version string
|
- `version`: client version string
|
||||||
|
- `deviceFamily` (optional): hardware family like `iPad`, `iPhone`, `Mac`
|
||||||
|
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
|
||||||
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
|
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
|
||||||
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
|
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
|
||||||
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
|
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
||||||
|
|
||||||
## Phase 1 — Protocol specification
|
## Phase 1 — Protocol specification
|
||||||
- Frames (WS text JSON, all with explicit `type`):
|
- Frames (WS text JSON, all with explicit `type`):
|
||||||
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
|
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
|
||||||
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
|
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
|
||||||
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
||||||
- `req {type:"req", id, method, params?}`
|
- `req {type:"req", id, method, params?}`
|
||||||
|
|
@ -31,7 +31,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
||||||
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
||||||
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
|
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
|
||||||
- Payload types:
|
- Payload types:
|
||||||
- `PresenceEntry {host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
|
- `PresenceEntry {host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
|
||||||
- `HealthSnapshot` (match existing `clawdis health --json` fields)
|
- `HealthSnapshot` (match existing `clawdis health --json` fields)
|
||||||
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
|
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
|
||||||
- `TickEvent {ts}`
|
- `TickEvent {ts}`
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ describe("gateway SIGTERM", () => {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLAWDIS_SKIP_PROVIDERS: "1",
|
CLAWDIS_SKIP_PROVIDERS: "1",
|
||||||
|
// Avoid port collisions with other test processes that may also start a bridge server.
|
||||||
|
CLAWDIS_BRIDGE_HOST: "127.0.0.1",
|
||||||
|
CLAWDIS_BRIDGE_PORT: "0",
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
@ -63,6 +65,8 @@ export const ConnectParamsSchema = Type.Object(
|
||||||
name: NonEmptyString,
|
name: NonEmptyString,
|
||||||
version: NonEmptyString,
|
version: NonEmptyString,
|
||||||
platform: NonEmptyString,
|
platform: NonEmptyString,
|
||||||
|
deviceFamily: Type.Optional(NonEmptyString),
|
||||||
|
modelIdentifier: Type.Optional(NonEmptyString),
|
||||||
mode: NonEmptyString,
|
mode: NonEmptyString,
|
||||||
instanceId: Type.Optional(NonEmptyString),
|
instanceId: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2109,6 +2109,8 @@ describe("gateway server", () => {
|
||||||
name: "fingerprint",
|
name: "fingerprint",
|
||||||
version: "9.9.9",
|
version: "9.9.9",
|
||||||
platform: "test",
|
platform: "test",
|
||||||
|
deviceFamily: "iPad",
|
||||||
|
modelIdentifier: "iPad16,6",
|
||||||
mode: "ui",
|
mode: "ui",
|
||||||
instanceId: "abc",
|
instanceId: "abc",
|
||||||
},
|
},
|
||||||
|
|
@ -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("iPad");
|
||||||
|
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,8 @@ describe("node bridge server", () => {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
deviceFamily?: string;
|
||||||
|
modelIdentifier?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
|
|
@ -233,6 +235,8 @@ describe("node bridge server", () => {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
deviceFamily?: string;
|
||||||
|
modelIdentifier?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
|
|
@ -262,6 +266,8 @@ describe("node bridge server", () => {
|
||||||
displayName: "Iris",
|
displayName: "Iris",
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
|
deviceFamily: "iPad",
|
||||||
|
modelIdentifier: "iPad16,6",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Approve the pending request from the gateway side.
|
// Approve the pending request from the gateway side.
|
||||||
|
|
@ -296,6 +302,8 @@ describe("node bridge server", () => {
|
||||||
displayName: "Different name",
|
displayName: "Different name",
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
|
deviceFamily: "iPad",
|
||||||
|
modelIdentifier: "iPad99,1",
|
||||||
});
|
});
|
||||||
const line3 = JSON.parse(await readLine2()) as { type: string };
|
const line3 = JSON.parse(await readLine2()) as { type: string };
|
||||||
expect(line3.type).toBe("hello-ok");
|
expect(line3.type).toBe("hello-ok");
|
||||||
|
|
@ -310,6 +318,8 @@ describe("node bridge server", () => {
|
||||||
expect(lastAuthed?.displayName).toBe("Iris");
|
expect(lastAuthed?.displayName).toBe("Iris");
|
||||||
expect(lastAuthed?.platform).toBe("ios");
|
expect(lastAuthed?.platform).toBe("ios");
|
||||||
expect(lastAuthed?.version).toBe("1.0");
|
expect(lastAuthed?.version).toBe("1.0");
|
||||||
|
expect(lastAuthed?.deviceFamily).toBe("iPad");
|
||||||
|
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
|
||||||
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
|
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
|
||||||
|
|
||||||
socket2.destroy();
|
socket2.destroy();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -347,6 +357,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,
|
||||||
};
|
};
|
||||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,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;
|
||||||
|
|
@ -54,12 +56,20 @@ 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,
|
||||||
mode: "gateway",
|
mode: "gateway",
|
||||||
reason: "self",
|
reason: "self",
|
||||||
text,
|
text,
|
||||||
|
|
@ -123,6 +133,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 +159,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 ??
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue