mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 10:58:23 +00:00
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:
@@ -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.
|
||||
|
||||
+8
@@ -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 ->
|
||||
|
||||
+70
@@ -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)
|
||||
|
||||
+24
@@ -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())
|
||||
}
|
||||
}
|
||||
+18
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user