fix(desktop): keep the Tor session bypass out of the saved settings

Audit follow-ups on the Tor splash escape:

- The "Use regular connection" bypass lived in AppInner's torSettings, which
  the in-app Tor settings dialog shows and saves, so saving any Tor toggle
  after a bypass persisted torType=OFF for good. The bypass is now a
  window-level torSessionBypassFlow; torSettings stays the user's saved
  choice and effectiveTorSettings drives the gate and relay routing. Saving
  Tor settings re-applies them and ends the bypass.
- Saving Tor settings from the splash no longer calls onRestartApp():
  nothing below the gate exists yet, and the rebuild only reset the splash's
  escape timer.
- Splash reuses connect_via_tor2 ("Tor Settings") instead of a duplicate key,
  uses app_logo for the icon description, and the explainer no longer claims
  every connection goes through Tor (the default preset does not).
- Drop the inline fully-qualified DesktopTorPreferences names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HNwcGUk22pFsTSK3YqUek
This commit is contained in:
Claude
2026-09-28 12:57:51 +00:00
parent d67a9d46c0
commit 3cef10ca9d
4 changed files with 35 additions and 29 deletions
@@ -1865,8 +1865,7 @@
<string name="tor_keep_waiting">Keep waiting</string>
<string name="tor_splash_connecting">Connecting to Tor…</string>
<string name="tor_splash_error">Tor error: %1$s</string>
<string name="tor_splash_explainer">Amethyst sends your connections through Tor to hide your IP address. Starting Tor can take a minute and needs internet access.</string>
<string name="tor_splash_settings">Tor settings</string>
<string name="tor_splash_explainer">Amethyst uses Tor to hide your IP address from relays. Starting Tor can take a minute and needs internet access.</string>
<string name="notification_settings_categories_explainer">Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there.</string>
<string name="notification_channel_status_on">On</string>
<string name="notification_channel_status_silent">Silent</string>
@@ -347,14 +347,11 @@ fun main(args: Array<String>) {
val feedSearchActiveState = remember { mutableStateOf(false) }
// Tor state at Window level — survives key() app rebuild
var torSettings by remember {
mutableStateOf(
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.load(),
)
}
val torSettings = remember { DesktopTorPreferences.load() }
val torTypeFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.torType) }
val externalPortFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.externalSocksPort) }
// "Use regular connection" on the Tor splash: Tor off for this run only, never saved.
val torSessionBypassFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val windowScope = rememberCoroutineScope()
val torManager =
remember {
@@ -739,6 +736,7 @@ fun main(args: Array<String>) {
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
torSessionBypassFlow = torSessionBypassFlow,
initialTorSettings = torSettings,
onNavigateToScreen = { navigateToScreen = it },
)
@@ -775,6 +773,7 @@ fun App(
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
torSessionBypassFlow: kotlinx.coroutines.flow.MutableStateFlow<Boolean>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {},
testOverrides: LaunchTestOverrides? = null,
@@ -831,6 +830,7 @@ fun App(
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
torSessionBypassFlow = torSessionBypassFlow,
initialTorSettings = initialTorSettings,
onNavigateToScreen = onNavigateToScreen,
testOverrides = testOverrides,
@@ -866,6 +866,7 @@ private fun AppInner(
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
torSessionBypassFlow: kotlinx.coroutines.flow.MutableStateFlow<Boolean>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit,
testOverrides: LaunchTestOverrides?,
@@ -878,28 +879,26 @@ private fun AppInner(
}
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings.
// The mode comes from the window-level torTypeFlow instead, so a session-only "continue
// without Tor" (which never touches prefs) survives the rebuild.
// Tests can short-circuit the prefs read via `testOverrides.torSettingsOverride` so the
// Tor splash gate (below) does not block them behind a real kmp-tor runtime.
// torSettings is the user's saved choice (what the settings UI shows and saves);
// effectiveTorSettings is what this session actually routes by.
var torSettings by remember {
mutableStateOf(
testOverrides?.torSettingsOverride
?: DesktopTorPreferences.load().copy(torType = torTypeFlow.value),
)
mutableStateOf(testOverrides?.torSettingsOverride ?: DesktopTorPreferences.load())
}
val torSessionBypass by torSessionBypassFlow.collectAsState()
val effectiveTorSettings = if (torSessionBypass) torSettings.copy(torType = TorType.OFF) else torSettings
// Gate: block EVERYTHING until Tor proxy is ready (when Tor expected)
// This must be before any OkHttpClient/Coil/relay creation
val torStatus by torManager.status.collectAsState()
val isTorExpected = torSettings.torType != TorType.OFF
val isTorExpected = effectiveTorSettings.torType != TorType.OFF
if (isTorExpected && torStatus !is TorServiceStatus.Active) {
var showTorSettings by remember { mutableStateOf(false) }
TorConnectingSplash(
status = torStatus,
onContinueWithoutTor = {
// Session only: prefs keep the user's Tor choice for the next launch.
torSettings = torSettings.copy(torType = TorType.OFF)
torSessionBypassFlow.value = true
torTypeFlow.value = TorType.OFF
},
onOpenTorSettings = { showTorSettings = true },
@@ -909,11 +908,12 @@ private fun AppInner(
currentSettings = torSettings,
torStatus = torStatus,
onSettingsChanged = { newSettings ->
// No onRestartApp(): nothing below the gate has been built yet, and a
// rebuild would only restart the splash (and its escape timer).
torSettings = newSettings
DesktopTorPreferences.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
onRestartApp()
},
onDismiss = { showTorSettings = false },
)
@@ -977,15 +977,15 @@ private fun AppInner(
// Build TorRelayEvaluation for per-relay routing
val torRelayEvaluation =
remember(torSettings) {
remember(effectiveTorSettings) {
com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation(
torSettings =
com.vitorpamplona.amethyst.commons.tor.TorRelaySettings(
torType = torSettings.torType,
onionRelaysViaTor = torSettings.onionRelaysViaTor,
dmRelaysViaTor = torSettings.dmRelaysViaTor,
newRelaysViaTor = torSettings.newRelaysViaTor,
trustedRelaysViaTor = torSettings.trustedRelaysViaTor,
torType = effectiveTorSettings.torType,
onionRelaysViaTor = effectiveTorSettings.onionRelaysViaTor,
dmRelaysViaTor = effectiveTorSettings.dmRelaysViaTor,
newRelaysViaTor = effectiveTorSettings.newRelaysViaTor,
trustedRelaysViaTor = effectiveTorSettings.trustedRelaysViaTor,
),
// TODO: populate from account relay lists
classification =
@@ -1491,9 +1491,11 @@ private fun AppInner(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
// Saving re-applies the saved choice, ending a
// session bypass from the splash.
torSessionBypassFlow.value = false
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
DesktopTorPreferences.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
@@ -47,11 +47,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_logo
import com.vitorpamplona.amethyst.commons.resources.connect_via_tor2
import com.vitorpamplona.amethyst.commons.resources.tor_continue_without_for_session
import com.vitorpamplona.amethyst.commons.resources.tor_splash_connecting
import com.vitorpamplona.amethyst.commons.resources.tor_splash_error
import com.vitorpamplona.amethyst.commons.resources.tor_splash_explainer
import com.vitorpamplona.amethyst.commons.resources.tor_splash_settings
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.desktop.platform.IconResources
import kotlinx.coroutines.delay
@@ -112,7 +113,7 @@ fun TorConnectingSplash(
Spacer(Modifier.height(24.dp))
Icon(
painter = IconResources.rawBitmapPainter,
contentDescription = "Amethyst",
contentDescription = stringResource(Res.string.app_logo),
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary,
)
@@ -120,7 +121,7 @@ fun TorConnectingSplash(
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onOpenTorSettings) {
Text(stringResource(Res.string.tor_splash_settings))
Text(stringResource(Res.string.connect_via_tor2))
}
Button(onClick = onContinueWithoutTor) {
Text(stringResource(Res.string.tor_continue_without_for_session))
@@ -129,6 +129,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
@@ -190,6 +191,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
@@ -266,6 +268,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
@@ -342,6 +345,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(