Files
amethyst/docs/plans/2026-04-23-feat-desktop-multi-account-support-plan.md
T
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
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>
2026-04-29 09:35:33 +03:00

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

  1. KMP extraction corrected — only extract types + interface to commons (not managers); AccountSessionManager/CacheState too Android-coupled
  2. Switch ordering fixed — load new account BEFORE cancelling old scope (prevents unrecoverable partial failure)
  3. Encryption key strategy changed — store random AES key in OS keychain via SecureKeyStorage instead of fragile machine-derived key
  4. SignerType changed to sealed class — carries packageName in Remote subtype instead of leaking nullable field
  5. Background notification filters specified — kinds 1, 6, 9735, 1059, 4 with #p tag; exclude kind 7 reactions (too noisy)
  6. Desktop API patterns grounded — Compose Tray dynamic menus, custom Painter for badge overlay, DialogWindow for add-account, DropdownMenu with offset for sidebar

Critical Corrections Found

  • AccountSessionManager and AccountCacheState cannot go to commons/commonMain as written (Android-only deps: ContentResolver, MLS stores, Route class)
  • java.security.KeyStore on JVM desktop provides no real security (file-based, no TPM) — use OS keychain instead
  • AccountState.LoggedIn has mutable var route — breaks @Stable contract; must be val
  • nwc_connection.txt stored in plaintext — high-severity, must migrate to encrypted storage
  • macOS TrayIcon.displayMessage() is broken since macOS 10.14+ — use two-slices library 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 SignerType as sealed class (not enum) in commons/commonMain:
    @Immutable
    sealed class SignerType {
        data object Internal : SignerType()
        data class Remote(val packageName: String) : SignerType()
        data object ViewOnly : SignerType()
    }
    
  • Create AccountInfo in commons/commonMain:
    @Immutable
    data class AccountInfo(
        val npub: String,
        val signerType: SignerType,
        val isTransient: Boolean = false,
    )
    
  • Create AccountState sealed class in commons/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 AccountStorage interface in commons/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 AccountSessionManager to use commons AccountInfo, AccountState (future PR)
  • Update Android LocalPreferences to implement AccountStorage (future PR)
  • Tests: unit tests for AccountInfo/AccountState in commons/jvmTest (deferred — type tests trivial)

Research Insights:

Kotlin patterns (kotlin-expert):

  • Use data object (Kotlin 1.9+) for Loading/LoggedOff — proper toString() and equality
  • LoggedIn must be data class with val route (not var) — var breaks @Stable/@Immutable
  • Route mutation via _accountState.update { (it as? LoggedIn)?.copy(route = newRoute) ?: it }
  • Private MutableStateFlow, public StateFlow — never expose mutable flow externally

KMP patterns (kotlin-multiplatform):

  • AccountStorage uses constructor injection because the interface is identical across platforms — only the storage substrate differs
  • SecureKeyStorage uses expect/actual because the mechanism (Android Keystore vs macOS Keychain) is fundamentally different — AccountStorage doesn'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.kt implementing AccountStorage
    • 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 AccountInfo entry
    • Write to new encrypted format, read back and verify decrypted npub matches
    • Best-effort secure-delete old files (zero-overwrite + delete)
  • Migrate nwc_connection.txt to 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.KeyStore on 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 / SecureKeyStorage already 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.txt contains wallet credentials stored in plaintext — must migrate to encrypted storage
  • .bak files should be zero-overwritten before deletion (best-effort on SSDs, but good hygiene)
  • Remove nsec from AccountState.LoggedIn state object — fetch on-demand from SecureKeyStorage instead 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() with Mutex thread-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 AccountManager to support multiple accounts:
    • MutableStateFlow<AccountState> driven by DesktopAccountStorage
    • StateFlow<List<AccountInfo>> for all accounts list
    • switchAccount(accountInfo): load new account FIRST, then cancel old scope
    • addAccount(key/bunkerUri/npub): validate, save to storage, optionally switch
    • removeAccount(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 App composable in Main.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
  • Share DesktopLocalCache across accounts (keep cache on switch)
  • Update RelayConnectionManager lifecycle 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
  • CoroutineExceptionHandler on 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 DeckState and WorkspaceManager should 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
    • BadgedBox with total unread count on avatar
    • Click opens DropdownMenu with DpOffset(x = 48.dp) (expand right of sidebar)
    • Account rows: avatar (via existing UserAvatar commons composable), name, active checkmark, per-account Badge count
    • HorizontalDivider + "Add Account" at bottom
    • AnimatedContent with fade for avatar transition on switch
  • 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..9 for direct account quick-select (also free)
  • Modify DeckSidebar.kt:
    • Replace "A" text with AccountSwitcherDropdown at top
    • Add params: activeAccount, allAccounts, unreadCounts, onSwitchAccount, onAddAccount
  • 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 LoginCard composable (already has nsec/bunker/nostrconnect tabs) — do NOT duplicate
    • Suppress "Generate New" path, different title/subtitle
  • Account context menu (right-click on account in dropdown):
    • "Remove Account" with confirmation dialog
    • "Copy npub"
  • Add "Accounts" menu to MenuBar with shortcut items per account

Research Insights:

Compose (compose-expert):

  • @Immutable on AccountInfo — passed to every row; without it, every row recomposes on any state change
  • Use derivedStateOf inside each badge to isolate per-account count reads (prevents full dropdown recomposition on any count change)
  • Use ImmutableMap from kotlinx.collections.immutable (already in project) for unreadCounts StateFlow
  • allAccounts.forEach (not LazyColumn) inside DropdownMenu — Material3 DropdownMenu uses Column internally, LazyColumn causes scroll conflicts. Fine for max ~9 accounts
  • BadgedBox + Badge — correct M3 API, available in CMP 1.7.x, no @Experimental needed
  • Use HorizontalDivider (not deprecated Divider)

Desktop (desktop-expert):

  • DropdownMenu is correct over modal Dialog for the switcher — lightweight, dismisses on outside click, expands right from 48dp sidebar
  • DialogWindow (not Dialog composable) 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=true JVM arg
  • onPreviewKeyEvent on 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-guarded mutableMapOf<String, Job>()
    • supervisorScope per 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
  • 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) (not while(true)) for cancellation
  • Wire counts into AccountSwitcherDropdown and tray menu
  • Tie to app-level CoroutineScope, cancel in shutdown hook

Research Insights:

Nostr (nostr-expert):

  • Use #p tag filters (not authors) — 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 10050 ChatMessageRelayListEvent for DM-specific inbox relays
  • Max 5 relay connections per inactive account (3 inbox + 2 DM)
  • Always set since field — prevents relay flooding on reconnect
  • Separate RelayAuthenticator for background connections (don't reuse active account's)

Coroutines (kotlin-coroutines):

  • supervisorScope inside each account job so one relay failure doesn't kill other relays
  • Mutex on accountJobs map — switch can race with notification events
  • startTracking(account) cancels existing job for that account before creating new one
  • stopTracking(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 Tray composable in application {} scope (NOT java.awt.SystemTray)
    • Dynamic menu driven by collectAsState() — updates when accounts added/removed
    • Custom Painter subclass 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
  • Desktop notification integration:
    • macOS: TrayIcon.displayMessage() is broken since macOS 10.14+ — use two-slices library (com.sshtools:two-slices:0.9.6) for native Notification Center
    • Windows/Linux: TrayIcon.displayMessage() works, or two-slices for consistency
    • two-slices supports click callbacks (defaultAction { }) for notification-to-account routing
    • Prefix with account name: [Alice] New mention from Bob
    • Click notification → switch to that account
  • macOS dark mode: set apple.awt.enableTemplateImages=true JVM arg for proper template image behavior

Research Insights:

Desktop API (desktop-api-researcher):

  • Compose Tray composable 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.addActionListener or two-slices library
  • 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
  • Tray onAction fires 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 — add Tray in application {} 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
  • 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

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 new CoroutineScope → cancels old scope → relay connections killed → BackgroundNotificationManager starts tracking old account → new relay connections established → new subscriptions → UI recomposes via AccountState.LoggedIn
  • Background notification → BackgroundNotificationManager receives event → updates ImmutableMap StateFlow → derivedStateOf in each badge → only affected badge recomposes → DesktopTrayIntegration updates menu via recomposition + fires OS notification via two-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: BackgroundNotificationManager tied to app-level CoroutineScope, cancelled in shutdown hook. Per-account jobs tracked in Mutex-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 NostrClient with its own CoroutineScope (proven fix from docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md).

API Surface Parity

  • AccountStorage interface shared between Android (LocalPreferences) and Desktop (DesktopAccountStorage)
  • AccountInfo, AccountState types shared — both platforms use identical state representations
  • IAccount interface 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+A opens account switcher
  • Cmd+Shift+1..9 / Ctrl+Shift+1..9 for 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 spotlessApply passes
  • ./gradlew :desktopApp:compileKotlin succeeds
  • ./gradlew :commons:compileKotlinJvm succeeds
  • Manual test: multi-account flow end-to-end on macOS, verify tray + notifications

Dependencies & Prerequisites

  • SecureKeyStorage in quartz (already implemented — used for both nsecs and metadata AES key)
  • IAccount interface 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 Tray API (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 shortcutsjnativehook library for shortcuts even when app unfocused (stretch goal)

Open Questions

  1. lastSeenTimestamp persistence — where to store per-account since timestamp for background subs? In encrypted metadata file or separate preference?
  2. 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?
  3. Legacy NIP-04 DMs — always include kind 4 in background filters, or only if account has prior DM history?
  4. two-slices maturity — evaluate library stability; fallback to in-app toast notifications (dorkbox/Notify) if issues arise
  5. Workspace state per-account — should deck layout and workspace configuration be per-account or global?

Sources & References

Origin

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

External References