feat(tor): restart confirmation dialog + key() app rebuild on Tor toggle

When user changes Tor settings, shows "Restart Required" confirmation
dialog. On confirm: saves prefs, triggers appRestartKey++ which forces
Compose to unmount/remount entire App tree.

Key behaviors:
- Window stays open, user stays logged in (AccountManager outside App)
- Column layout preserved (DeckState outside App)
- DisposableEffect stops old Tor daemon before new one starts
- TorSettingsSection mode change → confirmation dialog
- TorSettingsDialog Save → confirmation dialog
- Relays, subscriptions, caches all recreated fresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-02 12:17:39 +03:00
parent 01fc5f3ac6
commit 2df621056e
3 changed files with 76 additions and 24 deletions
@@ -55,6 +55,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -184,6 +185,7 @@ fun main() {
height = 800.dp, height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center), position = WindowPosition.Aligned(Alignment.Center),
) )
var appRestartKey by remember { mutableStateOf(0) }
var showComposeDialog by remember { mutableStateOf(false) } var showComposeDialog by remember { mutableStateOf(false) }
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) } var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
val deckScope = rememberCoroutineScope() val deckScope = rememberCoroutineScope()
@@ -410,25 +412,28 @@ fun main() {
LocalAwtWindow provides window, LocalAwtWindow provides window,
LocalIsImmersiveFullscreen provides immersiveFullscreenState, LocalIsImmersiveFullscreen provides immersiveFullscreenState,
) { ) {
App( key(appRestartKey) {
layoutMode = layoutMode, App(
deckState = deckState, layoutMode = layoutMode,
accountManager = accountManager, deckState = deckState,
showComposeDialog = showComposeDialog, accountManager = accountManager,
showAddColumnDialog = showAddColumnDialog, showComposeDialog = showComposeDialog,
onShowComposeDialog = { showComposeDialog = true }, showAddColumnDialog = showAddColumnDialog,
onShowReplyDialog = { event -> onShowComposeDialog = { showComposeDialog = true },
replyToNote = event onShowReplyDialog = { event ->
showComposeDialog = true replyToNote = event
}, showComposeDialog = true
onDismissComposeDialog = { },
showComposeDialog = false onDismissComposeDialog = {
replyToNote = null showComposeDialog = false
}, replyToNote = null
onDismissAddColumnDialog = { showAddColumnDialog = false }, },
onShowAddColumnDialog = { showAddColumnDialog = true }, onDismissAddColumnDialog = { showAddColumnDialog = false },
replyToNote = replyToNote, onShowAddColumnDialog = { showAddColumnDialog = true },
) replyToNote = replyToNote,
onRestartApp = { appRestartKey++ },
)
}
} }
} }
} }
@@ -447,6 +452,7 @@ fun App(
onDismissAddColumnDialog: () -> Unit, onDismissAddColumnDialog: () -> Unit,
onShowAddColumnDialog: () -> Unit, onShowAddColumnDialog: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
onRestartApp: () -> Unit = {},
) { ) {
val localCache = remember { DesktopLocalCache() } val localCache = remember { DesktopLocalCache() }
val accountState by accountManager.accountState.collectAsState() val accountState by accountManager.accountState.collectAsState()
@@ -468,6 +474,14 @@ fun App(
} }
} }
// Clean up Tor daemon on unmount (key() change triggers this)
DisposableEffect(torManager) {
onDispose {
torManager.stopSync()
activeTorManager = null
}
}
// Build TorRelayEvaluation for per-relay routing // Build TorRelayEvaluation for per-relay routing
val torRelayEvaluation = val torRelayEvaluation =
remember(torSettings) { remember(torSettings) {
@@ -606,6 +620,8 @@ fun App(
.save(newSettings) .save(newSettings)
torTypeFlow.value = newSettings.torType torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
onRestartApp()
}, },
), ),
) { ) {
@@ -71,6 +71,8 @@ fun TorSettingsDialog(
) { ) {
var editSettings by remember { mutableStateOf(currentSettings) } var editSettings by remember { mutableStateOf(currentSettings) }
var showRestartConfirm by remember { mutableStateOf(false) }
DialogWindow( DialogWindow(
onCloseRequest = onDismiss, onCloseRequest = onDismiss,
title = "Tor Settings", title = "Tor Settings",
@@ -196,10 +198,24 @@ fun TorSettingsDialog(
) { ) {
TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
TextButton(onClick = { TextButton(onClick = { showRestartConfirm = true }) { Text("Save") }
onSettingsChanged(editSettings) }
onDismiss()
}) { Text("Save") } if (showRestartConfirm) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showRestartConfirm = false },
title = { Text("Restart Required") },
text = { Text("Tor changes require restarting. Proceed?") },
confirmButton = {
TextButton(onClick = {
onSettingsChanged(editSettings)
onDismiss()
}) { Text("Restart") }
},
dismissButton = {
TextButton(onClick = { showRestartConfirm = false }) { Text("Cancel") }
},
)
} }
} }
} }
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
@@ -58,6 +59,7 @@ fun TorSettingsSection(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
var pendingSettings by remember { mutableStateOf<TorSettings?>(null) }
Column(modifier = modifier) { Column(modifier = modifier) {
Row( Row(
@@ -94,7 +96,7 @@ fun TorSettingsSection(
TorType.entries.forEachIndexed { index, torType -> TorType.entries.forEachIndexed { index, torType ->
SegmentedButton( SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = TorType.entries.size), shape = SegmentedButtonDefaults.itemShape(index = index, count = TorType.entries.size),
onClick = { onSettingsChanged(currentSettings.copy(torType = torType)) }, onClick = { pendingSettings = currentSettings.copy(torType = torType) },
selected = currentSettings.torType == torType, selected = currentSettings.torType == torType,
) { ) {
Text(torType.name.lowercase().replaceFirstChar { it.uppercase() }) Text(torType.name.lowercase().replaceFirstChar { it.uppercase() })
@@ -136,4 +138,22 @@ fun TorSettingsSection(
onDismiss = { showDialog = false }, onDismiss = { showDialog = false },
) )
} }
// Restart confirmation dialog
pendingSettings?.let { settings ->
AlertDialog(
onDismissRequest = { pendingSettings = null },
title = { Text("Restart Required") },
text = { Text("Changing Tor mode requires restarting. Your session will be briefly interrupted.") },
confirmButton = {
TextButton(onClick = {
onSettingsChanged(settings)
pendingSettings = null
}) { Text("Restart") }
},
dismissButton = {
TextButton(onClick = { pendingSettings = null }) { Text("Cancel") }
},
)
}
} }