From 23eac569e8004d0a72df02c53450c4f697efeb3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 14:05:39 +0000 Subject: [PATCH] desktop: readable text buttons + mobile relay validation on add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i --- .../nostrsigner/desktop/core/AmberDesktop.kt | 3 + .../nostrsigner/desktop/core/RelayChecker.kt | 153 ++++++++++++++++++ .../desktop/ui/ApplicationDetailScreen.kt | 11 +- .../desktop/ui/ApplicationsScreen.kt | 28 ++-- .../nostrsigner/desktop/ui/Components.kt | 27 ++++ .../nostrsigner/desktop/ui/LoginScreen.kt | 8 +- .../nostrsigner/desktop/ui/RelaysScreen.kt | 70 +++++++- .../nostrsigner/desktop/ui/SettingsScreen.kt | 82 ++++++---- .../nostrsigner/desktop/RelayCheckerTest.kt | 43 +++++ 9 files changed, 370 insertions(+), 55 deletions(-) create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/RelayChecker.kt create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/RelayCheckerTest.kt diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt index 10ef1027..9a37f08b 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt @@ -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 { diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/RelayChecker.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/RelayChecker.kt new file mode 100644 index 00000000..56cae320 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/RelayChecker.kt @@ -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 { + val saved = mutableSetOf() + 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( + 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) + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt index 5b8cae72..c7f958d1 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt @@ -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 }, + ) }, ) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt index 07e5768c..f6d121ac 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt @@ -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, + ) }, ) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt index 20d8cd53..3bf865d4 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt @@ -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, ) { diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt index cbdd76fa..9ddf4084 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt @@ -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 }, + ) } } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt index 25ad92ec..ac6b206c 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt @@ -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?>(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 + } } }, ) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt index d30e270a..eadd6c9d 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt @@ -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, + ) }, ) } diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/RelayCheckerTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/RelayCheckerTest.kt new file mode 100644 index 00000000..52562674 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/RelayCheckerTest.kt @@ -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")) + } +}