From 9c6502b0895b4f76c8fa8557128ed1c944540b2f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Apr 2026 10:15:50 +0300 Subject: [PATCH] feat(multi-account): add account switching, storage, and migration to AccountManager Phase 3: wire multi-account lifecycle into desktop AccountManager. - Add DesktopAccountStorage field and allAccounts StateFlow - switchAccount(): loads new account BEFORE cancelling old state (prevents unrecoverable partial failure per coroutines review) - addAccountToStorage / removeAccountFromStorage for CRUD - loadViewOnlyAccount() for npub-only accounts - migrateAndLoadAccounts() for legacy file migration on startup - saveCurrentAccount / saveBunkerAccount now save to encrypted storage - Updated test mock setup to support SecureKeyStorage key store Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/account/AccountManager.kt | 118 ++++++++++++++++++ .../account/AccountManagerKeyLoginTest.kt | 15 +++ 2 files changed, 133 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 535be3a80..92dc15fdb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.SignerType import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -50,6 +51,9 @@ import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -92,6 +96,11 @@ class AccountManager internal constructor( return AccountManager(storage) } + fun createWithStorage( + secureStorage: SecureKeyStorage, + homeDir: File = File(System.getProperty("user.home")), + ): AccountManager = AccountManager(secureStorage, homeDir) + internal const val HEARTBEAT_INTERVAL_MS = 60_000L internal const val MAX_CONSECUTIVE_FAILURES = 3 internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" @@ -103,6 +112,13 @@ class AccountManager internal constructor( File(homeDir, ".amethyst") } + val accountStorage: DesktopAccountStorage by lazy { + DesktopAccountStorage(secureStorage, homeDir) + } + + private val _allAccounts = MutableStateFlow>(persistentListOf()) + val allAccounts: StateFlow> = _allAccounts.asStateFlow() + private val _accountState = MutableStateFlow(AccountState.LoggedOut) val accountState: StateFlow = _accountState.asStateFlow() @@ -421,6 +437,11 @@ class AccountManager internal constructor( saveLastNpub(npub) secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex) saveBunkerUri(bunkerUri) + + // Also save to multi-account storage + val info = AccountInfo(npub = npub, signerType = SignerType.Remote(bunkerUri)) + addAccountToStorage(info) + accountStorage.setCurrentAccount(npub) } fun hasBunkerAccount(): Boolean = getBunkerFile().exists() @@ -448,6 +469,12 @@ class AccountManager internal constructor( secureStorage.savePrivateKey(current.npub, privKeyHex) saveLastNpub(current.npub) + + // Also save to multi-account storage + val info = AccountInfo(npub = current.npub, signerType = current.signerType) + addAccountToStorage(info) + accountStorage.setCurrentAccount(current.npub) + Result.success(Unit) } catch (e: SecureStorageException) { Result.failure(e) @@ -597,6 +624,97 @@ class AccountManager internal constructor( heartbeatJob = null } + // --- Multi-account management --- + + suspend fun refreshAccountList() { + _allAccounts.value = accountStorage.loadAccounts().toImmutableList() + } + + suspend fun addAccountToStorage(info: AccountInfo) { + accountStorage.saveAccount(info) + refreshAccountList() + } + + suspend fun removeAccountFromStorage(npub: String) { + val current = currentAccount() + accountStorage.deleteAccount(npub) + + // Clean up keys + try { + secureStorage.deletePrivateKey(npub) + } catch (_: SecureStorageException) { + } + + refreshAccountList() + + // If we removed the active account, load the next one or log out + if (current?.npub == npub) { + val next = accountStorage.currentAccount() + if (next != null) { + loadSavedAccount() + } else { + logout(deleteKey = false) + } + } + } + + /** + * Switch to a different account. + * Critical: loads new account BEFORE cancelling old state to prevent + * unrecoverable partial failure. + */ + suspend fun switchAccount(targetNpub: String): Result { + val accounts = accountStorage.loadAccounts() + val target = + accounts.find { it.npub == targetNpub } + ?: return Result.failure(Exception("Account not found: $targetNpub")) + + // Phase 1: load + validate new account BEFORE touching current state + val sType = target.signerType + val newState = + when (sType) { + is SignerType.Internal -> loadInternalAccount(target.npub) + is SignerType.Remote -> loadBunkerAccount(sType.bunkerUri, target.npub) + is SignerType.ViewOnly -> loadViewOnlyAccount(target.npub) + } + + if (newState.isFailure) return newState + + // Phase 2: transition succeeded — update storage and cleanup + accountStorage.setCurrentAccount(targetNpub) + + // Reload NWC for the new account + loadNwcConnection() + + return newState + } + + private fun loadViewOnlyAccount(npub: String): Result { + val pubKeyHex = + decodePublicKeyAsHexOrNull(npub) + ?: return Result.failure(Exception("Invalid npub: $npub")) + + val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = pubKeyHex, + npub = npub, + nsec = null, + isReadOnly = true, + signerType = SignerType.ViewOnly, + ) + _accountState.value = state + return Result.success(state) + } + + suspend fun migrateAndLoadAccounts() { + accountStorage.migrateFromLegacyFiles(this) + refreshAccountList() + } + // --- Accessors --- fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt index 78e52ca7a..2e3b38187 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -26,8 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.test.runTest import java.io.File import kotlin.io.path.createTempDirectory @@ -45,9 +47,22 @@ class AccountManagerKeyLoginTest { private lateinit var tempDir: File private lateinit var manager: AccountManager + private val keyStore = mutableMapOf() + @BeforeTest fun setup() { + keyStore.clear() storage = mockk(relaxed = true) + + // Wire up key store so DesktopAccountStorage encryption works + val keySlot = slot() + val valueSlot = slot() + coEvery { storage.getPrivateKey(capture(keySlot)) } answers { keyStore[keySlot.captured] } + coEvery { storage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers { + keyStore[keySlot.captured] = valueSlot.captured + } + coEvery { storage.hasPrivateKey(any()) } answers { keyStore.containsKey(firstArg()) } + tempDir = createTempDirectory("acctmgr-key-test").toFile() manager = AccountManager(storage, tempDir) }