Pairing codes now carry each address the computer listens on. The phone tries them fastest first, saves the routes the host reports in Welcome and Routes, and shows the address in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 10 changed files with 162 additions and 35 deletions.
| @@ -8,7 +8,10 @@ the computer, with its agents, files, terminals and devices. | ||
| 8 | 8 | |
| 9 | 9 | On the computer, open Settings → Connections and turn on **Allow remote connections**. Then, on |
| 10 | 10 | the phone, scan the QR code shown under **Pairing code**, or paste the code |
| 11 | - (`192.168.2.10:47823/<key>`). A `vectrum://…` link pairs too. Several computers can be paired; | |
| 11 | + (`192.168.2.10:47823,100.101.1.2:47823/<key>`: every address the computer listens on, and its | |
| 12 | + key). A `vectrum://…` link pairs too. The phone tries the addresses fastest first (the LAN, then | |
| 13 | + Tailscale, then another VPN), keeps the ones the computer reports as it moves between | |
| 14 | + networks, and falls back on the next one when an address doesn't answer. Several computers can be paired; | |
| 12 | 15 | their threads share the sidebar, tagged with each one's name. The connection isn't encrypted, |
| 13 | 16 | so pair on networks you trust or over a VPN such as Tailscale or WireGuard. |
| 14 | 17 | |
| @@ -12,7 +12,6 @@ import androidx.compose.runtime.DisposableEffect | ||
| 12 | 12 | import androidx.compose.runtime.LaunchedEffect |
| 13 | 13 | import androidx.compose.runtime.remember |
| 14 | 14 | import cc.vectrum.code.net.parsePairing |
| 15 | - import cc.vectrum.code.net.withPort | |
| 16 | 15 | import cc.vectrum.code.theme.ProvidePalette |
| 17 | 16 | import cc.vectrum.code.theme.Themes |
| 18 | 17 | import cc.vectrum.code.ui.Root |
| @@ -56,13 +55,13 @@ class MainActivity : ComponentActivity() { | ||
| 56 | 55 | app.resume() |
| 57 | 56 | } |
| 58 | 57 | |
| 59 | - /** A `vectrum://address/key` link pairs with that host. */ | |
| 58 | + /** A `vectrum://addresses/key` link pairs with that host. */ | |
| 60 | 59 | private fun handle(intent: Intent?) { |
| 61 | 60 | val data = intent?.data ?: return |
| 62 | 61 | if (data.scheme != "vectrum") return |
| 63 | - val (address, key) = parsePairing(data.toString()) | |
| 64 | - if (address.isBlank() || key == null) return | |
| 65 | - app.addHost(withPort(address), key) | |
| 62 | + val (addresses, key) = parsePairing(data.toString()) | |
| 63 | + if (addresses.isEmpty() || key == null) return | |
| 64 | + app.addHost(addresses, key) | |
| 66 | 65 | nav.home() |
| 67 | 66 | } |
| 68 | 67 | } |
| @@ -16,6 +16,8 @@ import cc.vectrum.code.net.ProjectInfo | ||
| 16 | 16 | import cc.vectrum.code.net.RemoteFileDiff |
| 17 | 17 | import cc.vectrum.code.net.RemoteInstance |
| 18 | 18 | import cc.vectrum.code.net.RemoteModel |
| 19 | + import cc.vectrum.code.net.Route | |
| 20 | + import cc.vectrum.code.net.routeRank | |
| 19 | 21 | import cc.vectrum.code.net.SavedItem |
| 20 | 22 | import cc.vectrum.code.net.ServerMessage |
| 21 | 23 | import cc.vectrum.code.net.SettingsPatch |
| @@ -23,6 +25,7 @@ import cc.vectrum.code.net.ThreadAction | ||
| 23 | 25 | import cc.vectrum.code.net.ThreadDetail |
| 24 | 26 | import cc.vectrum.code.net.ThreadSummary |
| 25 | 27 | import cc.vectrum.code.net.WireJson |
| 28 | + import cc.vectrum.code.net.withPort | |
| 26 | 29 | import kotlinx.coroutines.CompletableDeferred |
| 27 | 30 | import kotlinx.serialization.Serializable |
| 28 | 31 | import kotlinx.serialization.builtins.ListSerializer |
| @@ -37,10 +40,35 @@ data class SavedHost( | ||
| 37 | 40 | val id: String = UUID.randomUUID().toString(), |
| 38 | 41 | /** The host's name, as it last said; shown until it says again. */ |
| 39 | 42 | val name: String = "", |
| 43 | + /** The address paired with, or the pairing code's addresses, comma-separated. */ | |
| 40 | 44 | val address: String = "", |
| 41 | 45 | val key: String = "", |
| 42 | 46 | val enabled: Boolean = true, |
| 43 | - ) | |
| 47 | + /** The routes the host reported, saved for the next time. */ | |
| 48 | + val routes: List<Route> = emptyList(), | |
| 49 | + ) { | |
| 50 | + /** Every way to reach the host, fastest kind first, the paired ones before learned ones of a kind. */ | |
| 51 | + val addresses: List<String> | |
| 52 | + get() { | |
| 53 | + val paired = address.split(',').map { it.trim() }.filter { it.isNotEmpty() }.map(::withPort) | |
| 54 | + val all = paired.map { Route(it, guessKind(it)) } + routes.map { it.copy(address = withPort(it.address)) } | |
| 55 | + return all.sortedBy { routeRank(it.kind) }.map { it.address }.distinct() | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** A route's kind from its address alone, as the desktop guesses for typed addresses. */ | |
| 60 | + fun guessKind(address: String): String { | |
| 61 | + val host = address.substringBeforeLast(':') | |
| 62 | + val octets = host.split('.').mapNotNull { it.toIntOrNull() } | |
| 63 | + if (octets.size != 4) return "other" | |
| 64 | + val (a, b) = octets | |
| 65 | + return when { | |
| 66 | + a == 127 -> "loopback" | |
| 67 | + a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168) -> "lan" | |
| 68 | + a == 100 && b in 64..127 -> "vpn" | |
| 69 | + else -> "other" | |
| 70 | + } | |
| 71 | + } | |
| 44 | 72 | |
| 45 | 73 | /** A thread anywhere: the host it lives on and its id there. */ |
| 46 | 74 | data class ThreadRef(val host: String, val thread: String) |
| @@ -81,10 +109,13 @@ class AppState(context: Context) { | ||
| 81 | 109 | |
| 82 | 110 | fun host(id: String): HostState? = hosts.find { it.saved.id == id } |
| 83 | 111 | |
| 84 | - fun addHost(address: String, key: String, name: String = ""): HostState { | |
| 85 | - val existing = hosts.find { it.saved.address == address } | |
| 112 | + /** Pairs with a host at `addresses` (a pairing code's, or one typed); the same host again updates it. */ | |
| 113 | + fun addHost(addresses: List<String>, key: String, name: String = ""): HostState { | |
| 114 | + val wanted = addresses.map(::withPort) | |
| 115 | + val address = wanted.joinToString(",") | |
| 116 | + val existing = hosts.find { host -> host.saved.addresses.any { it in wanted } } | |
| 86 | 117 | if (existing != null) { |
| 87 | - existing.update(existing.saved.copy(key = key, enabled = true)) | |
| 118 | + existing.update(existing.saved.copy(address = address, key = key, enabled = true)) | |
| 88 | 119 | existing.retry() |
| 89 | 120 | return existing |
| 90 | 121 | } |
| @@ -132,7 +163,11 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 132 | 163 | private set |
| 133 | 164 | |
| 134 | 165 | /** The name the host gives itself, or the address until it does. */ |
| 135 | - val name: String get() = saved.name.ifBlank { saved.address } | |
| 166 | + val name: String get() = saved.name.ifBlank { activeAddress } | |
| 167 | + | |
| 168 | + /** The address the connection dials or uses now. */ | |
| 169 | + var activeAddress by mutableStateOf(saved.addresses.firstOrNull() ?: saved.address) | |
| 170 | + private set | |
| 136 | 171 | var version by mutableStateOf("") |
| 137 | 172 | private set |
| 138 | 173 | |
| @@ -175,7 +210,7 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 175 | 210 | |
| 176 | 211 | fun connect(deviceName: String) { |
| 177 | 212 | connection?.stop() |
| 178 | - val connection = Connection(saved.address, saved.key, deviceName, ::onStatus, ::onMessage) | |
| 213 | + val connection = Connection({ saved.addresses }, saved.key, deviceName, ::onStatus, ::onMessage) { activeAddress = it } | |
| 179 | 214 | this.connection = connection |
| 180 | 215 | connection.start() |
| 181 | 216 | } |
| @@ -197,6 +232,13 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 197 | 232 | |
| 198 | 233 | fun send(message: ClientMessage): Boolean = connection?.send(message) ?: false |
| 199 | 234 | |
| 235 | + /** Keeps the routes a host reports, replacing the ones it reported before. Older hosts report none. */ | |
| 236 | + private fun learnRoutes(routes: List<Route>) { | |
| 237 | + if (routes.isEmpty()) return | |
| 238 | + val learned = routes.filter { it.kind != "loopback" }.map { it.copy(learned = true) } | |
| 239 | + if (learned != saved.routes) update(saved.copy(routes = learned)) | |
| 240 | + } | |
| 241 | + | |
| 200 | 242 | private fun onStatus(status: ConnectionStatus) { |
| 201 | 243 | this.status = status |
| 202 | 244 | if (status is ConnectionStatus.Connected) { |
| @@ -250,7 +292,9 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 250 | 292 | favorites = message.favorites |
| 251 | 293 | } |
| 252 | 294 | is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message) |
| 253 | - is ServerMessage.Welcome, is ServerMessage.Denied -> {} | |
| 295 | + is ServerMessage.Welcome -> learnRoutes(message.routes) | |
| 296 | + is ServerMessage.Routes -> learnRoutes(message.routes) | |
| 297 | + is ServerMessage.Denied -> {} | |
| 254 | 298 | } |
| 255 | 299 | } |
| 256 | 300 | |
| @@ -25,14 +25,17 @@ sealed interface ConnectionStatus { | ||
| 25 | 25 | /** |
| 26 | 26 | * One WebSocket to a host, kept up: it says hello with the key, hands every |
| 27 | 27 | * message to [onMessage] on the main thread, and reconnects with a backoff |
| 28 | - * when the connection drops. | |
| 28 | + * when the connection drops. A host can be reached several ways (the LAN, | |
| 29 | + * Tailscale, another VPN): each attempt goes down [addresses], fastest first, | |
| 30 | + * until one answers, and [onAddress] says which one is in use. | |
| 29 | 31 | */ |
| 30 | 32 | class Connection( |
| 31 | - private val address: String, | |
| 33 | + private val addresses: () -> List<String>, | |
| 32 | 34 | private val key: String, |
| 33 | 35 | private val deviceName: String, |
| 34 | 36 | private val onStatus: (ConnectionStatus) -> Unit, |
| 35 | 37 | private val onMessage: (ServerMessage) -> Unit, |
| 38 | + private val onAddress: (String) -> Unit = {}, | |
| 36 | 39 | ) { |
| 37 | 40 | private val main = Handler(Looper.getMainLooper()) |
| 38 | 41 | private var socket: WebSocket? = null |
| @@ -41,6 +44,9 @@ class Connection( | ||
| 41 | 44 | private var attempt = 0 |
| 42 | 45 | private var generation = 0 |
| 43 | 46 | |
| 47 | + /** Which of [addresses] this attempt dials. */ | |
| 48 | + private var route = 0 | |
| 49 | + | |
| 44 | 50 | fun start() { |
| 45 | 51 | stopped = false |
| 46 | 52 | open() |
| @@ -62,6 +68,7 @@ class Connection( | ||
| 62 | 68 | socket?.cancel() |
| 63 | 69 | socket = null |
| 64 | 70 | attempt = 0 |
| 71 | + route = 0 | |
| 65 | 72 | open() |
| 66 | 73 | } |
| 67 | 74 | |
| @@ -78,7 +85,14 @@ class Connection( | ||
| 78 | 85 | val gen = ++generation |
| 79 | 86 | welcomed = false |
| 80 | 87 | onStatus(ConnectionStatus.Connecting) |
| 81 | - val request = Request.Builder().url("ws://${withPort(address)}/").build() | |
| 88 | + val list = addresses() | |
| 89 | + if (list.isEmpty()) { | |
| 90 | + onStatus(ConnectionStatus.Offline("No address to reach this computer at.")) | |
| 91 | + return | |
| 92 | + } | |
| 93 | + val address = withPort(list[route.coerceAtMost(list.lastIndex)]) | |
| 94 | + onAddress(address) | |
| 95 | + val request = Request.Builder().url("ws://$address/").build() | |
| 82 | 96 | socket = client.newWebSocket( |
| 83 | 97 | request, |
| 84 | 98 | object : WebSocketListener() { |
| @@ -95,6 +109,8 @@ class Connection( | ||
| 95 | 109 | welcomed = true |
| 96 | 110 | attempt = 0 |
| 97 | 111 | onStatus(ConnectionStatus.Connected(message.host, message.version)) |
| 112 | + // Its routes, for the next time. | |
| 113 | + onMessage(message) | |
| 98 | 114 | } |
| 99 | 115 | is ServerMessage.Denied -> { |
| 100 | 116 | stopped = true |
| @@ -117,8 +133,16 @@ class Connection( | ||
| 117 | 133 | private fun dropped(gen: Int, reason: String) { |
| 118 | 134 | main.post { |
| 119 | 135 | if (gen != generation || stopped) return@post |
| 136 | + val wasWelcomed = welcomed | |
| 120 | 137 | welcomed = false |
| 121 | 138 | socket = null |
| 139 | + // The next route, unless this one was working: then start over from the fastest. | |
| 140 | + if (!wasWelcomed && route + 1 < addresses().size) { | |
| 141 | + route++ | |
| 142 | + open() | |
| 143 | + return@post | |
| 144 | + } | |
| 145 | + route = 0 | |
| 122 | 146 | onStatus(ConnectionStatus.Offline(reason)) |
| 123 | 147 | val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)] |
| 124 | 148 | attempt++ |
| @@ -130,7 +154,7 @@ class Connection( | ||
| 130 | 154 | private val RETRY_DELAYS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000) |
| 131 | 155 | |
| 132 | 156 | private val client = OkHttpClient.Builder() |
| 133 | - .connectTimeout(8, TimeUnit.SECONDS) | |
| 157 | + .connectTimeout(5, TimeUnit.SECONDS) | |
| 134 | 158 | .readTimeout(0, TimeUnit.SECONDS) |
| 135 | 159 | .pingInterval(0, TimeUnit.SECONDS) |
| 136 | 160 | .build() |
| @@ -444,7 +444,17 @@ data class RemoteFileDiff(val path: String = "", val binary: Boolean = false, va | ||
| 444 | 444 | @Serializable |
| 445 | 445 | sealed class ServerMessage { |
| 446 | 446 | @Serializable @SerialName("welcome") |
| 447 | - data class Welcome(val host: String = "", val version: String = "", val protocol: Int = 0) : ServerMessage() | |
| 447 | + data class Welcome( | |
| 448 | + val host: String = "", | |
| 449 | + val version: String = "", | |
| 450 | + val protocol: Int = 0, | |
| 451 | + /** Where else the host listens. Older hosts don't say. */ | |
| 452 | + val routes: List<Route> = emptyList(), | |
| 453 | + ) : ServerMessage() | |
| 454 | + | |
| 455 | + /** The host's addresses changed, as when it joined another Wi-Fi. */ | |
| 456 | + @Serializable @SerialName("routes") | |
| 457 | + data class Routes(val routes: List<Route> = emptyList()) : ServerMessage() | |
| 448 | 458 | |
| 449 | 459 | @Serializable @SerialName("denied") |
| 450 | 460 | data class Denied(val reason: String = "") : ServerMessage() |
| @@ -517,13 +527,33 @@ fun parseServerMessage(text: String): ServerMessage? = try { | ||
| 517 | 527 | |
| 518 | 528 | fun encode(message: ClientMessage): String = WireJson.encodeToString(ClientMessage.serializer(), message) |
| 519 | 529 | |
| 520 | - /** A pairing code bundles a host's address and key: `192.168.2.10:47823/<key>`. */ | |
| 521 | - fun parsePairing(code: String): Pair<String, String?> { | |
| 530 | + /** | |
| 531 | + * One way to reach a host: `192.168.2.10:47823` on the LAN, a Tailscale or | |
| 532 | + * other VPN address. `kind`: `loopback`, `lan`, `tailnet`, `vpn` or `other`. | |
| 533 | + */ | |
| 534 | + @Serializable | |
| 535 | + data class Route(val address: String = "", val kind: String = "lan", val learned: Boolean = false) | |
| 536 | + | |
| 537 | + /** How fast a route likely is, fastest first, as the desktop ranks them. */ | |
| 538 | + fun routeRank(kind: String): Int = when (kind) { | |
| 539 | + "loopback" -> 0 | |
| 540 | + "lan" -> 1 | |
| 541 | + "tailnet" -> 2 | |
| 542 | + "vpn" -> 3 | |
| 543 | + else -> 4 | |
| 544 | + } | |
| 545 | + | |
| 546 | + /** | |
| 547 | + * A pairing code bundles a host's addresses and key, every address it listens | |
| 548 | + * on comma-separated: `192.168.2.10:47823,100.101.1.2:47823/<key>`. Returns the | |
| 549 | + * addresses (or the one address typed) and the key, if there is one. | |
| 550 | + */ | |
| 551 | + fun parsePairing(code: String): Pair<List<String>, String?> { | |
| 522 | 552 | val trimmed = code.trim().removePrefix("ws://").removePrefix("vectrum://") |
| 523 | 553 | val slash = trimmed.indexOf('/') |
| 524 | - if (slash < 0) return trimmed to null | |
| 525 | - val key = trimmed.substring(slash + 1).trim() | |
| 526 | - return trimmed.substring(0, slash) to key.ifEmpty { null } | |
| 554 | + val (addresses, key) = if (slash < 0) trimmed to null | |
| 555 | + else trimmed.substring(0, slash) to trimmed.substring(slash + 1).trim().ifEmpty { null } | |
| 556 | + return addresses.split(',').map { it.trim() }.filter { it.isNotEmpty() } to key | |
| 527 | 557 | } |
| 528 | 558 | |
| 529 | 559 | /** `host` or `host:port`, with the default port filled in. */ |
| @@ -98,7 +98,7 @@ fun DeviceView( | ||
| 98 | 98 | val p = palette |
| 99 | 99 | val context = LocalContext.current |
| 100 | 100 | val scope = rememberCoroutineScope() |
| 101 | - val stream = remember(device.id, host.saved.address, host.saved.key) { DeviceStream(host.saved.address, host.saved.key, device) } | |
| 101 | + val stream = remember(device.id, host.activeAddress, host.saved.key) { DeviceStream(host.activeAddress, host.saved.key, device) } | |
| 102 | 102 | DisposableEffect(stream) { |
| 103 | 103 | stream.start() |
| 104 | 104 | onDispose { stream.stop() } |
| @@ -161,7 +161,7 @@ fun AddProjectSheet(app: AppState, host: HostState?, open: Boolean, onDismiss: ( | ||
| 161 | 161 | private fun HostList(app: AppState, onPick: (HostState) -> Unit) { |
| 162 | 162 | for (host in app.hosts.toList()) { |
| 163 | 163 | val detail = when (host.status) { |
| 164 | - is ConnectionStatus.Connected -> host.saved.address | |
| 164 | + is ConnectionStatus.Connected -> host.activeAddress | |
| 165 | 165 | ConnectionStatus.Connecting -> "Connecting…" |
| 166 | 166 | else -> "Not connected" |
| 167 | 167 | } |
| @@ -40,7 +40,6 @@ import cc.vectrum.code.data.AppState | ||
| 40 | 40 | import cc.vectrum.code.data.HostState |
| 41 | 41 | import cc.vectrum.code.net.ConnectionStatus |
| 42 | 42 | import cc.vectrum.code.net.parsePairing |
| 43 | - import cc.vectrum.code.net.withPort | |
| 44 | 43 | import cc.vectrum.code.theme.Cap |
| 45 | 44 | import cc.vectrum.code.theme.CapKind |
| 46 | 45 | import cc.vectrum.code.theme.CapSize |
| @@ -76,24 +75,24 @@ fun PairScreen(app: AppState, nav: Nav) { | ||
| 76 | 75 | var key by remember { mutableStateOf(TextFieldValue("")) } |
| 77 | 76 | var error by remember { mutableStateOf<String?>(null) } |
| 78 | 77 | val host = Pairing.host?.let { app.host(it) } |
| 79 | - val (address, codeKey) = parsePairing(code.text) | |
| 80 | - val needsKey = address.isNotBlank() && codeKey == null | |
| 78 | + val (addresses, codeKey) = parsePairing(code.text) | |
| 79 | + val needsKey = addresses.isNotEmpty() && codeKey == null | |
| 81 | 80 | val onPairScreen = nav.screen == Screen.Pair |
| 82 | 81 | |
| 83 | 82 | // The pairing is forgotten once this screen is left for another. |
| 84 | 83 | DisposableEffect(Unit) { onDispose { if (nav.screen != Screen.Pair) Pairing.host = null } } |
| 85 | 84 | |
| 86 | 85 | fun connect(text: String = code.text, typedKey: String = key.text) { |
| 87 | - val (addr, fromCode) = parsePairing(text) | |
| 86 | + val (found, fromCode) = parsePairing(text) | |
| 88 | 87 | val secret = typedKey.trim().ifEmpty { null } ?: fromCode |
| 89 | - if (addr.isBlank() || secret == null) { | |
| 88 | + if (found.isEmpty() || secret == null) { | |
| 90 | 89 | error = "Enter the other device's pairing code, or its address and key." |
| 91 | 90 | return |
| 92 | 91 | } |
| 93 | 92 | error = null |
| 94 | 93 | // On Home the screen goes as soon as there's a host; the status shows on the Pair screen. |
| 95 | 94 | if (!onPairScreen) nav.go(Screen.Pair) |
| 96 | - Pairing.host = app.addHost(withPort(addr), secret).saved.id | |
| 95 | + Pairing.host = app.addHost(found, secret).saved.id | |
| 97 | 96 | } |
| 98 | 97 | |
| 99 | 98 | LaunchedEffect(host?.status) { |
| @@ -185,7 +184,7 @@ fun PairScreen(app: AppState, nav: Nav) { | ||
| 185 | 184 | label = "Connect", |
| 186 | 185 | icon = Lucide.Plug, |
| 187 | 186 | kind = CapKind.Primary, |
| 188 | - enabled = address.isNotBlank() && (!needsKey || key.text.isNotBlank()), | |
| 187 | + enabled = addresses.isNotEmpty() && (!needsKey || key.text.isNotBlank()), | |
| 189 | 188 | ) { connect() } |
| 190 | 189 | } |
| 191 | 190 | if (host != null) PairStatus(host) |
| @@ -246,7 +245,7 @@ private fun Steps() { | ||
| 246 | 245 | private fun PairStatus(host: HostState) { |
| 247 | 246 | val p = palette |
| 248 | 247 | val (led, title, detail) = when (val status = host.status) { |
| 249 | - ConnectionStatus.Connecting -> Triple(Led.Waiting, "Connecting…", withPort(host.saved.address)) | |
| 248 | + ConnectionStatus.Connecting -> Triple(Led.Waiting, "Connecting…", host.activeAddress) | |
| 250 | 249 | is ConnectionStatus.Connected -> Triple(Led.Live, "Connected to ${status.host}", "Opening its threads…") |
| 251 | 250 | is ConnectionStatus.Denied -> Triple(Led.Failed, status.reason, "Check the key, or copy the pairing code again.") |
| 252 | 251 | is ConnectionStatus.Offline -> Triple( |
| @@ -12,7 +12,6 @@ import androidx.compose.ui.unit.dp | ||
| 12 | 12 | import cc.vectrum.code.data.AppState |
| 13 | 13 | import cc.vectrum.code.data.HostState |
| 14 | 14 | import cc.vectrum.code.net.ConnectionStatus |
| 15 | - import cc.vectrum.code.net.withPort | |
| 16 | 15 | import cc.vectrum.code.theme.Cap |
| 17 | 16 | import cc.vectrum.code.theme.CapKind |
| 18 | 17 | import cc.vectrum.code.theme.CapSize |
| @@ -76,7 +75,7 @@ private fun HostRow(app: AppState, host: HostState) { | ||
| 76 | 75 | var removing by remember { mutableStateOf(false) } |
| 77 | 76 | SettingRow( |
| 78 | 77 | host.name, |
| 79 | - description = "${withPort(host.saved.address)} · $text", | |
| 78 | + description = "${host.activeAddress} · $text", | |
| 80 | 79 | inline = true, |
| 81 | 80 | titleSuffix = { |
| 82 | 81 | Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { |
| @@ -0,0 +1,29 @@ | ||
| 1 | + package cc.vectrum.code.net | |
| 2 | + | |
| 3 | + import cc.vectrum.code.data.SavedHost | |
| 4 | + import org.junit.Assert.assertEquals | |
| 5 | + import org.junit.Test | |
| 6 | + | |
| 7 | + class PairingTest { | |
| 8 | + @Test | |
| 9 | + fun pairingCodes() { | |
| 10 | + assertEquals(listOf("192.168.2.10:47823") to "abc", parsePairing("192.168.2.10:47823/abc")) | |
| 11 | + assertEquals( | |
| 12 | + listOf("192.168.2.10:47823", "100.101.1.2:47823") to "abc", | |
| 13 | + parsePairing("vectrum://192.168.2.10:47823, 100.101.1.2:47823/abc"), | |
| 14 | + ) | |
| 15 | + assertEquals(listOf("10.0.0.2") to null, parsePairing(" 10.0.0.2 ")) | |
| 16 | + assertEquals("10.0.0.2:$DEFAULT_PORT", withPort("10.0.0.2")) | |
| 17 | + assertEquals("[::1]:9000", withPort("[::1]:9000")) | |
| 18 | + } | |
| 19 | + | |
| 20 | + /** The LAN first, then Tailscale, then other VPNs, with what the host reported added. */ | |
| 21 | + @Test | |
| 22 | + fun routesFastestFirst() { | |
| 23 | + val host = SavedHost( | |
| 24 | + address = "100.101.1.2:47823,192.168.2.10:47823", | |
| 25 | + routes = listOf(Route("100.90.0.1:47823", "tailnet", learned = true), Route("192.168.2.10:47823", "lan", learned = true)), | |
| 26 | + ) | |
| 27 | + assertEquals(listOf("192.168.2.10:47823", "100.90.0.1:47823", "100.101.1.2:47823"), host.addresses) | |
| 28 | + } | |
| 29 | + } | |