feat(tor): full app rebuild on Tor toggle via key() + bootstrap gate + retry

Complete Tor toggle UX:
- key(appRestartKey) wraps App — full Compose tree rebuild on toggle
- TorManager at Window level — survives rebuild, no stop/start collision
- Tor bootstrap gate at top of App — blocks ALL creation (clients, relays,
  Coil, subscriptions) until Tor proxy is ready. Zero clearnet during bootstrap.
- Coil ImageLoader reinitialized after setInstance on each rebuild
- Connection pool evicted on rebuild
- DesktopTorManager retries start up to 3x (handles previous daemon stopping)
- Settings reload from prefs on rebuild (fixes stale toggle display)
- Confirmation dialog on mode change in both TorSettingsSection and Dialog

Known: 2-4 stale Coil CDN connections may persist briefly after toggle
(image loads, not relay traffic — close after OkHttp 5min keep-alive).
Relay WebSocket connections are fully through Tor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-13 06:40:56 +03:00
parent 2df621056e
commit c9c0cda2c1
2 changed files with 92 additions and 25 deletions
@@ -193,6 +193,23 @@ fun main() {
val accountManager = remember { AccountManager.create() } val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState() val accountState by accountManager.accountState.collectAsState()
var showAddColumnDialog by remember { mutableStateOf(false) } var showAddColumnDialog by 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 torTypeFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.torType) }
val externalPortFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.externalSocksPort) }
val windowScope = rememberCoroutineScope()
val torManager =
remember {
com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, windowScope).also {
activeTorManager = it
}
}
var layoutMode by remember { var layoutMode by remember {
mutableStateOf( mutableStateOf(
try { try {
@@ -432,6 +449,10 @@ fun main() {
onShowAddColumnDialog = { showAddColumnDialog = true }, onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote, replyToNote = replyToNote,
onRestartApp = { appRestartKey++ }, onRestartApp = { appRestartKey++ },
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
initialTorSettings = torSettings,
) )
} }
} }
@@ -453,35 +474,55 @@ fun App(
onShowAddColumnDialog: () -> Unit, onShowAddColumnDialog: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
onRestartApp: () -> Unit = {}, onRestartApp: () -> Unit = {},
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
) { ) {
val localCache = remember { DesktopLocalCache() } // Always reload from prefs — after key() rebuild, prefs have the latest saved settings
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
// Tor support: load settings, create manager, create proxy-aware HTTP client
var torSettings by remember { var torSettings by remember {
mutableStateOf( mutableStateOf(
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.load(), .load(),
) )
} }
val torTypeFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.torType) }
val externalPortFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.externalSocksPort) } // Gate: block EVERYTHING until Tor proxy is ready (when Tor expected)
val torManager = // This must be before any OkHttpClient/Coil/relay creation
remember { val torStatus by torManager.status.collectAsState()
com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, scope).also { val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
activeTorManager = it if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
androidx.compose.foundation.layout.Box(
modifier =
androidx.compose.ui.Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.foundation.layout.Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
androidx.compose.material3.CircularProgressIndicator()
androidx.compose.foundation.layout.Spacer(
modifier =
androidx.compose.ui.Modifier
.height(16.dp),
)
if (torStatus is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error) {
androidx.compose.material3.Text(
"Tor error: ${(torStatus as com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error).message}",
)
} else {
androidx.compose.material3.Text("Connecting to Tor...")
}
} }
} }
return // Nothing below runs until Tor is Active
// Clean up Tor daemon on unmount (key() change triggers this)
DisposableEffect(torManager) {
onDispose {
torManager.stopSync()
activeTorManager = null
}
} }
val localCache = remember { DesktopLocalCache() }
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
// Build TorRelayEvaluation for per-relay routing // Build TorRelayEvaluation for per-relay routing
val torRelayEvaluation = val torRelayEvaluation =
remember(torSettings) { remember(torSettings) {
@@ -512,6 +553,20 @@ fun App(
.setInstance(it) .setInstance(it)
} }
} }
// Clean up old httpClient's connection pool on rebuild
DisposableEffect(httpClient) {
onDispose {
httpClient.sharedConnectionPool.evictAll()
}
}
// Reinitialize Coil after setInstance so image loads use the Tor-aware client
remember(httpClient) {
httpClient.evictConnections()
com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
.setup()
}
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) } val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
// Subscriptions coordinator — uses default relay URLs for metadata indexing. // Subscriptions coordinator — uses default relay URLs for metadata indexing.
@@ -121,12 +121,22 @@ class DesktopTorManager(
when (mode) { when (mode) {
TorType.INTERNAL -> { TorType.INTERNAL -> {
_status.value = TorServiceStatus.Connecting _status.value = TorServiceStatus.Connecting
try { // Retry start — previous daemon may still be stopping
runtime.startDaemonAsync() var started = false
// LISTENERS event will set Active(port) for (attempt in 1..3) {
} catch (e: Exception) { try {
Log.e("DesktopTorManager", "Failed to start Tor", e) runtime.startDaemonAsync()
_status.value = TorServiceStatus.Error(e.message ?: "Unknown error") started = true
break
} catch (e: Exception) {
if (attempt < 3) {
Log.d("DesktopTorManager") { "Start attempt $attempt failed, retrying..." }
kotlinx.coroutines.delay(1000L * attempt)
} else {
Log.e("DesktopTorManager", "Failed to start Tor after 3 attempts", e)
_status.value = TorServiceStatus.Error(e.message ?: "Unknown error")
}
}
} }
} }
@@ -165,10 +175,12 @@ class DesktopTorManager(
} }
} }
/** Call from shutdown hook to stop Tor synchronously. */ /** Call from shutdown hook to stop Tor synchronously. Waits for daemon exit. */
fun stopSync() { fun stopSync() {
try { try {
runtime.stopDaemonSync() runtime.stopDaemonSync()
// Wait for daemon process to fully exit
Thread.sleep(1000)
} catch (_: Exception) { } catch (_: Exception) {
// Best-effort // Best-effort
} }