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:
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+146
-7
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -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),
|
||||
)
|
||||
|
||||
+44
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-10
@@ -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
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+228
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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
|
||||
|
||||
+9
-1
@@ -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 -> {
|
||||
|
||||
+7
-1
@@ -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(
|
||||
|
||||
+13
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-9
@@ -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()
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+30
-5
@@ -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 ""}",
|
||||
|
||||
+37
@@ -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() }
|
||||
+259
@@ -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)
|
||||
}
|
||||
}
|
||||
+159
@@ -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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-41
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user