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")) + } +}