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 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.
|
||||||
|
|||||||
+19
-7
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user