--- title: "feat: Desktop Multi-Account Support" type: feat status: active date: 2026-04-23 origin: 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 + 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:** - [x] Create `SignerType` as **sealed class** (not enum) in `commons/commonMain`: ```kotlin @Immutable sealed class SignerType { data object Internal : SignerType() data class Remote(val packageName: String) : SignerType() data object ViewOnly : SignerType() } ``` - [x] Create `AccountInfo` in `commons/commonMain`: ```kotlin @Immutable data class AccountInfo( val npub: String, val signerType: SignerType, val isTransient: Boolean = false, ) ``` - [x] Create `AccountState` sealed class in `commons/commonMain`: ```kotlin 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() } ``` - [x] Create `AccountStorage` interface in `commons/commonMain` (constructor-injected, NOT expect/actual): ```kotlin interface AccountStorage { suspend fun loadAccounts(): List 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:** - [x] 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 - [x] Implement encryption key bootstrap via OS keychain: ```kotlin 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 } ``` - [x] Implement two save paths (debounced + immediate): ```kotlin // Debounced for preference changes (500ms coalesce) fun saveAsync(accounts: List) { saveRequests.tryEmit(accounts) } // Immediate for critical operations (add, remove, logout) suspend fun saveImmediate(accounts: List) { writeToDisk(accounts) } ``` - [x] 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:** - [x] Modify `AccountManager` to support multiple accounts: - `MutableStateFlow` driven by `DesktopAccountStorage` - `StateFlow>` 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): ```kotlin suspend fun switchAccount(newInfo: AccountInfo): Result { 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>` - `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:** - [x] 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) - [x] 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()` - `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: ```kotlin // 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>` — npub → unread count - Reset count for account when user switches to it - [ ] Reconnect with exponential backoff + jitter: ```kotlin 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): ```kotlin 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 shortcuts** — `jnativehook` 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 - **Brainstorm document:** [docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md](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` ### External References - [Compose Desktop Tray API](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md) - [two-slices notifications library](https://github.com/sshtools/two-slices) - [Compose Desktop keyboard handling](https://kotlinlang.org/docs/multiplatform/compose-desktop-keyboard.html) - [DropdownMenu positioning issue #3129](https://github.com/JetBrains/compose-multiplatform/issues/3129)