From 0f6413f263996652baa3a7e0f4628f8b5038c7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 19:30:37 +0000 Subject: [PATCH] Add multi-account switching/logout and remove-connected-app to desktop bunker Accounts move from a single flat ~/.amber-bunker/{account.key,bunker.db} to per-account directories under accounts// plus an active_account pointer file, with an idempotent migration for existing installs (aborts without touching any file if the legacy key can't be decrypted). BunkerApp owns the account list/active pointer and AppShell loads the active account's key and services keyed on its pubkey, so switching accounts tears down and rebuilds the whole signing/relay stack. The Home screen's avatar opens an account switcher dialog to add, switch, or log out (with a destructive-action confirmation). AppDetailScreen also gains a "Remove app" action, clearing an app's connected-app record and permissions while leaving its history intact for the activity log. --- .../nostrsigner/desktop/data/AccountStore.kt | 143 ++++++++++++++++-- .../desktop/data/BunkerDatabase.kt | 21 ++- .../data/SqliteBunkerPermissionStore.kt | 8 + .../desktop/ui/AccountSwitcherDialog.kt | 70 +++++++++ .../nostrsigner/desktop/ui/AppDetailScreen.kt | 29 ++++ .../nostrsigner/desktop/ui/AppShell.kt | 80 +++++++++- .../nostrsigner/desktop/ui/BunkerApp.kt | 93 +++++++++++- .../nostrsigner/desktop/ui/HomeScreen.kt | 3 +- .../desktop/ui/components/ConfirmDialog.kt | 24 +++ .../desktop/data/AccountStoreTest.kt | 78 ++++++++++ .../desktop/data/SqliteBunkerDataLayerTest.kt | 18 +++ 11 files changed, 535 insertions(+), 32 deletions(-) create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AccountSwitcherDialog.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ConfirmDialog.kt create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStoreTest.kt diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt index bd1484d2..c4bf815f 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt @@ -4,36 +4,149 @@ import com.greenart7c3.nostrsigner.shared.SecureCryptoHelper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** Loads/persists the single desktop bunker account's private key, encrypted at rest via [SecureCryptoHelper]. */ +/** Outcome of [AccountStore.migrateLegacyLayoutIfNeeded], surfaced so the UI can report a failure rather than silently losing an account. */ +sealed class MigrationResult { + data object NotNeeded : MigrationResult() + + data class Migrated(val pubKeyHex: String) : MigrationResult() + + /** A legacy `account.key` exists but couldn't be decrypted; left untouched on disk. */ + data object Failed : MigrationResult() +} + +/** Loads/persists the desktop bunker's accounts, each encrypted at rest via [SecureCryptoHelper] under its own `~/.amber-bunker/accounts//`. */ object AccountStore { - private val keyFile get() = AppDataDir.file("account.key") + private const val KEY_FILE_NAME = "account.key" - suspend fun hasAccount(): Boolean = withContext(Dispatchers.IO) { keyFile.exists() } + private fun keyFile(pubKeyHex: String) = File(AppDataDir.accountDir(pubKeyHex), KEY_FILE_NAME) - /** Loads the persisted account, or null if none has been set up yet. */ - suspend fun load(): KeyPair? { - if (!hasAccount()) return null - val encrypted = withContext(Dispatchers.IO) { keyFile.readText() } - val privKeyHex = SecureCryptoHelper.decrypt(encrypted) - return KeyPair(privKey = privKeyHex.hexToByteArray()) + suspend fun hasAnyAccount(): Boolean = listAccounts().isNotEmpty() + + /** All stored accounts' pubkeys (hex), sorted for a stable display order. */ + suspend fun listAccounts(): List = withContext(Dispatchers.IO) { + AppDataDir.accountsDir.listFiles { candidate -> candidate.isDirectory && File(candidate, KEY_FILE_NAME).exists() } + ?.map { it.name } + ?.sorted() + .orEmpty() } - /** Generates a brand-new key and persists it. */ - suspend fun generate(): KeyPair = save(KeyPair()) + /** The active account's pubkey, or null if unset or if it points at an account that no longer exists. */ + suspend fun activeAccount(): String? = withContext(Dispatchers.IO) { + val file = AppDataDir.activeAccountFile + if (!file.exists()) return@withContext null + val pubKeyHex = file.readText().trim() + pubKeyHex.takeIf { it.isNotBlank() && keyFile(it).exists() } + } - /** Imports an existing hex or nsec-decoded private key and persists it. */ - suspend fun import(privKeyHex: String): KeyPair = save(KeyPair(privKey = privKeyHex.hexToByteArray())) + suspend fun setActive(pubKeyHex: String) = withContext(Dispatchers.IO) { + val tmp = File(AppDataDir.directory, "active_account.tmp") + tmp.writeText(pubKeyHex) + restrictToOwnerOnly(tmp) + moveFile(tmp, AppDataDir.activeAccountFile, replaceExisting = true) + } + + /** Loads one account's key, or null if it isn't stored. */ + suspend fun load(pubKeyHex: String): KeyPair? = withContext(Dispatchers.IO) { + val file = keyFile(pubKeyHex) + if (!file.exists()) return@withContext null + val privKeyHex = SecureCryptoHelper.decrypt(file.readText()) + KeyPair(privKey = privKeyHex.hexToByteArray()) + } + + /** Generates a brand-new account, persists it, and makes it active. */ + suspend fun generate(): KeyPair { + val keyPair = save(KeyPair()) + setActive(keyPair.pubKey.toHexKey()) + return keyPair + } + + /** Imports an existing private key as a (possibly new) account and makes it active; never overwrites an already-stored key for the same pubkey. */ + suspend fun import(privKeyHex: String): KeyPair { + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val pubKeyHex = keyPair.pubKey.toHexKey() + if (pubKeyHex !in listAccounts()) { + save(keyPair) + } + setActive(pubKeyHex) + return keyPair + } + + /** Permanently deletes one account's key and local data. If it was active, clears the active pointer. */ + suspend fun logout(pubKeyHex: String) = withContext(Dispatchers.IO) { + AppDataDir.accountDir(pubKeyHex).deleteRecursively() + if (activeAccount() == null) { + AppDataDir.activeAccountFile.delete() + } + } private suspend fun save(keyPair: KeyPair): KeyPair { + val pubKeyHex = keyPair.pubKey.toHexKey() val privKeyHex = requireNotNull(keyPair.privKey) { "Generated key pair is missing a private key" }.toHexKey() val encrypted = SecureCryptoHelper.encrypt(privKeyHex) withContext(Dispatchers.IO) { - keyFile.writeText(encrypted) - restrictToOwnerOnly(keyFile) + val file = keyFile(pubKeyHex) + file.writeText(encrypted) + restrictToOwnerOnly(file) } return keyPair } + + /** + * One-time, idempotent move from the pre-multi-account layout + * (`~/.amber-bunker/account.key` + `~/.amber-bunker/bunker.db`) into + * `~/.amber-bunker/accounts//`. Safe to call on every launch: the fast path is + * a single [File.exists] check once already migrated (the legacy key is moved, not + * copied, so it no longer exists afterward). + * + * The private key only ever moves via one same-filesystem rename and is never deleted + * independent of that move succeeding; a decrypt failure aborts before touching any file. + * The database move is retried independently so an interrupted migration (key moved, db + * not yet) resumes correctly on the next call without re-touching the key. + */ + suspend fun migrateLegacyLayoutIfNeeded(): MigrationResult = withContext(Dispatchers.IO) { + val legacyKey = AppDataDir.file(KEY_FILE_NAME) + if (!legacyKey.exists()) return@withContext MigrationResult.NotNeeded + + val pubKeyHex = runCatching { + val privKeyHex = SecureCryptoHelper.decrypt(legacyKey.readText()) + KeyPair(privKey = privKeyHex.hexToByteArray()).pubKey.toHexKey() + }.getOrNull() ?: return@withContext MigrationResult.Failed + + val targetDir = AppDataDir.accountDir(pubKeyHex) + val targetKey = File(targetDir, KEY_FILE_NAME) + if (!targetKey.exists()) { + moveFile(legacyKey, targetKey) + } + + val legacyDb = AppDataDir.file("bunker.db") + val targetDb = File(targetDir, "bunker.db") + if (legacyDb.exists() && !targetDb.exists()) { + moveFile(legacyDb, targetDb) + } + + if (activeAccount() == null) { + setActive(pubKeyHex) + } + + MigrationResult.Migrated(pubKeyHex) + } + + private fun moveFile(source: File, target: File, replaceExisting: Boolean = false) { + val options = buildList { + add(StandardCopyOption.ATOMIC_MOVE) + if (replaceExisting) add(StandardCopyOption.REPLACE_EXISTING) + }.toTypedArray() + runCatching { + Files.move(source.toPath(), target.toPath(), *options) + }.getOrElse { + Files.move(source.toPath(), target.toPath(), *options.filterNot { it == StandardCopyOption.ATOMIC_MOVE }.toTypedArray()) + } + restrictToOwnerOnly(target) + } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt index 0a0c04ed..e44ba9f4 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt @@ -13,10 +13,25 @@ object AppDataDir { } } + val accountsDir: File by lazy { + File(directory, "accounts").apply { + mkdirs() + restrictToOwnerOnly(this, isDirectory = true) + } + } + + val activeAccountFile: File get() = file("active_account") + fun file(name: String): File = File(directory, name) + + /** Each account's own directory, holding its `account.key` and `bunker.db`. */ + fun accountDir(pubKeyHex: String): File = File(accountsDir, pubKeyHex).apply { + mkdirs() + restrictToOwnerOnly(this, isDirectory = true) + } } -/** Opens (and, on first run, creates the schema for) the desktop bunker's SQLite database. */ +/** Opens (and, on first run, creates the schema for) one account's SQLite database. */ object BunkerDatabase { private const val NO_KIND = -1 @@ -24,8 +39,8 @@ object BunkerDatabase { fun columnToKind(value: Int): Int? = if (value == NO_KIND) null else value - fun open(): Connection { - val dbFile = AppDataDir.file("bunker.db") + fun open(pubKeyHex: String): Connection { + val dbFile = File(AppDataDir.accountDir(pubKeyHex), "bunker.db") val connection = DriverManager.getConnection("jdbc:sqlite:${dbFile.absolutePath}") // Restrict on every open, not just first creation, so upgrading from a version // predating this fix also tightens an already-existing database file. diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt index 877c97b9..d452a27a 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt @@ -132,6 +132,14 @@ class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHist } } + /** Removes a connected app's `applications` record. Permissions/history are untouched — callers that also want those cleared should call [SqliteBunkerPermissionStore.revokeAll] separately. */ + suspend fun removeApp(appPubKey: String) = withContext(Dispatchers.IO) { + connection.prepareStatement("DELETE FROM applications WHERE app_pub_key = ?").use { statement -> + statement.setString(1, appPubKey) + statement.executeUpdate() + } + } + /** The last known display name for an app, if any — used as [com.greenart7c3.nostrsigner.shared.BunkerSigningEngine]'s `appNameLookup` fallback. */ suspend fun nameFor(appPubKey: String): String? = withContext(Dispatchers.IO) { connection.prepareStatement("SELECT name FROM applications WHERE app_pub_key = ?").use { statement -> diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AccountSwitcherDialog.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AccountSwitcherDialog.kt new file mode 100644 index 00000000..b8c76a29 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AccountSwitcherDialog.kt @@ -0,0 +1,70 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex +import com.greenart7c3.nostrsigner.desktop.ui.theme.negativeColor + +@Composable +fun AccountSwitcherDialog( + accounts: List, + currentPubKeyHex: String, + onSelect: (String) -> Unit, + onAddAccount: () -> Unit, + onLogout: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card { + Column(modifier = Modifier.padding(16.dp).width(320.dp)) { + Text("Accounts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) + + accounts.forEach { pubKeyHex -> + val isCurrent = pubKeyHex == currentPubKeyHex + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isCurrent) { onSelect(pubKeyHex) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LetterAvatar(pubKeyHex, size = 32.dp) + Text( + pubKeyHex.shortenHex() + if (isCurrent) " (current)" else "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + "+ Add another account", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().clickable(onClick = onAddAccount).padding(vertical = 8.dp), + ) + Text( + "Log out", + style = MaterialTheme.typography.bodyMedium, + color = negativeColor, + modifier = Modifier.fillMaxWidth().clickable(onClick = onLogout).padding(vertical = 8.dp), + ) + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt index 1fdcf869..96692cf4 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt @@ -19,14 +19,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.data.StoredPermission +import com.greenart7c3.nostrsigner.desktop.ui.components.ConfirmDialog import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar import com.greenart7c3.nostrsigner.desktop.ui.components.bunkerMethodDescription import com.greenart7c3.nostrsigner.desktop.ui.components.relativeTimeFromNow import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex +import com.greenart7c3.nostrsigner.desktop.ui.theme.negativeColor import com.greenart7c3.nostrsigner.desktop.ui.theme.orange import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry @@ -41,10 +47,12 @@ fun AppDetailScreen( onDeny: (StoredPermission) -> Unit, onAsk: (StoredPermission) -> Unit, onRevokeAll: () -> Unit, + onRemoveApp: () -> Unit, onCopyUri: (String) -> Unit, onBack: () -> Unit, ) { val displayName = appName.ifBlank { appPubKey.shortenHex() } + var showRemoveConfirm by remember { mutableStateOf(false) } Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { Row { @@ -98,6 +106,27 @@ fun AppDetailScreen( Text("Revoke all permissions") } } + + Button( + modifier = Modifier.padding(top = 12.dp), + colors = ButtonDefaults.buttonColors(containerColor = negativeColor), + onClick = { showRemoveConfirm = true }, + ) { + Text("Remove app") + } + } + + if (showRemoveConfirm) { + ConfirmDialog( + title = "Remove $displayName?", + message = "This removes the app from your connected apps list and revokes all of its permissions. It can reconnect later if it sends a new request.", + confirmLabel = "Remove", + onConfirm = { + showRemoveConfirm = false + onRemoveApp() + }, + onCancel = { showRemoveConfirm = false }, + ) } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt index 91437fc9..b81b4618 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt @@ -3,6 +3,7 @@ package com.greenart7c3.nostrsigner.desktop.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.History @@ -24,6 +25,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.data.AccountStore import com.greenart7c3.nostrsigner.desktop.data.AppDataDir import com.greenart7c3.nostrsigner.desktop.data.BunkerDatabase import com.greenart7c3.nostrsigner.desktop.data.ConnectedApp @@ -34,7 +37,9 @@ import com.greenart7c3.nostrsigner.desktop.data.SqliteBunkerPermissionStore import com.greenart7c3.nostrsigner.desktop.data.StoredPermission import com.greenart7c3.nostrsigner.desktop.relay.BunkerRelayConnection import com.greenart7c3.nostrsigner.desktop.relay.DEFAULT_BUNKER_RELAYS +import com.greenart7c3.nostrsigner.desktop.ui.components.ConfirmDialog import com.greenart7c3.nostrsigner.desktop.ui.components.copyToClipboard +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex import com.greenart7c3.nostrsigner.desktop.ui.nav.Screen import com.greenart7c3.nostrsigner.desktop.ui.theme.DesktopTheme import com.greenart7c3.nostrsigner.desktop.ui.theme.ThemeMode @@ -42,7 +47,6 @@ import com.greenart7c3.nostrsigner.desktop.ui.theme.resolveIsDark import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry import com.greenart7c3.nostrsigner.shared.BunkerSigner import com.greenart7c3.nostrsigner.shared.BunkerSigningEngine -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import java.awt.Desktop @@ -63,18 +67,39 @@ private class BunkerServices( /** The desktop bunker's main shell: nav rail + current screen + the approval dialog overlay. */ @Composable -fun AppShell(account: KeyPair, scope: CoroutineScope) { - val pubKeyHex = remember(account) { account.pubKey.toHexKey() } +fun AppShell( + pubKeyHex: String, + accounts: List, + scope: CoroutineScope, + onSwitchAccount: (String) -> Unit, + onAddAccount: () -> Unit, + onLogout: (String) -> Unit, +) { var currentScreen by remember { mutableStateOf(Screen.Home) } var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) } + var account by remember(pubKeyHex) { mutableStateOf(null) } + var showAccountSwitcher by remember { mutableStateOf(false) } + var showLogoutConfirm by remember { mutableStateOf(false) } - val services = remember(pubKeyHex) { - val db = BunkerDatabase.open() + LaunchedEffect(pubKeyHex) { + account = AccountStore.load(pubKeyHex) + } + + val currentAccount = account + if (currentAccount == null) { + DesktopTheme(darkTheme = themeMode.resolveIsDark()) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { Text("Loading...") } + } + return + } + + val services = remember(pubKeyHex, currentAccount) { + val db = BunkerDatabase.open(pubKeyHex) val permissionStore = SqliteBunkerPermissionStore(db) val historyLogger = SqliteBunkerHistoryLogger(db) val approvalPort = DesktopApprovalPort() val engine = BunkerSigningEngine( - account = BunkerSigner(account), + account = BunkerSigner(currentAccount), permissionStore = permissionStore, approvalPort = approvalPort, historyLogger = historyLogger, @@ -156,6 +181,7 @@ fun AppShell(account: KeyPair, scope: CoroutineScope) { connectedAppsCount = connectedApps.size, pendingApprovalCount = pending.size, onCopyPubKey = { copyToClipboard(pubKeyHex) }, + onOpenAccountMenu = { showAccountSwitcher = true }, ) Screen.Connect -> ConnectScreen( bunkerUri = relayConnection?.let { conn -> "bunker://$pubKeyHex?" + conn.relays.joinToString("&") { "relay=${it.url}" } }, @@ -197,6 +223,14 @@ fun AppShell(account: KeyPair, scope: CoroutineScope) { refreshDetail(screen.appPubKey) } }, + onRemoveApp = { + scope.launch { + services.permissionStore.revokeAll(screen.appPubKey) + services.historyLogger.removeApp(screen.appPubKey) + currentScreen = Screen.ConnectedApps + refreshConnectedApps() + } + }, onCopyUri = { copyToClipboard(it) }, onBack = { currentScreen = Screen.ConnectedApps }, ) @@ -236,6 +270,40 @@ fun AppShell(account: KeyPair, scope: CoroutineScope) { } } } + + if (showAccountSwitcher) { + AccountSwitcherDialog( + accounts = accounts, + currentPubKeyHex = pubKeyHex, + onSelect = { selected -> + showAccountSwitcher = false + onSwitchAccount(selected) + }, + onAddAccount = { + showAccountSwitcher = false + onAddAccount() + }, + onLogout = { + showAccountSwitcher = false + showLogoutConfirm = true + }, + onDismiss = { showAccountSwitcher = false }, + ) + } + + if (showLogoutConfirm) { + ConfirmDialog( + title = "Log out of this account?", + message = "This permanently deletes the locally-stored key for ${pubKeyHex.shortenHex()} from this device. " + + "Make sure you have it backed up elsewhere — it cannot be recovered from Amber Bunker afterward.", + confirmLabel = "Log out", + onConfirm = { + showLogoutConfirm = false + onLogout(pubKeyHex) + }, + onCancel = { showLogoutConfirm = false }, + ) + } } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt index 218d6898..69a2713b 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt @@ -11,31 +11,110 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.greenart7c3.nostrsigner.desktop.data.AccountStore +import com.greenart7c3.nostrsigner.desktop.data.MigrationResult import com.greenart7c3.nostrsigner.desktop.ui.theme.DesktopTheme -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Composable fun BunkerApp(scope: CoroutineScope) { - var account by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } + var migrationFailed by remember { mutableStateOf(false) } + var accounts by remember { mutableStateOf>(emptyList()) } + var activePubKey by remember { mutableStateOf(null) } + var showAddAccount by remember { mutableStateOf(false) } + + suspend fun refreshAccounts() { + accounts = AccountStore.listAccounts() + activePubKey = AccountStore.activeAccount() + } LaunchedEffect(Unit) { - account = AccountStore.load() + if (AccountStore.migrateLegacyLayoutIfNeeded() is MigrationResult.Failed) { + migrationFailed = true + } + refreshAccounts() loading = false } + // When the active account is logged out but others remain, active_account has no valid + // pointer yet — fall back to another stored account instead of dropping to SetupScreen. + LaunchedEffect(activePubKey, accounts, loading) { + if (!loading && activePubKey == null && accounts.isNotEmpty()) { + AccountStore.setActive(accounts.first()) + refreshAccounts() + } + } + DesktopTheme(darkTheme = false) { Surface { when { loading -> Text("Loading...", modifier = Modifier.padding(24.dp)) - account == null -> SetupScreen( - onGenerate = { scope.launch { account = AccountStore.generate() } }, - onImport = { hex -> scope.launch { account = AccountStore.import(hex) } }, + migrationFailed -> Text( + "Amber Bunker found an existing account key but couldn't read it. It has been left " + + "untouched at ~/.amber-bunker/account.key. Please back it up or restore a working copy " + + "before continuing.", + modifier = Modifier.padding(24.dp), ) - else -> AppShell(account!!, scope) + activePubKey == null -> SetupScreen( + onGenerate = { + scope.launch { + AccountStore.generate() + refreshAccounts() + } + }, + onImport = { hex -> + scope.launch { + AccountStore.import(hex) + refreshAccounts() + } + }, + ) + else -> { + AppShell( + pubKeyHex = activePubKey!!, + accounts = accounts, + scope = scope, + onSwitchAccount = { pubKeyHex -> + scope.launch { + AccountStore.setActive(pubKeyHex) + refreshAccounts() + } + }, + onAddAccount = { showAddAccount = true }, + onLogout = { pubKeyHex -> + scope.launch { + AccountStore.logout(pubKeyHex) + refreshAccounts() + } + }, + ) + + if (showAddAccount) { + Dialog(onDismissRequest = { showAddAccount = false }) { + Surface { + SetupScreen( + onGenerate = { + scope.launch { + AccountStore.generate() + refreshAccounts() + showAddAccount = false + } + }, + onImport = { hex -> + scope.launch { + AccountStore.import(hex) + refreshAccounts() + showAddAccount = false + } + }, + ) + } + } + } + } } } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt index ebcc22c3..0207b807 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt @@ -31,10 +31,11 @@ fun HomeScreen( connectedAppsCount: Int, pendingApprovalCount: Int, onCopyPubKey: () -> Unit, + onOpenAccountMenu: () -> Unit, ) { Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { Row { - LetterAvatar(pubKeyHex, size = 56.dp) + LetterAvatar(pubKeyHex, size = 56.dp, modifier = Modifier.clickable(onClick = onOpenAccountMenu)) Spacer(Modifier.width(16.dp)) Column { Text("Amber Bunker", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ConfirmDialog.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ConfirmDialog.kt new file mode 100644 index 00000000..7b74be94 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ConfirmDialog.kt @@ -0,0 +1,24 @@ +package com.greenart7c3.nostrsigner.desktop.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +/** A generic destructive-action confirmation, reused for logout and app removal. */ +@Composable +fun ConfirmDialog( + title: String, + message: String, + confirmLabel: String, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } }, + ) +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStoreTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStoreTest.kt new file mode 100644 index 00000000..d7b5fbbe --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStoreTest.kt @@ -0,0 +1,78 @@ +package com.greenart7c3.nostrsigner.desktop.data + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +/** + * Exercises [AccountStore]'s filesystem bookkeeping (listing, active-pointer, logout, and the + * legacy-layout migration's early-exit/failure paths) without touching [SecureCryptoHelper] — + * that needs a running OS keychain / Secret Service provider that CI/sandboxes don't have. + * `[AccountStore.generate]`/`[AccountStore.load]`, which do need it, are exercised manually + * (see the plan's verification section) rather than here. + */ +class AccountStoreTest { + companion object { + init { + // AppDataDir.directory is a process-wide `by lazy` resolved from user.home on first + // access — override it before any test in this class can trigger that resolution, + // so these tests never touch the real ~/.amber-bunker. + val tempHome = Files.createTempDirectory("amber-bunker-account-store-test").toFile() + System.setProperty("user.home", tempHome.absolutePath) + } + } + + @AfterTest + fun tearDown() { + AppDataDir.accountsDir.listFiles()?.forEach { it.deleteRecursively() } + AppDataDir.activeAccountFile.delete() + AppDataDir.file("account.key").delete() + AppDataDir.file("bunker.db").delete() + } + + @Test + fun migrateLegacyLayoutIfNeededIsNotNeededWhenNoLegacyKeyExists() = runTest { + assertEquals(MigrationResult.NotNeeded, AccountStore.migrateLegacyLayoutIfNeeded()) + } + + @Test + fun migrateLegacyLayoutIfNeededFailsAndLeavesUndecryptableKeyUntouched() = runTest { + val legacyKey = AppDataDir.file("account.key") + legacyKey.writeText("not a real encrypted key") + + val result = AccountStore.migrateLegacyLayoutIfNeeded() + + assertEquals(MigrationResult.Failed, result) + assertTrue(legacyKey.exists()) + assertEquals("not a real encrypted key", legacyKey.readText()) + } + + @Test + fun listActiveSetAndLogoutTrackStoredAccountsByKeyFilePresence() = runTest { + val pubKeyA = "aaaa0000111122223333444455556666777788889999aaaabbbbccccddddee" + val pubKeyB = "bbbb0000111122223333444455556666777788889999aaaabbbbccccddddff" + File(AppDataDir.accountDir(pubKeyA), "account.key").writeText("dummy-a") + File(AppDataDir.accountDir(pubKeyB), "account.key").writeText("dummy-b") + + assertEquals(listOf(pubKeyA, pubKeyB).sorted(), AccountStore.listAccounts().sorted()) + assertTrue(AccountStore.hasAnyAccount()) + assertNull(AccountStore.activeAccount()) + + AccountStore.setActive(pubKeyA) + assertEquals(pubKeyA, AccountStore.activeAccount()) + + AccountStore.logout(pubKeyA) + assertEquals(listOf(pubKeyB), AccountStore.listAccounts()) + // The active pointer still names pubKeyA, but its key file is gone, so it no longer resolves. + assertNull(AccountStore.activeAccount()) + + AccountStore.logout(pubKeyB) + assertTrue(AccountStore.listAccounts().isEmpty()) + assertTrue(!AccountStore.hasAnyAccount()) + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt index e42d27ac..71911f5a 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt @@ -79,6 +79,24 @@ class SqliteBunkerDataLayerTest { assertTrue(app1Only.all { it.entry.appPubKey == "app1" }) } + @Test + fun removeAppDeletesApplicationRecordButKeepsHistoryAndPermissions() = runTest { + val permissionStore = SqliteBunkerPermissionStore(connection) + val logger = SqliteBunkerHistoryLogger(connection) + permissionStore.remember("app1", BunkerMethod.SIGN_EVENT, 1, true) + logger.log(BunkerHistoryEntry("app1", BunkerMethod.CONNECT, null, true, 1000L, appName = "My App")) + + assertEquals(1, logger.connectedApps().size) + + logger.removeApp("app1") + + assertTrue(logger.connectedApps().isEmpty()) + assertNull(logger.nameFor("app1")) + // History and permission rules are an audit trail / user choice independent of the "connected apps" list. + assertEquals(1, logger.recentHistoryFor("app1").size) + assertEquals(1, permissionStore.permissionsFor("app1").size) + } + @Test fun relayStoreAddsListsAndRemoves() = runTest { val relayStore = RelayStore(connection)