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/<pubkeyHex>/ 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.
This commit is contained in:
Claude
2026-07-01 19:30:37 +00:00
parent f71bfdc712
commit 0f6413f263
11 changed files with 535 additions and 32 deletions
@@ -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]. */
object AccountStore {
private val keyFile get() = AppDataDir.file("account.key")
/** 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()
suspend fun hasAccount(): Boolean = withContext(Dispatchers.IO) { keyFile.exists() }
data class Migrated(val pubKeyHex: String) : MigrationResult()
/** 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())
/** A legacy `account.key` exists but couldn't be decrypted; left untouched on disk. */
data object Failed : MigrationResult()
}
/** Generates a brand-new key and persists it. */
suspend fun generate(): KeyPair = save(KeyPair())
/** Loads/persists the desktop bunker's accounts, each encrypted at rest via [SecureCryptoHelper] under its own `~/.amber-bunker/accounts/<pubkeyHex>/`. */
object AccountStore {
private const val KEY_FILE_NAME = "account.key"
/** Imports an existing hex or nsec-decoded private key and persists it. */
suspend fun import(privKeyHex: String): KeyPair = save(KeyPair(privKey = privKeyHex.hexToByteArray()))
private fun keyFile(pubKeyHex: String) = File(AppDataDir.accountDir(pubKeyHex), KEY_FILE_NAME)
suspend fun hasAnyAccount(): Boolean = listAccounts().isNotEmpty()
/** All stored accounts' pubkeys (hex), sorted for a stable display order. */
suspend fun listAccounts(): List<String> = withContext(Dispatchers.IO) {
AppDataDir.accountsDir.listFiles { candidate -> candidate.isDirectory && File(candidate, KEY_FILE_NAME).exists() }
?.map { it.name }
?.sorted()
.orEmpty()
}
/** 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() }
}
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/<pubkeyHex>/`. 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)
}
}
@@ -13,10 +13,25 @@ object AppDataDir {
}
}
fun file(name: String): File = File(directory, name)
val accountsDir: File by lazy {
File(directory, "accounts").apply {
mkdirs()
restrictToOwnerOnly(this, isDirectory = true)
}
}
/** Opens (and, on first run, creates the schema for) the desktop bunker's SQLite database. */
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) 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.
@@ -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 ->
@@ -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<String>,
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),
)
}
}
}
}
@@ -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 },
)
}
}
@@ -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<String>,
scope: CoroutineScope,
onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit,
onLogout: (String) -> Unit,
) {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) }
var account by remember(pubKeyHex) { mutableStateOf<KeyPair?>(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 },
)
}
}
}
@@ -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<KeyPair?>(null) }
var loading by remember { mutableStateOf(true) }
var migrationFailed by remember { mutableStateOf(false) }
var accounts by remember { mutableStateOf<List<String>>(emptyList()) }
var activePubKey by remember { mutableStateOf<String?>(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),
)
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
}
},
)
else -> AppShell(account!!, scope)
}
}
}
}
}
}
}
@@ -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)
@@ -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") } },
)
}
@@ -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())
}
}
@@ -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)