mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-09-14 00:35:08 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -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 },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+18
-10
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user