Finishes the phone's routes to match the desktop's. A route that answers with another device's key is skipped instead of ending the connection; it's denied only when every route that answered refused. OkHttp pings the host, so a route that went away is noticed. While on a slower route the phone tries the faster ones every minute and whenever its network changes, and moves as soon as one works. LAN addresses get a short connect timeout, and Connections shows the route in use and the ones it can fall back on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 5 changed files with 324 additions and 101 deletions.
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | package cc.vectrum.code.data |
| 2 | 2 | |
| 3 | 3 | import android.content.Context |
| 4 | + import android.net.ConnectivityManager | |
| 5 | + import android.net.Network | |
| 6 | + import android.os.Handler | |
| 7 | + import android.os.Looper | |
| 4 | 8 | import android.content.SharedPreferences |
| 5 | 9 | import android.os.Build |
| 6 | 10 | import androidx.compose.runtime.getValue |
| @@ -17,6 +21,8 @@ import cc.vectrum.code.net.RemoteFileDiff | ||
| 17 | 21 | import cc.vectrum.code.net.RemoteInstance |
| 18 | 22 | import cc.vectrum.code.net.RemoteModel |
| 19 | 23 | import cc.vectrum.code.net.Route |
| 24 | + import cc.vectrum.code.net.guessKind | |
| 25 | + import cc.vectrum.code.net.routeLabel | |
| 20 | 26 | import cc.vectrum.code.net.routeRank |
| 21 | 27 | import cc.vectrum.code.net.SavedItem |
| 22 | 28 | import cc.vectrum.code.net.ServerMessage |
| @@ -56,20 +62,6 @@ data class SavedHost( | ||
| 56 | 62 | } |
| 57 | 63 | } |
| 58 | 64 | |
| 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 | - } | |
| 72 | - | |
| 73 | 65 | /** A thread anywhere: the host it lives on and its id there. */ |
| 74 | 66 | data class ThreadRef(val host: String, val thread: String) |
| 75 | 67 | |
| @@ -100,9 +92,29 @@ class AppState(context: Context) { | ||
| 100 | 92 | saved.forEach { hosts.add(HostState(it, this)) } |
| 101 | 93 | } |
| 102 | 94 | |
| 103 | - fun start() = hosts.filter { it.saved.enabled }.forEach { it.connect(deviceName) } | |
| 95 | + private val connectivity = context.getSystemService(ConnectivityManager::class.java) | |
| 96 | + private val main = Handler(Looper.getMainLooper()) | |
| 97 | + | |
| 98 | + /** Tells the connections when the phone's network changes: Wi-Fi, mobile data, a VPN such as Tailscale. */ | |
| 99 | + private val networkCallback = object : ConnectivityManager.NetworkCallback() { | |
| 100 | + override fun onAvailable(network: Network) { | |
| 101 | + main.post { hosts.forEach { it.networkChanged() } } | |
| 102 | + } | |
| 103 | + | |
| 104 | + override fun onLost(network: Network) { | |
| 105 | + main.post { hosts.forEach { it.networkChanged() } } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + fun start() { | |
| 110 | + hosts.filter { it.saved.enabled }.forEach { it.connect(deviceName) } | |
| 111 | + runCatching { connectivity?.registerDefaultNetworkCallback(networkCallback) } | |
| 112 | + } | |
| 104 | 113 | |
| 105 | - fun stop() = hosts.forEach { it.disconnect() } | |
| 114 | + fun stop() { | |
| 115 | + runCatching { connectivity?.unregisterNetworkCallback(networkCallback) } | |
| 116 | + hosts.forEach { it.disconnect() } | |
| 117 | + } | |
| 106 | 118 | |
| 107 | 119 | /** Reconnects whatever dropped, as when the app comes back to the foreground. */ |
| 108 | 120 | fun resume() = hosts.filter { it.saved.enabled && it.status !is ConnectionStatus.Connected }.forEach { it.retry() } |
| @@ -168,6 +180,10 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 168 | 180 | /** The address the connection dials or uses now. */ |
| 169 | 181 | var activeAddress by mutableStateOf(saved.addresses.firstOrNull() ?: saved.address) |
| 170 | 182 | private set |
| 183 | + | |
| 184 | + /** The network [activeAddress] goes over, as the host reported it or guessed: `LAN`, `Tailscale`, `VPN`. */ | |
| 185 | + val activeRoute: String | |
| 186 | + get() = routeLabel(saved.routes.find { it.address == activeAddress }?.kind ?: guessKind(activeAddress)) | |
| 171 | 187 | var version by mutableStateOf("") |
| 172 | 188 | private set |
| 173 | 189 | |
| @@ -210,7 +226,7 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 210 | 226 | |
| 211 | 227 | fun connect(deviceName: String) { |
| 212 | 228 | connection?.stop() |
| 213 | - val connection = Connection({ saved.addresses }, saved.key, deviceName, ::onStatus, ::onMessage) { activeAddress = it } | |
| 229 | + val connection = Connection({ saved.addresses }, saved.key, deviceName, ::onStatus, ::onMessage, ::onAddress) | |
| 214 | 230 | this.connection = connection |
| 215 | 231 | connection.start() |
| 216 | 232 | } |
| @@ -232,6 +248,16 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 232 | 248 | |
| 233 | 249 | fun send(message: ClientMessage): Boolean = connection?.send(message) ?: false |
| 234 | 250 | |
| 251 | + internal fun networkChanged() { | |
| 252 | + connection?.networkChanged() | |
| 253 | + } | |
| 254 | + | |
| 255 | + private fun onAddress(address: String) { | |
| 256 | + // Moving to a faster route while connected: the old connection's shells and replies go with it. | |
| 257 | + if (connected && address != activeAddress) failPending("The connection moved to a faster route.") | |
| 258 | + activeAddress = address | |
| 259 | + } | |
| 260 | + | |
| 235 | 261 | /** Keeps the routes a host reports, replacing the ones it reported before. Older hosts report none. */ |
| 236 | 262 | private fun learnRoutes(routes: List<Route>) { |
| 237 | 263 | if (routes.isEmpty()) return |
| @@ -248,18 +274,22 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 248 | 274 | subscribed.forEach { send(ClientMessage.Subscribe(it)) } |
| 249 | 275 | } |
| 250 | 276 | if (status !is ConnectionStatus.Connected && status !is ConnectionStatus.Connecting) { |
| 251 | - // Whatever waits on a reply won't get one from this connection. | |
| 252 | - folderRequests.values.forEach { it.complete(ServerMessage.Folders(error = "The host is offline.")) } | |
| 253 | - folderRequests.clear() | |
| 254 | - diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) } | |
| 255 | - diffRequests.clear() | |
| 256 | - deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) } | |
| 257 | - deviceRequests.clear() | |
| 258 | - terminals.values.forEach { it.onExit("The connection dropped.") } | |
| 259 | - terminals.clear() | |
| 277 | + failPending("The connection dropped.") | |
| 260 | 278 | } |
| 261 | 279 | } |
| 262 | 280 | |
| 281 | + /** Whatever waits on a reply won't get one from this connection. */ | |
| 282 | + private fun failPending(reason: String) { | |
| 283 | + folderRequests.values.forEach { it.complete(ServerMessage.Folders(error = "The host is offline.")) } | |
| 284 | + folderRequests.clear() | |
| 285 | + diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) } | |
| 286 | + diffRequests.clear() | |
| 287 | + deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) } | |
| 288 | + deviceRequests.clear() | |
| 289 | + terminals.values.forEach { it.onExit(reason) } | |
| 290 | + terminals.clear() | |
| 291 | + } | |
| 292 | + | |
| 263 | 293 | private fun onMessage(message: ServerMessage) { |
| 264 | 294 | when (message) { |
| 265 | 295 | is ServerMessage.State -> { |
| @@ -26,8 +26,11 @@ sealed interface ConnectionStatus { | ||
| 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 | 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 | + * Tailscale, another VPN), like the desktop's routes: each attempt goes down | |
| 30 | + * [addresses], fastest first, until one answers with the right key, and | |
| 31 | + * [onAddress] says which one is in use. While on a slower one it keeps trying | |
| 32 | + * the faster ones, every minute and whenever the phone's network changes, and | |
| 33 | + * moves as soon as one works. Pings notice a route that went away. | |
| 31 | 34 | */ |
| 32 | 35 | class Connection( |
| 33 | 36 | private val addresses: () -> List<String>, |
| @@ -38,14 +41,23 @@ class Connection( | ||
| 38 | 41 | private val onAddress: (String) -> Unit = {}, |
| 39 | 42 | ) { |
| 40 | 43 | private val main = Handler(Looper.getMainLooper()) |
| 41 | - private var socket: WebSocket? = null | |
| 42 | - private var welcomed = false | |
| 43 | 44 | private var stopped = false |
| 44 | 45 | private var attempt = 0 |
| 45 | 46 | private var generation = 0 |
| 46 | 47 | |
| 47 | - /** Which of [addresses] this attempt dials. */ | |
| 48 | - private var route = 0 | |
| 48 | + /** The connection in use, or the one being dialed. */ | |
| 49 | + private var current: Dial? = null | |
| 50 | + | |
| 51 | + /** A faster route being tried while [current] works. */ | |
| 52 | + private var probe: Dial? = null | |
| 53 | + | |
| 54 | + /** This round's addresses, which one it dials, and what the others said. */ | |
| 55 | + private var round = emptyList<String>() | |
| 56 | + private var index = 0 | |
| 57 | + private var denied: String? = null | |
| 58 | + private val failures = mutableListOf<String>() | |
| 59 | + | |
| 60 | + private val welcomed: Boolean get() = current?.welcomed == true | |
| 49 | 61 | |
| 50 | 62 | fun start() { |
| 51 | 63 | stopped = false |
| @@ -56,8 +68,10 @@ class Connection( | ||
| 56 | 68 | stopped = true |
| 57 | 69 | generation++ |
| 58 | 70 | main.removeCallbacksAndMessages(null) |
| 59 | - socket?.close(1000, "bye") | |
| 60 | - socket = null | |
| 71 | + probe?.socket?.cancel() | |
| 72 | + probe = null | |
| 73 | + current?.socket?.close(1000, "bye") | |
| 74 | + current = null | |
| 61 | 75 | } |
| 62 | 76 | |
| 63 | 77 | /** Drops the connection and dials again at once, as after the network comes back. */ |
| @@ -65,98 +79,218 @@ class Connection( | ||
| 65 | 79 | if (stopped) return |
| 66 | 80 | generation++ |
| 67 | 81 | main.removeCallbacksAndMessages(null) |
| 68 | - socket?.cancel() | |
| 69 | - socket = null | |
| 82 | + probe?.socket?.cancel() | |
| 83 | + probe = null | |
| 84 | + current?.socket?.cancel() | |
| 85 | + current = null | |
| 70 | 86 | attempt = 0 |
| 71 | - route = 0 | |
| 72 | 87 | open() |
| 73 | 88 | } |
| 74 | 89 | |
| 90 | + /** | |
| 91 | + * The phone's network changed: a host that was out of reach may answer | |
| 92 | + * now, and a faster route may have come up (home Wi-Fi again). | |
| 93 | + */ | |
| 94 | + fun networkChanged() { | |
| 95 | + if (stopped) return | |
| 96 | + when { | |
| 97 | + welcomed -> probeFaster() | |
| 98 | + // Still going down this round's addresses. | |
| 99 | + current != null -> {} | |
| 100 | + else -> reconnect() | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 75 | 104 | /** Sends once welcomed; messages before that are dropped like the desktop drops them. */ |
| 76 | 105 | fun send(message: ClientMessage): Boolean { |
| 77 | - val socket = socket ?: return false | |
| 78 | - if (!welcomed) return false | |
| 79 | - return socket.send(encode(message)) | |
| 106 | + val dial = current ?: return false | |
| 107 | + if (!dial.welcomed) return false | |
| 108 | + return dial.socket?.send(encode(message)) ?: false | |
| 80 | 109 | } |
| 81 | 110 | |
| 82 | - val connected: Boolean get() = welcomed && socket != null | |
| 111 | + val connected: Boolean get() = welcomed | |
| 83 | 112 | |
| 84 | 113 | private fun open() { |
| 85 | - val gen = ++generation | |
| 86 | - welcomed = false | |
| 114 | + generation++ | |
| 87 | 115 | onStatus(ConnectionStatus.Connecting) |
| 88 | - val list = addresses() | |
| 89 | - if (list.isEmpty()) { | |
| 116 | + round = addresses().map(::withPort).distinct() | |
| 117 | + index = 0 | |
| 118 | + denied = null | |
| 119 | + failures.clear() | |
| 120 | + if (round.isEmpty()) { | |
| 90 | 121 | onStatus(ConnectionStatus.Offline("No address to reach this computer at.")) |
| 91 | 122 | return |
| 92 | 123 | } |
| 93 | - val address = withPort(list[route.coerceAtMost(list.lastIndex)]) | |
| 124 | + dialNext() | |
| 125 | + } | |
| 126 | + | |
| 127 | + /** Dials this round's next address, or gives up on the round. */ | |
| 128 | + private fun dialNext() { | |
| 129 | + val address = round.getOrNull(index) | |
| 130 | + if (address == null) { | |
| 131 | + exhausted() | |
| 132 | + return | |
| 133 | + } | |
| 94 | 134 | onAddress(address) |
| 95 | - val request = Request.Builder().url("ws://$address/").build() | |
| 96 | - socket = client.newWebSocket( | |
| 97 | - request, | |
| 98 | - object : WebSocketListener() { | |
| 99 | - override fun onOpen(webSocket: WebSocket, response: Response) { | |
| 100 | - webSocket.send(encode(ClientMessage.Hello(key = key, name = deviceName))) | |
| 101 | - } | |
| 135 | + current = dial(address, probe = false) | |
| 136 | + } | |
| 102 | 137 | |
| 103 | - override fun onMessage(webSocket: WebSocket, text: String) { | |
| 104 | - val message = parseServerMessage(text) ?: return | |
| 105 | - main.post { | |
| 106 | - if (gen != generation) return@post | |
| 107 | - when (message) { | |
| 108 | - is ServerMessage.Welcome -> { | |
| 109 | - welcomed = true | |
| 110 | - attempt = 0 | |
| 111 | - onStatus(ConnectionStatus.Connected(message.host, message.version)) | |
| 112 | - // Its routes, for the next time. | |
| 113 | - onMessage(message) | |
| 114 | - } | |
| 115 | - is ServerMessage.Denied -> { | |
| 116 | - stopped = true | |
| 117 | - onStatus(ConnectionStatus.Denied(message.reason)) | |
| 118 | - } | |
| 119 | - else -> onMessage(message) | |
| 120 | - } | |
| 121 | - } | |
| 122 | - } | |
| 138 | + private fun dial(address: String, probe: Boolean): Dial { | |
| 139 | + val dial = Dial(generation, address, probe) | |
| 140 | + // A LAN address answers quickly when it's there; away from home it isn't. | |
| 141 | + val http = if (guessKind(address) in setOf("lan", "loopback")) lanClient else client | |
| 142 | + dial.socket = http.newWebSocket(Request.Builder().url("ws://$address/").build(), dial) | |
| 143 | + return dial | |
| 144 | + } | |
| 123 | 145 | |
| 124 | - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = | |
| 125 | - dropped(gen, reason.ifBlank { "The host closed the connection." }) | |
| 126 | - | |
| 127 | - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = | |
| 128 | - dropped(gen, t.message ?: t.javaClass.simpleName) | |
| 129 | - }, | |
| 130 | - ) | |
| 131 | - } | |
| 132 | - | |
| 133 | - private fun dropped(gen: Int, reason: String) { | |
| 134 | - main.post { | |
| 135 | - if (gen != generation || stopped) return@post | |
| 136 | - val wasWelcomed = welcomed | |
| 137 | - welcomed = false | |
| 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 | |
| 146 | + /** No address worked. Denied only when every one that answered refused the key: an address on another network can be another device's. */ | |
| 147 | + private fun exhausted() { | |
| 148 | + current = null | |
| 149 | + val denied = denied | |
| 150 | + if (denied != null && failures.isEmpty()) { | |
| 151 | + stopped = true | |
| 152 | + onStatus(ConnectionStatus.Denied(denied)) | |
| 153 | + return | |
| 154 | + } | |
| 155 | + onStatus(ConnectionStatus.Offline(failures.joinToString("; ").ifEmpty { "Couldn't connect." })) | |
| 156 | + val gen = generation | |
| 157 | + val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)] | |
| 158 | + attempt++ | |
| 159 | + main.postDelayed({ if (gen == generation && !stopped) open() }, delay) | |
| 160 | + } | |
| 161 | + | |
| 162 | + private fun welcome(dial: Dial, message: ServerMessage.Welcome) { | |
| 163 | + dial.welcomed = true | |
| 164 | + if (dial.probe) { | |
| 165 | + // A faster route answered: move to it. | |
| 166 | + probe = null | |
| 167 | + current?.let { | |
| 168 | + it.over = true | |
| 169 | + it.socket?.close(1000, "moved") | |
| 144 | 170 | } |
| 145 | - route = 0 | |
| 146 | - onStatus(ConnectionStatus.Offline(reason)) | |
| 147 | - val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)] | |
| 148 | - attempt++ | |
| 149 | - main.postDelayed({ if (gen == generation && !stopped) open() }, delay) | |
| 171 | + onAddress(dial.address) | |
| 172 | + } | |
| 173 | + current = dial | |
| 174 | + attempt = 0 | |
| 175 | + onStatus(ConnectionStatus.Connected(message.host, message.version)) | |
| 176 | + // Its routes, for the next time. | |
| 177 | + onMessage(message) | |
| 178 | + scheduleProbe() | |
| 179 | + } | |
| 180 | + | |
| 181 | + private val probeTick = Runnable { if (!stopped) probeFaster() } | |
| 182 | + | |
| 183 | + /** Tries the faster routes again in a while; one timer, however often it's asked. */ | |
| 184 | + private fun scheduleProbe() { | |
| 185 | + main.removeCallbacks(probeTick) | |
| 186 | + main.postDelayed(probeTick, PROBE_EVERY) | |
| 187 | + } | |
| 188 | + | |
| 189 | + /** Tries the routes faster than the one in use, one by one, without dropping it. */ | |
| 190 | + private fun probeFaster(after: String? = null) { | |
| 191 | + val active = current?.takeIf { it.welcomed } ?: return | |
| 192 | + if (probe != null && after == null) return | |
| 193 | + val faster = addresses().map(::withPort).distinct().takeWhile { it != active.address } | |
| 194 | + val next = if (after == null) faster.firstOrNull() else faster.getOrNull(faster.indexOf(after) + 1) | |
| 195 | + if (next == null) { | |
| 196 | + probe = null | |
| 197 | + scheduleProbe() | |
| 198 | + return | |
| 199 | + } | |
| 200 | + probe = dial(next, probe = true) | |
| 201 | + } | |
| 202 | + | |
| 203 | + private fun lost(dial: Dial, reason: String) { | |
| 204 | + if (dial.over) return | |
| 205 | + dial.over = true | |
| 206 | + if (dial.gen != generation || stopped) return | |
| 207 | + when { | |
| 208 | + dial.probe -> if (probe === dial) probeFaster(after = dial.address) | |
| 209 | + dial !== current -> {} | |
| 210 | + !dial.welcomed -> { | |
| 211 | + failures.add(if (round.size == 1) reason else "${routeLabel(guessKind(dial.address))}: $reason") | |
| 212 | + index++ | |
| 213 | + dialNext() | |
| 214 | + } | |
| 215 | + else -> { | |
| 216 | + // The route in use went away; start over from the fastest. | |
| 217 | + generation++ | |
| 218 | + probe?.socket?.cancel() | |
| 219 | + probe = null | |
| 220 | + current = null | |
| 221 | + onStatus(ConnectionStatus.Offline(reason)) | |
| 222 | + val gen = generation | |
| 223 | + val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)] | |
| 224 | + attempt++ | |
| 225 | + main.postDelayed({ if (gen == generation && !stopped) open() }, delay) | |
| 226 | + } | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 230 | + private fun refused(dial: Dial, reason: String) { | |
| 231 | + if (dial.over) return | |
| 232 | + dial.over = true | |
| 233 | + dial.socket?.cancel() | |
| 234 | + if (dial.gen != generation || stopped) return | |
| 235 | + if (dial.probe) { | |
| 236 | + if (probe === dial) probeFaster(after = dial.address) | |
| 237 | + } else if (dial === current) { | |
| 238 | + denied = denied ?: reason | |
| 239 | + index++ | |
| 240 | + dialNext() | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + /** One WebSocket: the connection in use, one being dialed, or a probe of a faster route. */ | |
| 245 | + private inner class Dial(val gen: Int, val address: String, val probe: Boolean) : WebSocketListener() { | |
| 246 | + var socket: WebSocket? = null | |
| 247 | + var welcomed = false | |
| 248 | + | |
| 249 | + /** Done with: moved from, refused, or lost. */ | |
| 250 | + var over = false | |
| 251 | + | |
| 252 | + override fun onOpen(webSocket: WebSocket, response: Response) { | |
| 253 | + webSocket.send(encode(ClientMessage.Hello(key = key, name = deviceName))) | |
| 254 | + } | |
| 255 | + | |
| 256 | + override fun onMessage(webSocket: WebSocket, text: String) { | |
| 257 | + val message = parseServerMessage(text) ?: return | |
| 258 | + main.post { | |
| 259 | + if (over || gen != generation) return@post | |
| 260 | + when { | |
| 261 | + !welcomed && message is ServerMessage.Welcome -> welcome(this, message) | |
| 262 | + !welcomed && message is ServerMessage.Denied -> refused(this, message.reason) | |
| 263 | + welcomed && this === current -> onMessage(message) | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + | |
| 268 | + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { | |
| 269 | + webSocket.close(1000, null) | |
| 270 | + } | |
| 271 | + | |
| 272 | + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { | |
| 273 | + main.post { lost(this, reason.ifBlank { "The host closed the connection." }) } | |
| 274 | + } | |
| 275 | + | |
| 276 | + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { | |
| 277 | + main.post { lost(this, t.message ?: t.javaClass.simpleName) } | |
| 150 | 278 | } |
| 151 | 279 | } |
| 152 | 280 | |
| 153 | 281 | companion object { |
| 154 | 282 | private val RETRY_DELAYS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000) |
| 155 | 283 | |
| 284 | + /** How often to try the faster routes while on a slower one. */ | |
| 285 | + private const val PROBE_EVERY = 60_000L | |
| 286 | + | |
| 156 | 287 | private val client = OkHttpClient.Builder() |
| 157 | - .connectTimeout(5, TimeUnit.SECONDS) | |
| 288 | + .connectTimeout(6, TimeUnit.SECONDS) | |
| 158 | 289 | .readTimeout(0, TimeUnit.SECONDS) |
| 159 | - .pingInterval(0, TimeUnit.SECONDS) | |
| 290 | + // The host answers pings even when idle; a missed pong means the route went away. | |
| 291 | + .pingInterval(15, TimeUnit.SECONDS) | |
| 160 | 292 | .build() |
| 293 | + | |
| 294 | + private val lanClient = client.newBuilder().connectTimeout(2, TimeUnit.SECONDS).build() | |
| 161 | 295 | } |
| 162 | 296 | } |
| @@ -534,6 +534,36 @@ fun encode(message: ClientMessage): String = WireJson.encodeToString(ClientMessa | ||
| 534 | 534 | @Serializable |
| 535 | 535 | data class Route(val address: String = "", val kind: String = "lan", val learned: Boolean = false) |
| 536 | 536 | |
| 537 | + /** A route's kind from its address alone, as the desktop guesses for typed addresses. */ | |
| 538 | + fun guessKind(address: String): String { | |
| 539 | + val host = withPort(address).substringBeforeLast(':').lowercase() | |
| 540 | + val octets = host.split('.').map { it.toIntOrNull() } | |
| 541 | + if (octets.size == 4 && octets.all { it != null && it in 0..255 }) { | |
| 542 | + val (a, b) = octets.map { it!! } | |
| 543 | + return when { | |
| 544 | + a == 127 -> "loopback" | |
| 545 | + a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168) -> "lan" | |
| 546 | + a == 100 && b in 64..127 -> "vpn" | |
| 547 | + else -> "other" | |
| 548 | + } | |
| 549 | + } | |
| 550 | + return when { | |
| 551 | + host == "localhost" -> "loopback" | |
| 552 | + host.endsWith(".ts.net") -> "tailnet" | |
| 553 | + host.endsWith(".local") || !host.contains('.') && !host.contains(':') -> "lan" | |
| 554 | + else -> "other" | |
| 555 | + } | |
| 556 | + } | |
| 557 | + | |
| 558 | + /** A route's kind as the desktop names it: `LAN`, `Tailscale`, `VPN`. */ | |
| 559 | + fun routeLabel(kind: String): String = when (kind) { | |
| 560 | + "loopback" -> "This device" | |
| 561 | + "lan" -> "LAN" | |
| 562 | + "tailnet" -> "Tailscale" | |
| 563 | + "vpn" -> "VPN" | |
| 564 | + else -> "Direct" | |
| 565 | + } | |
| 566 | + | |
| 537 | 567 | /** How fast a route likely is, fastest first, as the desktop ranks them. */ |
| 538 | 568 | fun routeRank(kind: String): Int = when (kind) { |
| 539 | 569 | "loopback" -> 0 |
| @@ -12,6 +12,8 @@ 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.guessKind | |
| 16 | + import cc.vectrum.code.net.routeLabel | |
| 15 | 17 | import cc.vectrum.code.theme.Cap |
| 16 | 18 | import cc.vectrum.code.theme.CapKind |
| 17 | 19 | import cc.vectrum.code.theme.CapSize |
| @@ -75,7 +77,7 @@ private fun HostRow(app: AppState, host: HostState) { | ||
| 75 | 77 | var removing by remember { mutableStateOf(false) } |
| 76 | 78 | SettingRow( |
| 77 | 79 | host.name, |
| 78 | - description = "${host.activeAddress} · $text", | |
| 80 | + description = hostDescription(host, text), | |
| 79 | 81 | inline = true, |
| 80 | 82 | titleSuffix = { |
| 81 | 83 | Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { |
| @@ -103,3 +105,14 @@ private fun HostRow(app: AppState, host: HostState) { | ||
| 103 | 105 | ) |
| 104 | 106 | } |
| 105 | 107 | } |
| 108 | + | |
| 109 | + /** Where a host is reached: the route in use and the others it can fall back on, as the desktop shows it. */ | |
| 110 | + private fun hostDescription(host: HostState, status: String): String { | |
| 111 | + val others = host.saved.addresses.filter { it != host.activeAddress } | |
| 112 | + val also = if (others.isEmpty()) "" else { | |
| 113 | + " · also " + others.joinToString(", ") { address -> | |
| 114 | + "${routeLabel(host.saved.routes.find { it.address == address }?.kind ?: guessKind(address))} $address" | |
| 115 | + } | |
| 116 | + } | |
| 117 | + return "${host.activeRoute} · ${host.activeAddress} · $status$also" | |
| 118 | + } | |
| @@ -26,4 +26,20 @@ class PairingTest { | ||
| 26 | 26 | ) |
| 27 | 27 | assertEquals(listOf("192.168.2.10:47823", "100.90.0.1:47823", "100.101.1.2:47823"), host.addresses) |
| 28 | 28 | } |
| 29 | + | |
| 30 | + /** As the desktop guesses for addresses typed or pasted; only the host knows Tailscale from other VPNs by number. */ | |
| 31 | + @Test | |
| 32 | + fun guessesKinds() { | |
| 33 | + assertEquals("lan", guessKind("192.168.2.10:47823")) | |
| 34 | + assertEquals("lan", guessKind("10.0.0.5")) | |
| 35 | + assertEquals("vpn", guessKind("100.101.1.2:47823")) | |
| 36 | + assertEquals("loopback", guessKind("127.0.0.1")) | |
| 37 | + assertEquals("other", guessKind("8.8.8.8")) | |
| 38 | + assertEquals("tailnet", guessKind("box.tail1234.ts.net")) | |
| 39 | + assertEquals("lan", guessKind("box.local")) | |
| 40 | + assertEquals("lan", guessKind("box")) | |
| 41 | + assertEquals("other", guessKind("example.com:9000")) | |
| 42 | + assertEquals("Tailscale", routeLabel("tailnet")) | |
| 43 | + assertEquals("LAN", routeLabel(guessKind("192.168.2.10"))) | |
| 44 | + } | |
| 29 | 45 | } |