desktop: readable text buttons + mobile relay validation on add

TextButtons (Log out, Switch, Close, Clear, dialog actions) used the
Material default content color — our light amber primary — which is
unreadable on the light background. Add AmberTextButton, which picks the
dark orange variant on light surfaces and keeps amber on dark ones, and
use it for every text action.

Adding a relay now validates it like the mobile onAddRelay flow
(RelayChecker): bare hosts get wss:// (.onion and private addresses get
ws://), relays already in use are accepted directly, and new ones are
live-tested by publishing a throwaway kind-24133 event with a fresh key
and confirming the relay serves it back through a #p subscription. While
checking, the Add button shows a working state; on connect failure or a
relay that won't serve bunker filters, a dialog asks whether to add it
anyway — same strings and semantics as Android.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i
This commit is contained in:
Claude
2026-07-23 14:05:39 +00:00
parent 5118d87b1e
commit ed796b3be3
9 changed files with 370 additions and 55 deletions
@@ -42,6 +42,9 @@ object AmberDesktop {
val client: NostrClient by lazy { NostrClient(socketBuilder, applicationIOScope) }
/** A fresh, throwaway relay client (e.g. to probe a relay before adding it). */
fun newClient(): NostrClient = NostrClient(socketBuilder, applicationIOScope)
// Authenticates with relays that request NIP-42 AUTH.
@Suppress("unused")
private val authCoordinator by lazy {
@@ -0,0 +1,153 @@
package com.greenart7c3.nostrsigner.desktop.core
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
/**
* Validates a relay before adding it, mirroring the mobile `onAddRelay` flow:
* publish a throwaway kind-24133 event with a fresh key and confirm the relay
* both accepts it and serves it back through a `#p` subscription — proving it
* is actually usable as a bunker relay, not just reachable.
*/
object RelayChecker {
enum class Outcome {
/** Relay accepted and returned the test event (or is already in use). */
OK,
/** Could not open a connection to the relay at all. */
CANNOT_CONNECT,
/** Connected, but the relay rejected the event or won't serve the filter. */
FILTER_FAILED,
}
/** Mirrors the mobile `Amber.isPrivateIp`. */
fun isPrivateIp(url: String): Boolean = url.contains("127.0.0.1") ||
url.contains("localhost") ||
url.contains("192.168.") ||
(16..31).any { url.contains("172.$it.") } ||
url.contains("10.")
/**
* Mirrors the mobile URL preparation: bare hosts get `wss://`, except
* `.onion` and private addresses which get plain `ws://`.
*/
fun normalizeUserInput(url: String): NormalizedRelayUrl? {
val trimmed = url.trim()
if (trimmed.isBlank() || trimmed == "/") return null
return if (!trimmed.startsWith("wss://") && !trimmed.startsWith("ws://")) {
if (trimmed.endsWith(".onion") || trimmed.endsWith(".onion/") || isPrivateIp(trimmed)) {
RelayUrlNormalizer.normalizeOrNull("ws://$trimmed")
} else {
RelayUrlNormalizer.normalizeOrNull("wss://$trimmed")
}
} else {
RelayUrlNormalizer.normalizeOrNull(trimmed)
}
}
/** Relays already trusted by the user: defaults plus every connected app's. */
private fun savedRelays(): Set<NormalizedRelayUrl> {
val saved = mutableSetOf<NormalizedRelayUrl>()
saved += SettingsStore.settings.value.normalizedDefaultRelays()
AccountsStore.accounts.value.forEach { record ->
AmberDesktop.store(record.npub).apps.value.forEach { app ->
saved += app.app.normalizedRelays()
}
}
return saved
}
suspend fun check(relay: NormalizedRelayUrl): Outcome {
// Already used somewhere -> it has proven itself; skip the round-trip.
if (relay in savedRelays()) return Outcome.OK
val client = AmberDesktop.newClient()
val signer = NostrSignerInternal(KeyPair())
val pubKeyHex = signer.keyPair.pubKey.toHexKey()
val encrypted = signer.signerSync.nip04Encrypt("Test bunker event", pubKeyHex)
val signedEvent = signer.signerSync.sign<Event>(
TimeUtils.now(),
NostrConnectEvent.KIND,
arrayOf(arrayOf("p", pubKeyHex)),
encrypted,
)
val subId = UUID.randomUUID().toString().substring(0, 4)
var connected = false
var filterResult = false
val listener = object : RelayConnectionListener {
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
connected = true
super.onConnected(relay, pingMillis, compressed)
}
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
if (msg is EventMessage && msg.subId == subId && msg.event.id == signedEvent.id) {
filterResult = true
}
super.onIncomingMessage(relay, msgStr, msg)
}
}
client.addConnectionListener(listener)
try {
client.connect()
client.subscribe(
subId,
mapOf(
relay to listOf(
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(pubKeyHex)),
),
),
),
)
val canContinue = withTimeoutOrNull(30_000) {
while (!connected) delay(200)
true
}
if (canContinue == null) return Outcome.CANNOT_CONNECT
var published = false
var attempts = 0
while (!published && attempts < 3) {
published = client.publishAndConfirm(signedEvent, setOf(relay))
if (!published) {
attempts++
delay(1_000)
}
}
if (!published) return Outcome.FILTER_FAILED
var count = 0
while (!filterResult && count < 10) {
delay(1_000)
count++
}
return if (filterResult) Outcome.OK else Outcome.FILTER_FAILED
} finally {
runCatching { client.unsubscribe(subId) }
runCatching { client.disconnect() }
client.removeConnectionListener(listener)
}
}
}
@@ -25,7 +25,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -122,15 +121,19 @@ fun ApplicationDetailScreen(
title = { Text(Strings.get("remove", language)) },
text = { Text(Strings.get("remove_all_message", language)) },
confirmButton = {
TextButton(
AmberTextButton(
text = Strings.get("remove", language),
onClick = {
showRemoveAll = false
store.upsert(app.copy(permissions = mutableListOf()))
},
) { Text(Strings.get("remove", language)) }
)
},
dismissButton = {
TextButton(onClick = { showRemoveAll = false }) { Text(Strings.get("cancel", language)) }
AmberTextButton(
text = Strings.get("cancel", language),
onClick = { showRemoveAll = false },
)
},
)
}
@@ -22,7 +22,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -144,7 +143,8 @@ private fun NostrConnectDialog(
}
},
confirmButton = {
TextButton(
AmberTextButton(
text = Strings.get("add", language),
onClick = {
scope.launch {
if (!uri.trim().startsWith("nostrconnect://")) {
@@ -159,10 +159,13 @@ private fun NostrConnectDialog(
}
}
},
) { Text(Strings.get("add", language)) }
)
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(Strings.get("cancel", language)) }
AmberTextButton(
text = Strings.get("cancel", language),
onClick = onDismiss,
)
},
)
}
@@ -206,11 +209,12 @@ private fun NewBunkerDialog(
confirmButton = {
val uri = bunkerUri
if (uri == null) {
TextButton(
AmberTextButton(
text = Strings.get("d_create", language),
onClick = {
if (name.isBlank()) {
Toaster.toast(Strings.get("d_name_required", language))
return@TextButton
return@AmberTextButton
}
scope.launch {
bunkerUri = AmberDesktop.engine.createBunkerConnection(
@@ -220,18 +224,22 @@ private fun NewBunkerDialog(
)
}
},
) { Text(Strings.get("d_create", language)) }
)
} else {
TextButton(
AmberTextButton(
text = Strings.get("copy", language).trim(),
onClick = {
clipboard.setText(AnnotatedString(uri))
Toaster.toast(Strings.get("d_copied_clipboard", language))
},
) { Text(Strings.get("copy", language).trim()) }
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(if (bunkerUri == null) Strings.get("cancel", language) else Strings.get("d_close", language)) }
AmberTextButton(
text = if (bunkerUri == null) Strings.get("cancel", language) else Strings.get("d_close", language),
onClick = onDismiss,
)
},
)
}
@@ -14,6 +14,7 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -22,6 +23,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -104,15 +106,40 @@ fun AmberButton(
}
}
/**
* TextButton with a readable accent in both themes. The Material default uses
* `primary` — our light amber — which is unreadable on the light background,
* so pick the dark orange variant there and keep amber on dark surfaces.
*/
@Composable
fun AmberTextButton(
text: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.textButtonColors(contentColor = if (darkTheme) primaryColor else primaryVariant),
) {
Text(text)
}
}
@Composable
fun AmberOutlinedButton(
modifier: Modifier = Modifier,
text: String,
enabled: Boolean = true,
fillWidth: Boolean = false,
onClick: () -> Unit,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = ButtonBorder,
modifier = if (fillWidth) modifier.fillMaxWidth() else modifier,
) {
@@ -20,7 +20,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -91,9 +90,10 @@ fun LoginScreen(
}
if (hasAccounts) {
TextButton(onClick = { Session.addingAccount.value = false }) {
Text(Strings.get("cancel", language))
}
AmberTextButton(
text = Strings.get("cancel", language),
onClick = { Session.addingAccount.value = false },
)
}
}
}
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -30,9 +31,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
import com.greenart7c3.nostrsigner.desktop.core.RelayChecker
import com.greenart7c3.nostrsigner.desktop.core.SettingsStore
import com.greenart7c3.nostrsigner.desktop.core.Strings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.launch
@Composable
@@ -40,8 +42,46 @@ fun RelaysScreen() {
val settings by SettingsStore.settings.collectAsState()
val language by Strings.currentLanguage.collectAsState()
var newRelay by remember { mutableStateOf("") }
var checking by remember { mutableStateOf(false) }
// Non-null while asking "the check failed — add anyway?"; holds the relay
// and the message key (could_not_connect_to_relay / relay_filter_failed).
var addAnyway by remember { mutableStateOf<Pair<NormalizedRelayUrl, String>?>(null) }
val scope = rememberCoroutineScope()
fun addRelay(relay: NormalizedRelayUrl) {
SettingsStore.update {
it.copy(defaultRelays = (it.defaultRelays + relay.url).distinct())
}
newRelay = ""
scope.launch {
AmberDesktop.engine.updateFilter()
AmberDesktop.client.connect()
}
}
addAnyway?.let { (relay, messageKey) ->
AlertDialog(
onDismissRequest = { addAnyway = null },
title = { Text(Strings.get("relay", language)) },
text = { Text(Strings.get(messageKey, language)) },
confirmButton = {
AmberTextButton(
text = Strings.get("yes", language),
onClick = {
addAnyway = null
addRelay(relay)
},
)
},
dismissButton = {
AmberTextButton(
text = Strings.get("no", language),
onClick = { addAnyway = null },
)
},
)
}
Column(Modifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
@@ -49,23 +89,37 @@ fun RelaysScreen() {
onValueChange = { newRelay = it },
label = { Text(Strings.get("d_relay_hint", language)) },
singleLine = true,
enabled = !checking,
modifier = Modifier.widthIn(max = 420.dp).weight(1f, fill = false),
)
AmberOutlinedButton(
text = Strings.get("add", language),
text = if (checking) Strings.get("d_working", language) else Strings.get("add", language),
enabled = !checking,
onClick = {
val normalized = RelayUrlNormalizer.normalizeOrNull(newRelay.trim())
// Mirrors the mobile onAddRelay flow: normalize (bare hosts
// get wss://, .onion and private IPs ws://), then live-test
// the relay as a bunker relay before adding; on failure ask
// whether to add it anyway.
val normalized = RelayChecker.normalizeUserInput(newRelay)
if (normalized == null) {
Toaster.toast(Strings.get("d_invalid_relay", language))
return@AmberOutlinedButton
}
SettingsStore.update {
it.copy(defaultRelays = (it.defaultRelays + normalized.url).distinct())
if (settings.defaultRelays.contains(normalized.url)) {
newRelay = ""
return@AmberOutlinedButton
}
newRelay = ""
checking = true
scope.launch {
AmberDesktop.engine.updateFilter()
AmberDesktop.client.connect()
try {
when (RelayChecker.check(normalized)) {
RelayChecker.Outcome.OK -> addRelay(normalized)
RelayChecker.Outcome.CANNOT_CONNECT -> addAnyway = normalized to "could_not_connect_to_relay"
RelayChecker.Outcome.FILTER_FAILED -> addAnyway = normalized to "relay_filter_failed"
}
} finally {
checking = false
}
}
},
)
@@ -22,7 +22,6 @@ 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.collectAsState
import androidx.compose.runtime.getValue
@@ -172,13 +171,15 @@ fun SettingsScreen(account: DesktopAccount) {
}
}
if (record.npub != account.npub) {
TextButton(onClick = { scope.launch { Session.switchTo(record.npub) } }) {
Text(Strings.get("d_switch", language))
}
}
TextButton(onClick = { showLogoutConfirm = record.npub }) {
Text(Strings.get("d_log_out", language))
AmberTextButton(
text = Strings.get("d_switch", language),
onClick = { scope.launch { Session.switchTo(record.npub) } },
)
}
AmberTextButton(
text = Strings.get("d_log_out", language),
onClick = { showLogoutConfirm = record.npub },
)
}
HorizontalDivider()
}
@@ -212,15 +213,19 @@ fun SettingsScreen(account: DesktopAccount) {
title = { Text(Strings.get("d_log_out_q", language)) },
text = { Text(Strings.get("d_log_out_confirm", language)) },
confirmButton = {
TextButton(
AmberTextButton(
text = Strings.get("d_log_out", language),
onClick = {
showLogoutConfirm = null
scope.launch { Session.logout(npub) }
},
) { Text(Strings.get("d_log_out", language)) }
)
},
dismissButton = {
TextButton(onClick = { showLogoutConfirm = null }) { Text(Strings.get("cancel", language)) }
AmberTextButton(
text = Strings.get("cancel", language),
onClick = { showLogoutConfirm = null },
)
},
)
}
@@ -335,7 +340,8 @@ private fun SecuritySection() {
Text(Strings.get("d_remove_passphrase_desc", language))
},
confirmButton = {
TextButton(
AmberTextButton(
text = Strings.get("remove", language),
onClick = {
showRemoveConfirm = false
scope.launch {
@@ -343,10 +349,13 @@ private fun SecuritySection() {
Toaster.toast(Strings.get("d_passphrase_removed", language))
}
},
) { Text(Strings.get("remove", language)) }
)
},
dismissButton = {
TextButton(onClick = { showRemoveConfirm = false }) { Text(Strings.get("cancel", language)) }
AmberTextButton(
text = Strings.get("cancel", language),
onClick = { showRemoveConfirm = false },
)
},
)
}
@@ -407,16 +416,17 @@ private fun PassphraseDialog(
}
},
confirmButton = {
TextButton(
AmberTextButton(
text = if (working) Strings.get("d_working", language) else Strings.get("save", language),
enabled = !working,
onClick = {
if (new.length < 8) {
Toaster.toast(Strings.get("d_use_8_chars", language))
return@TextButton
return@AmberTextButton
}
if (new != confirm) {
Toaster.toast(Strings.get("d_passphrases_no_match", language))
return@TextButton
return@AmberTextButton
}
working = true
scope.launch {
@@ -439,10 +449,14 @@ private fun PassphraseDialog(
working = false
}
},
) { Text(if (working) Strings.get("d_working", language) else Strings.get("save", language)) }
)
},
dismissButton = {
TextButton(enabled = !working, onClick = onDismiss) { Text(Strings.get("cancel", language)) }
AmberTextButton(
text = Strings.get("cancel", language),
enabled = !working,
onClick = onDismiss,
)
},
)
}
@@ -474,22 +488,24 @@ private fun BackupDialog(
Text(seedWords, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
}
} else {
TextButton(
AmberTextButton(
text = Strings.get("d_show", language),
onClick = {
showSecret = true
scope.launch { seedWords = AccountManager.seedWords(account.npub) }
},
) { Text(Strings.get("d_show", language)) }
)
}
Row {
TextButton(
AmberTextButton(
text = Strings.get("d_copy_nsec", language),
onClick = {
clipboard.setText(AnnotatedString(account.getNsec()))
account.didBackup = true
Session.saveMeta(account)
Toaster.toast(Strings.get("d_nsec_copied", language))
},
) { Text(Strings.get("d_copy_nsec", language)) }
)
}
Spacer(Modifier.height(12.dp))
@@ -510,11 +526,12 @@ private fun BackupDialog(
modifier = Modifier.heightIn(max = 90.dp).verticalScroll(rememberScrollState()),
)
}
TextButton(
AmberTextButton(
text = Strings.get("d_encrypt_and_copy", language),
onClick = {
if (password.isBlank()) {
Toaster.toast(Strings.get("d_password_required", language))
return@TextButton
return@AmberTextButton
}
ncryptsec = account.nip49Encrypt(password)
clipboard.setText(AnnotatedString(ncryptsec))
@@ -522,11 +539,14 @@ private fun BackupDialog(
Session.saveMeta(account)
Toaster.toast(Strings.get("d_encrypted_key_copied", language))
},
) { Text(Strings.get("d_encrypt_and_copy", language)) }
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(Strings.get("d_close", language)) }
AmberTextButton(
text = Strings.get("d_close", language),
onClick = onDismiss,
)
},
)
}
@@ -559,14 +579,18 @@ private fun LogsDialog(
}
},
confirmButton = {
TextButton(
AmberTextButton(
text = Strings.get("d_clear", language),
onClick = {
store.clearLogs()
},
) { Text(Strings.get("d_clear", language)) }
)
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(Strings.get("d_close", language)) }
AmberTextButton(
text = Strings.get("d_close", language),
onClick = onDismiss,
)
},
)
}
@@ -0,0 +1,43 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.RelayChecker
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** URL preparation for the add-relay flow, mirroring the mobile onAddRelay. */
class RelayCheckerTest {
@Test
fun bareHostsGetWss() {
assertEquals("wss://relay.example.com/", RelayChecker.normalizeUserInput("relay.example.com")?.url)
}
@Test
fun onionAndPrivateAddressesGetPlainWs() {
assertEquals("ws://abcdef.onion/", RelayChecker.normalizeUserInput("abcdef.onion")?.url)
assertTrue(RelayChecker.normalizeUserInput("192.168.1.5:4869")!!.url.startsWith("ws://"))
assertTrue(RelayChecker.normalizeUserInput("localhost:8080")!!.url.startsWith("ws://"))
}
@Test
fun explicitSchemesAreKept() {
assertEquals("wss://relay.example.com/", RelayChecker.normalizeUserInput("wss://relay.example.com")?.url)
assertEquals("ws://relay.example.com/", RelayChecker.normalizeUserInput("ws://relay.example.com")?.url)
}
@Test
fun blankAndSlashAreRejected() {
assertNull(RelayChecker.normalizeUserInput(""))
assertNull(RelayChecker.normalizeUserInput(" "))
assertNull(RelayChecker.normalizeUserInput("/"))
}
@Test
fun privateIpDetectionMatchesMobile() {
assertTrue(RelayChecker.isPrivateIp("127.0.0.1:7777"))
assertTrue(RelayChecker.isPrivateIp("172.20.0.3"))
assertFalse(RelayChecker.isPrivateIp("relay.example.com"))
}
}