Merge pull request #3077 from mstrofnone/feat/desktop-namecoin-core-rpc-backend

feat(desktop): Namecoin Core RPC backend + composite fallback
This commit is contained in:
Vitor Pamplona
2026-05-27 16:07:01 -04:00
committed by GitHub
5 changed files with 1003 additions and 16 deletions
@@ -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()
@@ -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<String> = { 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<ElectrumxServer>,
): 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.
*/
@@ -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<NamecoinCoreRpcConfig>(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
@@ -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<RpcProbeResult?>(null) }
var pendingPin by remember { mutableStateOf<RpcPendingCertPin?>(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>.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,
)
}
}
@@ -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)
}
}