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

627 lines
35 KiB
Markdown

---
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<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:**
- [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<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:**
- [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<AccountInfo>) { saveRequests.tryEmit(accounts) }
// Immediate for critical operations (add, remove, logout)
suspend fun saveImmediate(accounts: List<AccountInfo>) { 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<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):
```kotlin
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:**
- [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<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:
```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<ImmutableMap<String, Int>>` — 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)