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:
@@ -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<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||
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.
|
||||
|
||||
+19
-7
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user