feat(aiturk): reach the IDE through an end-to-end encrypted tunnel
A phone on the same Wi-Fi could not reach the computer at all (client isolation on the router), and every failure was shown as one 'same Wi-Fi' message. Contract v3: the phone only dials turkservis.online. Transport: a relay pairing lives on the virtual host <relayId>.relay.aiturk.invalid. For that host only, the shared OkHttp client resolves a placeholder address, opens a WebSocket byte stream to the relay instead of a TCP connection, and runs TLS on top of it pinned to the IDE's certificate fingerprint. The relay sees ciphertext only. DNS, sockets, proxy choice and TLS are unchanged for every other host. Pairing: tunnel first (hello within 10 s), LAN candidates second, one claim only. The local-network permission is requested only when a LAN address may be tried; denying it no longer ends the pairing. Errors: host offline, unknown relay, busy, no internet, silent computer, certificate mismatch and the LAN failures each get their own text plus a short diagnostic code built from a fixed vocabulary (never a token, pairing code or address). After pairing the screen shows the first 8 characters of the fingerprint as a security code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
9afac6f508
commit
cbe716300c
14 files changed
+2469
-138
No files matched your search
@@ -106,8 +106,24 @@ fun AiturkPairScreen(
|
||||
LaunchedEffect(state.lanPermissionNeeded) {
|
||||
if (state.lanPermissionNeeded) lanPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
LaunchedEffect(state.connectionSuccess) {
|
||||
if (state.connectionSuccess) onConnected()
|
||||
// The connection is already saved; the dialog only shows the security code
|
||||
// (the same 8 characters AITURK IDE shows under the QR code) before leaving.
|
||||
if (state.connectionSuccess) {
|
||||
val securityCode = state.securityCode
|
||||
if (securityCode == null) {
|
||||
LaunchedEffect(Unit) { onConnected() }
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onConnected,
|
||||
title = { Text(stringResource(R.string.aiturk_pair_security_title)) },
|
||||
text = { Text(stringResource(R.string.aiturk_pair_security_message, securityCode)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConnected) {
|
||||
Text(stringResource(R.string.aiturk_pair_security_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val deepLink by AiturkPairDeepLink.pending.collectAsState()
|
||||
LaunchedEffect(deepLink) {
|
||||
@@ -256,6 +272,15 @@ fun AiturkPairScreen(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
// Diagnostic code for support: fixed words only, never a token, code or address.
|
||||
if (state.errorCode.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_error_code, state.errorCode),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||
|
||||
@@ -5,12 +5,12 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkDiag
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairError
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairResult
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingOffer
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult
|
||||
@@ -27,10 +27,14 @@ data class AiturkPairUiState(
|
||||
val code: String = "",
|
||||
val step: AiturkPairStep = AiturkPairStep.IDLE,
|
||||
@param:StringRes val errorRes: Int? = null,
|
||||
/** Short diagnostic (`relay:host_offline`, `lan:timeout` …) shown in small print under the error. */
|
||||
val errorCode: String = "",
|
||||
/** Address of a deep-link pairing waiting for the user's confirmation. */
|
||||
val confirmAddress: String? = null,
|
||||
val lanPermissionNeeded: Boolean = false,
|
||||
val connectionSuccess: Boolean = false,
|
||||
/** First 8 characters of the pinned certificate fingerprint; the IDE shows the same. */
|
||||
val securityCode: String? = null,
|
||||
) {
|
||||
val busy: Boolean get() = step != AiturkPairStep.IDLE
|
||||
}
|
||||
@@ -53,11 +57,11 @@ class AiturkPairViewModel(
|
||||
private val _uiState = MutableStateFlow(AiturkPairUiState())
|
||||
val uiState: StateFlow<AiturkPairUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var pending: Request? = null
|
||||
private var pending: AiturkPairingPayload? = null
|
||||
private var confirmPayload: AiturkPairingPayload? = null
|
||||
|
||||
fun onCodeChange(raw: String) {
|
||||
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null) }
|
||||
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null, errorCode = "") }
|
||||
}
|
||||
|
||||
fun submitCode(lanPermissionGranted: Boolean) {
|
||||
@@ -87,11 +91,12 @@ class AiturkPairViewModel(
|
||||
when (val result = AiturkPairing.parse(raw)) {
|
||||
is AiturkPayloadResult.Ok -> {
|
||||
confirmPayload = result.payload
|
||||
// A relay-only link (the default) has no LAN address to show.
|
||||
val address =
|
||||
result.payload.offer.endpoints
|
||||
.first()
|
||||
.hostPort
|
||||
_uiState.update { it.copy(confirmAddress = address, errorRes = null) }
|
||||
.firstOrNull()
|
||||
?.hostPort ?: RELAY_DISPLAY_NAME
|
||||
_uiState.update { it.copy(confirmAddress = address, errorRes = null, errorCode = "") }
|
||||
}
|
||||
|
||||
is AiturkPayloadResult.Invalid -> {
|
||||
@@ -115,13 +120,12 @@ class AiturkPairViewModel(
|
||||
|
||||
fun onLanPermissionResult(granted: Boolean) {
|
||||
_uiState.update { it.copy(lanPermissionNeeded = false) }
|
||||
val request = pending
|
||||
val payload = pending
|
||||
pending = null
|
||||
if (!granted) {
|
||||
showError(R.string.aiturk_pair_error_lan_permission)
|
||||
} else if (request != null) {
|
||||
run(request)
|
||||
}
|
||||
// A denied permission no longer stops the pairing: the tunnel needs no
|
||||
// local-network access. Only the LAN candidates are skipped, and if
|
||||
// nothing else works the result says so (`lan:permission`).
|
||||
if (payload != null) run(payload, lanAllowed = granted)
|
||||
}
|
||||
|
||||
/** The ViewModel outlives the screen; reset what must not replay on re-entry. */
|
||||
@@ -131,9 +135,11 @@ class AiturkPairViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorRes = null,
|
||||
errorCode = "",
|
||||
confirmAddress = null,
|
||||
lanPermissionNeeded = false,
|
||||
connectionSuccess = false,
|
||||
securityCode = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -143,26 +149,69 @@ class AiturkPairViewModel(
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
if (!lanPermissionGranted) {
|
||||
pending = request
|
||||
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null) }
|
||||
return
|
||||
when (request) {
|
||||
is Request.Payload -> {
|
||||
connect(request.payload, lanPermissionGranted)
|
||||
}
|
||||
|
||||
is Request.Code -> {
|
||||
_uiState.update { it.copy(step = AiturkPairStep.RESOLVING, errorRes = null, errorCode = "") }
|
||||
viewModelScope.launch {
|
||||
val resolved =
|
||||
try {
|
||||
client.resolve(request.code)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
|
||||
}
|
||||
_uiState.update { it.copy(step = AiturkPairStep.IDLE) }
|
||||
when (resolved) {
|
||||
is AiturkPairResult.Success -> {
|
||||
connect(AiturkPairingPayload(resolved.value, request.code), lanPermissionGranted)
|
||||
}
|
||||
|
||||
is AiturkPairResult.Failure -> {
|
||||
showFailure(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
run(request)
|
||||
}
|
||||
|
||||
private fun run(request: Request) {
|
||||
/**
|
||||
* The local-network permission is asked for only when a LAN candidate may
|
||||
* actually be tried. A pairing that carries only a relay id (the default)
|
||||
* goes through the tunnel and never asks.
|
||||
*/
|
||||
private fun connect(
|
||||
payload: AiturkPairingPayload,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
val firstStep = if (request is Request.Code) AiturkPairStep.RESOLVING else AiturkPairStep.CONNECTING
|
||||
_uiState.update { it.copy(step = firstStep, errorRes = null) }
|
||||
if (!lanPermissionGranted && payload.offer.endpoints.isNotEmpty()) {
|
||||
pending = payload
|
||||
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null, errorCode = "") }
|
||||
return
|
||||
}
|
||||
run(payload, lanAllowed = lanPermissionGranted)
|
||||
}
|
||||
|
||||
private fun run(
|
||||
payload: AiturkPairingPayload,
|
||||
lanAllowed: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING, errorRes = null, errorCode = "") }
|
||||
viewModelScope.launch {
|
||||
val outcome =
|
||||
try {
|
||||
pair(request)
|
||||
client.pair(payload.offer, payload.code, deviceName, lanAllowed)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
|
||||
}
|
||||
when (outcome) {
|
||||
is AiturkPairResult.Success -> {
|
||||
@@ -172,46 +221,43 @@ class AiturkPairViewModel(
|
||||
step = AiturkPairStep.IDLE,
|
||||
code = "",
|
||||
connectionSuccess = saved,
|
||||
securityCode = if (saved) securityCode(outcome.value.fingerprint) else null,
|
||||
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
|
||||
errorCode = if (saved) "" else "app:save_failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AiturkPairResult.Failure -> {
|
||||
_uiState.update { it.copy(step = AiturkPairStep.IDLE, errorRes = pairErrorRes(outcome.error)) }
|
||||
showFailure(outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pair(request: Request): AiturkPairResult<AiturkPairedDevice> {
|
||||
val offer: AiturkPairingOffer
|
||||
val code: String
|
||||
when (request) {
|
||||
is Request.Payload -> {
|
||||
offer = request.payload.offer
|
||||
code = request.payload.code
|
||||
}
|
||||
|
||||
is Request.Code -> {
|
||||
code = request.code
|
||||
when (val resolved = client.resolve(code)) {
|
||||
is AiturkPairResult.Success -> offer = resolved.value
|
||||
is AiturkPairResult.Failure -> return resolved
|
||||
}
|
||||
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING) }
|
||||
}
|
||||
private fun showFailure(failure: AiturkPairResult.Failure) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AiturkPairStep.IDLE,
|
||||
errorRes = pairErrorRes(failure.error),
|
||||
errorCode = AiturkDiag.sanitize(failure.code),
|
||||
)
|
||||
}
|
||||
return client.pair(offer, code, deviceName)
|
||||
}
|
||||
|
||||
private fun showError(
|
||||
@StringRes res: Int,
|
||||
) {
|
||||
_uiState.update { it.copy(errorRes = res) }
|
||||
_uiState.update { it.copy(errorRes = res, errorCode = "") }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SECURITY_CODE_LENGTH = 8
|
||||
private const val RELAY_DISPLAY_NAME = "turkservis.online"
|
||||
|
||||
/** What both screens show so the user can compare them. */
|
||||
fun securityCode(fingerprint: String): String = fingerprint.take(SECURITY_CODE_LENGTH)
|
||||
|
||||
@StringRes
|
||||
fun payloadErrorRes(error: AiturkPayloadError): Int =
|
||||
when (error) {
|
||||
@@ -223,6 +269,7 @@ class AiturkPairViewModel(
|
||||
AiturkPayloadError.BAD_FINGERPRINT,
|
||||
AiturkPayloadError.BAD_ADDRESS,
|
||||
AiturkPayloadError.BAD_ADDRESS_COUNT,
|
||||
AiturkPayloadError.BAD_RELAY,
|
||||
AiturkPayloadError.BAD_CODE,
|
||||
-> R.string.aiturk_pair_error_qr_invalid
|
||||
}
|
||||
@@ -238,6 +285,22 @@ class AiturkPairViewModel(
|
||||
|
||||
AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable
|
||||
|
||||
AiturkPairError.HOST_OFFLINE -> R.string.aiturk_pair_error_host_offline
|
||||
|
||||
AiturkPairError.HOST_TIMEOUT -> R.string.aiturk_pair_error_host_timeout
|
||||
|
||||
AiturkPairError.RELAY_BUSY -> R.string.aiturk_pair_error_relay_busy
|
||||
|
||||
AiturkPairError.RELAY_UNKNOWN -> R.string.aiturk_pair_error_relay_unknown
|
||||
|
||||
AiturkPairError.TLS_PIN -> R.string.aiturk_pair_error_tls_pin
|
||||
|
||||
AiturkPairError.NO_INTERNET -> R.string.aiturk_pair_error_no_internet
|
||||
|
||||
AiturkPairError.RELAY_ERROR -> R.string.aiturk_pair_error_relay
|
||||
|
||||
AiturkPairError.LAN_PERMISSION -> R.string.aiturk_pair_error_lan_permission
|
||||
|
||||
AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code
|
||||
|
||||
AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed
|
||||
|
||||
Reference in new issue
Block a user