feat(tor): wire per-relay routing, .onion badge, shutdown hook, CompositionLocal

Phase 6: Polish and wiring.

Per-relay routing:
- TorRelayEvaluation wired into DesktopHttpClient with actual settings
- shouldUseTorForRelay uses real evaluation instead of { false }
- Settings changes propagate to torTypeFlow/externalPortFlow reactively

CompositionLocal for Tor state:
- LocalTorState provides TorState (status, settings, onSettingsChanged)
- Avoids threading Tor params through every composable layer
- DeckColumnContainer reads from LocalTorState for RelaySettingsScreen

.onion relay badge:
- RelayStatusCard shows "Requires Tor" (red) when Tor is off
- Shows "via Tor" (green) when Tor is active
- Only on .onion relay URLs

Shutdown hook:
- activeTorManager.stopSync() in existing shutdown hook
- Ensures no orphaned Tor processes on app exit
- Set via volatile var from App composable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-01 09:03:14 +03:00
parent e6068c0916
commit 95ec190b0d
4 changed files with 142 additions and 30 deletions
@@ -159,6 +159,10 @@ sealed class DesktopScreen {
data object Settings : DesktopScreen()
}
/** Reference to active Tor manager for shutdown hook. Set by App composable. */
@Volatile
private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null
fun main() {
Log.minLevel = LogLevel.DEBUG
DesktopImageLoaderSetup.setup()
@@ -167,6 +171,8 @@ fun main() {
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.shutdown()
VlcjPlayerPool.shutdown()
// Stop Tor daemon if running — reference set by App composable
activeTorManager?.stopSync()
},
)
// Pre-init VLC on background thread so first play is fast
@@ -447,27 +453,47 @@ fun App(
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
// Tor support: load settings, create manager, create proxy-aware HTTP client
val torSettings =
remember {
var torSettings by remember {
mutableStateOf(
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) }
val torManager =
remember {
com.vitorpamplona.amethyst.desktop.tor
.DesktopTorManager(torTypeFlow, externalPortFlow, scope)
com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, scope).also {
activeTorManager = it
}
}
// Build TorRelayEvaluation for per-relay routing
val torRelayEvaluation =
remember(torSettings) {
com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation(
torSettings =
com.vitorpamplona.amethyst.commons.tor.TorRelaySettings(
torType = torSettings.torType,
onionRelaysViaTor = torSettings.onionRelaysViaTor,
dmRelaysViaTor = torSettings.dmRelaysViaTor,
newRelaysViaTor = torSettings.newRelaysViaTor,
trustedRelaysViaTor = torSettings.trustedRelaysViaTor,
),
trustedRelayList = emptySet(), // TODO: populate from account relay lists
dmRelayList = emptySet(), // TODO: populate from account relay lists
)
}
val httpClient =
remember {
remember(torRelayEvaluation) {
com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient(
torManager = torManager,
shouldUseTorForRelay = { false }, // TODO: wire TorRelayEvaluation when settings UI is built
shouldUseTorForRelay = { url -> torRelayEvaluation.useTor(url) },
scope = scope,
)
}
val relayManager = remember { DesktopRelayConnectionManager(httpClient) }
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
@@ -563,21 +589,37 @@ fun App(
accountManager.loadNwcConnection()
}
MainContent(
layoutMode = layoutMode,
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = scope,
torStatus = torManager.status.collectAsState().value,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAddColumnDialog = onShowAddColumnDialog,
)
val currentTorStatus = torManager.status.collectAsState().value
androidx.compose.runtime.CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
},
),
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAddColumnDialog = onShowAddColumnDialog,
)
}
// Compose dialog
if (showComposeDialog) {
@@ -312,7 +312,15 @@ internal fun RootContent(
}
DeckColumnType.Settings -> {
RelaySettingsScreen(relayManager, account, accountManager)
val torState = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current
RelaySettingsScreen(
relayManager = relayManager,
account = account,
accountManager = accountManager,
torStatus = torState.status,
torSettings = torState.settings,
onTorSettingsChanged = torState.onSettingsChanged,
)
}
is DeckColumnType.Profile -> {
@@ -41,7 +41,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
/**
* Card displaying the status of a Nostr relay connection.
@@ -99,11 +102,33 @@ fun RelayStatusCard(
}
Column {
Text(
status.url.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
status.url.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
// .onion badge: show "Requires Tor" when Tor is off
if (status.url.isOnion()) {
val torState = LocalTorState.current
val badgeColor =
if (torState.status is TorServiceStatus.Off) {
Color(0xFFF44336) // Red — Tor required but off
} else {
Color(0xFF4CAF50) // Green — routed via Tor
}
val badgeText =
if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor"
Text(
badgeText,
style = MaterialTheme.typography.labelSmall,
color = badgeColor,
)
}
}
if (status.connected && status.pingMs != null) {
Text(
"${status.pingMs}ms${if (status.compressed) " • compressed" else ""}",
@@ -0,0 +1,37 @@
/*
* 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.runtime.compositionLocalOf
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
/**
* Composition locals for Tor state, avoiding threading params through every layer.
*/
data class TorState(
val status: TorServiceStatus = TorServiceStatus.Off,
val settings: TorSettings = TorSettings(torType = TorType.OFF),
val onSettingsChanged: (TorSettings) -> Unit = {},
)
val LocalTorState = compositionLocalOf { TorState() }