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:
yilsemandClaude Opus 5.5 committed 2026-10-05 04:18:52 +03:00
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