diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ef2cad9..4edcae2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries). - macOS menu: device list now shows connected nodes only. - 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. - Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints. diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt index 17e9120c1..b33261ccb 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt @@ -130,20 +130,36 @@ class BridgeDiscovery( object : NsdManager.ResolveListener { override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {} - override fun onServiceResolved(resolved: NsdServiceInfo) { - val host = resolved.host?.hostAddress ?: return - val port = resolved.port - if (port <= 0) return + override fun onServiceResolved(resolved: NsdServiceInfo) { + val host = resolved.host?.hostAddress ?: return + val port = resolved.port + if (port <= 0) return - 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() - } - }, - ) + val rawServiceName = resolved.serviceName + val serviceName = BonjourEscapes.decode(rawServiceName) + val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName) + val lanHost = txt(resolved, "lanHost") + val tailnetDns = txt(resolved, "tailnetDns") + 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() { @@ -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) { val ptrName = "${serviceType}${domain}" val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return @@ -227,8 +247,24 @@ class BridgeDiscovery( } val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain)) 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) - 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() @@ -434,6 +470,10 @@ class BridgeDiscovery( return null } + private fun txtIntValue(records: List, key: String): Int? { + return txtValue(records, key)?.toIntOrNull() + } + private fun decodeDnsTxtString(raw: String): String { // 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. diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeEndpoint.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeEndpoint.kt index bd359e470..41c415c4b 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeEndpoint.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeEndpoint.kt @@ -5,6 +5,11 @@ data class BridgeEndpoint( val name: String, val host: String, 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 { fun manual(host: String, port: Int): BridgeEndpoint = @@ -16,4 +21,3 @@ data class BridgeEndpoint( ) } } - diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt index 038ef9faf..c7d011892 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt @@ -2,6 +2,7 @@ package com.steipete.clawdis.node.ui import android.Manifest import android.content.pm.PackageManager +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.unit.dp import androidx.core.content.ContextCompat +import com.steipete.clawdis.node.BuildConfig import com.steipete.clawdis.node.MainViewModel import com.steipete.clawdis.node.NodeForegroundService import com.steipete.clawdis.node.VoiceWakeMode @@ -74,6 +76,22 @@ fun SettingsSheet(viewModel: MainViewModel) { val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } 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(", ")) } @@ -142,6 +160,8 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } 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() } @@ -181,9 +201,27 @@ fun SettingsSheet(viewModel: MainViewModel) { item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { 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( 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 = { Button( onClick = { diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 162e13858..256417319 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -6,6 +6,15 @@ import Observation import SwiftUI import UIKit +protocol BridgePairingClient: Sendable { + func pairAndHello( + endpoint: NWEndpoint, + hello: BridgeHello, + onStatus: (@Sendable (String) -> Void)?) async throws -> String +} + +extension BridgeClient: BridgePairingClient {} + @MainActor @Observable final class BridgeConnectionController { @@ -18,8 +27,15 @@ final class BridgeConnectionController { private var didAutoConnect = false private var seenStableIDs = Set() - 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.bridgeClientFactory = bridgeClientFactory BridgeSettingsStore.bootstrapPersistence() let defaults = UserDefaults.standard @@ -85,7 +101,7 @@ final class BridgeConnectionController { let token = KeychainStore.loadString( service: "com.steipete.clawdis.bridge", - account: "bridge-token.\(instanceId)")? + account: self.keychainAccount(instanceId: instanceId))? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !token.isEmpty else { return } @@ -99,9 +115,8 @@ final class BridgeConnectionController { guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return } self.didAutoConnect = true - appModel.connectToBridge( - endpoint: .hostPort(host: NWEndpoint.Host(manualHost), port: port), - hello: self.makeHello(token: token)) + let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port) + self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId) return } @@ -112,7 +127,7 @@ final class BridgeConnectionController { guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } 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]) { @@ -140,6 +155,40 @@ final class BridgeConnectionController { 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 { let key = "node.displayName" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -265,5 +314,13 @@ extension BridgeConnectionController { func _test_appVersion() -> String { self.appVersion() } + + func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { + self.bridges = bridges + } + + func _test_triggerAutoConnect() { + self.maybeAutoConnect() + } } #endif diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index 2555de680..45df2a887 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -18,6 +18,12 @@ final class BridgeDiscoveryModel { var endpoint: NWEndpoint var stableID: 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] = [] @@ -68,7 +74,8 @@ final class BridgeDiscoveryModel { switch result.endpoint { case let .service(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 .map(Self.prettifyInstanceName) .flatMap { $0.isEmpty ? nil : $0 } @@ -77,7 +84,13 @@ final class BridgeDiscoveryModel { name: prettyName, endpoint: 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: return nil } @@ -191,4 +204,14 @@ final class BridgeDiscoveryModel { .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) 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) + } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 48b5e0aac..34feee23a 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -51,6 +51,9 @@ struct SettingsTab: View { } } } + LabeledContent("Platform", value: self.platformString()) + LabeledContent("Version", value: self.appVersion()) + LabeledContent("Model", value: self.modelIdentifier()) } Section("Bridge") { @@ -227,6 +230,12 @@ struct SettingsTab: View { HStack { VStack(alignment: .leading, spacing: 2) { Text(bridge.name) + let detailLines = self.bridgeDetailLines(bridge) + ForEach(detailLines, id: \.self) { line in + Text(line) + .font(.footnote) + .foregroundStyle(.secondary) + } } Spacer() @@ -504,4 +513,26 @@ struct SettingsTab: View { private static func httpURLString(host: String?, port: Int?, fallback: String) -> String { 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 + } } diff --git a/apps/ios/Tests/BridgeConnectionControllerTests.swift b/apps/ios/Tests/BridgeConnectionControllerTests.swift index 4ff359616..51e22ec5d 100644 --- a/apps/ios/Tests/BridgeConnectionControllerTests.swift +++ b/apps/ios/Tests/BridgeConnectionControllerTests.swift @@ -1,5 +1,6 @@ import ClawdisKit import Foundation +import Network import Testing import UIKit @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 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(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { let defaults = UserDefaults.standard var snapshot: [String: Any?] = [:] @@ -156,4 +176,52 @@ private func withKeychainValues(_ 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") + } + } + } }