Port Namecoin NIP-05 resolution to Desktop app

Add censorship-resistant NIP-05 verification using the Namecoin blockchain
to the Desktop (JVM) app, porting functionality from PRs #1734, #1771,

New files:
- DesktopNamecoinNameService: app-level service wrapping the Quartz
  NamecoinNameResolver with caching, custom server support, and live
  state flows. Uses plain JVM sockets (no Tor support on Desktop yet).
- DesktopNamecoinPreferences: Java Preferences API-backed persistence
  for Namecoin settings (enabled toggle + custom ElectrumX servers).
- NamecoinSettings: Desktop copy of the settings data class (no Android
  dependencies).
- LocalNamecoin: CompositionLocals for threading Namecoin service/prefs
  through the compose tree.
- NamecoinSettingsSection: Compose Desktop UI for configuring ElectrumX
  servers — toggle, active server display with DEFAULT/CUSTOM badge,
  add/remove custom servers, reset to defaults. Uses onPreviewKeyEvent
  for Enter-to-submit instead of Android KeyboardActions.
- ImportFollowListDialog: Dialog for importing follow lists via npub,
  hex, NIP-05, or Namecoin identifiers. Resolves identifiers to pubkeys
  with Namecoin blockchain support.

Modified:
- Main.kt: Instantiate DesktopNamecoinPreferences and
  DesktopNamecoinNameService in App composable, provide via
  CompositionLocals, wire into RelaySettingsScreen.
- SearchScreen.kt: Detect Namecoin identifiers (.bit, d/, id/) in the
  search bar, resolve via DesktopNamecoinNameService with loading/error
  states, display resolved user above standard results. Uses
  LaunchedEffect with key-based cancellation for stale lookups.
- DeckColumnContainer.kt: Pass namecoinPreferences to RelaySettingsScreen
  from CompositionLocal.

Tests:
- DesktopNamecoinPreferencesTest: round-trip persistence, add/remove
  servers, enable/disable, reset, duplicate handling.
- NamecoinSettingsTest: server string parsing/formatting, round-trips,
  edge cases, toElectrumxServers conversion.

The Quartz KMP library (commonMain + jvmAndroid) already contains the
core Namecoin resolution code (ElectrumXClient, NamecoinNameResolver,
NamecoinLookupCache) shared across Android and Desktop.
This commit is contained in:
M
2026-03-24 07:07:33 +11:00
committed by m
parent 2097089d3d
commit 5602429f9b
11 changed files with 1484 additions and 0 deletions
@@ -110,10 +110,15 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.param
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -695,6 +700,10 @@ fun App(
}
val localCache = remember { DesktopLocalCache() }
val namecoinPreferences = remember { DesktopNamecoinPreferences() }
val namecoinService = remember {
DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current })
}
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
@@ -872,6 +881,10 @@ fun App(
ProvideMaterialSymbols(
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
) {
CompositionLocalProvider(
LocalNamecoinPreferences provides namecoinPreferences,
LocalNamecoinService provides namecoinService,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
@@ -1036,6 +1049,7 @@ fun App(
}
}
}
} // end CompositionLocalProvider
}
}
@@ -1425,6 +1439,7 @@ fun RelaySettingsScreen(
com.vitorpamplona.amethyst.commons.tor
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
namecoinPreferences: DesktopNamecoinPreferences? = null,
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -1635,6 +1650,7 @@ fun RelaySettingsScreen(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f),
) {
items(relayStatuses.values.toList(), key = { it.url.url }) { status ->
RelayStatusCard(
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
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 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).
*/
class DesktopNamecoinNameService(
private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT },
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val electrumxClient = ElectrumXClient(
socketFactory = { SocketFactory.getDefault() },
)
private val resolver = NamecoinNameResolver(
electrumxClient = electrumxClient,
serverListProvider = {
val settings = preferencesProvider()
settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
},
)
private val cache = NamecoinLookupCache()
// ── Public API ─────────────────────────────────────────────────────
/**
* Resolve a Namecoin identifier to a Nostr pubkey.
*
* Returns cached results when available. This is the primary method
* that the search bar and NIP-05 verifier should call.
*
* @param identifier e.g. "alice@example.bit", "id/bob", "example.bit"
* @return [NamecoinNostrResult] or null
*/
suspend fun resolve(identifier: String): NamecoinNostrResult? {
val cached = cache.get(identifier)
if (cached != null) return cached.result
val result = resolver.resolve(identifier)
cache.put(identifier, result)
return result
}
/**
* Resolve and return just the hex pubkey, or null.
* Convenience for follow-import integration.
*/
suspend fun resolvePubkey(identifier: String): String? = resolve(identifier)?.pubkey
/**
* Resolve with detailed outcome for error reporting.
*/
suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome =
resolver.resolveDetailed(identifier)
/**
* Verify that a Namecoin name maps to the expected pubkey.
*/
suspend fun verifyNip05(
nip05Address: String,
expectedPubkeyHex: String,
): Boolean {
if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) return false
val result = resolve(nip05Address) ?: return false
return result.pubkey.equals(expectedPubkeyHex, ignoreCase = true)
}
/**
* Perform a lookup and emit results via a StateFlow.
*
* Useful for composable UIs that observe resolution state.
*/
fun resolveLive(
identifier: String,
scope: CoroutineScope = this.scope,
): StateFlow<NamecoinResolveState> {
val state = MutableStateFlow<NamecoinResolveState>(NamecoinResolveState.Loading)
scope.launch {
try {
val result = resolve(identifier)
state.value = if (result != null) {
NamecoinResolveState.Resolved(result)
} else {
NamecoinResolveState.NotFound
}
} catch (e: Exception) {
state.value = NamecoinResolveState.Error(e.message ?: "Unknown error")
}
}
return state
}
/**
* Clear the resolution cache.
*/
suspend fun clearCache() = cache.clear()
}
/**
* Observable state for a Namecoin resolution in progress.
*/
sealed class NamecoinResolveState {
data object Loading : NamecoinResolveState()
data class Resolved(
val result: NamecoinNostrResult,
) : NamecoinResolveState()
data object NotFound : NamecoinResolveState()
data class Error(
val message: String,
) : NamecoinResolveState()
}
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.prefs.Preferences
/**
* Persistent storage for [NamecoinSettings] on Desktop.
*
* Uses [java.util.prefs.Preferences] API, following the same pattern as
* [com.vitorpamplona.amethyst.desktop.DesktopPreferences].
*
* The current settings are available synchronously via [settings] (a
* [StateFlow]) and can be read in non-suspend contexts (e.g. in a
* `serverListProvider` lambda).
*/
class DesktopNamecoinPreferences(
private val prefs: Preferences = Preferences.userNodeForPackage(
DesktopNamecoinPreferences::class.java,
),
) {
private val mapper = jacksonObjectMapper()
companion object {
private const val KEY_ENABLED = "namecoin.enabled"
private const val KEY_CUSTOM_SERVERS = "namecoin.customServers"
}
private val _settings = MutableStateFlow(loadFromDisk())
val settings: StateFlow<NamecoinSettings> = _settings
/** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */
val current: NamecoinSettings get() = _settings.value
/**
* Parsed [ElectrumxServer] list from current custom settings, or `null`
* if the user hasn't configured any (meaning "use defaults").
*/
val customServersOrNull: List<ElectrumxServer>?
get() = current.toElectrumxServers()
// ── Mutators ───────────────────────────────────────────────────────
suspend fun setEnabled(enabled: Boolean) {
val updated = current.copy(enabled = enabled)
persist(updated)
}
suspend fun addServer(server: String) {
if (server.isBlank() || server in current.customServers) return
val updated = current.copy(customServers = current.customServers + server)
persist(updated)
}
suspend fun removeServer(server: String) {
val updated = current.copy(customServers = current.customServers - server)
persist(updated)
}
suspend fun reset() {
persist(NamecoinSettings.DEFAULT)
}
// ── Internal ───────────────────────────────────────────────────────
private fun persist(settings: NamecoinSettings) {
_settings.value = settings
try {
prefs.putBoolean(KEY_ENABLED, settings.enabled)
prefs.put(
KEY_CUSTOM_SERVERS,
mapper.writeValueAsString(settings.customServers.filter { it.isNotBlank() }),
)
prefs.flush()
} catch (e: Exception) {
System.err.println("NamecoinPrefs: Error writing preferences: ${e.message}")
}
}
private fun loadFromDisk(): NamecoinSettings {
return try {
val enabled = prefs.getBoolean(KEY_ENABLED, true)
val serversJson = prefs.get(KEY_CUSTOM_SERVERS, null)
val servers = if (serversJson != null) {
try {
mapper.readValue<List<String>>(serversJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList()
}
NamecoinSettings(enabled = enabled, customServers = servers)
} catch (e: Exception) {
System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}")
NamecoinSettings.DEFAULT
}
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.namecoin
import androidx.compose.runtime.compositionLocalOf
/**
* CompositionLocal for accessing Namecoin preferences and service
* throughout the Desktop compose tree without explicit parameter threading.
*/
val LocalNamecoinPreferences = compositionLocalOf<DesktopNamecoinPreferences?> { null }
val LocalNamecoinService = compositionLocalOf<DesktopNamecoinNameService?> { null }
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.namecoin
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
/**
* Immutable data class representing the current Namecoin resolution config.
*
* When custom servers are configured, they are used EXCLUSIVELY and the
* hardcoded defaults are ignored. This gives privacy-conscious users full
* control over which ElectrumX servers observe their name lookups.
*/
@Stable
data class NamecoinSettings(
/** Whether Namecoin resolution is enabled at all. */
val enabled: Boolean = true,
/**
* Custom ElectrumX servers. When non-empty, these replace the defaults.
*
* Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext).
*/
val customServers: List<String> = emptyList(),
) {
/** True when the user has configured at least one custom server. */
val hasCustomServers: Boolean get() = customServers.isNotEmpty()
/**
* Convert to [ElectrumxServer] instances used by the resolver.
* Returns `null` when no valid custom servers are configured (use defaults).
*/
fun toElectrumxServers(): List<ElectrumxServer>? {
if (customServers.isEmpty()) return null
return customServers
.mapNotNull { parseServerString(it) }
.ifEmpty { null }
}
companion object {
val DEFAULT = NamecoinSettings()
/**
* Parse `host:port` or `host:port:tcp` into an [ElectrumxServer].
*
* TLS is the default protocol. Append `:tcp` for plaintext
* (useful for `.onion` addresses and local servers).
*
* `.onion` addresses automatically get `trustAllCerts = true`
* since certificate verification is meaningless over Tor.
*/
fun parseServerString(s: String): ElectrumxServer? {
val parts = s.trim().split(":")
if (parts.size < 2) return null
val host = parts[0].trim()
val port = parts[1].trim().toIntOrNull() ?: return null
if (host.isEmpty() || port <= 0 || port > 65535) return null
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
val isOnion = host.endsWith(".onion")
return ElectrumxServer(
host = host,
port = port,
useSsl = useSsl,
trustAllCerts = isOnion || !useSsl,
)
}
/**
* Format an [ElectrumxServer] back to the `host:port[:tcp]` string form.
*/
fun formatServerString(server: ElectrumxServer): String {
val base = "${server.host}:${server.port}"
return if (server.useSsl) base else "$base:tcp"
}
}
}
@@ -0,0 +1,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
/**
* Import Follow List dialog for Desktop.
*
* Lets users enter an identifier (npub, hex, NIP-05, or Namecoin),
* resolves it to a pubkey. The actual follow list fetching from relays
* is a placeholder — full implementation requires relay subscription
* infrastructure integration.
*/
@Composable
fun ImportFollowListDialog(
onDismiss: () -> Unit,
onImport: (List<String>) -> Unit,
) {
val namecoinService = LocalNamecoinService.current
val scope = rememberCoroutineScope()
var input by remember { mutableStateOf("") }
var resolvedPubkey by remember { mutableStateOf<String?>(null) }
var isResolving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var followList = remember { mutableStateListOf<FollowEntry>() }
var isFetching by remember { mutableStateOf(false) }
fun resolveIdentifier() {
val trimmed = input.trim()
if (trimmed.isBlank()) {
error = "Enter an identifier"
return
}
error = null
isResolving = true
resolvedPubkey = null
followList.clear()
scope.launch {
try {
// Try bech32 first
val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
if (bech32Result != null) {
resolvedPubkey = bech32Result
isResolving = false
return@launch
}
// Try hex pubkey
if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
resolvedPubkey = trimmed.lowercase()
isResolving = false
return@launch
}
// Try Namecoin
if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) {
val result = namecoinService.resolvePubkey(trimmed)
if (result != null) {
resolvedPubkey = result
isResolving = false
return@launch
}
}
error = "Could not resolve identifier"
isResolving = false
} catch (e: Exception) {
error = e.message ?: "Resolution failed"
isResolving = false
}
}
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(24.dp).fillMaxWidth(),
) {
Text(
"Import Follow List",
style = MaterialTheme.typography.headlineSmall,
)
Spacer(Modifier.height(8.dp))
Text(
"Enter an npub, hex pubkey, NIP-05, or Namecoin identifier (.bit, d/, id/) " +
"to import their follow list.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// Input field
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
label = { Text("Identifier") },
placeholder = { Text("npub1..., alice@example.bit, d/example") },
singleLine = true,
isError = error != null,
supportingText = error?.let { err ->
{ Text(err, color = MaterialTheme.colorScheme.error) }
},
modifier = Modifier.weight(1f).onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
resolveIdentifier()
true
} else {
false
}
},
)
Button(
onClick = { resolveIdentifier() },
enabled = input.isNotBlank() && !isResolving,
) {
if (isResolving) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("Resolve")
}
}
}
// Resolved pubkey display
if (resolvedPubkey != null) {
Spacer(Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
"Resolved: ${resolvedPubkey!!.take(16)}...${resolvedPubkey!!.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(8.dp))
Text(
"Follow list fetching requires relay subscriptions. " +
"The resolved pubkey can be used to follow this user directly.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
// Follow list preview (placeholder for when relay fetching is implemented)
if (followList.isNotEmpty()) {
Text(
"Follow List (${followList.size} users)",
style = MaterialTheme.typography.labelMedium,
)
Spacer(Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.height(300.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(followList) { entry ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(4.dp),
) {
Checkbox(
checked = entry.selected,
onCheckedChange = { entry.selected = it },
)
Spacer(Modifier.width(8.dp))
Text(
entry.pubkey.take(16) + "...",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onDismiss) {
Text("Cancel")
}
if (resolvedPubkey != null) {
Spacer(Modifier.width(8.dp))
Button(
onClick = {
resolvedPubkey?.let { pk ->
onImport(listOf(pk))
}
},
) {
Text("Follow User")
}
}
}
}
}
}
}
data class FollowEntry(
val pubkey: String,
var selected: Boolean = true,
)
@@ -102,8 +102,15 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.amethyst.desktop.service.namecoin.NamecoinResolveState
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
@Composable
@@ -156,6 +163,35 @@ fun SearchScreen(
// Bech32 parsing (immediate, no debounce)
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
// Namecoin resolution
val namecoinService = LocalNamecoinService.current
val namecoinPrefs = LocalNamecoinPreferences.current
val namecoinEnabled = namecoinPrefs?.settings?.collectAsState()?.value?.enabled ?: false
val isNamecoinQuery = remember(displayText) {
displayText.isNotBlank() && NamecoinNameResolver.isNamecoinIdentifier(displayText.trim())
}
var namecoinState by remember { mutableStateOf<NamecoinResolveState?>(null) }
// Resolve Namecoin identifiers with cancellation of stale lookups
LaunchedEffect(displayText, namecoinEnabled) {
if (!namecoinEnabled || !isNamecoinQuery || namecoinService == null) {
namecoinState = null
return@LaunchedEffect
}
namecoinState = NamecoinResolveState.Loading
try {
val result = namecoinService.resolve(displayText.trim())
namecoinState = if (result != null) {
NamecoinResolveState.Resolved(result)
} else {
NamecoinResolveState.NotFound
}
} catch (e: Exception) {
namecoinState = NamecoinResolveState.Error(e.message ?: "Resolution failed")
}
}
// Skip people search when query specifies kinds that don't include profile (kind 0)
val shouldSearchPeople =
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
@@ -336,6 +337,7 @@ internal fun RootContent(
torStatus = torState.status,
torSettings = torState.settings,
onTorSettingsChanged = torState.onSettingsChanged,
namecoinPreferences = LocalNamecoinPreferences.current,
)
}
@@ -0,0 +1,401 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.desktop.service.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
/**
* Complete settings section for Namecoin ElectrumX server configuration.
*
* Desktop port of the Android `NamecoinSettingsSection.kt` composable.
* Uses `onPreviewKeyEvent` for Enter-key handling instead of Android's
* `KeyboardActions`/`LocalSoftwareKeyboardController`.
*
* @param settings Current [NamecoinSettings] state
* @param onToggleEnabled Called when user toggles the master switch
* @param onAddServer Called with `host:port[:tcp]` when user adds a server
* @param onRemoveServer Called with the server string to remove
* @param onReset Called when user resets to defaults
*/
@Composable
fun NamecoinSettingsSection(
settings: NamecoinSettings,
onToggleEnabled: (Boolean) -> Unit,
onAddServer: (String) -> Unit,
onRemoveServer: (String) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.padding(16.dp)) {
// ── Section header ─────────────────────────────────────────
NamecoinSectionHeader(enabled = settings.enabled, onToggle = onToggleEnabled)
AnimatedVisibility(
visible = settings.enabled,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column {
Spacer(Modifier.height(12.dp))
// ── 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.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// ── Active servers display ─────────────────────────
NamecoinActiveServersDisplay(settings = settings)
Spacer(Modifier.height(12.dp))
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
Spacer(Modifier.height(12.dp))
// ── Custom servers list ────────────────────────────
NamecoinCustomServersList(
servers = settings.customServers,
onRemove = onRemoveServer,
)
// ── Add server input ───────────────────────────────
NamecoinAddServerInput(onAdd = onAddServer)
Spacer(Modifier.height(8.dp))
// ── Reset button ───────────────────────────────────
if (settings.hasCustomServers) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onReset) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Reset to defaults")
}
}
}
}
}
}
}
// ── Sub-composables ────────────────────────────────────────────────────
@Composable
private fun NamecoinSectionHeader(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Lock,
contentDescription = null,
tint = Color(0xFF4A90D9), // Namecoin blue
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(10.dp))
Column {
Text(
"Namecoin Resolution",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
"Blockchain identity lookups (.bit)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@Composable
private fun NamecoinActiveServersDisplay(settings: NamecoinSettings) {
val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
val isCustom = settings.hasCustomServers
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Active servers",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
)
if (isCustom) {
Text(
"CUSTOM",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFF4A90D9),
modifier = Modifier
.background(
Color(0xFF4A90D9).copy(alpha = 0.1f),
RoundedCornerShape(4.dp),
)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
} else {
Text(
"DEFAULT",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(6.dp))
servers.forEach { server ->
NamecoinServerRow(
displayText = "${server.host}:${server.port}" +
if (!server.useSsl) " (tcp)" else " (tls)",
isActive = true,
)
}
}
}
@Composable
private fun NamecoinCustomServersList(
servers: List<String>,
onRemove: (String) -> Unit,
) {
if (servers.isEmpty()) {
Text(
"No custom servers configured",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Text(
"Custom servers (used exclusively)",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 4.dp),
)
servers.forEach { server ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = server,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { onRemove(server) },
modifier = Modifier.size(28.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove server",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
}
}
Spacer(Modifier.height(8.dp))
}
@Composable
private fun NamecoinAddServerInput(onAdd: (String) -> Unit) {
var input by rememberSaveable { mutableStateOf("") }
var validationError by remember { mutableStateOf<String?>(null) }
fun tryAdd() {
val trimmed = input.trim()
if (trimmed.isBlank()) {
validationError = "Enter a server address"
return
}
val parsed = NamecoinSettings.parseServerString(trimmed)
if (parsed == null) {
validationError = "Invalid format. Use host:port or host:port:tcp"
return
}
validationError = null
onAdd(trimmed)
input = ""
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
OutlinedTextField(
value = input,
onValueChange = {
input = it
validationError = null
},
label = { Text("Add ElectrumX server") },
placeholder = { Text("host:port or host:port:tcp") },
singleLine = true,
isError = validationError != null,
supportingText = validationError?.let { err ->
{ Text(err, color = MaterialTheme.colorScheme.error) }
},
modifier = Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
tryAdd()
true
} else {
false
}
},
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { tryAdd() },
modifier = Modifier
.padding(top = 8.dp)
.size(40.dp)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
RoundedCornerShape(8.dp),
),
) {
Icon(
Icons.Default.Add,
contentDescription = "Add server",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun NamecoinServerRow(
displayText: String,
isActive: Boolean,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
fontSize = 10.sp,
color = if (isActive) {
Color(0xFF2E8B57)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = displayText,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.namecoin
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.prefs.Preferences
class DesktopNamecoinPreferencesTest {
private lateinit var testPrefs: Preferences
private lateinit var namecoinPrefs: DesktopNamecoinPreferences
@Before
fun setup() {
// Use a unique test node to avoid polluting real preferences
testPrefs = Preferences.userRoot().node("amethyst-test-namecoin-${System.nanoTime()}")
namecoinPrefs = DesktopNamecoinPreferences(prefs = testPrefs)
}
@After
fun cleanup() {
try {
testPrefs.removeNode()
} catch (_: Exception) {
}
}
@Test
fun `default settings are enabled with no custom servers`() {
val settings = namecoinPrefs.current
assertTrue(settings.enabled)
assertTrue(settings.customServers.isEmpty())
assertFalse(settings.hasCustomServers)
}
@Test
fun `setEnabled persists and updates flow`() = runBlocking {
namecoinPrefs.setEnabled(false)
assertFalse(namecoinPrefs.current.enabled)
assertFalse(namecoinPrefs.settings.value.enabled)
// Verify persistence by creating a new instance with the same prefs node
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertFalse(reloaded.current.enabled)
}
@Test
fun `addServer persists and updates flow`() = runBlocking {
namecoinPrefs.addServer("example.com:50006")
assertEquals(listOf("example.com:50006"), namecoinPrefs.current.customServers)
assertTrue(namecoinPrefs.current.hasCustomServers)
// Verify persistence
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertEquals(listOf("example.com:50006"), reloaded.current.customServers)
}
@Test
fun `addServer ignores blank strings`() = runBlocking {
namecoinPrefs.addServer("")
namecoinPrefs.addServer(" ")
assertTrue(namecoinPrefs.current.customServers.isEmpty())
}
@Test
fun `addServer ignores duplicates`() = runBlocking {
namecoinPrefs.addServer("example.com:50006")
namecoinPrefs.addServer("example.com:50006")
assertEquals(1, namecoinPrefs.current.customServers.size)
}
@Test
fun `removeServer persists and updates flow`() = runBlocking {
namecoinPrefs.addServer("server1.com:50006")
namecoinPrefs.addServer("server2.com:50001:tcp")
assertEquals(2, namecoinPrefs.current.customServers.size)
namecoinPrefs.removeServer("server1.com:50006")
assertEquals(listOf("server2.com:50001:tcp"), namecoinPrefs.current.customServers)
// Verify persistence
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertEquals(listOf("server2.com:50001:tcp"), reloaded.current.customServers)
}
@Test
fun `reset restores defaults`() = runBlocking {
namecoinPrefs.setEnabled(false)
namecoinPrefs.addServer("example.com:50006")
assertFalse(namecoinPrefs.current.enabled)
assertTrue(namecoinPrefs.current.hasCustomServers)
namecoinPrefs.reset()
assertTrue(namecoinPrefs.current.enabled)
assertFalse(namecoinPrefs.current.hasCustomServers)
// Verify persistence
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertTrue(reloaded.current.enabled)
assertFalse(reloaded.current.hasCustomServers)
}
@Test
fun `customServersOrNull returns null when empty`() {
assertEquals(null, namecoinPrefs.customServersOrNull)
}
@Test
fun `customServersOrNull returns parsed servers when configured`() = runBlocking {
namecoinPrefs.addServer("example.com:50006")
val servers = namecoinPrefs.customServersOrNull
assertEquals(1, servers?.size)
assertEquals("example.com", servers?.first()?.host)
assertEquals(50006, servers?.first()?.port)
assertTrue(servers?.first()?.useSsl == true)
}
@Test
fun `round-trip multiple operations`() = runBlocking {
namecoinPrefs.setEnabled(true)
namecoinPrefs.addServer("server1.com:50006")
namecoinPrefs.addServer("onion.onion:50001:tcp")
namecoinPrefs.setEnabled(false)
namecoinPrefs.removeServer("server1.com:50006")
val settings = namecoinPrefs.current
assertFalse(settings.enabled)
assertEquals(listOf("onion.onion:50001:tcp"), settings.customServers)
// Verify full persistence round-trip
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
assertEquals(settings, reloaded.current)
}
}
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NamecoinSettingsTest {
// ── Server string parsing ──────────────────────────────────────────
@Test
fun `parses host colon port as TLS`() {
val s = NamecoinSettings.parseServerString("example.com:50006")
assertNotNull(s)
assertEquals("example.com", s!!.host)
assertEquals(50006, s.port)
assertTrue(s.useSsl)
}
@Test
fun `parses host colon port colon tcp as plaintext`() {
val s = NamecoinSettings.parseServerString("example.com:50001:tcp")
assertNotNull(s)
assertEquals("example.com", s!!.host)
assertEquals(50001, s.port)
assertFalse(s.useSsl)
}
@Test
fun `parses onion address`() {
val s = NamecoinSettings.parseServerString("abc123def.onion:50001:tcp")
assertNotNull(s)
assertEquals("abc123def.onion", s!!.host)
assertEquals(50001, s.port)
assertFalse(s.useSsl)
assertTrue(s.trustAllCerts)
}
@Test
fun `trims whitespace`() {
val s = NamecoinSettings.parseServerString(" example.com : 50006 ")
assertNotNull(s)
assertEquals("example.com", s!!.host)
assertEquals(50006, s.port)
}
@Test
fun `rejects empty host`() {
assertNull(NamecoinSettings.parseServerString(":50006"))
}
@Test
fun `rejects invalid port`() {
assertNull(NamecoinSettings.parseServerString("example.com:abc"))
assertNull(NamecoinSettings.parseServerString("example.com:0"))
assertNull(NamecoinSettings.parseServerString("example.com:99999"))
}
@Test
fun `rejects no port`() {
assertNull(NamecoinSettings.parseServerString("example.com"))
}
// ── Format round-trip ──────────────────────────────────────────────
@Test
fun `formats TLS server without suffix`() {
val server = ElectrumxServer("example.com", 50006, true)
assertEquals("example.com:50006", NamecoinSettings.formatServerString(server))
}
@Test
fun `formats TCP server with tcp suffix`() {
val server = ElectrumxServer("example.com", 50001, false)
assertEquals("example.com:50001:tcp", NamecoinSettings.formatServerString(server))
}
@Test
fun `round-trips server string through parse and format`() {
val original = "myserver.onion:50001:tcp"
val parsed = NamecoinSettings.parseServerString(original)!!
val formatted = NamecoinSettings.formatServerString(parsed)
assertEquals(original, formatted)
}
// ── toElectrumxServers ─────────────────────────────────────────────
@Test
fun `returns null when no custom servers`() {
val settings = NamecoinSettings(customServers = emptyList())
assertNull(settings.toElectrumxServers())
}
@Test
fun `returns parsed list for valid custom servers`() {
val settings = NamecoinSettings(
customServers = listOf(
"server1.com:50006",
"server2.onion:50001:tcp",
),
)
val servers = settings.toElectrumxServers()
assertNotNull(servers)
assertEquals(2, servers!!.size)
assertEquals("server1.com", servers[0].host)
assertTrue(servers[0].useSsl)
assertEquals("server2.onion", servers[1].host)
assertFalse(servers[1].useSsl)
assertTrue(servers[1].trustAllCerts)
}
@Test
fun `skips invalid entries in custom server list`() {
val settings = NamecoinSettings(
customServers = listOf(
"valid.com:50006",
"invalid", // no port
"also-invalid:abc", // non-numeric port
),
)
val servers = settings.toElectrumxServers()
assertNotNull(servers)
assertEquals(1, servers!!.size)
assertEquals("valid.com", servers[0].host)
}
@Test
fun `returns null when all custom servers are invalid`() {
val settings = NamecoinSettings(customServers = listOf("bad", "also-bad"))
assertNull(settings.toElectrumxServers())
}
// ── hasCustomServers flag ──────────────────────────────────────────
@Test
fun `hasCustomServers is false when empty`() {
assertFalse(NamecoinSettings().hasCustomServers)
}
@Test
fun `hasCustomServers is true when populated`() {
assertTrue(NamecoinSettings(customServers = listOf("x:1")).hasCustomServers)
}
// ── Default settings ───────────────────────────────────────────────
@Test
fun `default settings are enabled with no custom servers`() {
val d = NamecoinSettings.DEFAULT
assertTrue(d.enabled)
assertTrue(d.customServers.isEmpty())
assertFalse(d.hasCustomServers)
}
}