From c9c0cda2c14bdcc7fb86eed3beef23b23c005968 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 13 Apr 2026 06:40:56 +0300 Subject: [PATCH] feat(tor): full app rebuild on Tor toggle via key() + bootstrap gate + retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../vitorpamplona/amethyst/desktop/Main.kt | 91 +++++++++++++++---- .../amethyst/desktop/tor/DesktopTorManager.kt | 26 ++++-- 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index acc7c32a17..7d72e82e39 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -193,6 +193,23 @@ fun main() { val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() 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 { mutableStateOf( try { @@ -432,6 +449,10 @@ fun main() { onShowAddColumnDialog = { showAddColumnDialog = true }, replyToNote = replyToNote, onRestartApp = { appRestartKey++ }, + torManager = torManager, + torTypeFlow = torTypeFlow, + externalPortFlow = externalPortFlow, + initialTorSettings = torSettings, ) } } @@ -453,35 +474,55 @@ fun App( onShowAddColumnDialog: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, onRestartApp: () -> Unit = {}, + torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, + torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow, + externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, + initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { - val localCache = remember { DesktopLocalCache() } - val accountState by accountManager.accountState.collectAsState() - val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } - - // Tor support: load settings, create manager, create proxy-aware HTTP client + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings 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 torManager = - remember { - com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, scope).also { - activeTorManager = it + + // 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 != com.vitorpamplona.amethyst.commons.tor.TorType.OFF + 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...") + } } } - - // Clean up Tor daemon on unmount (key() change triggers this) - DisposableEffect(torManager) { - onDispose { - torManager.stopSync() - activeTorManager = null - } + return // Nothing below runs until Tor is Active } + val localCache = remember { DesktopLocalCache() } + val accountState by accountManager.accountState.collectAsState() + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Build TorRelayEvaluation for per-relay routing val torRelayEvaluation = remember(torSettings) { @@ -512,6 +553,20 @@ fun App( .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) } // Subscriptions coordinator — uses default relay URLs for metadata indexing. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt index 90dd4fd39e..210d2f2777 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt @@ -121,12 +121,22 @@ class DesktopTorManager( when (mode) { TorType.INTERNAL -> { _status.value = TorServiceStatus.Connecting - try { - runtime.startDaemonAsync() - // LISTENERS event will set Active(port) - } catch (e: Exception) { - Log.e("DesktopTorManager", "Failed to start Tor", e) - _status.value = TorServiceStatus.Error(e.message ?: "Unknown error") + // Retry start — previous daemon may still be stopping + var started = false + for (attempt in 1..3) { + try { + runtime.startDaemonAsync() + 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() { try { runtime.stopDaemonSync() + // Wait for daemon process to fully exit + Thread.sleep(1000) } catch (_: Exception) { // Best-effort }