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:
@@ -573,6 +573,7 @@ fun App(
|
|||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
appScope = scope,
|
appScope = scope,
|
||||||
|
torStatus = torManager.status.collectAsState().value,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onShowAddColumnDialog = onShowAddColumnDialog,
|
onShowAddColumnDialog = onShowAddColumnDialog,
|
||||||
@@ -624,6 +625,7 @@ fun MainContent(
|
|||||||
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
|
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onShowAddColumnDialog: () -> Unit,
|
onShowAddColumnDialog: () -> Unit,
|
||||||
@@ -796,6 +798,7 @@ fun MainContent(
|
|||||||
},
|
},
|
||||||
signerConnectionState = signerConnectionState,
|
signerConnectionState = signerConnectionState,
|
||||||
lastPingTimeSec = lastPingTimeSec,
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
|
torStatus = torStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
@@ -879,6 +882,11 @@ fun RelaySettingsScreen(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
accountManager: AccountManager,
|
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 relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
@@ -994,6 +1002,16 @@ fun RelaySettingsScreen(
|
|||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(24.dp))
|
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)
|
// Developer Settings Section (only in debug mode)
|
||||||
if (DebugConfig.isDebugMode) {
|
if (DebugConfig.isDebugMode) {
|
||||||
com.vitorpamplona.amethyst.desktop.ui
|
com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|||||||
+8
@@ -42,7 +42,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
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.commons.ui.components.BunkerHeartbeatIndicator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeckSidebar(
|
fun DeckSidebar(
|
||||||
@@ -50,6 +52,7 @@ fun DeckSidebar(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
signerConnectionState: SignerConnectionState,
|
signerConnectionState: SignerConnectionState,
|
||||||
lastPingTimeSec: Long?,
|
lastPingTimeSec: Long?,
|
||||||
|
torStatus: TorServiceStatus,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -87,6 +90,11 @@ fun DeckSidebar(
|
|||||||
lastPingTimeSec = lastPingTimeSec,
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (torStatus !is TorServiceStatus.Off) {
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
TorStatusIndicator(status = torStatus)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
|||||||
+238
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user