fix(desktop): address code review findings for NIP-46 bunker login
- Add bounds check in fromBunkerUri for missing query params (P2-1) - Preserve bunker keys on transient force-logout (P2-3) - Replace debug printlns with DebugConfig.log() (P2-4) - Replace unused Unstable with Disconnected state (P2-5) - Move validateBunkerUri to account package (P3-9) - Fix Compose state update from IO thread (P3-11) - Add disconnectNip46Client() to onDispose cleanup (P3-12) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
|
|||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
||||||
@@ -88,6 +89,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
|||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -95,7 +97,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||||
|
|
||||||
@@ -402,13 +404,18 @@ fun App(
|
|||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||||
|
|
||||||
// Subscriptions coordinator for metadata/reactions loading
|
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||||
|
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||||
val subscriptionsCoordinator =
|
val subscriptionsCoordinator =
|
||||||
remember(relayManager, localCache) {
|
remember(relayManager, localCache) {
|
||||||
DesktopRelaySubscriptionsCoordinator(
|
DesktopRelaySubscriptionsCoordinator(
|
||||||
client = relayManager.client,
|
client = relayManager.client,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
indexRelays = relayManager.availableRelays.value,
|
indexRelays =
|
||||||
|
DefaultRelays.RELAYS
|
||||||
|
.mapNotNull {
|
||||||
|
RelayUrlNormalizer.normalizeOrNull(it)
|
||||||
|
}.toSet(),
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -421,31 +428,24 @@ fun App(
|
|||||||
|
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
if (accountManager.hasBunkerAccount()) {
|
if (accountManager.hasBunkerAccount()) {
|
||||||
// Bunker accounts need relay connections before recreating signer
|
// Show connecting UI while dedicated NIP-46 client connects
|
||||||
accountManager.setConnectingRelays()
|
accountManager.setConnectingRelays()
|
||||||
val connected =
|
}
|
||||||
withTimeoutOrNull(30_000L) {
|
val result = accountManager.loadSavedAccount()
|
||||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
if (result.isSuccess) {
|
||||||
}
|
val current = accountManager.currentAccount()
|
||||||
if (connected == null) {
|
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
|
||||||
// No relays connected after 30s — fall back to login screen
|
accountManager.startHeartbeat(scope)
|
||||||
accountManager.logout(deleteKey = true)
|
|
||||||
} else {
|
|
||||||
val result = accountManager.loadSavedAccount(relayManager.client)
|
|
||||||
if (result.isSuccess) {
|
|
||||||
accountManager.startHeartbeat(scope)
|
|
||||||
} else {
|
|
||||||
// Corrupt bunker state — fall back to login screen
|
|
||||||
accountManager.logout(deleteKey = true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (accountManager.hasBunkerAccount()) {
|
||||||
accountManager.loadSavedAccount()
|
// Corrupt bunker state — fall back to login screen
|
||||||
|
accountManager.logout(deleteKey = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
accountManager.stopHeartbeat()
|
accountManager.stopHeartbeat()
|
||||||
|
runBlocking { accountManager.disconnectNip46Client() }
|
||||||
subscriptionsCoordinator.clear()
|
subscriptionsCoordinator.clear()
|
||||||
relayManager.disconnect()
|
relayManager.disconnect()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
@@ -463,7 +463,6 @@ fun App(
|
|||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
relayClient = relayManager.client,
|
|
||||||
onLoginSuccess = {
|
onLoginSuccess = {
|
||||||
// Start heartbeat if bunker account
|
// Start heartbeat if bunker account
|
||||||
val current = accountManager.currentAccount()
|
val current = accountManager.currentAccount()
|
||||||
@@ -475,7 +474,11 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is AccountState.ConnectingRelays -> {
|
is AccountState.ConnectingRelays -> {
|
||||||
ConnectingRelaysScreen()
|
val relays by relayManager.relayStatuses.collectAsState()
|
||||||
|
ConnectingRelaysScreen(
|
||||||
|
subtitle = "Restoring remote signer session",
|
||||||
|
relayStatuses = relays,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AccountState.LoggedIn -> {
|
is AccountState.LoggedIn -> {
|
||||||
|
|||||||
+146
-50
@@ -23,15 +23,17 @@ package com.vitorpamplona.amethyst.desktop.account
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||||
@@ -39,12 +41,16 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
|||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -52,10 +58,15 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
sealed class SignerType {
|
sealed class SignerType {
|
||||||
@@ -71,9 +82,7 @@ sealed class SignerConnectionState {
|
|||||||
|
|
||||||
data object Connected : SignerConnectionState()
|
data object Connected : SignerConnectionState()
|
||||||
|
|
||||||
data class Unstable(
|
data object Disconnected : SignerConnectionState()
|
||||||
val failCount: Int,
|
|
||||||
) : SignerConnectionState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class AccountState {
|
sealed class AccountState {
|
||||||
@@ -106,6 +115,7 @@ class AccountManager internal constructor(
|
|||||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||||
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
||||||
internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
|
internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
|
||||||
|
internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
|
||||||
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,16 +137,53 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
private var heartbeatJob: Job? = null
|
private var heartbeatJob: Job? = null
|
||||||
|
|
||||||
|
// --- Dedicated NIP-46 client (isolated from general relay pool) ---
|
||||||
|
|
||||||
|
private val nip46ClientMutex = Mutex()
|
||||||
|
private var nip46Client: NostrClient? = null
|
||||||
|
|
||||||
|
private suspend fun getOrCreateNip46Client(): NostrClient =
|
||||||
|
nip46ClientMutex.withLock {
|
||||||
|
nip46Client ?: NostrClient(
|
||||||
|
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
|
||||||
|
).also {
|
||||||
|
nip46Client = it
|
||||||
|
it.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnectNip46Client() =
|
||||||
|
nip46ClientMutex.withLock {
|
||||||
|
nip46Client?.disconnect()
|
||||||
|
nip46Client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for the NIP-46 client to connect to at least one of the target relays.
|
||||||
|
* openSubscription() triggers async relay connection via sendOrConnectAndSync,
|
||||||
|
* but we must wait for the websocket to be ready before sending requests.
|
||||||
|
*/
|
||||||
|
private suspend fun awaitNip46RelayConnection(
|
||||||
|
client: NostrClient,
|
||||||
|
targetRelays: Set<NormalizedRelayUrl>,
|
||||||
|
) {
|
||||||
|
withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) {
|
||||||
|
client.connectedRelaysFlow().first { connected ->
|
||||||
|
targetRelays.any { it in connected }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Account loading ---
|
// --- Account loading ---
|
||||||
|
|
||||||
suspend fun loadSavedAccount(client: INostrClient? = null): Result<AccountState.LoggedIn> =
|
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
|
||||||
try {
|
try {
|
||||||
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
|
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
|
||||||
|
|
||||||
// Check for bunker account first
|
// Check for bunker account first
|
||||||
val bunkerUri = getBunkerUri()
|
val bunkerUri = getBunkerUri()
|
||||||
if (bunkerUri != null && client != null) {
|
if (bunkerUri != null) {
|
||||||
loadBunkerAccount(bunkerUri, lastNpub, client)
|
loadBunkerAccount(bunkerUri, lastNpub)
|
||||||
} else {
|
} else {
|
||||||
loadInternalAccount(lastNpub)
|
loadInternalAccount(lastNpub)
|
||||||
}
|
}
|
||||||
@@ -167,7 +214,6 @@ class AccountManager internal constructor(
|
|||||||
private suspend fun loadBunkerAccount(
|
private suspend fun loadBunkerAccount(
|
||||||
bunkerUri: String,
|
bunkerUri: String,
|
||||||
npub: String,
|
npub: String,
|
||||||
client: INostrClient,
|
|
||||||
): Result<AccountState.LoggedIn> {
|
): Result<AccountState.LoggedIn> {
|
||||||
val ephemeralPrivKeyHex =
|
val ephemeralPrivKeyHex =
|
||||||
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
|
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||||
@@ -176,7 +222,8 @@ class AccountManager internal constructor(
|
|||||||
val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray())
|
val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray())
|
||||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||||
|
|
||||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)
|
val nip46Client = getOrCreateNip46Client()
|
||||||
|
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
|
||||||
remoteSigner.openSubscription()
|
remoteSigner.openSubscription()
|
||||||
|
|
||||||
val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
|
val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
|
||||||
@@ -197,17 +244,18 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
// --- Bunker login ---
|
// --- Bunker login ---
|
||||||
|
|
||||||
suspend fun loginWithBunker(
|
suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn> =
|
||||||
bunkerUri: String,
|
|
||||||
client: INostrClient,
|
|
||||||
): Result<AccountState.LoggedIn> =
|
|
||||||
try {
|
try {
|
||||||
val ephemeralKeyPair = KeyPair()
|
val ephemeralKeyPair = KeyPair()
|
||||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||||
|
|
||||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)
|
val nip46Client = getOrCreateNip46Client()
|
||||||
|
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
|
||||||
remoteSigner.openSubscription()
|
remoteSigner.openSubscription()
|
||||||
|
|
||||||
|
// Wait for websocket to be ready before sending connect request
|
||||||
|
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
|
||||||
|
|
||||||
val remotePubkey = remoteSigner.connect()
|
val remotePubkey = remoteSigner.connect()
|
||||||
|
|
||||||
val state =
|
val state =
|
||||||
@@ -230,6 +278,8 @@ class AccountManager internal constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Result.success(state)
|
Result.success(state)
|
||||||
|
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||||
|
Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection."))
|
||||||
} catch (e: SignerExceptions.TimedOutException) {
|
} catch (e: SignerExceptions.TimedOutException) {
|
||||||
Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
|
Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
|
||||||
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
|
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
|
||||||
@@ -242,10 +292,7 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
// --- Nostrconnect login ---
|
// --- Nostrconnect login ---
|
||||||
|
|
||||||
suspend fun loginWithNostrConnect(
|
suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result<AccountState.LoggedIn> =
|
||||||
client: INostrClient,
|
|
||||||
onUriGenerated: (String) -> Unit,
|
|
||||||
): Result<AccountState.LoggedIn> =
|
|
||||||
try {
|
try {
|
||||||
val ephemeralKeyPair = KeyPair()
|
val ephemeralKeyPair = KeyPair()
|
||||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||||
@@ -257,17 +304,20 @@ class AccountManager internal constructor(
|
|||||||
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
|
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
|
||||||
onUriGenerated(uri)
|
onUriGenerated(uri)
|
||||||
|
|
||||||
|
val nip46Client = getOrCreateNip46Client()
|
||||||
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
|
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
|
||||||
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, client)
|
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client)
|
||||||
|
|
||||||
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, client)
|
if (connectData.requestId != null) {
|
||||||
|
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client)
|
||||||
|
}
|
||||||
|
|
||||||
val remoteSigner =
|
val remoteSigner =
|
||||||
NostrSignerRemote(
|
NostrSignerRemote(
|
||||||
signer = ephemeralSigner,
|
signer = ephemeralSigner,
|
||||||
remotePubkey = connectData.signerPubkey,
|
remotePubkey = connectData.signerPubkey,
|
||||||
relays = normalizedRelays,
|
relays = normalizedRelays,
|
||||||
client = client,
|
client = nip46Client,
|
||||||
)
|
)
|
||||||
remoteSigner.openSubscription()
|
remoteSigner.openSubscription()
|
||||||
|
|
||||||
@@ -303,7 +353,7 @@ class AccountManager internal constructor(
|
|||||||
ephemeralPubKey: HexKey,
|
ephemeralPubKey: HexKey,
|
||||||
relays: Set<NormalizedRelayUrl>,
|
relays: Set<NormalizedRelayUrl>,
|
||||||
expectedSecret: String,
|
expectedSecret: String,
|
||||||
client: INostrClient,
|
client: NostrClient,
|
||||||
): ConnectRequestData {
|
): ConnectRequestData {
|
||||||
val deferred = CompletableDeferred<NostrConnectEvent>()
|
val deferred = CompletableDeferred<NostrConnectEvent>()
|
||||||
|
|
||||||
@@ -314,6 +364,7 @@ class AccountManager internal constructor(
|
|||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(NostrConnectEvent.KIND),
|
kinds = listOf(NostrConnectEvent.KIND),
|
||||||
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
||||||
|
since = TimeUtils.now() - 60,
|
||||||
),
|
),
|
||||||
) { event ->
|
) { event ->
|
||||||
if (event is NostrConnectEvent && !deferred.isCompleted) {
|
if (event is NostrConnectEvent && !deferred.isCompleted) {
|
||||||
@@ -327,28 +378,53 @@ class AccountManager internal constructor(
|
|||||||
deferred.await()
|
deferred.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
val otherPubkey = event.talkingWith(ephemeralSigner.pubKey)
|
val signerPubkey = event.talkingWith(ephemeralSigner.pubKey)
|
||||||
val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey)
|
val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey)
|
||||||
val message = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(decryptedJson)
|
val message = OptimizedJsonMapper.fromJsonTo<BunkerMessage>(decryptedJson)
|
||||||
|
|
||||||
if (message.method != BunkerRequestConnect.METHOD_NAME) {
|
return when (message) {
|
||||||
throw Exception("Expected 'connect' method, got '${message.method}'")
|
is BunkerRequest -> {
|
||||||
|
if (message.method != BunkerRequestConnect.METHOD_NAME) {
|
||||||
|
throw Exception("Expected 'connect' method, got '${message.method}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
val userPubkey =
|
||||||
|
message.params.getOrNull(0)
|
||||||
|
?: throw Exception("Missing user pubkey in connect request")
|
||||||
|
val receivedSecret = message.params.getOrNull(1)
|
||||||
|
|
||||||
|
if (receivedSecret != expectedSecret) {
|
||||||
|
throw Exception("Secret mismatch in connect request")
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectRequestData(
|
||||||
|
requestId = message.id,
|
||||||
|
signerPubkey = signerPubkey,
|
||||||
|
userPubkey = userPubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BunkerResponse -> {
|
||||||
|
if (message.error != null) {
|
||||||
|
throw Exception("Signer rejected connection: ${message.error}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val userPubkey =
|
||||||
|
message.result
|
||||||
|
?.takeIf { it.length == 64 && Hex.isHex(it) }
|
||||||
|
?: signerPubkey
|
||||||
|
|
||||||
|
ConnectRequestData(
|
||||||
|
requestId = null,
|
||||||
|
signerPubkey = signerPubkey,
|
||||||
|
userPubkey = userPubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw Exception("Unexpected NIP-46 message format")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val userPubkey =
|
|
||||||
message.params.getOrNull(0)
|
|
||||||
?: throw Exception("Missing user pubkey in connect request")
|
|
||||||
val receivedSecret = message.params.getOrNull(1)
|
|
||||||
|
|
||||||
if (receivedSecret != expectedSecret) {
|
|
||||||
throw Exception("Secret mismatch in connect request")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConnectRequestData(
|
|
||||||
requestId = message.id,
|
|
||||||
signerPubkey = event.pubKey,
|
|
||||||
userPubkey = userPubkey,
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
subscription.close()
|
subscription.close()
|
||||||
}
|
}
|
||||||
@@ -358,9 +434,9 @@ class AccountManager internal constructor(
|
|||||||
ephemeralSigner: NostrSignerInternal,
|
ephemeralSigner: NostrSignerInternal,
|
||||||
connectData: ConnectRequestData,
|
connectData: ConnectRequestData,
|
||||||
relays: Set<NormalizedRelayUrl>,
|
relays: Set<NormalizedRelayUrl>,
|
||||||
client: INostrClient,
|
client: NostrClient,
|
||||||
) {
|
) {
|
||||||
val ackResponse = BunkerResponseAck(id = connectData.requestId)
|
val ackResponse = BunkerResponseAck(id = connectData.requestId!!)
|
||||||
val ackEvent =
|
val ackEvent =
|
||||||
NostrConnectEvent.create(
|
NostrConnectEvent.create(
|
||||||
message = ackResponse,
|
message = ackResponse,
|
||||||
@@ -377,7 +453,7 @@ class AccountManager internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private data class ConnectRequestData(
|
private data class ConnectRequestData(
|
||||||
val requestId: String,
|
val requestId: String?,
|
||||||
val signerPubkey: HexKey,
|
val signerPubkey: HexKey,
|
||||||
val userPubkey: HexKey,
|
val userPubkey: HexKey,
|
||||||
)
|
)
|
||||||
@@ -511,6 +587,7 @@ class AccountManager internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
disconnectNip46Client()
|
||||||
_signerConnectionState.value = SignerConnectionState.NotRemote
|
_signerConnectionState.value = SignerConnectionState.NotRemote
|
||||||
_accountState.value = AccountState.LoggedOut
|
_accountState.value = AccountState.LoggedOut
|
||||||
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
||||||
@@ -519,7 +596,7 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
suspend fun forceLogoutWithReason(reason: String) {
|
suspend fun forceLogoutWithReason(reason: String) {
|
||||||
_forceLogoutReason.value = reason
|
_forceLogoutReason.value = reason
|
||||||
logout(deleteKey = true)
|
logout(deleteKey = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearForceLogoutReason() {
|
fun clearForceLogoutReason() {
|
||||||
@@ -552,7 +629,7 @@ class AccountManager internal constructor(
|
|||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
_signerConnectionState.value = SignerConnectionState.Unstable(consecutiveFailures)
|
_signerConnectionState.value = SignerConnectionState.Disconnected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,8 +678,27 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
// --- File storage helpers ---
|
// --- File storage helpers ---
|
||||||
|
|
||||||
|
private fun ensureAmethystDir() {
|
||||||
|
if (!amethystDir.exists()) {
|
||||||
|
amethystDir.mkdirs()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(
|
||||||
|
amethystDir.toPath(),
|
||||||
|
setOf(
|
||||||
|
PosixFilePermission.OWNER_READ,
|
||||||
|
PosixFilePermission.OWNER_WRITE,
|
||||||
|
PosixFilePermission.OWNER_EXECUTE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: UnsupportedOperationException) {
|
||||||
|
// Windows — file system ACLs handle this
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveNwcUri(uri: String) {
|
private fun saveNwcUri(uri: String) {
|
||||||
amethystDir.mkdirs()
|
ensureAmethystDir()
|
||||||
getNwcFile().writeText(uri)
|
getNwcFile().writeText(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +710,7 @@ class AccountManager internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLastNpub(npub: String) {
|
private fun saveLastNpub(npub: String) {
|
||||||
amethystDir.mkdirs()
|
ensureAmethystDir()
|
||||||
getPrefsFile().writeText(npub)
|
getPrefsFile().writeText(npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,7 +726,7 @@ class AccountManager internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveBunkerUri(uri: String) {
|
private fun saveBunkerUri(uri: String) {
|
||||||
amethystDir.mkdirs()
|
ensureAmethystDir()
|
||||||
getBunkerFile().writeText(uri)
|
getBunkerFile().writeText(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
|
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
|
||||||
|
|
||||||
|
fun validateBunkerUri(input: String): String? {
|
||||||
|
val trimmed = input.trim()
|
||||||
|
if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
|
||||||
|
|
||||||
|
val afterScheme = trimmed.substring("bunker://".length)
|
||||||
|
val parts = afterScheme.split("?", limit = 2)
|
||||||
|
val pubkeyPart = parts[0]
|
||||||
|
|
||||||
|
if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) {
|
||||||
|
return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) {
|
||||||
|
return "Bunker URI must include at least one relay parameter (relay=wss://...)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null // valid
|
||||||
|
}
|
||||||
+4
-5
@@ -99,7 +99,7 @@ fun ChessScreen(
|
|||||||
remember(account.pubKeyHex) {
|
remember(account.pubKeyHex) {
|
||||||
DesktopChessViewModelNew(account, relayManager, scope)
|
DesktopChessViewModelNew(account, relayManager, scope)
|
||||||
}
|
}
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val broadcastStatus by viewModel.broadcastStatus.collectAsState()
|
val broadcastStatus by viewModel.broadcastStatus.collectAsState()
|
||||||
val activeGames by viewModel.activeGames.collectAsState()
|
val activeGames by viewModel.activeGames.collectAsState()
|
||||||
// Observe state version to force recomposition when game state changes
|
// Observe state version to force recomposition when game state changes
|
||||||
@@ -147,11 +147,10 @@ fun ChessScreen(
|
|||||||
|
|
||||||
// Subscribe to user metadata for pubkeys that need it
|
// Subscribe to user metadata for pubkeys that need it
|
||||||
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
|
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
|
||||||
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
|
||||||
createMetadataListSubscription(
|
createMetadataListSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeys = pubkeysNeeded.toList(),
|
pubKeys = pubkeysNeeded.toList(),
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
viewModel.handleIncomingEvent(event)
|
viewModel.handleIncomingEvent(event)
|
||||||
|
|||||||
+1
-1
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
|
|||||||
cmd: Command,
|
cmd: Command,
|
||||||
success: Boolean,
|
success: Boolean,
|
||||||
) {
|
) {
|
||||||
// Command send tracking
|
// Command send tracking — no-op for now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.subscriptions
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import com.vitorpamplona.amethyst.desktop.DebugConfig
|
||||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||||
@@ -74,6 +75,7 @@ fun rememberSubscription(
|
|||||||
DisposableEffect(*keys, subscription?.subId) {
|
DisposableEffect(*keys, subscription?.subId) {
|
||||||
subscription?.let { cfg ->
|
subscription?.let { cfg ->
|
||||||
if (cfg.relays.isNotEmpty()) {
|
if (cfg.relays.isNotEmpty()) {
|
||||||
|
DebugConfig.log("SUB OPEN ${cfg.subId} relays=${cfg.relays.size}")
|
||||||
relayManager.subscribe(
|
relayManager.subscribe(
|
||||||
subId = cfg.subId,
|
subId = cfg.subId,
|
||||||
filters = cfg.filters,
|
filters = cfg.filters,
|
||||||
@@ -101,7 +103,10 @@ fun rememberSubscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
subscription?.let { relayManager.unsubscribe(it.subId) }
|
subscription?.let {
|
||||||
|
DebugConfig.log("SUB CLOSE ${it.subId}")
|
||||||
|
relayManager.unsubscribe(it.subId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-13
@@ -76,7 +76,7 @@ fun BookmarksScreen(
|
|||||||
onNavigateToThread: (String) -> Unit = {},
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
@@ -121,9 +121,8 @@ fun BookmarksScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to user's bookmark list (kind 30001)
|
// Subscribe to user's bookmark list (kind 30001)
|
||||||
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
|
subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
|
||||||
filters =
|
filters =
|
||||||
@@ -134,7 +133,7 @@ fun BookmarksScreen(
|
|||||||
limit = 1,
|
limit = 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is BookmarkListEvent) {
|
if (event is BookmarkListEvent) {
|
||||||
bookmarkList = event
|
bookmarkList = event
|
||||||
@@ -179,9 +178,8 @@ fun BookmarksScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to fetch the actual public bookmarked events
|
// Subscribe to fetch the actual public bookmarked events
|
||||||
rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, publicBookmarkIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
|
|
||||||
publicEventState.clear()
|
publicEventState.clear()
|
||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = "public-bookmarked-events-${System.currentTimeMillis()}",
|
subId = "public-bookmarked-events-${System.currentTimeMillis()}",
|
||||||
@@ -189,7 +187,7 @@ fun BookmarksScreen(
|
|||||||
listOf(
|
listOf(
|
||||||
FilterBuilders.byIds(publicBookmarkIds),
|
FilterBuilders.byIds(publicBookmarkIds),
|
||||||
),
|
),
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
publicEventState.addItem(event)
|
publicEventState.addItem(event)
|
||||||
},
|
},
|
||||||
@@ -201,9 +199,8 @@ fun BookmarksScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to fetch the actual private bookmarked events
|
// Subscribe to fetch the actual private bookmarked events
|
||||||
rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, privateBookmarkIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
|
|
||||||
privateEventState.clear()
|
privateEventState.clear()
|
||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = "private-bookmarked-events-${System.currentTimeMillis()}",
|
subId = "private-bookmarked-events-${System.currentTimeMillis()}",
|
||||||
@@ -211,7 +208,7 @@ fun BookmarksScreen(
|
|||||||
listOf(
|
listOf(
|
||||||
FilterBuilders.byIds(privateBookmarkIds),
|
FilterBuilders.byIds(privateBookmarkIds),
|
||||||
),
|
),
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
privateEventState.addItem(event)
|
privateEventState.addItem(event)
|
||||||
},
|
},
|
||||||
|
|||||||
-3
@@ -177,15 +177,12 @@ private suspend fun publishNote(
|
|||||||
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Check read-only mode
|
|
||||||
if (account.isReadOnly) {
|
if (account.isReadOnly) {
|
||||||
throw IllegalStateException("Cannot post in read-only mode")
|
throw IllegalStateException("Cannot post in read-only mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use shared PublishAction from commons
|
|
||||||
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
|
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
|
||||||
|
|
||||||
// Broadcast to all configured relays
|
|
||||||
relayManager.broadcastToAll(signedEvent)
|
relayManager.broadcastToAll(signedEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.DebugConfig
|
||||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
@@ -158,7 +159,6 @@ fun FeedScreen(
|
|||||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val eventState =
|
val eventState =
|
||||||
remember {
|
remember {
|
||||||
@@ -190,15 +190,18 @@ fun FeedScreen(
|
|||||||
val initialLoadComplete = eoseReceivedCount > 0
|
val initialLoadComplete = eoseReceivedCount > 0
|
||||||
|
|
||||||
// Load followed users for Following feed mode
|
// Load followed users for Following feed mode
|
||||||
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
DebugConfig.log("contactList sub: relays=${connectedRelays.size}, account=${account?.pubKeyHex?.take(8)}, mode=$feedMode")
|
||||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||||
createContactListSubscription(
|
createContactListSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = account.pubKeyHex,
|
pubKeyHex = account.pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
|
DebugConfig.log("contactList event: kind=${event.kind}, isContactList=${event is ContactListEvent}, from=$relay")
|
||||||
if (event is ContactListEvent) {
|
if (event is ContactListEvent) {
|
||||||
followedUsers = event.verifiedFollowKeySet()
|
val follows = event.verifiedFollowKeySet()
|
||||||
|
DebugConfig.log("followedUsers: ${follows.size} users")
|
||||||
|
followedUsers = follows
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -208,9 +211,8 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load user's bookmark list
|
// Load user's bookmark list
|
||||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty() && account != null) {
|
||||||
if (configuredRelays.isNotEmpty() && account != null) {
|
|
||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = "bookmarks-${account.pubKeyHex.take(8)}",
|
subId = "bookmarks-${account.pubKeyHex.take(8)}",
|
||||||
filters =
|
filters =
|
||||||
@@ -221,7 +223,7 @@ fun FeedScreen(
|
|||||||
limit = 1,
|
limit = 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is BookmarkListEvent) {
|
if (event is BookmarkListEvent) {
|
||||||
bookmarkList = event
|
bookmarkList = event
|
||||||
@@ -249,16 +251,16 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to feed based on mode
|
// Subscribe to feed based on mode
|
||||||
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
DebugConfig.log("feedSub: mode=$feedMode, relays=${connectedRelays.size}, followedUsers=${followedUsers.size}")
|
||||||
if (configuredRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
FeedMode.GLOBAL -> {
|
FeedMode.GLOBAL -> {
|
||||||
createGlobalFeedSubscription(
|
createGlobalFeedSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
// Store metadata events in cache
|
// Store metadata events in cache
|
||||||
if (event is MetadataEvent) {
|
if (event is MetadataEvent) {
|
||||||
@@ -275,7 +277,7 @@ fun FeedScreen(
|
|||||||
FeedMode.FOLLOWING -> {
|
FeedMode.FOLLOWING -> {
|
||||||
if (followedUsers.isNotEmpty()) {
|
if (followedUsers.isNotEmpty()) {
|
||||||
createFollowingFeedSubscription(
|
createFollowingFeedSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
followedUsers = followedUsers.toList(),
|
followedUsers = followedUsers.toList(),
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
// Store metadata events in cache
|
// Store metadata events in cache
|
||||||
@@ -297,14 +299,13 @@ fun FeedScreen(
|
|||||||
|
|
||||||
// Subscribe to zaps for visible events
|
// Subscribe to zaps for visible events
|
||||||
val eventIds = events.map { it.id }
|
val eventIds = events.map { it.id }
|
||||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createZapsSubscription(
|
createZapsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = eventIds,
|
eventIds = eventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is LnZapEvent) {
|
if (event is LnZapEvent) {
|
||||||
@@ -328,9 +329,8 @@ fun FeedScreen(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.map { it.senderPubKey }
|
.map { it.senderPubKey }
|
||||||
.distinct()
|
.distinct()
|
||||||
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, zapSenderPubkeys, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
createBatchMetadataSubscription(
|
createBatchMetadataSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHexList = missingPubkeys,
|
pubKeyHexList = missingPubkeys,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is MetadataEvent) {
|
if (event is MetadataEvent) {
|
||||||
@@ -359,14 +359,13 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to reactions for visible events
|
// Subscribe to reactions for visible events
|
||||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createReactionsSubscription(
|
createReactionsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = eventIds,
|
eventIds = eventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is ReactionEvent) {
|
if (event is ReactionEvent) {
|
||||||
@@ -382,14 +381,13 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to replies for visible events
|
// Subscribe to replies for visible events
|
||||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createRepliesSubscription(
|
createRepliesSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = eventIds,
|
eventIds = eventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
// Find the event this is replying to
|
// Find the event this is replying to
|
||||||
@@ -410,14 +408,13 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to reposts for visible events
|
// Subscribe to reposts for visible events
|
||||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createRepostsSubscription(
|
createRepostsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = eventIds,
|
eventIds = eventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is RepostEvent) {
|
if (event is RepostEvent) {
|
||||||
@@ -443,14 +440,13 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback subscription if coordinator not available
|
// Fallback subscription if coordinator not available
|
||||||
rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||||
// Skip if using coordinator
|
// Skip if using coordinator
|
||||||
if (subscriptionsCoordinator != null) {
|
if (subscriptionsCoordinator != null) {
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || authorPubkeys.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +464,7 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
createBatchMetadataSubscription(
|
createBatchMetadataSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHexList = missingPubkeys,
|
pubKeyHexList = missingPubkeys,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is MetadataEvent) {
|
if (event is MetadataEvent) {
|
||||||
|
|||||||
+113
-25
@@ -20,14 +20,24 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -38,15 +48,16 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
|
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
|
||||||
import com.vitorpamplona.amethyst.commons.resources.login_title
|
import com.vitorpamplona.amethyst.commons.resources.login_title
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
|
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -55,7 +66,6 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
relayClient: INostrClient?,
|
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showNewKeyDialog by remember { mutableStateOf(false) }
|
var showNewKeyDialog by remember { mutableStateOf(false) }
|
||||||
@@ -96,26 +106,16 @@ fun LoginScreen(
|
|||||||
generatedAccount = accountManager.generateNewAccount()
|
generatedAccount = accountManager.generateNewAccount()
|
||||||
showNewKeyDialog = true
|
showNewKeyDialog = true
|
||||||
},
|
},
|
||||||
onLoginBunker =
|
onLoginBunker = { bunkerUri ->
|
||||||
if (relayClient != null) {
|
accountManager.loginWithBunker(bunkerUri).map {
|
||||||
{ bunkerUri ->
|
onLoginSuccess()
|
||||||
accountManager.loginWithBunker(bunkerUri, relayClient).map {
|
}
|
||||||
onLoginSuccess()
|
},
|
||||||
}
|
onLoginNostrConnect = { onUriGenerated ->
|
||||||
}
|
accountManager.loginWithNostrConnect(onUriGenerated).map {
|
||||||
} else {
|
onLoginSuccess()
|
||||||
null
|
}
|
||||||
},
|
},
|
||||||
onLoginNostrConnect =
|
|
||||||
if (relayClient != null) {
|
|
||||||
{ onUriGenerated ->
|
|
||||||
accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map {
|
|
||||||
onLoginSuccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val account = generatedAccount
|
val account = generatedAccount
|
||||||
@@ -137,7 +137,15 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectingRelaysScreen() {
|
fun ConnectingRelaysScreen(
|
||||||
|
subtitle: String = "Restoring session",
|
||||||
|
relayStatuses: Map<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, RelayStatus> = emptyMap(),
|
||||||
|
) {
|
||||||
|
val total = relayStatuses.size
|
||||||
|
val connected = relayStatuses.values.count { it.connected }
|
||||||
|
val failed = relayStatuses.values.count { it.error != null }
|
||||||
|
val progress = if (total > 0) connected.toFloat() / total else 0f
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -156,7 +164,7 @@ fun ConnectingRelaysScreen() {
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Connecting to relays...",
|
if (total > 0) "Connecting to relays ($connected/$total)" else "Connecting to relays...",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -164,9 +172,89 @@ fun ConnectingRelaysScreen() {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Restoring remote signer session",
|
subtitle,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
if (total > 0) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
label = "relay-progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Per-relay status rows
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
relayStatuses.values.forEach { status ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
status.connected -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
status.error != null -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 1.5.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
status.url.url
|
||||||
|
.removePrefix("wss://")
|
||||||
|
.removeSuffix("/"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color =
|
||||||
|
if (status.error != null) {
|
||||||
|
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (status.connected && status.pingMs != null) {
|
||||||
|
Text(
|
||||||
|
"${status.pingMs}ms",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -113,7 +113,6 @@ fun NotificationsScreen(
|
|||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val notificationState =
|
val notificationState =
|
||||||
remember {
|
remember {
|
||||||
@@ -139,11 +138,10 @@ fun NotificationsScreen(
|
|||||||
val initialLoadComplete = eoseReceivedCount > 0
|
val initialLoadComplete = eoseReceivedCount > 0
|
||||||
|
|
||||||
// Subscribe to notifications
|
// Subscribe to notifications
|
||||||
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
createNotificationsSubscription(
|
createNotificationsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = account.pubKeyHex,
|
pubKeyHex = account.pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
// Skip events from the user themselves (except zaps)
|
// Skip events from the user themselves (except zaps)
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ fun ReadsScreen(
|
|||||||
onNavigateToArticle: (String) -> Unit = {},
|
onNavigateToArticle: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val eventState =
|
val eventState =
|
||||||
@@ -195,11 +194,11 @@ fun ReadsScreen(
|
|||||||
val initialLoadComplete = eoseReceivedCount > 0
|
val initialLoadComplete = eoseReceivedCount > 0
|
||||||
|
|
||||||
// Load followed users for Following feed mode
|
// Load followed users for Following feed mode
|
||||||
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val connectedRelays = connectedRelays
|
||||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||||
createContactListSubscription(
|
createContactListSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = account.pubKeyHex,
|
pubKeyHex = account.pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is ContactListEvent) {
|
if (event is ContactListEvent) {
|
||||||
@@ -219,16 +218,16 @@ fun ReadsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to long-form content feed
|
// Subscribe to long-form content feed
|
||||||
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val connectedRelays = connectedRelays
|
||||||
if (configuredRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
FeedMode.GLOBAL -> {
|
FeedMode.GLOBAL -> {
|
||||||
createLongFormFeedSubscription(
|
createLongFormFeedSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is LongTextNoteEvent) {
|
if (event is LongTextNoteEvent) {
|
||||||
eventState.addItem(event)
|
eventState.addItem(event)
|
||||||
@@ -243,7 +242,7 @@ fun ReadsScreen(
|
|||||||
FeedMode.FOLLOWING -> {
|
FeedMode.FOLLOWING -> {
|
||||||
if (followedUsers.isNotEmpty()) {
|
if (followedUsers.isNotEmpty()) {
|
||||||
createFollowingLongFormFeedSubscription(
|
createFollowingLongFormFeedSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
followedUsers = followedUsers.toList(),
|
followedUsers = followedUsers.toList(),
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is LongTextNoteEvent) {
|
if (event is LongTextNoteEvent) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ fun SearchScreen(
|
|||||||
searchState.updateSearchText(initialQuery)
|
searchState.updateSearchText(initialQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
|
|
||||||
// Collect state from SearchBarState
|
// Collect state from SearchBarState
|
||||||
val searchText by searchState.searchText.collectAsState()
|
val searchText by searchState.searchText.collectAsState()
|
||||||
@@ -104,15 +104,14 @@ fun SearchScreen(
|
|||||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||||
|
|
||||||
// NIP-50 relay search when local cache has few/no results
|
// NIP-50 relay search when local cache has few/no results
|
||||||
rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||||
if (configuredRelays.isEmpty()) return@rememberSubscription null
|
|
||||||
|
|
||||||
// Only search relays if we have a real query and limited local results
|
// Only search relays if we have a real query and limited local results
|
||||||
if (searchState.shouldSearchRelays) {
|
if (searchState.shouldSearchRelays) {
|
||||||
searchState.startRelaySearch()
|
searchState.startRelaySearch()
|
||||||
createSearchPeopleSubscription(
|
createSearchPeopleSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
searchQuery = searchText,
|
searchQuery = searchText,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
@@ -134,9 +133,8 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to metadata for searched users (to populate cache)
|
// Subscribe to metadata for searched users (to populate cache)
|
||||||
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
||||||
if (configuredRelays.isEmpty() || searchText.length < 2) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +142,7 @@ fun SearchScreen(
|
|||||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
||||||
if (pubkeyHex != null) {
|
if (pubkeyHex != null) {
|
||||||
createMetadataSubscription(
|
createMetadataSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = pubkeyHex,
|
pubKeyHex = pubkeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is MetadataEvent) {
|
if (event is MetadataEvent) {
|
||||||
|
|||||||
+21
-29
@@ -96,7 +96,6 @@ fun ThreadScreen(
|
|||||||
onReply: (Event) -> Unit = {},
|
onReply: (Event) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// State for the root note
|
// State for the root note
|
||||||
@@ -149,9 +148,8 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to user's bookmark list
|
// Subscribe to user's bookmark list
|
||||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty() && account != null) {
|
||||||
if (configuredRelays.isNotEmpty() && account != null) {
|
|
||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
|
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
|
||||||
filters =
|
filters =
|
||||||
@@ -162,7 +160,7 @@ fun ThreadScreen(
|
|||||||
limit = 1,
|
limit = 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is BookmarkListEvent) {
|
if (event is BookmarkListEvent) {
|
||||||
bookmarkList = event
|
bookmarkList = event
|
||||||
@@ -183,11 +181,10 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to the root note
|
// Subscribe to the root note
|
||||||
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
createNoteSubscription(
|
createNoteSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
noteId = noteId,
|
noteId = noteId,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event.id == noteId) {
|
if (event.id == noteId) {
|
||||||
@@ -205,11 +202,10 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to replies
|
// Subscribe to replies
|
||||||
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
createThreadRepliesSubscription(
|
createThreadRepliesSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
noteId = noteId,
|
noteId = noteId,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
replyEventState.addItem(event)
|
replyEventState.addItem(event)
|
||||||
@@ -225,14 +221,13 @@ fun ThreadScreen(
|
|||||||
|
|
||||||
// Subscribe to zaps for thread events
|
// Subscribe to zaps for thread events
|
||||||
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
|
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
|
||||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createZapsSubscription(
|
createZapsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = allEventIds,
|
eventIds = allEventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is LnZapEvent) {
|
if (event is LnZapEvent) {
|
||||||
@@ -251,14 +246,13 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to reactions for thread events
|
// Subscribe to reactions for thread events
|
||||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createReactionsSubscription(
|
createReactionsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = allEventIds,
|
eventIds = allEventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is ReactionEvent) {
|
if (event is ReactionEvent) {
|
||||||
@@ -274,14 +268,13 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to replies for thread events (for counts)
|
// Subscribe to replies for thread events (for counts)
|
||||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createRepliesSubscription(
|
createRepliesSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = allEventIds,
|
eventIds = allEventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
val replyToId =
|
val replyToId =
|
||||||
@@ -301,14 +294,13 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to reposts for thread events
|
// Subscribe to reposts for thread events
|
||||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
createRepostsSubscription(
|
createRepostsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
eventIds = allEventIds,
|
eventIds = allEventIds,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is RepostEvent) {
|
if (event is RepostEvent) {
|
||||||
|
|||||||
+16
-22
@@ -109,7 +109,6 @@ fun UserProfileScreen(
|
|||||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
|
||||||
|
|
||||||
// User metadata
|
// User metadata
|
||||||
var displayName by remember { mutableStateOf<String?>(null) }
|
var displayName by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -154,11 +153,10 @@ fun UserProfileScreen(
|
|||||||
var eoseReceivedCount by remember(account) { mutableStateOf(0) }
|
var eoseReceivedCount by remember(account) { mutableStateOf(0) }
|
||||||
|
|
||||||
// Load current user's contact list (for follow state)
|
// Load current user's contact list (for follow state)
|
||||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty() && account != null) {
|
||||||
if (configuredRelays.isNotEmpty() && account != null) {
|
|
||||||
createContactListSubscription(
|
createContactListSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = account.pubKeyHex,
|
pubKeyHex = account.pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is ContactListEvent) {
|
if (event is ContactListEvent) {
|
||||||
@@ -175,7 +173,7 @@ fun UserProfileScreen(
|
|||||||
eoseReceivedCount++
|
eoseReceivedCount++
|
||||||
|
|
||||||
// Wait for EOSE from at least 2 relays or all relays before enabling button
|
// Wait for EOSE from at least 2 relays or all relays before enabling button
|
||||||
val minEoseCount = minOf(2, configuredRelays.size)
|
val minEoseCount = minOf(2, connectedRelays.size)
|
||||||
if (eoseReceivedCount >= minEoseCount && !contactListLoaded) {
|
if (eoseReceivedCount >= minEoseCount && !contactListLoaded) {
|
||||||
contactListLoaded = true
|
contactListLoaded = true
|
||||||
}
|
}
|
||||||
@@ -194,11 +192,10 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to user metadata
|
// Subscribe to user metadata
|
||||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
createMetadataSubscription(
|
createMetadataSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = pubKeyHex,
|
pubKeyHex = pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is MetadataEvent) {
|
if (event is MetadataEvent) {
|
||||||
@@ -229,11 +226,10 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to profile user's contact list (for following count)
|
// Subscribe to profile user's contact list (for following count)
|
||||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
createContactListSubscription(
|
createContactListSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = pubKeyHex,
|
pubKeyHex = pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is ContactListEvent) {
|
if (event is ContactListEvent) {
|
||||||
@@ -252,9 +248,8 @@ fun UserProfileScreen(
|
|||||||
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
|
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
|
||||||
|
|
||||||
// Subscribe to followers (contact lists that tag this user)
|
// Subscribe to followers (contact lists that tag this user)
|
||||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
// Clear previous followers when subscription restarts
|
// Clear previous followers when subscription restarts
|
||||||
followerAuthors.clear()
|
followerAuthors.clear()
|
||||||
followersCount = 0
|
followersCount = 0
|
||||||
@@ -269,7 +264,7 @@ fun UserProfileScreen(
|
|||||||
limit = 500,
|
limit = 500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
// Count unique authors who follow this user
|
// Count unique authors who follow this user
|
||||||
if (followerAuthors.add(event.pubKey)) {
|
if (followerAuthors.add(event.pubKey)) {
|
||||||
@@ -284,13 +279,12 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to user posts
|
// Subscribe to user posts
|
||||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isNotEmpty()) {
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
postsLoading = true
|
postsLoading = true
|
||||||
postsError = null
|
postsError = null
|
||||||
createUserPostsSubscription(
|
createUserPostsSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = pubKeyHex,
|
pubKeyHex = pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
eventState.addItem(event)
|
eventState.addItem(event)
|
||||||
|
|||||||
+2
-22
@@ -61,32 +61,12 @@ import com.vitorpamplona.amethyst.commons.resources.login_button
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle
|
import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle
|
||||||
import com.vitorpamplona.amethyst.commons.resources.login_card_title
|
import com.vitorpamplona.amethyst.commons.resources.login_card_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.login_generate_button
|
import com.vitorpamplona.amethyst.commons.resources.login_generate_button
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
|
|
||||||
|
|
||||||
fun validateBunkerUri(input: String): String? {
|
|
||||||
val trimmed = input.trim()
|
|
||||||
if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
|
|
||||||
|
|
||||||
val afterScheme = trimmed.substring("bunker://".length)
|
|
||||||
val parts = afterScheme.split("?", limit = 2)
|
|
||||||
val pubkeyPart = parts[0]
|
|
||||||
|
|
||||||
if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) {
|
|
||||||
return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) {
|
|
||||||
return "Bunker URI must include at least one relay parameter (relay=wss://...)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return null // valid
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginCard(
|
fun LoginCard(
|
||||||
onLogin: (String) -> Result<Unit>,
|
onLogin: (String) -> Result<Unit>,
|
||||||
@@ -291,7 +271,7 @@ private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (S
|
|||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val result =
|
val result =
|
||||||
onLoginNostrConnect { uri ->
|
onLoginNostrConnect { uri ->
|
||||||
nostrConnectUri = uri
|
scope.launch(Dispatchers.Main) { nostrConnectUri = uri }
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
result.onFailure {
|
result.onFailure {
|
||||||
|
|||||||
+41
-30
@@ -22,54 +22,65 @@ package com.vitorpamplona.amethyst.desktop.ui.auth
|
|||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
import com.google.zxing.EncodeHintType
|
import com.google.zxing.EncodeHintType
|
||||||
import com.google.zxing.qrcode.QRCodeWriter
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QrCodeCanvas(
|
fun QrCodeCanvas(
|
||||||
data: String,
|
data: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
size: Dp = 200.dp,
|
size: Dp = 200.dp,
|
||||||
foreground: Color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
background: Color = Color.White,
|
|
||||||
) {
|
) {
|
||||||
val matrix =
|
val bitmap =
|
||||||
remember(data) {
|
remember(data) {
|
||||||
QRCodeWriter().encode(
|
createQrBitmap(data)
|
||||||
data,
|
|
||||||
BarcodeFormat.QR_CODE,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
mapOf(EncodeHintType.MARGIN to 1),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Canvas(modifier = modifier.size(size)) {
|
Canvas(modifier = modifier.size(size)) {
|
||||||
val cellWidth = this.size.width / matrix.width
|
drawImage(
|
||||||
val cellHeight = this.size.height / matrix.height
|
image = bitmap,
|
||||||
|
srcOffset = IntOffset.Zero,
|
||||||
drawRect(background, Offset.Zero, this.size)
|
srcSize = IntSize(bitmap.width, bitmap.height),
|
||||||
|
dstOffset = IntOffset.Zero,
|
||||||
for (x in 0 until matrix.width) {
|
dstSize = IntSize(this.size.width.toInt(), this.size.height.toInt()),
|
||||||
for (y in 0 until matrix.height) {
|
filterQuality = FilterQuality.None,
|
||||||
if (matrix.get(x, y)) {
|
)
|
||||||
drawRect(
|
|
||||||
color = foreground,
|
|
||||||
topLeft = Offset(x * cellWidth, y * cellHeight),
|
|
||||||
size = Size(cellWidth, cellHeight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createQrBitmap(data: String): ImageBitmap {
|
||||||
|
val matrix =
|
||||||
|
QRCodeWriter().encode(
|
||||||
|
data,
|
||||||
|
BarcodeFormat.QR_CODE,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
mapOf(EncodeHintType.MARGIN to 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
val w = matrix.width
|
||||||
|
val h = matrix.height
|
||||||
|
val black = 0xFF000000.toInt()
|
||||||
|
val white = 0xFFFFFFFF.toInt()
|
||||||
|
|
||||||
|
val image = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
for (x in 0 until w) {
|
||||||
|
for (y in 0 until h) {
|
||||||
|
image.setRGB(x, y, if (matrix.get(x, y)) black else white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return image.toComposeImageBitmap()
|
||||||
|
}
|
||||||
|
|||||||
+7
-9
@@ -80,18 +80,17 @@ fun NewDmDialog(
|
|||||||
val cachedUsers by searchState.cachedUserResults.collectAsState()
|
val cachedUsers by searchState.cachedUserResults.collectAsState()
|
||||||
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
||||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
// NIP-50 relay search when local cache has few/no results
|
// NIP-50 relay search when local cache has few/no results
|
||||||
rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, searchText, cachedUsers.size, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||||
if (configuredRelays.isEmpty()) return@rememberSubscription null
|
|
||||||
|
|
||||||
if (searchState.shouldSearchRelays) {
|
if (searchState.shouldSearchRelays) {
|
||||||
searchState.startRelaySearch()
|
searchState.startRelaySearch()
|
||||||
createSearchPeopleSubscription(
|
createSearchPeopleSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
searchQuery = searchText,
|
searchQuery = searchText,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
@@ -113,16 +112,15 @@ fun NewDmDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bech32 npub metadata loading
|
// Bech32 npub metadata loading
|
||||||
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
||||||
val configuredRelays = relayStatuses.keys
|
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
||||||
if (configuredRelays.isEmpty() || searchText.length < 2) {
|
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
||||||
if (pubkeyHex != null) {
|
if (pubkeyHex != null) {
|
||||||
createMetadataSubscription(
|
createMetadataSubscription(
|
||||||
relays = configuredRelays,
|
relays = connectedRelays,
|
||||||
pubKeyHex = pubkeyHex,
|
pubKeyHex = pubkeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is MetadataEvent) {
|
if (event is MetadataEvent) {
|
||||||
|
|||||||
+2
-25
@@ -111,30 +111,10 @@ class AccountManagerLoadAccountTest {
|
|||||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||||
} returns null
|
} returns null
|
||||||
|
|
||||||
val result = manager.loadSavedAccount(client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient)
|
val result = manager.loadSavedAccount()
|
||||||
assertTrue(result.isFailure)
|
assertTrue(result.isFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loadSavedAccountBunkerNoClientFallsBackToInternal() =
|
|
||||||
runTest {
|
|
||||||
val validHex = "a".repeat(64)
|
|
||||||
val keyPair = KeyPair()
|
|
||||||
val npub = keyPair.pubKey.toNpub()
|
|
||||||
val privKeyHex = keyPair.privKey!!.toHexKey()
|
|
||||||
|
|
||||||
File(amethystDir, "last_account.txt").writeText(npub)
|
|
||||||
File(amethystDir, "bunker_uri.txt").writeText(
|
|
||||||
"bunker://$validHex?relay=wss://r.com",
|
|
||||||
)
|
|
||||||
coEvery { storage.getPrivateKey(npub) } returns privKeyHex
|
|
||||||
|
|
||||||
// client=null → bunkerUri is found but ignored, falls back to internal
|
|
||||||
val result = manager.loadSavedAccount(client = null)
|
|
||||||
assertTrue(result.isSuccess)
|
|
||||||
assertIs<SignerType.Internal>(result.getOrThrow().signerType)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun loadSavedAccountBunkerSuccess() =
|
fun loadSavedAccountBunkerSuccess() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -152,10 +132,7 @@ class AccountManagerLoadAccountTest {
|
|||||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||||
} returns ephemeralPrivKeyHex
|
} returns ephemeralPrivKeyHex
|
||||||
|
|
||||||
val result =
|
val result = manager.loadSavedAccount()
|
||||||
manager.loadSavedAccount(
|
|
||||||
client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient,
|
|
||||||
)
|
|
||||||
assertTrue(result.isSuccess)
|
assertTrue(result.isSuccess)
|
||||||
val state = result.getOrThrow()
|
val state = result.getOrThrow()
|
||||||
assertIs<SignerType.Remote>(state.signerType)
|
assertIs<SignerType.Remote>(state.signerType)
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests verifying NIP-46 relay isolation — the dedicated NIP-46 client
|
||||||
|
* is created internally by AccountManager, not passed from callers.
|
||||||
|
*
|
||||||
|
* These tests reproduce the root cause of Bug 2 (shared client relay
|
||||||
|
* contamination) by verifying the API no longer accepts an external client.
|
||||||
|
*/
|
||||||
|
class AccountManagerNip46IsolationTest {
|
||||||
|
private lateinit var storage: SecureKeyStorage
|
||||||
|
private lateinit var tempDir: File
|
||||||
|
private lateinit var amethystDir: File
|
||||||
|
private lateinit var manager: AccountManager
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
storage = mockk(relaxed = true)
|
||||||
|
tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile()
|
||||||
|
amethystDir = File(tempDir, ".amethyst")
|
||||||
|
amethystDir.mkdirs()
|
||||||
|
manager = AccountManager(storage, tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bunkerLoadCreatesRemoteSignerInternally() =
|
||||||
|
runTest {
|
||||||
|
val validHex = "a".repeat(64)
|
||||||
|
val ephemeralKeyPair = KeyPair()
|
||||||
|
File(amethystDir, "bunker_uri.txt").writeText(
|
||||||
|
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||||
|
)
|
||||||
|
File(amethystDir, "last_account.txt").writeText(
|
||||||
|
ephemeralKeyPair.pubKey.toNpub(),
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||||
|
} returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||||
|
|
||||||
|
val result = manager.loadSavedAccount()
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
|
||||||
|
val state = manager.currentAccount()
|
||||||
|
assertNotNull(state)
|
||||||
|
assertIs<SignerType.Remote>(state.signerType)
|
||||||
|
assertIs<NostrSignerRemote>(state.signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginWithBunkerDoesNotRequireExternalClient() =
|
||||||
|
runTest {
|
||||||
|
// loginWithBunker() compiles with just bunkerUri — no client param
|
||||||
|
try {
|
||||||
|
manager.loginWithBunker(
|
||||||
|
"bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app",
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Expected — no real relay. Verify it's a connection error, not API error.
|
||||||
|
assertTrue(
|
||||||
|
e.message?.contains("Connection") == true ||
|
||||||
|
e.message?.contains("timed out") == true ||
|
||||||
|
e.message?.contains("failed") == true,
|
||||||
|
"Unexpected error type: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginWithNostrConnectDoesNotRequireExternalClient() =
|
||||||
|
runTest {
|
||||||
|
var generatedUri: String? = null
|
||||||
|
try {
|
||||||
|
manager.loginWithNostrConnect { uri -> generatedUri = uri }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Expected — no signer scanning the QR
|
||||||
|
assertNotNull(e.message)
|
||||||
|
}
|
||||||
|
// URI should have been generated even if connection timed out
|
||||||
|
assertNotNull(generatedUri, "nostrconnect URI was never generated")
|
||||||
|
assertTrue(
|
||||||
|
generatedUri!!.startsWith("nostrconnect://"),
|
||||||
|
"URI format wrong: $generatedUri",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logoutResetsStateAfterBunkerLoad() =
|
||||||
|
runTest {
|
||||||
|
val validHex = "a".repeat(64)
|
||||||
|
val ephemeralKeyPair = KeyPair()
|
||||||
|
File(amethystDir, "bunker_uri.txt").writeText(
|
||||||
|
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||||
|
)
|
||||||
|
File(amethystDir, "last_account.txt").writeText(
|
||||||
|
ephemeralKeyPair.pubKey.toNpub(),
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||||
|
} returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||||
|
|
||||||
|
manager.loadSavedAccount()
|
||||||
|
assertIs<AccountState.LoggedIn>(manager.accountState.value)
|
||||||
|
|
||||||
|
manager.logout()
|
||||||
|
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||||
|
assertIs<SignerConnectionState.NotRemote>(
|
||||||
|
manager.signerConnectionState.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logoutThenReloadCreatesFreshNip46Client() =
|
||||||
|
runTest {
|
||||||
|
val validHex = "a".repeat(64)
|
||||||
|
val ephemeralKeyPair = KeyPair()
|
||||||
|
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
|
||||||
|
|
||||||
|
fun writeBunkerFiles() {
|
||||||
|
File(amethystDir, "bunker_uri.txt").writeText(bunkerUri)
|
||||||
|
File(amethystDir, "last_account.txt").writeText(
|
||||||
|
ephemeralKeyPair.pubKey.toNpub(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||||
|
} returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||||
|
|
||||||
|
// First load
|
||||||
|
writeBunkerFiles()
|
||||||
|
manager.loadSavedAccount()
|
||||||
|
val firstSigner = manager.currentAccount()?.signer
|
||||||
|
assertNotNull(firstSigner)
|
||||||
|
|
||||||
|
// Logout — disconnects NIP-46 client
|
||||||
|
manager.logout()
|
||||||
|
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||||
|
|
||||||
|
// Second load — should create a fresh NIP-46 client
|
||||||
|
writeBunkerFiles()
|
||||||
|
manager.loadSavedAccount()
|
||||||
|
val secondSigner = manager.currentAccount()?.signer
|
||||||
|
assertNotNull(secondSigner)
|
||||||
|
|
||||||
|
// Both should be remote signers — different instances
|
||||||
|
assertIs<NostrSignerRemote>(firstSigner)
|
||||||
|
assertIs<NostrSignerRemote>(secondSigner)
|
||||||
|
assertTrue(
|
||||||
|
firstSigner !== secondSigner,
|
||||||
|
"Second load should create a new signer instance",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests verifying AccountManager state transitions during NIP-46 flows.
|
||||||
|
* Reproduces Bug 1 UX issue — user should see ConnectingRelays while
|
||||||
|
* the dedicated NIP-46 client is being set up.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AccountManagerStateTransitionTest {
|
||||||
|
private lateinit var storage: SecureKeyStorage
|
||||||
|
private lateinit var tempDir: File
|
||||||
|
private lateinit var amethystDir: File
|
||||||
|
private lateinit var manager: AccountManager
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
storage = mockk(relaxed = true)
|
||||||
|
tempDir = createTempDirectory("acctmgr-state-test").toFile()
|
||||||
|
amethystDir = File(tempDir, ".amethyst")
|
||||||
|
amethystDir.mkdirs()
|
||||||
|
manager = AccountManager(storage, tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connectingRelaysThenLoggedInTransition() =
|
||||||
|
runTest {
|
||||||
|
val states = mutableListOf<AccountState>()
|
||||||
|
val collector =
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
manager.accountState.toList(states)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate Main.kt bunker restore flow
|
||||||
|
manager.setConnectingRelays()
|
||||||
|
|
||||||
|
// Then load an internal account (simulating successful load)
|
||||||
|
val keyPair = KeyPair()
|
||||||
|
val npub = keyPair.pubKey.toNpub()
|
||||||
|
File(amethystDir, "last_account.txt").writeText(npub)
|
||||||
|
coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey()
|
||||||
|
manager.loadSavedAccount()
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Should see: LoggedOut → ConnectingRelays → LoggedIn
|
||||||
|
assertTrue(
|
||||||
|
states.size >= 3,
|
||||||
|
"Expected at least 3 state transitions, got ${states.size}: $states",
|
||||||
|
)
|
||||||
|
assertIs<AccountState.LoggedOut>(states[0])
|
||||||
|
assertIs<AccountState.ConnectingRelays>(states[1])
|
||||||
|
assertIs<AccountState.LoggedIn>(states[2])
|
||||||
|
|
||||||
|
collector.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connectingRelaysThenFailureFallsBackToLoggedOut() =
|
||||||
|
runTest {
|
||||||
|
val states = mutableListOf<AccountState>()
|
||||||
|
val collector =
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
manager.accountState.toList(states)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate bunker restore that fails
|
||||||
|
manager.setConnectingRelays()
|
||||||
|
|
||||||
|
// loadSavedAccount fails → caller does logout
|
||||||
|
val result = manager.loadSavedAccount() // no saved account
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
manager.logout()
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Should see: LoggedOut → ConnectingRelays → LoggedOut
|
||||||
|
assertTrue(
|
||||||
|
states.size >= 3,
|
||||||
|
"Expected at least 3 state transitions, got ${states.size}: $states",
|
||||||
|
)
|
||||||
|
assertIs<AccountState.LoggedOut>(states[0])
|
||||||
|
assertIs<AccountState.ConnectingRelays>(states[1])
|
||||||
|
assertIs<AccountState.LoggedOut>(states[2])
|
||||||
|
|
||||||
|
collector.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
-1
@@ -20,7 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.account
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.validateBunkerUri
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
|
|||||||
+4
-1
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
|||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -73,6 +74,7 @@ class NostrSignerRemote(
|
|||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(NostrConnectEvent.KIND),
|
kinds = listOf(NostrConnectEvent.KIND),
|
||||||
tags = mapOf("p" to listOf(signer.pubKey)),
|
tags = mapOf("p" to listOf(signer.pubKey)),
|
||||||
|
since = TimeUtils.now() - 60,
|
||||||
),
|
),
|
||||||
) { event ->
|
) { event ->
|
||||||
if (event is NostrConnectEvent) {
|
if (event is NostrConnectEvent) {
|
||||||
@@ -301,9 +303,10 @@ class NostrSignerRemote(
|
|||||||
permissions: String? = null,
|
permissions: String? = null,
|
||||||
): NostrSignerRemote {
|
): NostrSignerRemote {
|
||||||
if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri")
|
if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri")
|
||||||
val splitData = bunkerUri.split("?")
|
val splitData = bunkerUri.split("?", limit = 2)
|
||||||
val remotePubkey = splitData[0].removePrefix("bunker://")
|
val remotePubkey = splitData[0].removePrefix("bunker://")
|
||||||
if (!Hex.isHex(remotePubkey)) throw Exception("Invalid pubkey in bunker uri")
|
if (!Hex.isHex(remotePubkey)) throw Exception("Invalid pubkey in bunker uri")
|
||||||
|
if (splitData.size < 2) throw Exception("Missing query parameters in bunker uri")
|
||||||
val params = splitData[1].split("&")
|
val params = splitData[1].split("&")
|
||||||
val relays = mutableSetOf<NormalizedRelayUrl>()
|
val relays = mutableSetOf<NormalizedRelayUrl>()
|
||||||
var secret: String? = null
|
var secret: String? = null
|
||||||
|
|||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INostrClient that records openReqSubscription calls for verification.
|
||||||
|
* Used instead of mockk since commonTest doesn't have mockk.
|
||||||
|
*/
|
||||||
|
private class TrackingNostrClient : INostrClient {
|
||||||
|
data class SubscriptionRecord(
|
||||||
|
val subId: String,
|
||||||
|
val filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
val subscriptions = mutableListOf<SubscriptionRecord>()
|
||||||
|
val sentEvents = mutableListOf<Pair<Event, Set<NormalizedRelayUrl>>>()
|
||||||
|
|
||||||
|
override fun connectedRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
|
||||||
|
|
||||||
|
override fun availableRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
|
||||||
|
|
||||||
|
override fun connect() {}
|
||||||
|
|
||||||
|
override fun disconnect() {}
|
||||||
|
|
||||||
|
override fun reconnect(
|
||||||
|
onlyIfChanged: Boolean,
|
||||||
|
ignoreRetryDelays: Boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
override fun isActive() = false
|
||||||
|
|
||||||
|
override fun renewFilters(relay: IRelayClient) {}
|
||||||
|
|
||||||
|
override fun openReqSubscription(
|
||||||
|
subId: String,
|
||||||
|
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||||
|
listener: IRequestListener?,
|
||||||
|
) {
|
||||||
|
subscriptions.add(SubscriptionRecord(subId, filters))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryCount(
|
||||||
|
subId: String,
|
||||||
|
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
override fun close(subId: String) {}
|
||||||
|
|
||||||
|
override fun send(
|
||||||
|
event: Event,
|
||||||
|
relayList: Set<NormalizedRelayUrl>,
|
||||||
|
) {
|
||||||
|
sentEvents.add(event to relayList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subscribe(listener: IRelayClientListener) {}
|
||||||
|
|
||||||
|
override fun unsubscribe(listener: IRelayClientListener) {}
|
||||||
|
|
||||||
|
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||||
|
|
||||||
|
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests verifying NIP-46 relay isolation at the NostrSignerRemote level.
|
||||||
|
* Ensures subscription filters only target bunker relays and contain
|
||||||
|
* the correct kind + p-tag.
|
||||||
|
*/
|
||||||
|
class NostrSignerRemoteIsolationTest {
|
||||||
|
private val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
|
||||||
|
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||||
|
private val validHex = "a".repeat(64)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscriptionFilterTargetsOnlyBunkerRelays() {
|
||||||
|
val trackingClient = TrackingNostrClient()
|
||||||
|
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||||
|
|
||||||
|
// Construction triggers client.req() which calls openReqSubscription
|
||||||
|
NostrSignerRemote(
|
||||||
|
signer = ephemeralSigner,
|
||||||
|
remotePubkey = validHex,
|
||||||
|
relays = setOf(bunkerRelay),
|
||||||
|
client = trackingClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify subscription was opened
|
||||||
|
assertTrue(
|
||||||
|
trackingClient.subscriptions.isNotEmpty(),
|
||||||
|
"No subscription opened on construction",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify ALL filters only target the bunker relay
|
||||||
|
trackingClient.subscriptions.forEach { record ->
|
||||||
|
assertTrue(
|
||||||
|
record.filters.keys.all { it == bunkerRelay },
|
||||||
|
"Filter contains non-bunker relay: ${record.filters.keys}",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
generalRelay !in record.filters.keys,
|
||||||
|
"General relay leaked into NIP-46 subscription",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscriptionFilterContainsCorrectKindAndPTag() {
|
||||||
|
val trackingClient = TrackingNostrClient()
|
||||||
|
val ephemeralKeyPair = KeyPair()
|
||||||
|
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||||
|
|
||||||
|
NostrSignerRemote(
|
||||||
|
signer = ephemeralSigner,
|
||||||
|
remotePubkey = validHex,
|
||||||
|
relays = setOf(bunkerRelay),
|
||||||
|
client = trackingClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
val allFilters =
|
||||||
|
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
|
||||||
|
assertTrue(allFilters.isNotEmpty(), "No filters captured")
|
||||||
|
|
||||||
|
allFilters.forEach { filter ->
|
||||||
|
// Must filter for NIP-46 event kind (24133)
|
||||||
|
assertTrue(
|
||||||
|
filter.kinds?.contains(NostrConnectEvent.KIND) == true,
|
||||||
|
"Filter missing kind ${NostrConnectEvent.KIND}, got: ${filter.kinds}",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Must filter for our ephemeral pubkey in p-tag
|
||||||
|
val pTags = filter.tags?.get("p")
|
||||||
|
assertNotNull(pTags, "Filter missing p-tag")
|
||||||
|
assertTrue(
|
||||||
|
pTags.contains(ephemeralSigner.pubKey),
|
||||||
|
"Filter p-tag doesn't contain ephemeral pubkey",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleRelaysAllIncludedInFilter() {
|
||||||
|
val trackingClient = TrackingNostrClient()
|
||||||
|
val relay1 = NormalizedRelayUrl("wss://relay1.nsec.app/")
|
||||||
|
val relay2 = NormalizedRelayUrl("wss://relay2.nsec.app/")
|
||||||
|
|
||||||
|
NostrSignerRemote(
|
||||||
|
signer = NostrSignerInternal(KeyPair()),
|
||||||
|
remotePubkey = validHex,
|
||||||
|
relays = setOf(relay1, relay2),
|
||||||
|
client = trackingClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(trackingClient.subscriptions.isNotEmpty())
|
||||||
|
val allRelayKeys =
|
||||||
|
trackingClient.subscriptions.flatMap { it.filters.keys }.toSet()
|
||||||
|
|
||||||
|
assertTrue(relay1 in allRelayKeys, "Missing relay1 in subscription")
|
||||||
|
assertTrue(relay2 in allRelayKeys, "Missing relay2 in subscription")
|
||||||
|
// General relay should NOT be present
|
||||||
|
assertTrue(
|
||||||
|
generalRelay !in allRelayKeys,
|
||||||
|
"General relay leaked into multi-relay subscription",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TDD RED TEST — will fail until Step 8 adds `since` filter.
|
||||||
|
*
|
||||||
|
* Reproduces Bug 3 mitigation: stale NIP-46 responses from previous
|
||||||
|
* sessions should be filtered out via `since` timestamp.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun subscriptionFilterHasSinceTimestamp() {
|
||||||
|
val trackingClient = TrackingNostrClient()
|
||||||
|
val beforeTime = TimeUtils.now()
|
||||||
|
|
||||||
|
NostrSignerRemote(
|
||||||
|
signer = NostrSignerInternal(KeyPair()),
|
||||||
|
remotePubkey = validHex,
|
||||||
|
relays = setOf(bunkerRelay),
|
||||||
|
client = trackingClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
val allFilters =
|
||||||
|
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
|
||||||
|
assertTrue(allFilters.isNotEmpty())
|
||||||
|
|
||||||
|
allFilters.forEach { filter ->
|
||||||
|
val since =
|
||||||
|
assertNotNull(
|
||||||
|
filter.since,
|
||||||
|
"Filter missing 'since' timestamp — stale responses won't be filtered",
|
||||||
|
)
|
||||||
|
// since should be roughly now - 60s (with tolerance)
|
||||||
|
val expectedMin = beforeTime - 120 // extra tolerance for test execution time
|
||||||
|
assertTrue(
|
||||||
|
since >= expectedMin,
|
||||||
|
"since too old: $since (expected >= $expectedMin)",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
since <= beforeTime,
|
||||||
|
"since in the future: $since",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromBunkerUriPreservesRelayIsolation() {
|
||||||
|
val trackingClient = TrackingNostrClient()
|
||||||
|
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||||
|
|
||||||
|
val remote =
|
||||||
|
NostrSignerRemote.fromBunkerUri(
|
||||||
|
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||||
|
ephemeralSigner,
|
||||||
|
trackingClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify relay set matches URI
|
||||||
|
assertEquals(1, remote.relays.size)
|
||||||
|
val parsedRelay = remote.relays.first()
|
||||||
|
assertTrue(
|
||||||
|
parsedRelay.url.contains("relay.nsec.app"),
|
||||||
|
"Parsed relay doesn't match URI: ${parsedRelay.url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify subscription was opened targeting ONLY the bunker relay
|
||||||
|
assertTrue(trackingClient.subscriptions.isNotEmpty())
|
||||||
|
trackingClient.subscriptions.forEach { record ->
|
||||||
|
assertTrue(
|
||||||
|
record.filters.keys.all { it in remote.relays },
|
||||||
|
"fromBunkerUri subscription targets wrong relays: ${record.filters.keys}",
|
||||||
|
)
|
||||||
|
// General relay must NOT appear
|
||||||
|
assertTrue(
|
||||||
|
generalRelay !in record.filters.keys,
|
||||||
|
"General relay leaked into fromBunkerUri subscription",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user