fix: refresh bridge tokens and enrich node settings
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).
|
- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).
|
||||||
- macOS menu: device list now shows connected nodes only.
|
- macOS menu: device list now shows connected nodes only.
|
||||||
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
|
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
|
||||||
|
- iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.
|
||||||
- CLI: avoid spurious gateway close errors after successful request/response cycles.
|
- CLI: avoid spurious gateway close errors after successful request/response cycles.
|
||||||
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
|
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
|
||||||
|
|
||||||
|
|||||||
@@ -130,20 +130,36 @@ class BridgeDiscovery(
|
|||||||
object : NsdManager.ResolveListener {
|
object : NsdManager.ResolveListener {
|
||||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||||
|
|
||||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||||
val host = resolved.host?.hostAddress ?: return
|
val host = resolved.host?.hostAddress ?: return
|
||||||
val port = resolved.port
|
val port = resolved.port
|
||||||
if (port <= 0) return
|
if (port <= 0) return
|
||||||
|
|
||||||
val rawServiceName = resolved.serviceName
|
val rawServiceName = resolved.serviceName
|
||||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||||
val id = stableId(serviceName, "local.")
|
val lanHost = txt(resolved, "lanHost")
|
||||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
val tailnetDns = txt(resolved, "tailnetDns")
|
||||||
publish()
|
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||||
}
|
val bridgePort = txtInt(resolved, "bridgePort")
|
||||||
},
|
val canvasPort = txtInt(resolved, "canvasPort")
|
||||||
)
|
val id = stableId(serviceName, "local.")
|
||||||
|
localById[id] =
|
||||||
|
BridgeEndpoint(
|
||||||
|
stableId = id,
|
||||||
|
name = displayName,
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
lanHost = lanHost,
|
||||||
|
tailnetDns = tailnetDns,
|
||||||
|
gatewayPort = gatewayPort,
|
||||||
|
bridgePort = bridgePort,
|
||||||
|
canvasPort = canvasPort,
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
@@ -189,6 +205,10 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||||
|
return txt(info, key)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
@@ -227,8 +247,24 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||||
|
val lanHost = txtValue(txt, "lanHost")
|
||||||
|
val tailnetDns = txtValue(txt, "tailnetDns")
|
||||||
|
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||||
|
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||||
|
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||||
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,
|
||||||
|
lanHost = lanHost,
|
||||||
|
tailnetDns = tailnetDns,
|
||||||
|
gatewayPort = gatewayPort,
|
||||||
|
bridgePort = bridgePort,
|
||||||
|
canvasPort = canvasPort,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
unicastById.clear()
|
unicastById.clear()
|
||||||
@@ -434,6 +470,10 @@ class BridgeDiscovery(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||||
|
return txtValue(records, key)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private fun decodeDnsTxtString(raw: String): String {
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ data class BridgeEndpoint(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
|
val lanHost: String? = null,
|
||||||
|
val tailnetDns: String? = null,
|
||||||
|
val gatewayPort: Int? = null,
|
||||||
|
val bridgePort: Int? = null,
|
||||||
|
val canvasPort: Int? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
@@ -16,4 +21,3 @@ data class BridgeEndpoint(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.steipete.clawdis.node.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -46,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.BuildConfig
|
||||||
import com.steipete.clawdis.node.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
import com.steipete.clawdis.node.NodeForegroundService
|
import com.steipete.clawdis.node.NodeForegroundService
|
||||||
import com.steipete.clawdis.node.VoiceWakeMode
|
import com.steipete.clawdis.node.VoiceWakeMode
|
||||||
@@ -74,6 +76,22 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
|
val deviceModel =
|
||||||
|
remember {
|
||||||
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
.ifEmpty { "Android" }
|
||||||
|
}
|
||||||
|
val appVersion =
|
||||||
|
remember {
|
||||||
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
|
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
|
"$versionName-dev"
|
||||||
|
} else {
|
||||||
|
versionName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
|
||||||
@@ -142,6 +160,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
@@ -181,9 +201,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
} else {
|
} else {
|
||||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||||
|
val detailLines =
|
||||||
|
buildList {
|
||||||
|
add("IP: ${bridge.host}:${bridge.port}")
|
||||||
|
bridge.lanHost?.let { add("LAN: $it") }
|
||||||
|
bridge.tailnetDns?.let { add("Tailnet: $it") }
|
||||||
|
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
|
||||||
|
val gw = bridge.gatewayPort?.toString() ?: "—"
|
||||||
|
val br = (bridge.bridgePort ?: bridge.port).toString()
|
||||||
|
val canvas = bridge.canvasPort?.toString() ?: "—"
|
||||||
|
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
||||||
|
}
|
||||||
|
}
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(bridge.name) },
|
headlineContent = { Text(bridge.name) },
|
||||||
supportingContent = { Text("${bridge.host}:${bridge.port}") },
|
supportingContent = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
detailLines.forEach { line ->
|
||||||
|
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
protocol BridgePairingClient: Sendable {
|
||||||
|
func pairAndHello(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
hello: BridgeHello,
|
||||||
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BridgeClient: BridgePairingClient {}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class BridgeConnectionController {
|
final class BridgeConnectionController {
|
||||||
@@ -18,8 +27,15 @@ final class BridgeConnectionController {
|
|||||||
private var didAutoConnect = false
|
private var didAutoConnect = false
|
||||||
private var seenStableIDs = Set<String>()
|
private var seenStableIDs = Set<String>()
|
||||||
|
|
||||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
|
||||||
|
|
||||||
|
init(
|
||||||
|
appModel: NodeAppModel,
|
||||||
|
startDiscovery: Bool = true,
|
||||||
|
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
|
||||||
|
{
|
||||||
self.appModel = appModel
|
self.appModel = appModel
|
||||||
|
self.bridgeClientFactory = bridgeClientFactory
|
||||||
|
|
||||||
BridgeSettingsStore.bootstrapPersistence()
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
@@ -85,7 +101,7 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
let token = KeychainStore.loadString(
|
let token = KeychainStore.loadString(
|
||||||
service: "com.steipete.clawdis.bridge",
|
service: "com.steipete.clawdis.bridge",
|
||||||
account: "bridge-token.\(instanceId)")?
|
account: self.keychainAccount(instanceId: instanceId))?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !token.isEmpty else { return }
|
guard !token.isEmpty else { return }
|
||||||
|
|
||||||
@@ -99,9 +115,8 @@ final class BridgeConnectionController {
|
|||||||
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
appModel.connectToBridge(
|
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||||
endpoint: .hostPort(host: NWEndpoint.Host(manualHost), port: port),
|
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||||
hello: self.makeHello(token: token))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +127,7 @@ final class BridgeConnectionController {
|
|||||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
appModel.connectToBridge(endpoint: target.endpoint, hello: self.makeHello(token: token))
|
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||||
@@ -140,6 +155,40 @@ final class BridgeConnectionController {
|
|||||||
commands: self.currentCommands())
|
commands: self.currentCommands())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func keychainAccount(instanceId: String) -> String {
|
||||||
|
"bridge-token.\(instanceId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||||
|
guard let appModel else { return }
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let hello = self.makeHello(token: token)
|
||||||
|
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||||
|
endpoint: endpoint,
|
||||||
|
hello: hello,
|
||||||
|
onStatus: { status in
|
||||||
|
Task { @MainActor in
|
||||||
|
appModel.bridgeStatusText = status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let resolvedToken = refreshed.isEmpty ? token : refreshed
|
||||||
|
if !refreshed.isEmpty, refreshed != token {
|
||||||
|
_ = KeychainStore.saveString(
|
||||||
|
refreshed,
|
||||||
|
service: "com.steipete.clawdis.bridge",
|
||||||
|
account: self.keychainAccount(instanceId: instanceId))
|
||||||
|
}
|
||||||
|
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
@@ -265,5 +314,13 @@ extension BridgeConnectionController {
|
|||||||
func _test_appVersion() -> String {
|
func _test_appVersion() -> String {
|
||||||
self.appVersion()
|
self.appVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||||
|
self.bridges = bridges
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_triggerAutoConnect() {
|
||||||
|
self.maybeAutoConnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ final class BridgeDiscoveryModel {
|
|||||||
var endpoint: NWEndpoint
|
var endpoint: NWEndpoint
|
||||||
var stableID: String
|
var stableID: String
|
||||||
var debugID: String
|
var debugID: String
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var gatewayPort: Int?
|
||||||
|
var bridgePort: Int?
|
||||||
|
var canvasPort: Int?
|
||||||
|
var cliPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
var bridges: [DiscoveredBridge] = []
|
var bridges: [DiscoveredBridge] = []
|
||||||
@@ -68,7 +74,8 @@ final class BridgeDiscoveryModel {
|
|||||||
switch result.endpoint {
|
switch result.endpoint {
|
||||||
case let .service(name, _, _, _):
|
case let .service(name, _, _, _):
|
||||||
let decodedName = BonjourEscapes.decode(name)
|
let decodedName = BonjourEscapes.decode(name)
|
||||||
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
|
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||||
|
let advertisedName = txt["displayName"]
|
||||||
let prettyAdvertised = advertisedName
|
let prettyAdvertised = advertisedName
|
||||||
.map(Self.prettifyInstanceName)
|
.map(Self.prettifyInstanceName)
|
||||||
.flatMap { $0.isEmpty ? nil : $0 }
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
@@ -77,7 +84,13 @@ final class BridgeDiscoveryModel {
|
|||||||
name: prettyName,
|
name: prettyName,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||||
|
lanHost: Self.txtValue(txt, key: "lanHost"),
|
||||||
|
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
|
||||||
|
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||||
|
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||||
|
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||||
|
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -191,4 +204,14 @@ final class BridgeDiscoveryModel {
|
|||||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||||
|
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return raw.isEmpty ? nil : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func txtIntValue(_ dict: [String: String], key: String) -> Int? {
|
||||||
|
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||||
|
return Int(raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LabeledContent("Platform", value: self.platformString())
|
||||||
|
LabeledContent("Version", value: self.appVersion())
|
||||||
|
LabeledContent("Model", value: self.modelIdentifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Bridge") {
|
Section("Bridge") {
|
||||||
@@ -227,6 +230,12 @@ struct SettingsTab: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(bridge.name)
|
Text(bridge.name)
|
||||||
|
let detailLines = self.bridgeDetailLines(bridge)
|
||||||
|
ForEach(detailLines, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -504,4 +513,26 @@ struct SettingsTab: View {
|
|||||||
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
|
||||||
|
var lines: [String] = []
|
||||||
|
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
|
||||||
|
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||||
|
|
||||||
|
let gatewayPort = bridge.gatewayPort
|
||||||
|
let bridgePort = bridge.bridgePort
|
||||||
|
let canvasPort = bridge.canvasPort
|
||||||
|
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
|
||||||
|
let gw = gatewayPort.map(String.init) ?? "—"
|
||||||
|
let br = bridgePort.map(String.init) ?? "—"
|
||||||
|
let canvas = canvasPort.map(String.init) ?? "—"
|
||||||
|
lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.isEmpty {
|
||||||
|
lines.append(bridge.debugID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Network
|
||||||
import Testing
|
import Testing
|
||||||
import UIKit
|
import UIKit
|
||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
@@ -15,6 +16,25 @@ private let instanceIdEntry = KeychainEntry(service: nodeService, account: "inst
|
|||||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||||
|
|
||||||
|
private actor MockBridgePairingClient: BridgePairingClient {
|
||||||
|
private(set) var lastToken: String?
|
||||||
|
private let resultToken: String
|
||||||
|
|
||||||
|
init(resultToken: String) {
|
||||||
|
self.resultToken = resultToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairAndHello(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
hello: BridgeHello,
|
||||||
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
|
{
|
||||||
|
self.lastToken = hello.token
|
||||||
|
onStatus?("Testing…")
|
||||||
|
return self.resultToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
var snapshot: [String: Any?] = [:]
|
var snapshot: [String: Any?] = [:]
|
||||||
@@ -156,4 +176,52 @@ private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async {
|
||||||
|
let bridge = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
|
name: "Gateway",
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
|
||||||
|
stableID: "bridge-1",
|
||||||
|
debugID: "bridge-debug",
|
||||||
|
lanHost: "Mac.local",
|
||||||
|
tailnetDns: nil,
|
||||||
|
gatewayPort: 18789,
|
||||||
|
bridgePort: 18790,
|
||||||
|
canvasPort: 18793,
|
||||||
|
cliPath: nil)
|
||||||
|
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||||
|
let account = "bridge-token.ios-test"
|
||||||
|
|
||||||
|
withKeychainValues([
|
||||||
|
instanceIdEntry: nil,
|
||||||
|
preferredBridgeEntry: nil,
|
||||||
|
lastBridgeEntry: nil,
|
||||||
|
KeychainEntry(service: bridgeService, account: account): "old-token",
|
||||||
|
]) {
|
||||||
|
withUserDefaults([
|
||||||
|
"node.instanceId": "ios-test",
|
||||||
|
"bridge.lastDiscoveredStableID": "bridge-1",
|
||||||
|
"bridge.manual.enabled": false,
|
||||||
|
]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(
|
||||||
|
appModel: appModel,
|
||||||
|
startDiscovery: false,
|
||||||
|
bridgeClientFactory: { mock })
|
||||||
|
controller._test_setBridges([bridge])
|
||||||
|
controller._test_triggerAutoConnect()
|
||||||
|
|
||||||
|
for _ in 0..<20 {
|
||||||
|
if appModel.connectedBridgeID == bridge.stableID { break }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(appModel.connectedBridgeID == bridge.stableID)
|
||||||
|
let stored = KeychainStore.loadString(service: bridgeService, account: account)
|
||||||
|
#expect(stored == "new-token")
|
||||||
|
let lastToken = await mock.lastToken
|
||||||
|
#expect(lastToken == "old-token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user