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 ee2123873..12aeaee23 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -442,11 +442,33 @@ fun App( onShowAddColumnDialog: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { - val relayManager = remember { DesktopRelayConnectionManager() } val localCache = remember { DesktopLocalCache() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Tor support: load settings, create manager, create proxy-aware HTTP client + val torSettings = + remember { + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .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) + } + val httpClient = + remember { + com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient( + torManager = torManager, + shouldUseTorForRelay = { false }, // TODO: wire TorRelayEvaluation when settings UI is built + scope = scope, + ) + } + val relayManager = remember { DesktopRelayConnectionManager(httpClient) } + // Subscriptions coordinator — uses default relay URLs for metadata indexing. // Feed subscriptions (inside MainContent) drive actual relay pool connections. val subscriptionsCoordinator = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 7dd9b9f8e..89fcf1567 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -138,7 +138,7 @@ class AccountManager internal constructor( private suspend fun getOrCreateNip46Client(): INostrClient = nip46ClientMutex.withLock { nip46Client ?: NostrClient( - BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), + BasicOkHttpWebSocket.Builder(DesktopHttpClient::getSimpleHttpClient), ).also { nip46Client = it it.connect() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt index b9641dac9..c9bd67491 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -20,21 +20,118 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.amethyst.commons.tor.ITorManager import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import okhttp3.ConnectionPool import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy import java.util.concurrent.TimeUnit -object DesktopHttpClient { - private val client: OkHttpClient by lazy { +/** + * Desktop HTTP client with optional SOCKS proxy support for Tor. + * + * Maintains two clients: one with SOCKS proxy (for Tor-routed traffic) + * and one without (for direct connections). Selects the appropriate + * client per relay URL based on Tor settings. + * + * When Tor is OFF, all relays use the direct client. + * When Tor is ON, .onion relays always use the proxy client. + * Localhost relays always use the direct client. + */ +class DesktopHttpClient( + torManager: ITorManager, + private val shouldUseTorForRelay: (NormalizedRelayUrl) -> Boolean, + scope: CoroutineScope, +) { + private val sharedConnectionPool = ConnectionPool() + + private val directClient: OkHttpClient by lazy { OkHttpClient .Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .pingInterval(30, TimeUnit.SECONDS) + .connectionPool(sharedConnectionPool) + .connectTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build() } - fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient = client + /** Proxy client, rebuilt when SOCKS port changes. */ + val proxyClient: StateFlow = + torManager.activePortOrNull + .map { port -> + if (port == null) { + null + } else { + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)) + val torTimeout = BASE_TIMEOUT_SECONDS * TOR_TIMEOUT_MULTIPLIER + OkHttpClient + .Builder() + .connectionPool(sharedConnectionPool) + .proxy(proxy) + .connectTimeout(torTimeout, TimeUnit.SECONDS) + .readTimeout(torTimeout, TimeUnit.SECONDS) + .writeTimeout(torTimeout, TimeUnit.SECONDS) + .pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + }.stateIn(scope, SharingStarted.Eagerly, null) + + /** + * Returns the appropriate OkHttpClient for the given relay URL. + * + * - Localhost relays → always direct (no proxy) + * - .onion relays when Tor active → proxy client + * - Other relays → based on TorRelayEvaluation routing decision + * - If proxy needed but not available (Tor bootstrapping) → direct client as fallback + */ + fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient { + if (url.isLocalHost()) return directClient + + val torActive = proxyClient.value != null + if (!torActive) return directClient + + // .onion always needs Tor + if (url.isOnion()) return proxyClient.value ?: directClient + + // Delegate to TorRelayEvaluation for routing decision + return if (shouldUseTorForRelay(url)) { + proxyClient.value ?: directClient + } else { + directClient + } + } + + /** Returns the direct (non-proxy) client for non-relay HTTP traffic. */ + fun getNonProxyClient(): OkHttpClient = directClient + + companion object { + private const val BASE_TIMEOUT_SECONDS = 30L + private const val TOR_TIMEOUT_MULTIPLIER = 2 // Desktop: 2x, not Android's 3x + + /** Simple direct client for code that doesn't need Tor routing (e.g. NIP-46 bunker). */ + private val simpleClient: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + /** Backward-compatible static accessor for code that doesn't need Tor. */ + fun getSimpleHttpClient(url: NormalizedRelayUrl): OkHttpClient = simpleClient + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index 58312aada..9b4dd1ed5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -24,9 +24,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc /** * Desktop-specific relay connection manager that configures OkHttp for websockets. - * Delegates to the shared RelayConnectionManager from commons. + * Now Tor-aware: passes the DesktopHttpClient's getHttpClient which selects + * proxy or direct client per relay URL based on Tor settings. */ -class DesktopRelayConnectionManager : - RelayConnectionManager( - websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), +class DesktopRelayConnectionManager( + httpClient: DesktopHttpClient, +) : RelayConnectionManager( + websocketBuilder = BasicOkHttpWebSocket.Builder(httpClient::getHttpClient), ) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt index 999962794..d5157dab1 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt @@ -20,17 +20,25 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class DesktopHttpClientTest { + // --- Simple (companion) client tests --- + @Test - fun testGetHttpClientReturnsConfiguredClient() { - val url = NormalizedRelayUrl("wss://relay.damus.io") - val client = DesktopHttpClient.getHttpClient(url) + fun simpleClient_returnsConfiguredClient() { + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = DesktopHttpClient.getSimpleHttpClient(url) assertNotNull(client) assertEquals(30_000, client.connectTimeoutMillis) @@ -41,52 +49,105 @@ class DesktopHttpClientTest { } @Test - fun testGetHttpClientReturnsSameInstance() { - val url1 = NormalizedRelayUrl("wss://relay.damus.io") - val url2 = NormalizedRelayUrl("wss://nos.lol") + fun simpleClient_returnsSameInstance() { + val url1 = NormalizedRelayUrl("wss://relay.damus.io/") + val url2 = NormalizedRelayUrl("wss://nos.lol/") + assertEquals(DesktopHttpClient.getSimpleHttpClient(url1), DesktopHttpClient.getSimpleHttpClient(url2)) + } - val client1 = DesktopHttpClient.getHttpClient(url1) - val client2 = DesktopHttpClient.getHttpClient(url2) + // --- Tor-aware client tests --- - // Should return the same singleton instance - assertEquals(client1, client2) + private fun buildTorAwareClient( + torPort: Int? = null, + shouldUseTor: (NormalizedRelayUrl) -> Boolean = { false }, + ): DesktopHttpClient { + val statusFlow = + MutableStateFlow( + if (torPort != null) TorServiceStatus.Active(torPort) else TorServiceStatus.Off, + ) + val portFlow = MutableStateFlow(torPort) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val fakeTorManager = + object : com.vitorpamplona.amethyst.commons.tor.ITorManager { + override val status = statusFlow + override val activePortOrNull = portFlow + + override suspend fun dormant() {} + + override suspend fun active() {} + + override suspend fun newIdentity() {} + } + return DesktopHttpClient(fakeTorManager, shouldUseTor, scope) } @Test - fun testHttpClientHasExpectedTimeouts() { - val url = NormalizedRelayUrl("wss://relay.nostr.band") - val client = DesktopHttpClient.getHttpClient(url) + fun torOff_returnsDirectClient() { + val httpClient = buildTorAwareClient(torPort = null) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + assertNotNull(client) + assertNull(client.proxy, "Should not have proxy when Tor is off") + } + + @Test + fun torOn_localhostRelay_returnsDirectClient() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true }) + val url = NormalizedRelayUrl("ws://127.0.0.1:8080/") + val client = httpClient.getHttpClient(url) + + assertNull(client.proxy, "Localhost should never use proxy") + } + + @Test + fun torOn_clearnetRelay_shouldUseTorTrue_returnsProxiedClient() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true }) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + assertNotNull(client.proxy, "Should have SOCKS proxy when Tor routing is enabled") + assertEquals(java.net.Proxy.Type.SOCKS, client.proxy!!.type()) + } + + @Test + fun torOn_clearnetRelay_shouldUseTorFalse_returnsDirectClient() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { false }) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + assertNull(client.proxy, "Should not proxy when shouldUseTor returns false") + } + + @Test + fun torOn_onionRelay_alwaysProxied() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { false }) + val url = NormalizedRelayUrl("wss://abc123.onion/") + val client = httpClient.getHttpClient(url) + + assertNotNull(client.proxy, ".onion relays should always use proxy when Tor is active") + } + + @Test + fun proxyClient_hasDoubledTimeouts() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true }) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + // Desktop uses 2x multiplier: 30 * 2 = 60 seconds + assertEquals(60_000, client.connectTimeoutMillis) + assertEquals(60_000, client.readTimeoutMillis) + assertEquals(60_000, client.writeTimeoutMillis) + } + + @Test + fun directClient_hasBaseTimeouts() { + val httpClient = buildTorAwareClient(torPort = null) + val client = httpClient.getNonProxyClient() - // Verify all timeouts are 30 seconds assertEquals(30_000, client.connectTimeoutMillis) assertEquals(30_000, client.readTimeoutMillis) assertEquals(30_000, client.writeTimeoutMillis) - assertEquals(30_000, client.pingIntervalMillis) - } - - @Test - fun testHttpClientHasRetryEnabled() { - val url = NormalizedRelayUrl("wss://relay.snort.social") - val client = DesktopHttpClient.getHttpClient(url) - - assertTrue(client.retryOnConnectionFailure, "Retry on connection failure should be enabled") - } - - @Test - fun testHttpClientIsLazyInitialized() { - // This test verifies the lazy initialization pattern - // The client should be created only once even with multiple calls - val url1 = NormalizedRelayUrl("wss://relay1.example.com") - val url2 = NormalizedRelayUrl("wss://relay2.example.com") - val url3 = NormalizedRelayUrl("wss://relay3.example.com") - - val client1 = DesktopHttpClient.getHttpClient(url1) - val client2 = DesktopHttpClient.getHttpClient(url2) - val client3 = DesktopHttpClient.getHttpClient(url3) - - // All should be the same instance due to lazy singleton - assertEquals(client1, client2) - assertEquals(client2, client3) - assertEquals(client1, client3) } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt index 0f494ac3e..ac091e3dc 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt @@ -20,20 +20,41 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue class DesktopRelayConnectionManagerTest { + private fun createManager(): DesktopRelayConnectionManager { + val fakeTorManager = + object : com.vitorpamplona.amethyst.commons.tor.ITorManager { + override val status = MutableStateFlow(TorServiceStatus.Off) + override val activePortOrNull = MutableStateFlow(null) + + override suspend fun dormant() {} + + override suspend fun active() {} + + override suspend fun newIdentity() {} + } + val scope = CoroutineScope(SupervisorJob()) + val httpClient = DesktopHttpClient(fakeTorManager, { false }, scope) + return DesktopRelayConnectionManager(httpClient) + } + @Test fun testRelayConnectionManagerCanBeInstantiated() { - val manager = DesktopRelayConnectionManager() + val manager = createManager() assertNotNull(manager) } @Test fun testRelayConnectionManagerHasNoActiveConnectionsInitially() { - val manager = DesktopRelayConnectionManager() + val manager = createManager() val connectedRelays = manager.connectedRelays.value val availableRelays = manager.availableRelays.value assertTrue(connectedRelays.isEmpty(), "Should have no connected relays on initialization")