From 95ec190b0da08ade3750a92dd23baf45c77c1e78 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 1 Apr 2026 09:03:14 +0300 Subject: [PATCH] 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) --- .../vitorpamplona/amethyst/desktop/Main.kt | 90 ++++++++++++++----- .../desktop/ui/deck/DeckColumnContainer.kt | 10 ++- .../desktop/ui/relay/RelayStatusCard.kt | 35 ++++++-- .../amethyst/desktop/ui/tor/LocalTorState.kt | 37 ++++++++ 4 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 334ba1efc..8c4b6e085 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index a72032acd..205b41b36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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 -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt index 072739f87..06b8b8a19 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt @@ -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 ""}", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt new file mode 100644 index 000000000..d7690442d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt @@ -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() }