diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9787f46b65..fef81a722b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -969,13 +969,21 @@ fun App( val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() - // Lazy-load Namecoin services + // Lazy-load Namecoin services. The Core RPC HTTP + // client is sourced from the Tor-aware DesktopHttpClient + // singleton so .onion RPC URLs route through the + // user's Tor settings without extra plumbing. val namecoinPreferences = remember { DesktopNamecoinPreferences() } val namecoinService = remember { DesktopNamecoinNameService( preferencesProvider = { namecoinPreferences.current }, pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() }, + coreRpcHttpClientProvider = { _ -> + com.vitorpamplona.amethyst.desktop.network + .DesktopHttpClient + .currentClient() + }, ) } @@ -1682,17 +1690,53 @@ fun RelaySettingsScreen( { pem -> namecoinPrefsHere.addPinnedCert(pem) // Apply immediately so the next lookup uses the new pin. + // The same list is shared with the Namecoin Core RPC + // client when present, mirroring Android's behaviour + // where both backends consume one trust store. namecoinScope.launch { try { - svc.client.setDynamicCerts( - namecoinPrefsHere.loadPinnedCerts(), - ) + val pins = namecoinPrefsHere.loadPinnedCerts() + svc.client.setDynamicCerts(pins) + svc.rpcClient?.setDynamicCerts(pins) } catch (_: Exception) { // Best-effort — persisted, will apply on next restart. } } } }, + onSetBackend = { backend -> + namecoinScope.launch { namecoinPrefsHere.setBackend(backend) } + }, + onSetCoreRpcConfig = { cfg -> + namecoinScope.launch { + namecoinPrefsHere.setCoreRpcConfig(cfg) + // Push the new config into the live client so the + // next lookup uses it without restarting the app. + namecoinServiceHere?.rpcClient?.setConfig(cfg) + } + }, + onSetFallbackToCustomElectrumx = { enabled -> + namecoinScope.launch { + namecoinPrefsHere.setFallbackToCustomElectrumx(enabled) + } + }, + onSetFallbackToDefaultElectrumx = { enabled -> + namecoinScope.launch { + namecoinPrefsHere.setFallbackToDefaultElectrumx(enabled) + } + }, + onTestCoreRpc = + namecoinServiceHere?.let { svc -> + { cfg -> + svc.probeCoreRpc(cfg) + ?: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin + .RpcProbeResult( + success = false, + elapsedMs = 0, + error = "Namecoin Core RPC client not available", + ) + } + }, ) Spacer(Modifier.height(24.dp)) HorizontalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt index 0b9f899ed4..1d1f832628 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt @@ -22,30 +22,56 @@ package com.vitorpamplona.amethyst.desktop.service.namecoin import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.CompositeNamecoinBackend import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxNameBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.IElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NameShowResult +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameBackend import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import okhttp3.OkHttpClient import javax.net.SocketFactory /** * Desktop application-level singleton for Namecoin name resolution. * * Same functionality as the Android `NamecoinNameService` but instantiated - * directly (no Koin/Hilt DI). Uses plain JVM sockets (no Tor support on - * Desktop yet). + * directly (no Koin/Hilt DI). Uses plain JVM sockets for ElectrumX (no + * Tor support on Desktop yet) and an injectable [OkHttpClient] provider + * for Namecoin Core JSON-RPC traffic. + * + * Backend dispatch mirrors Android's `AppModules.kt#buildNamecoinBackend` + * exactly: for every lookup the service builds a fresh + * [CompositeNamecoinBackend] from the current [NamecoinSettings] so that + * changes in the Settings UI take effect immediately, without restarting + * the app. */ class DesktopNamecoinNameService( private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT }, pinnedCertsProvider: () -> List = { emptyList() }, + /** + * OkHttpClient provider for Namecoin Core JSON-RPC traffic. The lambda + * receives the target URL so a Tor-routing implementation can pick + * the right client (direct vs. proxy) per request. When `null`, the + * Core RPC backend isn't built and resolution behaves exactly as + * before (ElectrumX only). + */ + private val coreRpcHttpClientProvider: ((String) -> OkHttpClient)? = null, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -68,6 +94,30 @@ class DesktopNamecoinNameService( } } + /** + * Long-lived Namecoin Core JSON-RPC client. Lazy because callers that + * never enable the Core RPC backend shouldn't pay for it. Bootstraps + * the active config + pinned trust store from the same shared + * preferences entry the ElectrumX client uses, exactly like Android's + * `AppModules.namecoinCoreRpcClient` path. + */ + private val namecoinCoreRpcClient: NamecoinCoreRpcClient? = + coreRpcHttpClientProvider?.let { provider -> + NamecoinCoreRpcClient(httpClientForUrl = provider).also { client -> + scope.launch { + try { + client.setConfig(preferencesProvider().namecoinCoreRpc) + val pinned = pinnedCertsProvider() + if (pinned.isNotEmpty()) { + client.setDynamicCerts(pinned) + } + } catch (_: Exception) { + // Non-fatal — user can re-pin via Settings. + } + } + } + } + /** * Expose the underlying ElectrumX client so the Settings UI can push a * newly-accepted cert in via [ElectrumXClient.setDynamicCerts] without @@ -76,12 +126,86 @@ class DesktopNamecoinNameService( */ val client: ElectrumXClient get() = electrumxClient + /** + * Expose the underlying Namecoin Core RPC client so the Settings UI + * can call [NamecoinCoreRpcClient.probe] and push newly-accepted + * pins in via [NamecoinCoreRpcClient.setDynamicCerts]. `null` when + * the service was constructed without a Core RPC HTTP provider + * (Core RPC backend disabled). + */ + val rpcClient: NamecoinCoreRpcClient? get() = namecoinCoreRpcClient + + /** + * Compose the active Namecoin lookup backend based on user settings. + * + * Builds a fresh composite per call so settings changes take effect + * immediately. Same shape as Android's `AppModules.buildNamecoinBackend()`, + * minus the Tor-server-list branch which Desktop doesn't have yet. + */ + private fun buildNamecoinBackend(): IElectrumXClient { + val settings = preferencesProvider() + val custom = settings.toElectrumxServers() + val defaults = DEFAULT_ELECTRUMX_SERVERS + + val customExBackend = + custom?.let { servers -> ElectrumxNameBackend(electrumxClient) { servers } } + val defaultExBackend = ElectrumxNameBackend(electrumxClient) { defaults } + + return when (settings.backend) { + NamecoinBackend.NAMECOIN_CORE_RPC -> { + val rpc = namecoinCoreRpcClient + if (rpc == null) { + // No HTTP provider plumbed in — degrade gracefully to + // ElectrumX so the user isn't stranded. Mirrors the + // "primary unreachable" path inside CompositeNamecoinBackend. + buildElectrumxComposite(customExBackend, defaultExBackend, settings) + } else { + // Refresh client config in case the user just saved it. + rpc.setConfig(settings.namecoinCoreRpc) + CompositeNamecoinBackend( + primary = rpc, + customElectrumx = customExBackend, + defaultElectrumx = defaultExBackend, + policy = settings.toFallbackPolicy(), + isPrimaryCoreRpc = true, + ) + } + } + NamecoinBackend.ELECTRUMX -> buildElectrumxComposite(customExBackend, defaultExBackend, settings) + } + } + + private fun buildElectrumxComposite( + customExBackend: NamecoinNameBackend?, + defaultExBackend: NamecoinNameBackend, + settings: NamecoinSettings, + ): IElectrumXClient { + // Custom servers first (if any). If the user only has the public + // defaults configured, primary == defaultElectrumx and the + // fallback toggle is moot. + val primary: NamecoinNameBackend = customExBackend ?: defaultExBackend + return CompositeNamecoinBackend( + primary = primary, + customElectrumx = null, + defaultElectrumx = if (customExBackend != null) defaultExBackend else null, + policy = settings.toFallbackPolicy(), + isPrimaryCoreRpc = false, + ) + } + private val resolver = NamecoinNameResolver( - electrumxClient = electrumxClient, + electrumxClient = + object : IElectrumXClient { + override suspend fun nameShowWithFallback( + identifier: String, + servers: List, + ): NameShowResult? = buildNamecoinBackend().nameShowWithFallback(identifier, servers) + }, serverListProvider = { - val settings = preferencesProvider() - settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + // Kept for compatibility with NamecoinNameResolver's API; the + // composite backend ignores this and consults settings directly. + preferencesProvider().toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS }, ) @@ -156,6 +280,14 @@ class DesktopNamecoinNameService( return state } + /** + * Probe a candidate Namecoin Core RPC config (URL + creds) without + * mutating the persisted client config. Used by the Settings + * "Test RPC" button. Returns null when no Core RPC HTTP provider + * was plumbed in — callers should hide the Test RPC UI in that case. + */ + suspend fun probeCoreRpc(cfg: NamecoinCoreRpcConfig): RpcProbeResult? = namecoinCoreRpcClient?.probe(cfg) + /** * Clear the resolution cache. */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt index 2237b86934..abbf27fa4a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt @@ -20,10 +20,16 @@ */ package com.vitorpamplona.amethyst.desktop.service.namecoin +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.prefs.Preferences @@ -37,6 +43,18 @@ import java.util.prefs.Preferences * The current settings are available synchronously via [settings] (a * [StateFlow]) and can be read in non-suspend contexts (e.g. in a * `serverListProvider` lambda). + * + * Backend / Core RPC / fallback fields mirror the Android + * `NamecoinSharedPreferences` API exactly, except the mutators are + * non-suspend here because [java.util.prefs.Preferences] is synchronous — + * unlike Android's coroutine-backed DataStore. + * + * Note on credential storage: the Core RPC password lives in plain + * `java.util.prefs` (alongside the URL/username), exactly as Android + * stores it in DataStore. Both are user-readable on disk; we accept + * that trade-off because the alternative (system keychain) would + * require platform-specific plumbing that isn't worth it for an + * already-local secret the user typed in themselves. */ class DesktopNamecoinPreferences( private val prefs: Preferences = @@ -44,12 +62,41 @@ class DesktopNamecoinPreferences( DesktopNamecoinPreferences::class.java, ), ) { - private val mapper = jacksonObjectMapper() + /** + * Jackson mapper configured to round-trip kotlinx `@Serializable` data + * classes that ship computed `is*` properties (e.g. + * [NamecoinCoreRpcConfig.isUsable]). Without these tweaks Jackson + * writes `isUsable` on the way out but fails on the way back in + * because the class has no matching setter, which silently nukes + * the persisted Core RPC config. + * + * - Field-only visibility → ignore Kotlin's computed getters. + * - FAIL_ON_UNKNOWN_PROPERTIES=false → belt-and-braces in case a + * future quartz release adds another computed property. + */ + private val mapper = + jacksonObjectMapper().apply { + setVisibility( + serializationConfig.defaultVisibilityChecker + .withFieldVisibility(JsonAutoDetect.Visibility.ANY) + .withGetterVisibility(JsonAutoDetect.Visibility.NONE) + .withIsGetterVisibility(JsonAutoDetect.Visibility.NONE) + .withSetterVisibility(JsonAutoDetect.Visibility.NONE) + .withCreatorVisibility(JsonAutoDetect.Visibility.ANY), + ) + setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + } companion object { private const val KEY_ENABLED = "namecoin.enabled" private const val KEY_CUSTOM_SERVERS = "namecoin.customServers" private const val KEY_PINNED_CERTS = "namecoin.pinnedCerts" + private const val KEY_BACKEND = "namecoin.backend" + private const val KEY_CORE_RPC = "namecoin.coreRpc" + private const val KEY_FALLBACK_CUSTOM_EX = "namecoin.fallback.customElectrumx" + private const val KEY_FALLBACK_DEFAULT_EX = "namecoin.fallback.defaultElectrumx" } private val _settings = MutableStateFlow(loadFromDisk()) @@ -83,6 +130,43 @@ class DesktopNamecoinPreferences( persist(updated) } + /** + * Change the primary resolution backend (ElectrumX vs Namecoin Core RPC). + * Mirrors `NamecoinSharedPreferences.setBackend` on Android. + */ + suspend fun setBackend(backend: NamecoinBackend) { + val updated = current.copy(backend = backend) + persist(updated) + } + + /** + * Persist the user's Namecoin Core RPC connection details. + * Mirrors `NamecoinSharedPreferences.setCoreRpcConfig` on Android. + */ + suspend fun setCoreRpcConfig(cfg: NamecoinCoreRpcConfig) { + val updated = current.copy(namecoinCoreRpc = cfg) + persist(updated) + } + + /** + * Toggle fallback from Core RPC to custom ElectrumX servers when the + * primary backend can't be reached. Only meaningful when + * `backend == NAMECOIN_CORE_RPC`. + */ + suspend fun setFallbackToCustomElectrumx(enabled: Boolean) { + val updated = current.copy(fallbackToCustomElectrumx = enabled) + persist(updated) + } + + /** + * Toggle fallback to the hardcoded public ElectrumX defaults. Applies + * to both backends — see [NamecoinSettings.fallbackToDefaultElectrumx]. + */ + suspend fun setFallbackToDefaultElectrumx(enabled: Boolean) { + val updated = current.copy(fallbackToDefaultElectrumx = enabled) + persist(updated) + } + suspend fun reset() { persist(NamecoinSettings.DEFAULT) clearPinnedCerts() @@ -97,7 +181,11 @@ class DesktopNamecoinPreferences( * [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient] * via `setDynamicCerts(...)` so it takes effect immediately. * - * Mirrors the Android API in `NamecoinSharedPreferences`. + * The same store is shared with the Namecoin Core RPC client — both + * `ElectrumXClient` and `NamecoinCoreRpcClient` accept + * `setDynamicCerts(...)` and only consume the certs that actually + * match their own connection. This matches Android's + * `NamecoinSharedPreferences` behaviour exactly. */ fun addPinnedCert(pem: String) { if (pem.isBlank()) return @@ -143,6 +231,10 @@ class DesktopNamecoinPreferences( KEY_CUSTOM_SERVERS, mapper.writeValueAsString(settings.customServers.filter { it.isNotBlank() }), ) + prefs.put(KEY_BACKEND, settings.backend.name) + prefs.put(KEY_CORE_RPC, mapper.writeValueAsString(settings.namecoinCoreRpc)) + prefs.putBoolean(KEY_FALLBACK_CUSTOM_EX, settings.fallbackToCustomElectrumx) + prefs.putBoolean(KEY_FALLBACK_DEFAULT_EX, settings.fallbackToDefaultElectrumx) prefs.flush() } catch (e: Exception) { System.err.println("NamecoinPrefs: Error writing preferences: ${e.message}") @@ -163,7 +255,32 @@ class DesktopNamecoinPreferences( } else { emptyList() } - NamecoinSettings(enabled = enabled, customServers = servers) + val backend = + prefs.get(KEY_BACKEND, null)?.let { + try { + NamecoinBackend.valueOf(it) + } catch (_: Exception) { + NamecoinBackend.ELECTRUMX + } + } ?: NamecoinBackend.ELECTRUMX + val coreRpc = + prefs.get(KEY_CORE_RPC, null)?.let { raw -> + try { + mapper.readValue(raw) + } catch (_: Exception) { + null + } + } ?: NamecoinCoreRpcConfig() + val fallbackCustom = prefs.getBoolean(KEY_FALLBACK_CUSTOM_EX, false) + val fallbackDefault = prefs.getBoolean(KEY_FALLBACK_DEFAULT_EX, false) + NamecoinSettings( + enabled = enabled, + customServers = servers, + backend = backend, + namecoinCoreRpc = coreRpc, + fallbackToCustomElectrumx = fallbackCustom, + fallbackToDefaultElectrumx = fallbackDefault, + ) } catch (e: Exception) { System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}") NamecoinSettings.DEFAULT diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt index fbe1aeabd2..ab82d59f3a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt @@ -36,11 +36,14 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -69,8 +72,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult import kotlinx.coroutines.launch +import java.util.Locale /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -91,6 +98,19 @@ import kotlinx.coroutines.launch * @param onPinCert Called with a PEM-encoded cert when the user * accepts a TOFU pin prompt. Mirrors the Android * callback contract. + * @param onSetBackend Switch the primary resolution backend + * (ElectrumX vs Namecoin Core RPC). + * @param onSetCoreRpcConfig Persist Namecoin Core JSON-RPC + * connection details. + * @param onSetFallbackToCustomElectrumx Toggle Core RPC → custom ElectrumX + * fallback. Ignored when ElectrumX + * is the primary backend. + * @param onSetFallbackToDefaultElectrumx Toggle fallback to hardcoded public + * ElectrumX defaults. + * @param onTestCoreRpc Probe an ad-hoc [NamecoinCoreRpcConfig] + * without persisting it. `null` hides + * the Test RPC button (e.g. when no + * HTTP client is plumbed in yet). */ @Composable fun NamecoinSettingsSection( @@ -102,6 +122,11 @@ fun NamecoinSettingsSection( modifier: Modifier = Modifier, onTestServer: (suspend (ElectrumxServer) -> ServerTestResult)? = null, onPinCert: ((String) -> Unit)? = null, + onSetBackend: (NamecoinBackend) -> Unit = {}, + onSetCoreRpcConfig: (NamecoinCoreRpcConfig) -> Unit = {}, + onSetFallbackToCustomElectrumx: (Boolean) -> Unit = {}, + onSetFallbackToDefaultElectrumx: (Boolean) -> Unit = {}, + onTestCoreRpc: (suspend (NamecoinCoreRpcConfig) -> RpcProbeResult)? = null, ) { Column(modifier = modifier.padding(16.dp)) { // ── Section header ───────────────────────────────────────── @@ -117,16 +142,52 @@ fun NamecoinSettingsSection( // ── Explanation ───────────────────────────────────── Text( - "Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " + - "By default, public community servers are used. " + - "For maximum privacy, add your own server below — when custom " + - "servers are set, the defaults are completely ignored.", + "Namecoin names (.bit, d/, id/) are resolved by querying the Namecoin " + + "blockchain. Choose ElectrumX (light-client) or Namecoin Core RPC " + + "(your own full node) below.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(16.dp)) + // ── Backend selector ─────────────────────────────── + NamecoinBackendSelector( + selected = settings.backend, + onSelect = onSetBackend, + ) + + Spacer(Modifier.height(12.dp)) + + AnimatedVisibility( + visible = settings.backend == NamecoinBackend.NAMECOIN_CORE_RPC, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + NamecoinCoreRpcSection( + config = settings.namecoinCoreRpc, + onConfigChange = onSetCoreRpcConfig, + onTestCoreRpc = onTestCoreRpc, + onPinCert = onPinCert ?: {}, + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + } + } + + // ── Fallback policy ──────────────────────────────── + NamecoinFallbacksSection( + settings = settings, + onSetFallbackToCustomElectrumx = onSetFallbackToCustomElectrumx, + onSetFallbackToDefaultElectrumx = onSetFallbackToDefaultElectrumx, + ) + + Spacer(Modifier.height(16.dp)) + // ── Active servers display ───────────────────────── NamecoinActiveServersDisplay(settings = settings) @@ -619,3 +680,517 @@ private fun NamecoinServerRow( ) } } + +// ── Backend selector ────────────────────────────────────────────────── + +@Composable +private fun NamecoinBackendSelector( + selected: NamecoinBackend, + onSelect: (NamecoinBackend) -> Unit, +) { + Column { + Text( + "Resolution backend", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + + NamecoinBackendRadioRow( + label = "ElectrumX (light client)", + description = "Query public or custom ElectrumX servers. Lightweight, no full node required.", + selected = selected == NamecoinBackend.ELECTRUMX, + onClick = { onSelect(NamecoinBackend.ELECTRUMX) }, + ) + NamecoinBackendRadioRow( + label = "Namecoin Core RPC", + description = "Query your own Namecoin Core node directly via JSON-RPC. Most sovereign.", + selected = selected == NamecoinBackend.NAMECOIN_CORE_RPC, + onClick = { onSelect(NamecoinBackend.NAMECOIN_CORE_RPC) }, + ) + } +} + +@Composable +private fun NamecoinBackendRadioRow( + label: String, + description: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + RadioButton( + selected = selected, + onClick = onClick, + ) + Spacer(Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Namecoin Core RPC sub-section ────────────────────────────────────── + +/** + * Holds a Core RPC server cert pending user confirmation before pinning + * (TOFU). Kept separate from the ElectrumX [PendingCertPin] above because + * the two prompts can race when both backends are configured. + */ +private data class RpcPendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + +@Composable +private fun NamecoinCoreRpcSection( + config: NamecoinCoreRpcConfig, + onConfigChange: (NamecoinCoreRpcConfig) -> Unit, + onTestCoreRpc: (suspend (NamecoinCoreRpcConfig) -> RpcProbeResult)?, + onPinCert: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var url by rememberSaveable(config.url) { mutableStateOf(config.url) } + var user by rememberSaveable(config.username) { mutableStateOf(config.username) } + var pass by rememberSaveable(config.password) { mutableStateOf(config.password) } + var passVisible by remember { mutableStateOf(false) } + var testing by remember { mutableStateOf(false) } + var lastProbe by remember { mutableStateOf(null) } + var pendingPin by remember { mutableStateOf(null) } + + // ── Cert confirmation dialog (TOFU) ────────────────────────────── + pendingPin?.let { pin -> + AlertDialog( + onDismissRequest = { pendingPin = null }, + title = { Text("Pin Namecoin Core RPC certificate?") }, + text = { + Column { + Text( + "Trust this certificate for ${pin.serverHost}? " + + "Subsequent Namecoin Core RPC calls against this host will " + + "require the same cert.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pin.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pin.pem) + onConfigChange( + config.copy( + url = url.trim(), + username = user.trim(), + password = pass, + usePinnedTrustStore = true, + ), + ) + pendingPin = null + }) { + Text("Trust") + } + }, + dismissButton = { + TextButton(onClick = { pendingPin = null }) { + Text("Skip") + } + }, + ) + } + + fun commit() { + onConfigChange( + config.copy( + url = url.trim(), + username = user.trim(), + password = pass, + ), + ) + } + + Column { + Text( + "Namecoin Core RPC endpoint", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + "URL, username and password for your Namecoin Core node. " + + "StartOS / Start9 users: copy these from the package's Properties tab. " + + "Umbrel users: see the Namecoin Core app's 'Connect From Outside' card. " + + "Tor onion URL recommended for remote access.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("RPC URL") }, + placeholder = { Text("http://.onion:8336/") }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) { + commit() + true + } else { + false + } + }, + shape = RoundedCornerShape(8.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = user, + onValueChange = { user = it }, + label = { Text("RPC username") }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) { + commit() + true + } else { + false + } + }, + shape = RoundedCornerShape(8.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = pass, + onValueChange = { pass = it }, + label = { Text("RPC password") }, + singleLine = true, + visualTransformation = + if (passVisible) { + androidx.compose.ui.text.input.VisualTransformation.None + } else { + androidx.compose.ui.text.input + .PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passVisible = !passVisible }) { + Icon( + MaterialSymbols.Lock, + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) { + commit() + true + } else { + false + } + }, + shape = RoundedCornerShape(8.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { commit() }, + modifier = Modifier.weight(1f), + ) { + Text("Save") + } + if (onTestCoreRpc != null) { + Button( + onClick = { + commit() + if (testing) return@Button + testing = true + lastProbe = null + val candidate = + config.copy( + url = url.trim(), + username = user.trim(), + password = pass, + ) + scope.launch { + try { + val probe = onTestCoreRpc(candidate) + lastProbe = probe + val pem = probe.serverCertPem + val fp = probe.certFingerprint + if ( + pem != null && fp != null && ( + probe.tlsHandshakeFailed || + !candidate.usePinnedTrustStore + ) + ) { + val host = + try { + java.net.URI(candidate.url).host + ?: candidate.url + } catch (_: Exception) { + candidate.url + } + pendingPin = + RpcPendingCertPin( + serverHost = host, + fingerprint = fp, + pem = pem, + ) + } + } finally { + testing = false + } + } + }, + modifier = Modifier.weight(1f), + enabled = !testing && url.isNotBlank(), + ) { + if (testing) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Test RPC") + } + } + } + } + + lastProbe?.let { probe -> + Spacer(Modifier.height(10.dp)) + Card( + colors = + CardDefaults.cardColors( + containerColor = + if (probe.success) { + Color(0x222E8B57) + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f) + }, + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(10.dp)) { + if (probe.success) { + Text( + "Connected (${probe.elapsedMs} ms)", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + val chain = probe.chain ?: "?" + val blocks = probe.blocks?.toString() ?: "?" + val pct = + probe.verificationProgress?.let { + String.format(Locale.ROOT, "%.2f%%", it * 100) + } ?: "?" + Text( + "chain=$chain height=$blocks sync=$pct" + + (if (probe.initialBlockDownload == true) " (IBD)" else ""), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } else { + Text( + "Failed", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + probe.error.orEmpty(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + if (probe.tlsHandshakeFailed && probe.certFingerprint != null) { + Spacer(Modifier.height(6.dp)) + Text( + "TLS rejected by the system trust store. Tap Test RPC " + + "again, then choose Trust to pin this server's certificate.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + val fp = probe.certFingerprint + if (fp != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = + "cert SHA-256: ${fp.take(23)}\u2026" + + if (config.usePinnedTrustStore) " (pinned)" else "", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +// ── Fallback policy section ──────────────────────────────────────────── + +@Composable +private fun NamecoinFallbacksSection( + settings: NamecoinSettings, + onSetFallbackToCustomElectrumx: (Boolean) -> Unit, + onSetFallbackToDefaultElectrumx: (Boolean) -> Unit, +) { + Column { + Text( + "Fallback policy", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + "What to try if the primary backend can't be reached. " + + "Off by default — fallbacks can leak your lookups to other operators.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + if (settings.backend == NamecoinBackend.NAMECOIN_CORE_RPC) { + NamecoinToggleRow( + label = "Fall back to my custom ElectrumX servers", + description = + if (settings.hasCustomServers) { + "If Namecoin Core RPC fails, try the ${settings.customServers.size} " + + "custom ElectrumX server(s) configured below." + } else { + "No custom ElectrumX servers configured. Add one in the section below " + + "to enable this option." + }, + checked = settings.fallbackToCustomElectrumx, + onCheckedChange = onSetFallbackToCustomElectrumx, + enabled = settings.hasCustomServers, + ) + } + + NamecoinToggleRow( + label = "Fall back to default public ElectrumX servers", + description = + if (settings.backend == NamecoinBackend.ELECTRUMX) { + if (settings.hasCustomServers) { + "If all of my custom ElectrumX servers fail, also try the hardcoded " + + "public defaults." + } else { + "(Already using defaults — toggle has no extra effect.)" + } + } else { + "If everything above fails, try the hardcoded public ElectrumX servers." + }, + checked = settings.fallbackToDefaultElectrumx, + onCheckedChange = onSetFallbackToDefaultElectrumx, + enabled = + settings.backend == NamecoinBackend.NAMECOIN_CORE_RPC || + settings.hasCustomServers, + ) + } +} + +@Composable +private fun NamecoinToggleRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt index 94a57dee30..34d8cc3dd2 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.desktop.service.namecoin +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -226,4 +229,120 @@ class DesktopNamecoinPreferencesTest { assertEquals(listOf(samplePem1), namecoinPrefs.loadPinnedCerts()) } + + // ── Backend / Core RPC / fallback round-trip ────────────────────── + + @Test + fun `default backend is ElectrumX with empty Core RPC config and no fallbacks`() { + val settings = namecoinPrefs.current + assertEquals(NamecoinBackend.ELECTRUMX, settings.backend) + assertEquals(NamecoinCoreRpcConfig(), settings.namecoinCoreRpc) + assertFalse(settings.fallbackToCustomElectrumx) + assertFalse(settings.fallbackToDefaultElectrumx) + } + + @Test + fun `setBackend persists and updates flow`() = + runBlocking { + namecoinPrefs.setBackend(NamecoinBackend.NAMECOIN_CORE_RPC) + assertEquals(NamecoinBackend.NAMECOIN_CORE_RPC, namecoinPrefs.current.backend) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(NamecoinBackend.NAMECOIN_CORE_RPC, reloaded.current.backend) + } + + @Test + fun `setCoreRpcConfig persists URL username password`() = + runBlocking { + val cfg = + NamecoinCoreRpcConfig( + url = "http://abc.onion:8336/", + username = "rpcuser", + password = "hunter2", + ) + namecoinPrefs.setCoreRpcConfig(cfg) + assertEquals(cfg, namecoinPrefs.current.namecoinCoreRpc) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(cfg, reloaded.current.namecoinCoreRpc) + } + + @Test + fun `setCoreRpcConfig round-trips usePinnedTrustStore flag`() = + runBlocking { + val cfg = + NamecoinCoreRpcConfig( + url = "https://lan-host:8336/", + username = "u", + password = "p", + usePinnedTrustStore = true, + ) + namecoinPrefs.setCoreRpcConfig(cfg) + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.namecoinCoreRpc.usePinnedTrustStore) + } + + @Test + fun `setFallbackToCustomElectrumx persists`() = + runBlocking { + namecoinPrefs.setFallbackToCustomElectrumx(true) + assertTrue(namecoinPrefs.current.fallbackToCustomElectrumx) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.fallbackToCustomElectrumx) + } + + @Test + fun `setFallbackToDefaultElectrumx persists`() = + runBlocking { + namecoinPrefs.setFallbackToDefaultElectrumx(true) + assertTrue(namecoinPrefs.current.fallbackToDefaultElectrumx) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.fallbackToDefaultElectrumx) + } + + @Test + fun `reset clears backend Core RPC and fallback flags`() = + runBlocking { + namecoinPrefs.setBackend(NamecoinBackend.NAMECOIN_CORE_RPC) + namecoinPrefs.setCoreRpcConfig( + NamecoinCoreRpcConfig(url = "http://x:8336/", username = "u", password = "p"), + ) + namecoinPrefs.setFallbackToCustomElectrumx(true) + namecoinPrefs.setFallbackToDefaultElectrumx(true) + + namecoinPrefs.reset() + + val s = namecoinPrefs.current + assertEquals(NamecoinBackend.ELECTRUMX, s.backend) + assertEquals(NamecoinCoreRpcConfig(), s.namecoinCoreRpc) + assertFalse(s.fallbackToCustomElectrumx) + assertFalse(s.fallbackToDefaultElectrumx) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(NamecoinSettings.DEFAULT, reloaded.current) + } + + @Test + fun `full round-trip across all backend fields`() = + runBlocking { + namecoinPrefs.setEnabled(true) + namecoinPrefs.addServer("server1.com:50006") + namecoinPrefs.setBackend(NamecoinBackend.NAMECOIN_CORE_RPC) + val cfg = + NamecoinCoreRpcConfig( + url = "http://onion.onion:8336/", + username = "alice", + password = "s3cret", + usePinnedTrustStore = false, + ) + namecoinPrefs.setCoreRpcConfig(cfg) + namecoinPrefs.setFallbackToCustomElectrumx(true) + namecoinPrefs.setFallbackToDefaultElectrumx(true) + + val snapshot = namecoinPrefs.current + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(snapshot, reloaded.current) + } }