feat(tor): desktop Tor settings UI — indicator, toggle, dialog

Phase 5: Desktop settings UI for Tor.

TorStatusIndicator:
- Shield icon in sidebar footer (gray/yellow/green/red)
- Tooltip shows status (no port number for security)
- Only visible when Tor is not Off

TorSettingsSection:
- Inline in settings screen between Media Server and Dev Settings
- Mode selector (Off/Internal/External) with segmented buttons
- External SOCKS port input with validation (1-65535)
- "Advanced..." button opens full dialog

TorSettingsDialog:
- Full DialogWindow with preset selector (radio buttons)
- 4 relay routing toggles + 7 content routing toggles
- Preset auto-detection (whichPreset)
- Save/Cancel with DesktopTorPreferences persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-01 08:54:18 +03:00
parent 8a149af34b
commit e6068c0916
5 changed files with 499 additions and 0 deletions
@@ -573,6 +573,7 @@ fun App(
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = scope,
torStatus = torManager.status.collectAsState().value,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAddColumnDialog = onShowAddColumnDialog,
@@ -624,6 +625,7 @@ fun MainContent(
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onShowAddColumnDialog: () -> Unit,
@@ -796,6 +798,7 @@ fun MainContent(
},
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
torStatus = torStatus,
)
VerticalDivider()
@@ -879,6 +882,11 @@ fun RelaySettingsScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
accountManager: AccountManager,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus = com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Off,
torSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings =
com.vitorpamplona.amethyst.commons.tor
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -994,6 +1002,16 @@ fun RelaySettingsScreen(
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Tor Settings
com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection(
torStatus = torStatus,
currentSettings = torSettings,
onSettingsChanged = onTorSettingsChanged,
)
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Developer Settings Section (only in debug mode)
if (DebugConfig.isDebugMode) {
com.vitorpamplona.amethyst.desktop.ui
@@ -42,7 +42,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
@Composable
fun DeckSidebar(
@@ -50,6 +52,7 @@ fun DeckSidebar(
onOpenSettings: () -> Unit,
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
torStatus: TorServiceStatus,
modifier: Modifier = Modifier,
) {
Column(
@@ -87,6 +90,11 @@ fun DeckSidebar(
lastPingTimeSec = lastPingTimeSec,
)
if (torStatus !is TorServiceStatus.Off) {
Spacer(Modifier.size(4.dp))
TorStatusIndicator(status = torStatus)
}
Spacer(Modifier.size(8.dp))
IconButton(onClick = onOpenSettings) {
@@ -0,0 +1,238 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.tor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.rememberDialogState
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
import com.vitorpamplona.amethyst.commons.tor.whichPreset
@Composable
fun TorSettingsDialog(
currentSettings: TorSettings,
torStatus: TorServiceStatus,
onSettingsChanged: (TorSettings) -> Unit,
onDismiss: () -> Unit,
) {
var editSettings by remember { mutableStateOf(currentSettings) }
DialogWindow(
onCloseRequest = onDismiss,
title = "Tor Settings",
state = rememberDialogState(size = DpSize(480.dp, 640.dp)),
) {
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier =
Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
) {
// Status
Row(verticalAlignment = Alignment.CenterVertically) {
TorStatusIndicator(status = torStatus)
Spacer(Modifier.width(8.dp))
Text(
when (torStatus) {
is TorServiceStatus.Off -> "Tor is off"
is TorServiceStatus.Connecting -> "Connecting to Tor..."
is TorServiceStatus.Active -> "Connected via Tor"
is TorServiceStatus.Error -> "Error: ${torStatus.message}"
},
style = MaterialTheme.typography.bodyLarge,
)
}
Spacer(Modifier.height(16.dp))
// Mode selector
Text("Mode", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
TorType.entries.forEachIndexed { index, torType ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, TorType.entries.size),
onClick = { editSettings = editSettings.copy(torType = torType) },
selected = editSettings.torType == torType,
) {
Text(torType.name.lowercase().replaceFirstChar { it.uppercase() })
}
}
}
if (editSettings.torType == TorType.EXTERNAL) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = editSettings.externalSocksPort.toString(),
onValueChange = { text ->
text.toIntOrNull()?.let { port ->
if (port in 1..65535) {
editSettings = editSettings.copy(externalSocksPort = port)
}
}
},
label = { Text("SOCKS Port") },
singleLine = true,
modifier = Modifier.width(150.dp),
)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
// Presets
Text("Preset", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
val currentPreset = whichPreset(editSettings)
PresetRow("Only When Needed", TorPresetType.ONLY_WHEN_NEEDED, currentPreset) {
editSettings = torOnlyWhenNeededPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
}
PresetRow("Default", TorPresetType.DEFAULT, currentPreset) {
editSettings = torDefaultPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
}
PresetRow("Small Payloads", TorPresetType.SMALL_PAYLOADS, currentPreset) {
editSettings = torSmallPayloadsPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
}
PresetRow("Full Privacy", TorPresetType.FULL_PRIVACY, currentPreset) {
editSettings = torFullyPrivate.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
}
PresetRow("Custom", TorPresetType.CUSTOM, currentPreset) {}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
// Relay Routing
Text("Relay Routing", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
ToggleRow(".onion relays via Tor", editSettings.onionRelaysViaTor) { editSettings = editSettings.copy(onionRelaysViaTor = it) }
ToggleRow("DM relays via Tor", editSettings.dmRelaysViaTor) { editSettings = editSettings.copy(dmRelaysViaTor = it) }
ToggleRow("Trusted relays via Tor", editSettings.trustedRelaysViaTor) { editSettings = editSettings.copy(trustedRelaysViaTor = it) }
ToggleRow("New/unknown relays via Tor", editSettings.newRelaysViaTor) { editSettings = editSettings.copy(newRelaysViaTor = it) }
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
// Content Routing
Text("Content Routing", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
ToggleRow("URL previews via Tor", editSettings.urlPreviewsViaTor) { editSettings = editSettings.copy(urlPreviewsViaTor = it) }
ToggleRow("Profile pictures via Tor", editSettings.profilePicsViaTor) { editSettings = editSettings.copy(profilePicsViaTor = it) }
ToggleRow("Images via Tor", editSettings.imagesViaTor) { editSettings = editSettings.copy(imagesViaTor = it) }
ToggleRow("Videos via Tor", editSettings.videosViaTor) { editSettings = editSettings.copy(videosViaTor = it) }
ToggleRow("NIP-05 verifications via Tor", editSettings.nip05VerificationsViaTor) { editSettings = editSettings.copy(nip05VerificationsViaTor = it) }
ToggleRow("Money operations via Tor", editSettings.moneyOperationsViaTor) { editSettings = editSettings.copy(moneyOperationsViaTor = it) }
ToggleRow("Media uploads via Tor", editSettings.mediaUploadsViaTor) { editSettings = editSettings.copy(mediaUploadsViaTor = it) }
Spacer(Modifier.height(24.dp))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.width(8.dp))
TextButton(onClick = {
onSettingsChanged(editSettings)
onDismiss()
}) { Text("Save") }
}
}
}
}
}
@Composable
private fun PresetRow(
label: String,
presetType: TorPresetType,
currentPreset: TorPresetType,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
RadioButton(
selected = currentPreset == presetType,
onClick = onClick,
)
Spacer(Modifier.width(4.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun ToggleRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.tor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
/**
* Inline Tor settings section for the desktop settings screen.
* Shows mode selector (Off/Internal/External) with status indicator
* and an "Advanced..." button to open the full dialog.
*/
@Composable
fun TorSettingsSection(
torStatus: TorServiceStatus,
currentSettings: TorSettings,
onSettingsChanged: (TorSettings) -> Unit,
modifier: Modifier = Modifier,
) {
var showDialog by remember { mutableStateOf(false) }
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Tor",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.width(12.dp))
TorStatusIndicator(status = torStatus)
}
TextButton(onClick = { showDialog = true }) {
Text("Advanced...")
}
}
Spacer(Modifier.height(8.dp))
Text(
"Route relay connections through Tor for privacy. Internal mode bundles a Tor daemon; External mode connects to your own SOCKS proxy.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
// Mode selector
SingleChoiceSegmentedButtonRow {
TorType.entries.forEachIndexed { index, torType ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = TorType.entries.size),
onClick = { onSettingsChanged(currentSettings.copy(torType = torType)) },
selected = currentSettings.torType == torType,
) {
Text(torType.name.lowercase().replaceFirstChar { it.uppercase() })
}
}
}
// External port input
if (currentSettings.torType == TorType.EXTERNAL) {
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"SOCKS Port:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
androidx.compose.material3.OutlinedTextField(
value = currentSettings.externalSocksPort.toString(),
onValueChange = { text ->
text.toIntOrNull()?.let { port ->
if (port in 1..65535) {
onSettingsChanged(currentSettings.copy(externalSocksPort = port))
}
}
},
modifier = Modifier.width(100.dp),
singleLine = true,
)
}
}
}
if (showDialog) {
TorSettingsDialog(
currentSettings = currentSettings,
torStatus = torStatus,
onSettingsChanged = onSettingsChanged,
onDismiss = { showDialog = false },
)
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.tor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.TooltipPlacement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
/**
* Small shield icon showing Tor connection status in the sidebar footer.
* Tooltip shows status text (no port number for security).
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TorStatusIndicator(
status: TorServiceStatus,
modifier: Modifier = Modifier,
) {
val (icon, tint, tooltip) =
when (status) {
is TorServiceStatus.Off -> {
Triple(Icons.Outlined.Shield, Color.Gray, "Tor: Off")
}
is TorServiceStatus.Connecting -> {
Triple(Icons.Filled.Shield, Color(0xFFFFB300), "Tor: Connecting...")
}
is TorServiceStatus.Active -> {
Triple(Icons.Filled.Shield, Color(0xFF4CAF50), "Tor: Connected")
}
is TorServiceStatus.Error -> {
Triple(Icons.Outlined.Shield, Color(0xFFF44336), "Tor: ${status.message}")
}
}
TooltipArea(
tooltip = {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface,
) {
Text(
text = tooltip,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.inverseOnSurface,
style = MaterialTheme.typography.bodySmall,
)
}
},
tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd, offset = DpOffset(0.dp, 16.dp)),
) {
Icon(
imageVector = icon,
contentDescription = tooltip,
tint = tint,
modifier = modifier.size(20.dp),
)
}
}