Discovery: wide-area bridge DNS-SD
# Conflicts: # apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift # src/cli/dns-cli.ts
This commit is contained in:
@@ -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
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import java.net.InetAddress
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.PTRRecord
|
||||
import org.xbill.DNS.SRVRecord
|
||||
@@ -25,6 +28,7 @@ class BridgeDiscovery(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val serviceType = "_clawdis-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdis.internal."
|
||||
|
||||
@@ -100,8 +104,10 @@ class BridgeDiscovery(
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
|
||||
val id = stableId(resolved.serviceName, "local.")
|
||||
val rawServiceName = resolved.serviceName
|
||||
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)
|
||||
publish()
|
||||
}
|
||||
@@ -133,13 +139,15 @@ class BridgeDiscovery(
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val resolver = createUnicastResolver()
|
||||
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>()
|
||||
for (ptr in ptrRecords) {
|
||||
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
|
||||
if (port <= 0) continue
|
||||
|
||||
@@ -152,9 +160,9 @@ class BridgeDiscovery(
|
||||
null
|
||||
} ?: continue
|
||||
|
||||
val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord }
|
||||
val instanceName = decodeInstanceName(instanceFqdn, domain)
|
||||
val displayName = txtValue(txt, "displayName") ?: instanceName
|
||||
val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord }
|
||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
}
|
||||
@@ -179,15 +187,41 @@ class BridgeDiscovery(
|
||||
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 {
|
||||
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()
|
||||
} catch (_: Throwable) {
|
||||
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? {
|
||||
val prefix = "$key="
|
||||
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,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version),
|
||||
version: hello.version,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier),
|
||||
over: connection)
|
||||
|
||||
onStatus?("Waiting for approval…")
|
||||
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -131,12 +132,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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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,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? {
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,6 +35,7 @@ final class PresenceReporter {
|
||||
let host = InstanceIdentity.displayName
|
||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||
let version = Self.appVersionString()
|
||||
let platform = Self.platformString()
|
||||
let lastInput = Self.lastInputSeconds()
|
||||
let text = Self.composePresenceSummary(mode: mode, reason: reason)
|
||||
var params: [String: AnyHashable] = [
|
||||
@@ -43,8 +44,11 @@ final class PresenceReporter {
|
||||
"ip": AnyHashable(ip),
|
||||
"mode": AnyHashable(mode),
|
||||
"version": AnyHashable(version),
|
||||
"platform": AnyHashable(platform),
|
||||
"deviceFamily": AnyHashable("Mac"),
|
||||
"reason": AnyHashable(reason),
|
||||
]
|
||||
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)
|
||||
@@ -78,6 +82,11 @@ final class PresenceReporter {
|
||||
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? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user