Merge pull request #2381 from nrobi144/feat/tor-support

feat: Desktop Tor support — embedded kmp-tor, full privacy routing, settings UI
This commit is contained in:
Vitor Pamplona
2026-04-13 11:52:42 -04:00
committed by GitHub
53 changed files with 3438 additions and 303 deletions
@@ -55,6 +55,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -159,6 +160,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 +172,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
@@ -178,6 +185,7 @@ fun main() {
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center),
)
var appRestartKey by remember { mutableStateOf(0) }
var showComposeDialog by remember { mutableStateOf(false) }
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
val deckScope = rememberCoroutineScope()
@@ -185,6 +193,23 @@ fun main() {
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
var showAddColumnDialog by remember { mutableStateOf(false) }
// Tor state at Window level — survives key() app rebuild
var torSettings by remember {
mutableStateOf(
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 windowScope = rememberCoroutineScope()
val torManager =
remember {
com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, windowScope).also {
activeTorManager = it
}
}
var layoutMode by remember {
mutableStateOf(
try {
@@ -404,25 +429,32 @@ fun main() {
LocalAwtWindow provides window,
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
) {
App(
layoutMode = layoutMode,
deckState = deckState,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAddColumnDialog = showAddColumnDialog,
onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
onDismissAddColumnDialog = { showAddColumnDialog = false },
onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote,
)
key(appRestartKey) {
App(
layoutMode = layoutMode,
deckState = deckState,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAddColumnDialog = showAddColumnDialog,
onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
onDismissAddColumnDialog = { showAddColumnDialog = false },
onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote,
onRestartApp = { appRestartKey++ },
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
initialTorSettings = torSettings,
)
}
}
}
}
@@ -441,12 +473,102 @@ fun App(
onDismissAddColumnDialog: () -> Unit,
onShowAddColumnDialog: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
onRestartApp: () -> Unit = {},
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
) {
val relayManager = remember { DesktopRelayConnectionManager() }
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
var torSettings by remember {
mutableStateOf(
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.load(),
)
}
// Gate: block EVERYTHING until Tor proxy is ready (when Tor expected)
// This must be before any OkHttpClient/Coil/relay creation
val torStatus by torManager.status.collectAsState()
val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
androidx.compose.foundation.layout.Box(
modifier =
androidx.compose.ui.Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.foundation.layout.Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
androidx.compose.material3.CircularProgressIndicator()
androidx.compose.foundation.layout.Spacer(
modifier =
androidx.compose.ui.Modifier
.height(16.dp),
)
if (torStatus is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error) {
androidx.compose.material3.Text(
"Tor error: ${(torStatus as com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error).message}",
)
} else {
androidx.compose.material3.Text("Connecting to Tor...")
}
}
}
return // Nothing below runs until Tor is Active
}
val localCache = remember { DesktopLocalCache() }
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
// 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(torRelayEvaluation) {
com.vitorpamplona.amethyst.desktop.network
.DesktopHttpClient(
torManager = torManager,
shouldUseTorForRelay = { url -> torRelayEvaluation.useTor(url) },
torTypeProvider = { torTypeFlow.value },
scope = scope,
).also {
com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
.setInstance(it)
}
}
// Clean up old httpClient's connection pool on rebuild
DisposableEffect(httpClient) {
onDispose {
httpClient.sharedConnectionPool.evictAll()
}
}
// Reinitialize Coil after setInstance so image loads use the Tor-aware client
remember(httpClient) {
httpClient.evictConnections()
com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
.setup()
}
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
val subscriptionsCoordinator =
@@ -541,20 +663,39 @@ fun App(
accountManager.loadNwcConnection()
}
MainContent(
layoutMode = layoutMode,
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = scope,
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
// Rebuild app to apply Tor changes
onRestartApp()
},
),
) {
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) {
@@ -602,6 +743,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,
@@ -774,6 +916,7 @@ fun MainContent(
},
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
torStatus = torStatus,
)
VerticalDivider()
@@ -857,6 +1000,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()
@@ -972,6 +1120,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
@@ -138,7 +138,7 @@ class AccountManager internal constructor(
private suspend fun getOrCreateNip46Client(): INostrClient =
nip46ClientMutex.withLock {
nip46Client ?: NostrClient(
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
BasicOkHttpWebSocket.Builder { url -> DesktopHttpClient.currentClient() },
).also {
nip46Client = it
it.connect()
@@ -20,21 +20,160 @@
*/
package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.amethyst.commons.tor.ITorManager
import com.vitorpamplona.amethyst.commons.tor.TorType
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,
private val torTypeProvider: () -> TorType,
scope: CoroutineScope,
) {
/** Returns true if user expects Tor routing (INTERNAL or EXTERNAL mode). */
fun isTorExpected(): Boolean = torTypeProvider() != TorType.OFF
val sharedConnectionPool = ConnectionPool()
/** Evict all idle connections — call on Tor state change to kill stale direct/proxy connections. */
fun evictConnections() {
sharedConnectionPool.evictAll()
}
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<OkHttpClient?> =
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
lateinit var instance: DesktopHttpClient
private set
fun setInstance(client: DesktopHttpClient) {
instance = client
}
/** Fail-closed client: SOCKS proxy on dead port 1. Requests fail instead of leaking IP. */
private val failClosedClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 1)))
.connectTimeout(1, TimeUnit.SECONDS)
.readTimeout(1, TimeUnit.SECONDS)
.build()
}
/** Simple direct client for pre-init only (tests, startup). */
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()
}
/**
* Returns the current Tor-aware client.
* - Tor active → proxy client (SOCKS)
* - Tor off → direct client
* - Tor expected but bootstrapping → FAIL-CLOSED (dead proxy, requests fail not leak)
* - Instance not set → simpleClient (pre-init/tests only)
*/
fun currentClient(): OkHttpClient {
if (!::instance.isInitialized) return simpleClient
val client = instance
val proxyClient = client.proxyClient.value
if (proxyClient != null) return proxyClient
return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient()
}
/** Backward-compatible static accessor for relay WebSocket builder. */
fun getSimpleHttpClient(url: NormalizedRelayUrl): OkHttpClient = currentClient()
}
}
@@ -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),
)
@@ -23,11 +23,22 @@ package com.vitorpamplona.amethyst.desktop.service.images
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.Uri
import coil3.annotation.DelicateCoilApi
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import coil3.fetch.Fetcher
import coil3.memory.MemoryCache
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import coil3.size.Precision
import coil3.svg.SvgDecoder
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import okhttp3.Call
import okio.Path.Companion.toOkioPath
import java.io.File
@@ -44,6 +55,7 @@ object DesktopImageLoaderSetup {
.diskCache { newDiskCache() }
.precision(Precision.INEXACT)
.components {
add(TorAwareOkHttpFactory { DesktopHttpClient.currentClient() })
add(SvgDecoder.Factory())
add(SkiaGifDecoder.Factory())
add(DesktopBase64Fetcher.Factory)
@@ -92,3 +104,35 @@ object DesktopImageLoaderSetup {
}
}
}
/**
* Custom Coil Fetcher.Factory that routes all image requests through the Tor-aware client.
* Pattern ported from Android's OkHttpFactory in ImageLoaderSetup.kt.
*
* Each image request calls [clientProvider] to get the current OkHttpClient,
* which returns the SOCKS proxy client when Tor is active or the direct client when off.
*/
@OptIn(ExperimentalCoilApi::class)
private class TorAwareOkHttpFactory(
val clientProvider: () -> Call.Factory,
) : Fetcher.Factory<Uri> {
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
if (data.scheme != "http" && data.scheme != "https") return null
return NetworkFetcher(
url = data.toString(),
options = options,
networkClient = lazy { clientProvider().asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { ConnectivityChecker(options.context) },
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
)
}
}
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
@@ -33,7 +33,7 @@ import java.util.concurrent.ConcurrentHashMap
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
*/
object EncryptedMediaService {
private val httpClient = OkHttpClient()
private val httpClient get() = DesktopHttpClient.currentClient()
private val cache = ConcurrentHashMap<String, ByteArray>()
private const val MAX_CACHE_ENTRIES = 20
@@ -20,20 +20,12 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
object ServerHealthCheck {
private val httpClient =
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
enum class ServerStatus {
ONLINE,
OFFLINE,
@@ -53,7 +45,7 @@ object ServerHealthCheck {
.url(url)
.head()
.build()
val response = httpClient.newCall(request).execute()
val response = DesktopHttpClient.currentClient().newCall(request).execute()
response.use {
if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
@@ -34,8 +35,10 @@ import okio.source
import java.io.File
class DesktopBlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
private val clientOverride: OkHttpClient? = null,
) {
private val okHttpClient: OkHttpClient get() = clientOverride ?: DesktopHttpClient.currentClient()
suspend fun upload(
file: File,
contentType: String,
@@ -0,0 +1,228 @@
/*
* 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.tor
import com.vitorpamplona.amethyst.commons.tor.ITorManager
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.quartz.utils.Log
import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec
import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync
import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonAsync
import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonSync
import io.matthewnelson.kmp.tor.runtime.RuntimeEvent
import io.matthewnelson.kmp.tor.runtime.TorRuntime
import io.matthewnelson.kmp.tor.runtime.TorState
import io.matthewnelson.kmp.tor.runtime.core.OnEvent
import io.matthewnelson.kmp.tor.runtime.core.OnFailure
import io.matthewnelson.kmp.tor.runtime.core.OnSuccess
import io.matthewnelson.kmp.tor.runtime.core.TorEvent
import io.matthewnelson.kmp.tor.runtime.core.config.TorOption
import io.matthewnelson.kmp.tor.runtime.core.ctrl.TorCmd
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
/**
* Desktop Tor daemon manager using kmp-tor.
*
* Reactive: automatically starts/stops Tor daemon based on [torTypeFlow] changes.
* Supports INTERNAL (embedded kmp-tor) and EXTERNAL (user-provided SOCKS port) modes.
*/
class DesktopTorManager(
private val torTypeFlow: StateFlow<TorType>,
private val externalPortFlow: StateFlow<Int>,
private val scope: CoroutineScope,
) : ITorManager {
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val activePortOrNull: StateFlow<Int?> =
_status
.map { (it as? TorServiceStatus.Active)?.port }
.stateIn(scope, SharingStarted.Eagerly, null)
private val runtime: TorRuntime by lazy {
TorRuntime.Builder(desktopEnvironment()) {
observerStatic(
RuntimeEvent.LISTENERS,
OnEvent.Executor.Immediate,
OnEvent { listeners ->
val port =
listeners.socks
.firstOrNull()
?.port
?.value
if (port != null) {
_status.value = TorServiceStatus.Active(port)
}
},
)
observerStatic(
RuntimeEvent.STATE,
OnEvent.Executor.Immediate,
OnEvent { state ->
val daemon = state.daemon
when {
daemon is TorState.Daemon.Off -> {
_status.value = TorServiceStatus.Off
}
daemon is TorState.Daemon.Starting -> {
_status.value = TorServiceStatus.Connecting
}
daemon is TorState.Daemon.Stopping -> {
_status.value = TorServiceStatus.Connecting
}
}
},
)
// Suppress verbose logging to prevent relay hostname leaks
required(TorEvent.ERR)
required(TorEvent.WARN)
config { _ ->
TorOption.__SocksPort.configure { auto() }
}
}
}
init {
// Reactive: auto-derive Tor lifecycle from settings changes
scope.launch {
torTypeFlow.collect { mode ->
when (mode) {
TorType.INTERNAL -> {
_status.value = TorServiceStatus.Connecting
// Retry start — previous daemon may still be stopping
var started = false
for (attempt in 1..3) {
try {
runtime.startDaemonAsync()
started = true
break
} catch (e: Exception) {
if (attempt < 3) {
Log.d("DesktopTorManager") { "Start attempt $attempt failed, retrying..." }
kotlinx.coroutines.delay(1000L * attempt)
} else {
Log.e("DesktopTorManager", "Failed to start Tor after 3 attempts", e)
_status.value = TorServiceStatus.Error(e.message ?: "Unknown error")
}
}
}
}
TorType.EXTERNAL -> {
_status.value = TorServiceStatus.Active(externalPortFlow.value)
}
TorType.OFF -> {
try {
runtime.stopDaemonAsync()
} catch (_: Exception) {
// May not be running
}
_status.value = TorServiceStatus.Off
}
}
}
}
}
override suspend fun dormant() {
if (_status.value is TorServiceStatus.Active) {
runtime.enqueue(TorCmd.Signal.Dormant, OnFailure.noOp(), OnSuccess.noOp())
}
}
override suspend fun active() {
if (_status.value is TorServiceStatus.Active) {
runtime.enqueue(TorCmd.Signal.Active, OnFailure.noOp(), OnSuccess.noOp())
}
}
override suspend fun newIdentity() {
if (_status.value is TorServiceStatus.Active) {
runtime.enqueue(TorCmd.Signal.NewNym, OnFailure.noOp(), OnSuccess.noOp())
}
}
/** Call from shutdown hook to stop Tor synchronously. Waits for daemon exit. */
fun stopSync() {
try {
runtime.stopDaemonSync()
// Wait for daemon process to fully exit
Thread.sleep(1000)
} catch (_: Exception) {
// Best-effort
}
}
companion object {
private fun desktopEnvironment(): TorRuntime.Environment {
val appDir = torDataDirectory()
appDir.mkdirs()
// Restrict permissions to owner only (700)
appDir.setReadable(false, false)
appDir.setReadable(true, true)
appDir.setWritable(false, false)
appDir.setWritable(true, true)
appDir.setExecutable(false, false)
appDir.setExecutable(true, true)
return TorRuntime.Environment.Builder(
workDirectory = appDir.resolve("work"),
cacheDirectory = appDir.resolve("cache"),
loader = ResourceLoaderTorExec::getOrCreate,
) {}
}
/** OS-specific data directory for Tor. */
internal fun torDataDirectory(): File {
val osName = System.getProperty("os.name", "").lowercase()
val home = System.getProperty("user.home") ?: "."
return when {
osName.contains("mac") -> {
File(home, "Library/Application Support/Amethyst/tor")
}
osName.contains("win") -> {
File(System.getenv("APPDATA") ?: "$home/AppData/Roaming", "Amethyst/tor")
}
else -> {
val xdgData = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share"
File(xdgData, "Amethyst/tor")
}
}
}
}
}
@@ -0,0 +1,71 @@
/*
* 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.tor
import com.vitorpamplona.amethyst.commons.tor.ITorSettingsPersistence
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
import java.util.prefs.Preferences
/**
* Desktop persistence for Tor settings using java.util.prefs.Preferences.
*
* Per-device settings (not synced across accounts).
* Default TorType is OFF — user must opt-in.
* No explicit flush() — matches existing DesktopPreferences pattern.
*/
object DesktopTorPreferences : ITorSettingsPersistence {
private val prefs = Preferences.userNodeForPackage(DesktopTorPreferences::class.java)
override fun load(): TorSettings =
TorSettings(
torType = TorType.entries.firstOrNull { it.name == prefs.get("tor_type", TorType.INTERNAL.name) } ?: TorType.INTERNAL,
externalSocksPort = prefs.getInt("tor_external_port", 9050),
onionRelaysViaTor = prefs.getBoolean("tor_onion_relays", true),
dmRelaysViaTor = prefs.getBoolean("tor_dm_relays", true),
newRelaysViaTor = prefs.getBoolean("tor_new_relays", true),
trustedRelaysViaTor = prefs.getBoolean("tor_trusted_relays", true),
urlPreviewsViaTor = prefs.getBoolean("tor_url_previews", true),
profilePicsViaTor = prefs.getBoolean("tor_profile_pics", true),
imagesViaTor = prefs.getBoolean("tor_images", true),
videosViaTor = prefs.getBoolean("tor_videos", true),
moneyOperationsViaTor = prefs.getBoolean("tor_money", true),
nip05VerificationsViaTor = prefs.getBoolean("tor_nip05", true),
mediaUploadsViaTor = prefs.getBoolean("tor_media_uploads", true),
)
override fun save(settings: TorSettings) {
prefs.put("tor_type", settings.torType.name)
prefs.putInt("tor_external_port", settings.externalSocksPort)
prefs.putBoolean("tor_onion_relays", settings.onionRelaysViaTor)
prefs.putBoolean("tor_dm_relays", settings.dmRelaysViaTor)
prefs.putBoolean("tor_new_relays", settings.newRelaysViaTor)
prefs.putBoolean("tor_trusted_relays", settings.trustedRelaysViaTor)
prefs.putBoolean("tor_url_previews", settings.urlPreviewsViaTor)
prefs.putBoolean("tor_profile_pics", settings.profilePicsViaTor)
prefs.putBoolean("tor_images", settings.imagesViaTor)
prefs.putBoolean("tor_videos", settings.videosViaTor)
prefs.putBoolean("tor_money", settings.moneyOperationsViaTor)
prefs.putBoolean("tor_nip05", settings.nip05VerificationsViaTor)
prefs.putBoolean("tor_media_uploads", settings.mediaUploadsViaTor)
// No explicit flush() — JVM auto-flushes on shutdown (matches DesktopPreferences pattern)
}
}
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -84,10 +85,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)
@@ -936,12 +935,7 @@ private suspend fun zapNote(
}
// Create HTTP client and resolver
val httpClient =
OkHttpClient
.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val httpClient = DesktopHttpClient.currentClient()
val resolver = LightningAddressResolver(httpClient)
// Get relay URLs for zap request
@@ -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 -> {
@@ -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,7 +90,10 @@ fun DeckSidebar(
lastPingTimeSec = lastPingTimeSec,
)
Spacer(Modifier.size(8.dp))
Spacer(Modifier.size(4.dp))
TorStatusIndicator(status = torStatus, onClick = onOpenSettings)
Spacer(Modifier.size(4.dp))
IconButton(onClick = onOpenSettings) {
Icon(
@@ -68,6 +68,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
@@ -161,6 +163,17 @@ fun SinglePaneLayout(
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 4.dp),
)
// Tor status — always last so it's never pushed off screen
val torState = LocalTorState.current
TorStatusIndicator(
status = torState.status,
onClick = {
currentColumnType = DeckColumnType.Settings
navState.clear()
},
modifier = Modifier.padding(bottom = 12.dp),
)
}
@@ -35,27 +35,20 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Codec
import org.jetbrains.skia.Data
import java.util.concurrent.TimeUnit
private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF
private const val MIN_FRAME_DURATION_MS = 20
private val gifHttpClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
private val gifHttpClient get() = DesktopHttpClient.currentClient()
fun isAnimatedGifUrl(url: String): Boolean {
val lower = url.lowercase()
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.media
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
object SaveMediaAction {
private val httpClient = OkHttpClient()
private val httpClient get() = DesktopHttpClient.currentClient()
/**
* Opens a save dialog and downloads the media URL to the chosen file.
@@ -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() }
@@ -0,0 +1,259 @@
/*
* 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) }
var showRestartConfirm by remember { mutableStateOf(false) }
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)) {
// Scrollable content
Column(
modifier =
Modifier
.weight(1f)
.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(16.dp))
} // end scrollable content
// Sticky bottom buttons — always visible
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.width(8.dp))
TextButton(onClick = { showRestartConfirm = true }) { Text("Save") }
}
if (showRestartConfirm) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showRestartConfirm = false },
title = { Text("Restart Required") },
text = { Text("Tor changes require restarting. Proceed?") },
confirmButton = {
TextButton(onClick = {
onSettingsChanged(editSettings)
onDismiss()
}) { Text("Restart") }
},
dismissButton = {
TextButton(onClick = { showRestartConfirm = false }) { Text("Cancel") }
},
)
}
}
}
}
}
@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,159 @@
/*
* 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.AlertDialog
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) }
var pendingSettings by remember { mutableStateOf<TorSettings?>(null) }
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 = { pendingSettings = 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 },
)
}
// Restart confirmation dialog
pendingSettings?.let { settings ->
AlertDialog(
onDismissRequest = { pendingSettings = null },
title = { Text("Restart Required") },
text = { Text("Changing Tor mode requires restarting. Your session will be briefly interrupted.") },
confirmButton = {
TextButton(onClick = {
onSettingsChanged(settings)
pendingSettings = null
}) { Text("Restart") }
},
dismissButton = {
TextButton(onClick = { pendingSettings = null }) { Text("Cancel") }
},
)
}
}
@@ -0,0 +1,109 @@
/*
* 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.IconButton
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.
* Clickable to open Tor settings. Tooltip shows status text.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TorStatusIndicator(
status: TorServiceStatus,
onClick: (() -> Unit)? = null,
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)),
) {
if (onClick != null) {
IconButton(onClick = onClick, modifier = modifier.size(28.dp)) {
Icon(
imageVector = icon,
contentDescription = tooltip,
tint = tint,
modifier = Modifier.size(20.dp),
)
}
} else {
Icon(
imageVector = icon,
contentDescription = tooltip,
tint = tint,
modifier = modifier.size(20.dp),
)
}
}
}
@@ -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,106 @@ 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 },
torType: com.vitorpamplona.amethyst.commons.tor.TorType = if (torPort != null) com.vitorpamplona.amethyst.commons.tor.TorType.INTERNAL else com.vitorpamplona.amethyst.commons.tor.TorType.OFF,
): DesktopHttpClient {
val statusFlow =
MutableStateFlow<TorServiceStatus>(
if (torPort != null) TorServiceStatus.Active(torPort) else TorServiceStatus.Off,
)
val portFlow = MutableStateFlow<Int?>(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, { torType }, 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)
}
}
@@ -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>(TorServiceStatus.Off)
override val activePortOrNull = MutableStateFlow<Int?>(null)
override suspend fun dormant() {}
override suspend fun active() {}
override suspend fun newIdentity() {}
}
val scope = CoroutineScope(SupervisorJob())
val httpClient = DesktopHttpClient(fakeTorManager, { false }, { com.vitorpamplona.amethyst.commons.tor.TorType.OFF }, 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")