mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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()
|
||||
|
||||
+137
-5
@@ -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.
|
||||
*/
|
||||
|
||||
+120
-3
@@ -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
|
||||
|
||||
+579
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user