Account switcher dropdown improvements: - Two-row display: Display Name on top, npub (middle-truncated) below e.g. 'Alice' / 'npub1abc...wxyz · Bunker' - Middle-truncation for npub: shows first 10 + last 6 chars - Resolves display names from DesktopLocalCache user metadata - Confirmation dialog also shows display name - npub-only (view-only) accounts now persist to encrypted storage (ensureCurrentAccountInStorage called in onLoginSuccess) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
35 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Desktop Multi-Account Support | feat | active | 2026-04-23 | docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md |
feat: Desktop Multi-Account Support
Enhancement Summary
Deepened on: 2026-04-24 Research agents used: kotlin-expert, kotlin-coroutines, kotlin-multiplatform, compose-expert, desktop-expert, nostr-expert, security-reviewer, desktop-api-researcher
Key Improvements
- KMP extraction corrected — only extract types + interface to commons (not managers); AccountSessionManager/CacheState too Android-coupled
- Switch ordering fixed — load new account BEFORE cancelling old scope (prevents unrecoverable partial failure)
- Encryption key strategy changed — store random AES key in OS keychain via SecureKeyStorage instead of fragile machine-derived key
- SignerType changed to sealed class — carries
packageNamein Remote subtype instead of leaking nullable field - Background notification filters specified — kinds 1, 6, 9735, 1059, 4 with
#ptag; exclude kind 7 reactions (too noisy) - Desktop API patterns grounded — Compose
Traydynamic menus, customPainterfor badge overlay,DialogWindowfor add-account,DropdownMenuwith offset for sidebar
Critical Corrections Found
AccountSessionManagerandAccountCacheStatecannot go tocommons/commonMainas written (Android-only deps: ContentResolver, MLS stores, Route class)java.security.KeyStoreon JVM desktop provides no real security (file-based, no TPM) — use OS keychain insteadAccountState.LoggedInhas mutablevar route— breaks@Stablecontract; must bevalnwc_connection.txtstored in plaintext — high-severity, must migrate to encrypted storage- macOS
TrayIcon.displayMessage()is broken since macOS 10.14+ — usetwo-sliceslibrary or in-app toasts
Overview
Add multi-account support to Amethyst Desktop — store multiple Nostr identities (nsec, NIP-46 bunker, npub view-only), switch between them via a sidebar dropdown, with background notification counts, system tray integration, and per-account desktop notifications. One active account at a time, kill & reconnect relay connections on switch.
Problem Statement
Desktop currently supports only a single account with file-based storage (last_account.txt, bunker_uri.txt). Users who manage multiple Nostr identities (personal, work, project) must log out and re-enter credentials each time. Android Amethyst already has full multi-account support — desktop should match.
Proposed Solution
Extract account types and interfaces to commons/commonMain/, build platform-specific session managers for both Android and Desktop, build desktop-specific UI (sidebar dropdown, add-account dialog), encrypted account metadata storage, background relay subscriptions for inactive account notification counts, system tray integration, and silent migration from single-account.
(see brainstorm: docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md)
Technical Approach
Architecture
commons/commonMain/
model/
AccountInfo.kt # npub, signerType sealed class, isTransient
AccountState.kt # Sealed: Loading, LoggedIn(IAccount), LoggedOff
AccountStorage.kt # Interface only — injected, NOT expect/actual
desktopApp/
account/
AccountManager.kt # MODIFY — multi-account lifecycle (platform-specific)
DesktopAccountStorage.kt # NEW — encrypted JSON + SecureKeyStorage for nsecs
DesktopAccountCacheState.kt # NEW — Map<HexKey, DesktopIAccount> + scope lifecycle
BackgroundNotificationManager.kt # NEW — lightweight relay subs for inactive accounts
ui/
AccountSwitcherDropdown.kt # NEW — top-of-sidebar dropdown
AddAccountDialog.kt # NEW — nsec / bunker / npub import via DialogWindow
tray/
DesktopTrayIntegration.kt # NEW — Compose Tray with custom badge Painter
amethyst/
AccountSessionManager.kt # MODIFY — use commons AccountState/AccountInfo types
LocalPreferences.kt # MODIFY — implement AccountStorage interface
Research Insight: What Goes Where
The KMP expert found that AccountSessionManager and AccountCacheState cannot be extracted to commons/commonMain as originally planned — they depend on Android-only types (ContentResolver, AndroidMlsGroupStateStore, Route). Only the types and interface go to commons:
| Component | Source Set | Reason |
|---|---|---|
AccountInfo data class |
commons/commonMain |
Pure Kotlin, no platform APIs |
AccountState sealed class |
commons/commonMain |
Uses IAccount (already in commonMain), String? route |
AccountStorage interface |
commons/commonMain |
Pure interface, domain types only |
AccountSessionManager |
Platform-specific | Android: NIP-55/ContentResolver; Desktop: file-based signers |
AccountCacheState equivalent |
Platform-specific | Android: MLS/Marmot stores; Desktop: different signer types |
DesktopAccountStorage |
desktopApp/jvmMain |
javax.crypto, file I/O, OS keychain |
Key Design Decisions (from brainstorm)
| Decision | Choice |
|---|---|
| Account model | One active, quick switch |
| UI placement | Top of sidebar dropdown |
| Relay on switch | Load new FIRST, then kill old scope |
| Login methods | nsec + NIP-46 bunker + npub (view-only) |
| Code sharing | Extract types + interface to commons; managers platform-specific |
| Nsec storage | SecureKeyStorage (already in quartz, uses OS keychain) |
| Metadata storage | Encrypted JSON file (~/.amethyst/accounts.json.enc) |
| Encryption key | Random AES key stored in OS keychain (not machine-derived) |
| App launch | Auto-login last account, optional lock |
| Add account UX | DialogWindow overlay from switcher dropdown |
| Unread badges | Live counts via lightweight background relay subs |
| Background subs | Up to 5 relay connections per inactive account |
| Background filters | Kinds 1, 6, 9735, 1059, 4 with #p tag (NOT kind 7) |
| Account removal | Delete everything (credentials + cached data) |
| NIP-46 multi-acct | One bunker URI per account (protocol is 1:1) |
| Migration | Auto-migrate existing single-account silently |
| Account ordering | Fixed by add date (stable kbd shortcuts 1-9) |
| Cache on switch | Keep SharedLocalCache across switches |
Implementation Phases
Phase 1: Extract Account Types to Commons
Goal: Share account data types and storage interface across platforms.
Tasks:
- Create
SignerTypeas sealed class (not enum) incommons/commonMain:@Immutable sealed class SignerType { data object Internal : SignerType() data class Remote(val packageName: String) : SignerType() data object ViewOnly : SignerType() } - Create
AccountInfoincommons/commonMain:@Immutable data class AccountInfo( val npub: String, val signerType: SignerType, val isTransient: Boolean = false, ) - Create
AccountStatesealed class incommons/commonMain:sealed class AccountState { data object Loading : AccountState() data object LoggedOff : AccountState() @Immutable data class LoggedIn( val account: IAccount, // NOT Android Account val route: String? = null, // route ID, NOT Route class ) : AccountState() } - Create
AccountStorageinterface incommons/commonMain(constructor-injected, NOT expect/actual):interface AccountStorage { suspend fun loadAccounts(): List<AccountInfo> suspend fun saveAccount(info: AccountInfo) suspend fun deleteAccount(npub: String) suspend fun currentAccount(): String? suspend fun setCurrentAccount(npub: String) } - Update Android
AccountSessionManagerto use commonsAccountInfo,AccountState(future PR) - Update Android
LocalPreferencesto implementAccountStorage(future PR) - Tests: unit tests for
AccountInfo/AccountStateincommons/jvmTest(deferred — type tests trivial)
Research Insights:
Kotlin patterns (kotlin-expert):
- Use
data object(Kotlin 1.9+) forLoading/LoggedOff— propertoString()and equality LoggedInmust bedata classwithval route(notvar) —varbreaks@Stable/@Immutable- Route mutation via
_accountState.update { (it as? LoggedIn)?.copy(route = newRoute) ?: it } - Private
MutableStateFlow, publicStateFlow— never expose mutable flow externally
KMP patterns (kotlin-multiplatform):
AccountStorageuses constructor injection because the interface is identical across platforms — only the storage substrate differsSecureKeyStorageuses expect/actual because the mechanism (Android Keystore vs macOS Keychain) is fundamentally different —AccountStoragedoesn't need that
Files modified:
commons/commonMain/kotlin/.../model/AccountInfo.kt(NEW)commons/commonMain/kotlin/.../model/AccountState.kt(NEW)commons/commonMain/kotlin/.../model/AccountStorage.kt(NEW)commons/commonMain/kotlin/.../model/SignerType.kt(NEW)amethyst/.../AccountSessionManager.kt(MODIFY — use commons types)amethyst/.../LocalPreferences.kt(MODIFY — implement AccountStorage)
Phase 2: Desktop Account Storage
Goal: Encrypted persistence for multiple accounts on desktop.
Tasks:
- Create
DesktopAccountStorage.ktimplementingAccountStorage- Account metadata (list, active npub, per-account prefs) in encrypted JSON
- File location:
~/.amethyst/accounts.json.enc - Encryption: AES-256-GCM via
javax.crypto - Nsec storage delegated to existing
SecureKeyStorage(quartz) — already uses OS keychain
- Implement encryption key bootstrap via OS keychain:
private suspend fun getOrCreateMetadataKey(): ByteArray { val alias = "account-metadata-key" val existing = secureStorage.getPrivateKey(alias) if (existing != null) return Base64.getDecoder().decode(existing) val key = ByteArray(32).also { SecureRandom().nextBytes(it) } secureStorage.savePrivateKey(alias, Base64.getEncoder().encodeToString(key)) return key } - Implement two save paths (debounced + immediate):
// Debounced for preference changes (500ms coalesce) fun saveAsync(accounts: List<AccountInfo>) { saveRequests.tryEmit(accounts) } // Immediate for critical operations (add, remove, logout) suspend fun saveImmediate(accounts: List<AccountInfo>) { writeToDisk(accounts) } - Migration logic: on first launch, detect
last_account.txt+bunker_uri.txt:- Read existing npub, key, bunker URI
- Create first
AccountInfoentry - Write to new encrypted format, read back and verify decrypted npub matches
- Best-effort secure-delete old files (zero-overwrite + delete)
- Migrate
nwc_connection.txtto encrypted storage (currently plaintext — high-severity security issue) - Per-account preferences namespace via
prefs.node(npub) - JSON serialization via Jackson (matches WorkspaceManager pattern)
Research Insights:
Security (security-reviewer):
java.security.KeyStoreon JVM desktop is file-based (PKCS12/JKS) with no TPM/HSM backing — provides zero additional security. If attacker can read~/.amethyst/, they can read both the encrypted file and the keystore- Use OS keychain (same
java-keyring/SecureKeyStoragealready in quartz) to store a random 256-bit AES key. This is the password-manager pattern (1Password, Bitwarden) - Hardware ID PBKDF2 is fragile — MAC addresses/disk serials change on hardware replacement/VM migration, locking users out
nwc_connection.txtcontains wallet credentials stored in plaintext — must migrate to encrypted storage.bakfiles should be zero-overwritten before deletion (best-effort on SSDs, but good hygiene)- Remove
nsecfromAccountState.LoggedInstate object — fetch on-demand fromSecureKeyStorageinstead of caching in memory for the entire session
Coroutines (kotlin-coroutines):
- Debounced saves use
MutableSharedFlow+.debounce(500)for preference changes - Immediate saves bypass debounce for account add/remove/logout — don't want 500ms delay before confirming data written
- Both paths use
Dispatchers.IO
NIP-46 relay isolation (from docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md):
- Each NIP-46 account MUST have a dedicated isolated
NostrClient - Created via
AccountManager.getOrCreateNip46Client()withMutexthread-safety - Prevents relay pool pollution (bunker relays leaking into general pool)
- Cancel scope (not just job) on logout — kills any pending auth flows
Files modified:
desktopApp/.../account/DesktopAccountStorage.kt(NEW)desktopApp/.../account/AccountManager.kt(MODIFY)desktopApp/.../DesktopPreferences.kt(MODIFY — add per-account scoping)
Phase 3: Account Switching Integration
Goal: Wire account switching into desktop app lifecycle.
Tasks:
- Modify
AccountManagerto support multiple accounts:MutableStateFlow<AccountState>driven byDesktopAccountStorageStateFlow<List<AccountInfo>>for all accounts listswitchAccount(accountInfo): load new account FIRST, then cancel old scopeaddAccount(key/bunkerUri/npub): validate, save to storage, optionally switchremoveAccount(npub): cancel scope, delete from storage + SecureKeyStorage, clear preferences
- Implement safe switch ordering (CRITICAL — coroutines review):
suspend fun switchAccount(newInfo: AccountInfo): Result<Unit> { val previousInfo = currentAccountInfo val previousScope = accountScope return try { // Phase 1: load + validate BEFORE cancelling old scope val newAccount = loadAccount(newInfo) // throws on failure // Phase 2: now safe to transition _accountState.value = AccountState.Loading accountScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) previousScope.cancel() // Only cancel after new scope ready // Phase 3: bring up new connections relayConnectionManager.reconnect(newAccount.relays) _accountState.value = AccountState.LoggedIn(newAccount) Result.success(Unit) } catch (e: Exception) { // Old scope still alive — no partial state _accountState.value = AccountState.LoggedIn(previousInfo!!) Result.failure(e) } } - Create
DesktopAccountCacheState:MutableStateFlow<Map<HexKey, DesktopIAccount>>loadAccount()/removeAccount()with scope lifecycle- Mirrors Android pattern but uses
DesktopIAccount(not extractable — Android has MLS/Marmot stores)
- Update
Appcomposable inMain.kt:- Gate on
AccountManager.accountState(now multi-account aware) - Use
remember(activeAccountKey)to rebuild deck/workspace state per account (no app restart) - Pass account switcher callbacks down to sidebar
- Gate on
- Share
DesktopLocalCacheacross accounts (keep cache on switch) - Update
RelayConnectionManagerlifecycle on switch
Research Insights:
Coroutines (kotlin-coroutines) — CRITICAL FIX:
- Original plan said "cancel old scope → load new account" — wrong order. You cannot reinstate a cancelled
CoroutineScope. Load and validate the new account FIRST, then cancel old scope - Use
SupervisorJob()for account scope so individual relay failures don't kill the entire account CoroutineExceptionHandleron account scope to log unhandled errors
Desktop (desktop-expert):
- Do NOT use
key(appRestartKey)for account switching (current pattern for logout/restart) — switching should be seamless - Use
remember(activeAccountKey) { DeckState(...) }— recreates when key changes, no app restart - The
DeckStateandWorkspaceManagershould be keyed per-account or reset on switch
Files modified:
desktopApp/.../account/AccountManager.kt(MODIFY)desktopApp/.../account/DesktopAccountCacheState.kt(NEW)desktopApp/.../Main.kt(MODIFY)desktopApp/.../model/DesktopIAccount.kt(MODIFY)desktopApp/.../network/RelayConnectionManager.kt(MODIFY)desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt(MODIFY)
Phase 4: Account Switcher UI
Goal: Sidebar dropdown for switching accounts.
Tasks:
- Create
AccountSwitcherDropdown.kt:- 48dp-wide avatar button replaces "A" logo at top of
DeckSidebar BadgedBoxwith total unread count on avatar- Click opens
DropdownMenuwithDpOffset(x = 48.dp)(expand right of sidebar) - Account rows: avatar (via existing
UserAvatarcommons composable), name, active checkmark, per-accountBadgecount HorizontalDivider+ "Add Account" at bottomAnimatedContentwith fade for avatar transition on switch
- 48dp-wide avatar button replaces "A" logo at top of
- Add keyboard shortcut
Cmd+Shift+A/Ctrl+Shift+A:- Register in
MenuBar"Accounts" menu (new menu between File and Edit) - No conflict with existing shortcuts (verified: Cmd+N, Cmd+S+Shift, Cmd+K, Cmd+D+Shift, Cmd+T, Cmd+W, Cmd+1..9 all free)
Cmd+Shift+1..9for direct account quick-select (also free)
- Register in
- Modify
DeckSidebar.kt:- Replace "A" text with
AccountSwitcherDropdownat top - Add params:
activeAccount,allAccounts,unreadCounts,onSwitchAccount,onAddAccount
- Replace "A" text with
- Create
AddAccountDialog.kt:- Use
DialogWindow(real OS window, not Compose overlay) — avoids AWT/VLC z-order issues - Size:
DpSize(480.dp, 600.dp),resizable = false - Wrap existing
LoginCardcomposable (already has nsec/bunker/nostrconnect tabs) — do NOT duplicate - Suppress "Generate New" path, different title/subtitle
- Use
- Account context menu (right-click on account in dropdown):
- "Remove Account" with confirmation dialog
- "Copy npub"
- Add "Accounts" menu to
MenuBarwith shortcut items per account
Research Insights:
Compose (compose-expert):
@ImmutableonAccountInfo— passed to every row; without it, every row recomposes on any state change- Use
derivedStateOfinside each badge to isolate per-account count reads (prevents full dropdown recomposition on any count change) - Use
ImmutableMapfromkotlinx.collections.immutable(already in project) forunreadCountsStateFlow allAccounts.forEach(notLazyColumn) insideDropdownMenu— Material3DropdownMenuusesColumninternally,LazyColumncauses scroll conflicts. Fine for max ~9 accountsBadgedBox+Badge— correct M3 API, available in CMP 1.7.x, no@Experimentalneeded- Use
HorizontalDivider(not deprecatedDivider)
Desktop (desktop-expert):
DropdownMenuis correct over modal Dialog for the switcher — lightweight, dismisses on outside click, expands right from 48dp sidebarDialogWindow(notDialogcomposable) for AddAccount — gets OS-level modality, proper focus, avoids z-order conflicts with VLC- macOS: tray icon renders monochrome by default — use template image with
apple.awt.enableTemplateImages=trueJVM arg onPreviewKeyEventon Window for shortcuts (intercepts before children)
Files modified:
desktopApp/.../ui/AccountSwitcherDropdown.kt(NEW)desktopApp/.../ui/AddAccountDialog.kt(NEW)desktopApp/.../ui/deck/DeckSidebar.kt(MODIFY)desktopApp/.../Main.kt(MODIFY — MenuBar "Accounts" menu)
Phase 5: Background Notification Subscriptions
Goal: Live unread counts for inactive accounts.
Tasks:
- Create
BackgroundNotificationManager.kt:- Per-account jobs in
Mutex-guardedmutableMapOf<String, Job>() supervisorScopeper account so one relay failure doesn't kill others- Connect to account's NIP-65 inbox relays (
readRelaysNorm(), max 3) + DM relays (kind 10050, max 2) - Subscribe with these filters per inactive account:
// Filter 1: Mentions + reposts Filter(kinds = listOf(1, 6), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 50) // Filter 2: Zap receipts Filter(kinds = listOf(9735), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 20) // Filter 3: NIP-17 gift-wrapped DMs (on DM inbox relays) Filter(kinds = listOf(1059), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 50) // Filter 4: Legacy NIP-04 DMs Filter(kinds = listOf(4), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 50) - Expose
StateFlow<ImmutableMap<String, Int>>— npub → unread count - Reset count for account when user switches to it
- Per-account jobs in
- Reconnect with exponential backoff + jitter:
val delayMs = minOf(30_000L, 1000L * (1L shl minOf(attempt, 5))) .plus(Random.nextLong(0, 500))- Use
while (currentCoroutineContext().isActive)(notwhile(true)) for cancellation
- Use
- Wire counts into
AccountSwitcherDropdownand tray menu - Tie to app-level
CoroutineScope, cancel in shutdown hook
Research Insights:
Nostr (nostr-expert):
- Use
#ptag filters (notauthors) — listening for the account, not from it - Exclude kind 7 reactions — too noisy for background counts. Use NIP-45 count query at foreground activation if needed
- Include kind 6 reposts — same filter as kind 1 mentions, relatively rare, worth tracking
- NIP-65
readRelaysNorm()for general notifications; kind 10050ChatMessageRelayListEventfor DM-specific inbox relays - Max 5 relay connections per inactive account (3 inbox + 2 DM)
- Always set
sincefield — prevents relay flooding on reconnect - Separate
RelayAuthenticatorfor background connections (don't reuse active account's)
Coroutines (kotlin-coroutines):
supervisorScopeinside each account job so one relay failure doesn't kill other relaysMutexonaccountJobsmap — switch can race with notification eventsstartTracking(account)cancels existing job for that account before creating new onestopTracking(npub)removes and cancels the job
Files modified:
desktopApp/.../account/BackgroundNotificationManager.kt(NEW)desktopApp/.../ui/AccountSwitcherDropdown.kt(MODIFY — add badges)
Phase 6: System Tray Integration
Goal: Tray icon with account menu and notification badges.
Tasks:
- Create
DesktopTrayIntegration.kt:- Use Compose Desktop
Traycomposable inapplication {}scope (NOTjava.awt.SystemTray) - Dynamic menu driven by
collectAsState()— updates when accounts added/removed - Custom
Paintersubclass for badge overlay on tray icon (no built-in badge API):class BadgedIconPainter(val base: Painter, val count: Int) : Painter() { override val intrinsicSize get() = base.intrinsicSize override fun DrawScope.onDraw() { with(base) { draw(size) } if (count > 0) { drawCircle(Color.Red, radius = size.minDimension * 0.25f, center = Offset(size.width * 0.75f, size.height * 0.25f)) } } } - Tray menu: active account (bold), separator, all accounts with unread counts, separator, Add Account, Quit
- Use Compose Desktop
- Desktop notification integration:
- macOS:
TrayIcon.displayMessage()is broken since macOS 10.14+ — usetwo-sliceslibrary (com.sshtools:two-slices:0.9.6) for native Notification Center - Windows/Linux:
TrayIcon.displayMessage()works, ortwo-slicesfor consistency two-slicessupports click callbacks (defaultAction { }) for notification-to-account routing- Prefix with account name:
[Alice] New mention from Bob - Click notification → switch to that account
- macOS:
- macOS dark mode: set
apple.awt.enableTemplateImages=trueJVM arg for proper template image behavior
Research Insights:
Desktop API (desktop-api-researcher):
- Compose
Traycomposable wraps AWT internally but exposes clean composable API with recomposition support TrayState.sendNotification(Notification)available but limited (no click callbacks through Compose)- For click-to-open-account on notification: need raw AWT
TrayIcon.addActionListenerortwo-sliceslibrary - No notification grouping API — tag account ID in title, route via click callback
- macOS tray icon size: 22x22, monochrome template; Windows: 16x16, colored OK; Linux: 22x22, varies by DE
TrayonActionfires on primary action (macOS double-click, Windows left-click, Linux varies)
Platform differences:
| Platform | Tray click | Notifications | Icon theme |
|---|---|---|---|
| macOS | Single-click = menu | two-slices needed |
Monochrome template |
| Windows | Left-click = onAction |
displayMessage() works |
Colored OK |
| Linux | Varies by DE | displayMessage() works |
Monochrome |
Files modified:
desktopApp/.../tray/DesktopTrayIntegration.kt(NEW)desktopApp/.../Main.kt(MODIFY — addTrayinapplication {}scope)
Dependencies to add:
com.sshtools:two-slices:0.9.6(cross-platform desktop notifications with click callbacks)
Phase 7: App Launch & Auto-Login
Goal: Seamless startup with auto-login and optional lock.
Tasks:
- Modify app startup in
Main.kt:- On launch:
DesktopAccountStorage.currentAccount() - If account exists → auto-login (skip login screen)
- If no accounts → show login screen
- If lock enabled → show account picker with unlock
- On launch:
- Add lock setting to
DesktopPreferences:appLockEnabled: Boolean(default false)- When enabled: show account picker on every launch
- Optional: PIN/passphrase unlock (stretch goal)
- Migration on first launch:
- Detect
last_account.txt/bunker_uri.txt/nwc_connection.txt - Auto-migrate to new encrypted multi-account store
- Verify: read back decrypted npub matches before deleting old files
- Set migrated account as active
- Best-effort secure-delete old files (zero-overwrite + delete)
- User sees no change
- Detect
Files modified:
desktopApp/.../Main.kt(MODIFY)desktopApp/.../DesktopPreferences.kt(MODIFY — add lock setting)
System-Wide Impact
Interaction Graph
- Account switch →
AccountManager.switchAccount()→ loads new account (validates) → creates newCoroutineScope→ cancels old scope → relay connections killed →BackgroundNotificationManagerstarts tracking old account → new relay connections established → new subscriptions → UI recomposes viaAccountState.LoggedIn - Background notification →
BackgroundNotificationManagerreceives event → updatesImmutableMapStateFlow →derivedStateOfin each badge → only affected badge recomposes →DesktopTrayIntegrationupdates menu via recomposition + fires OS notification viatwo-slices
Error Propagation
- Failed relay connection during switch → show error toast, offer retry
- Corrupted encrypted storage → fallback to empty account list, show login. Nsecs survive in OS keychain
- SecureKeyStorage failure (OS keychain unavailable) → log error, fall back to encrypted file storage
- NIP-46 bunker unreachable on switch → show "Connecting to signer..." state, timeout after 30s
- Switch failure → old scope still alive (new ordering), revert to previous
AccountState.LoggedIn
State Lifecycle Risks
- Partial switch failure: With corrected ordering (load first, cancel second), old scope remains alive on failure. Revert by re-emitting previous
AccountState.LoggedIn. No unrecoverable state. - Background subscription leak:
BackgroundNotificationManagertied to app-levelCoroutineScope, cancelled in shutdown hook. Per-account jobs tracked inMutex-guarded map. - Migration data loss: Write new encrypted format → read back and verify decrypted content → only then secure-delete old files.
- NIP-46 relay pollution: Each bunker account has dedicated isolated
NostrClientwith its ownCoroutineScope(proven fix fromdocs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md).
API Surface Parity
AccountStorageinterface shared between Android (LocalPreferences) and Desktop (DesktopAccountStorage)AccountInfo,AccountStatetypes shared — both platforms use identical state representationsIAccountinterface already in commons — both platforms implement it- Session managers remain platform-specific (necessary due to Android ContentResolver/MLS deps)
Acceptance Criteria
Functional Requirements
- Can add multiple accounts (nsec, NIP-46 bunker, npub view-only)
- Can switch between accounts via sidebar dropdown
- Relay connections properly killed and re-established on switch
- Active account persists across app restarts (auto-login)
- Per-account notification counts shown in switcher (kinds 1, 6, 9735, 1059, 4)
- System tray shows active account and switch menu with dynamic updates
- Desktop notifications tagged with account name (working on all platforms)
- Clicking notification switches to that account
- Account removal deletes all associated data
- Existing single-account users auto-migrated silently
Cmd+Shift+A/Ctrl+Shift+Aopens account switcherCmd+Shift+1..9/Ctrl+Shift+1..9for direct account switching- NWC URI migrated from plaintext to encrypted storage
Non-Functional Requirements
- Account switch completes in <2s (relay reconnection)
- Background subscriptions use <5MB memory per inactive account
- Max 5 relay connections per inactive account
- Encrypted storage uses AES-256-GCM with random key in OS keychain
- No nsec stored in plaintext anywhere
- No nsec cached in AccountState (fetch on-demand from SecureKeyStorage)
Quality Gates
- Unit tests for AccountInfo/AccountState types
- Unit tests for DesktopAccountStorage (encrypt/decrypt, migration, key bootstrap)
- Unit tests for switch ordering (load-first-cancel-second)
- Unit tests for BackgroundNotificationManager (start/stop tracking, count aggregation)
- Integration test: add account, switch, verify relay reconnection
./gradlew spotlessApplypasses./gradlew :desktopApp:compileKotlinsucceeds./gradlew :commons:compileKotlinJvmsucceeds- Manual test: multi-account flow end-to-end on macOS, verify tray + notifications
Dependencies & Prerequisites
SecureKeyStoragein quartz (already implemented — used for both nsecs and metadata AES key)IAccountinterface in commons (already exists)- NIP-46 relay isolation fix (from
docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md) - Compose Desktop
TrayAPI (available in Compose Multiplatform 1.7.x) kotlinx.collections.immutable(already in project)com.sshtools:two-slices:0.9.6(NEW — cross-platform desktop notifications)
Risk Analysis & Mitigation
| Risk | Impact | Mitigation |
|---|---|---|
| NIP-46 relay pool pollution | Bunker relays leak into general pool | Dedicated isolated NostrClient per NIP-46 account (proven fix) |
| Encrypted storage corruption | Account metadata lost | Nsecs survive in OS keychain independently; re-add accounts from keychain |
| Background sub memory leak | Growing memory usage | Mutex-guarded job map, supervisorScope, app-scope cancel in shutdown hook |
| Migration breaks existing users | Can't login after update | Verify read-back before deleting old files; keep .bak as fallback |
| OS keychain unavailable | Can't store nsecs or AES key | Fallback to encrypted file (SecureKeyStorage already handles this) |
| macOS notification broken | No OS notifications | two-slices library uses native Notification Center instead of broken AWT |
| Switch failure mid-transition | Stuck in loading state | Load new account FIRST; old scope alive until success; revert on failure |
| Kind 7 reaction flood | Background sub memory explosion | Excluded from background filters by design |
Future Considerations
- Simultaneous account views — architecture supports it (clean scope per account), would need column-level account binding
- NIP multi-key bunker — if NIP-46 adds
get_public_keys, could auto-discover keys from bunker - Account sync — export/import encrypted account bundle between machines
- Drag-to-reorder accounts — if users want custom ordering
- NIP-45 reaction counts — query on foreground activation instead of background subscription
- Global keyboard shortcuts —
jnativehooklibrary for shortcuts even when app unfocused (stretch goal)
Open Questions
lastSeenTimestamppersistence — where to store per-accountsincetimestamp for background subs? In encrypted metadata file or separate preference?- NIP-65 relay list for new accounts — if relay list hasn't been fetched yet for a newly added inactive account, fetch eagerly during add or use defaults?
- Legacy NIP-04 DMs — always include kind 4 in background filters, or only if account has prior DM history?
two-slicesmaturity — evaluate library stability; fallback to in-app toast notifications (dorkbox/Notify) if issues arise- Workspace state per-account — should deck layout and workspace configuration be per-account or global?
Sources & References
Origin
- Brainstorm document: docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md — Key decisions: one-active-account model, top-of-sidebar dropdown, kill & reconnect relays, extract to commons, encrypted storage
Internal References
- Android AccountSessionManager:
amethyst/src/main/java/.../ui/screen/AccountSessionManager.kt - Android AccountCacheState:
amethyst/src/main/java/.../model/accountsCache/AccountCacheState.kt - Android LocalPreferences:
amethyst/src/main/java/.../LocalPreferences.kt - Desktop AccountManager:
desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt - Desktop DeckSidebar:
desktopApp/src/jvmMain/.../desktop/ui/deck/DeckSidebar.kt - Desktop LoginCard:
desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt - SecureKeyStorage:
commons/src/commonMain/.../commons/keystorage/SecureKeyStorage.kt - IAccount:
commons/src/commonMain/.../commons/model/IAccount.kt - NIP-46 relay isolation:
docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md - Workspace persistence pattern:
docs/plans/2026-04-17-feat-workspaces-v1c-plan.md - Cache state extraction pattern:
docs/plans/2026-03-24-feat-weakref-cache-state-extraction-plan.md