Add OS keychain for persistent nsec sessions (roadmap #1)

- Rust: store_nsec / load_nsec / delete_nsec Tauri commands via keyring crate
  (macOS Keychain, Windows Credential Manager, Linux Secret Service)
- On nsec login: key is stored in OS keychain keyed by hex pubkey
- On startup: restoreSession() auto-loads nsec from keychain and re-establishes
  the NDK signer — no manual re-login required after restart
- On logout: keychain entry is deleted
- Graceful degradation: if keychain is unavailable (e.g. Linux without a Secret
  Service daemon), the app starts logged-out — same UX as before, no crash

Also updates ROADMAP.md with 4 new items from the Windows playtest (multi-account
switcher, NWC wizard, system tray, zap history view) and reorders the list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-10 17:21:44 +01:00
parent ee26edfe65
commit 4ef824a26a
6 changed files with 123 additions and 22 deletions
+37 -8
View File
@@ -25,12 +25,21 @@ platform that happens to live on Nostr, not a social feed that happens to suppor
- nsec sessions don't survive app restart — keychain fixes this permanently
- Tauri has keychain plugins ready (`tauri-plugin-keychain`)
### 2. SQLite note caching
### 2. Multi-account / profile switcher
- Nostr users regularly maintain separate identities (personal, professional, pseudonymous)
- Near-blocker-level friction discovered during Windows playtest — session re-login every
restart is currently the #1 UX pain point
- Depends on OS keychain (#1) — keys must persist for instant switching
- UI: small account switcher in sidebar footer; click → list of saved accounts; one click to switch
- No re-login flow — switching is instant once accounts are stored in keychain
- v1: stored nsec accounts only; v2 could add NIP-46 remote signer support
### 3. SQLite note caching
- Notes disappear on every restart — no local persistence
- Would make the app feel dramatically more solid and fast
- Rust backend is the right place for this
### 3. About / Funding page
### 4. About / Funding page
- Hardcoded in-app page with all support options
- Bitcoin on-chain address with scannable QR code
- Lightning address with scannable QR code
@@ -39,39 +48,57 @@ platform that happens to live on Nostr, not a social feed that happens to suppor
- Lives in the sidebar footer or as a dedicated view — tasteful, never nagging
- Ties into the zap infrastructure already built
### 4. Mute / ignore user + anti-spam
### 5. Mute / ignore user + anti-spam
- "Ignore this user" from profile or note context menu (NIP-51 mute list)
- Mute list persisted to Nostr so it follows you across clients
- Settings toggles for basic spam filters (e.g. hide notes from accounts < N days old,
hide notes with no followers, hide pure bot patterns)
- Consider: Web of Trust (WOT) score as an optional feed filter — needs design session
### 5. Quote / Repost (NIP-18)
### 6. Quote / Repost (NIP-18)
- "Quote" wraps a note in your own post with added commentary
- "Repost" is a plain re-broadcast (kind 6)
- Both are standard and expected by Nostr users
- Quote is more valuable — it drives conversation
### 6. Sidebar: collapsible to icon-only + auto-hide
### 7. NWC setup UX — guided wizard
- Plain-text NWC URI field is confusing for non-technical users (confirmed in Windows playtest)
- Wizard: detect wallet type (Alby Hub, Mutiny, Phoenix), deep-link to the right wallet page,
show inline validation + clear error states on connection failure
- Keep raw URI field as advanced fallback
### 8. System tray / minimize to tray
- Standard expectation for any messaging/social app on Windows
- Without it, closing the window exits — unexpected for a persistent social client
- Research needed for macOS (menu bar?) and Linux (varies by DE) before implementing
- Tauri 2.0 has a tray API — Windows implementation should be straightforward
### 9. Zap history view
- Sent and received zaps should be visible in the app
- Zap infrastructure (NIP-47 + NIP-57) already built — this is display-layer only
- v1: simple list in a "Zaps" tab on the profile view, or a section in Settings
- Good demo material for OpenSats reviewers
### 10. Sidebar: collapsible to icon-only + auto-hide
- Toggle already exists (clicking WRYSTR collapses to w-12 icons), but it's not obvious
- Make the toggle affordance clearer — a visible / button
- Auto-hide mode: sidebar expands on hover/click, collapses automatically after N seconds
of activity in the main pane
- Most important: the icon-only state should be the default or easily reachable
### 7. Profile helpers for newcomers
### 11. Profile helpers for newcomers
- **NIP-05**: link to a guide or offer a basic self-hosted verification path
- **Avatar / banner image upload**: instead of pasting a URL, let users upload directly
(NIP-96 file storage or a simple Blossom upload via Tauri)
- Newcomers fill in a URL field and have no idea what to put — this is a friction point
### 8. Search: improve full-text + people
### 12. Search: improve full-text + people
- NIP-50 full-text (`bitcoin` query) returns zero results on most relays — the UI
should detect this and suggest using `#hashtag` instead, or show which relays support it
- People search only works on NIP-50-capable relays; most don't support it
- Consider: local people search by scanning follows-of-follows graph
### 9. Direct Messages (NIP-44 / NIP-17)
### 13. Direct Messages (NIP-44 / NIP-17)
- Significant complexity (encryption, key handling, inbox model)
- Major feature gap but non-trivial to implement well
- NIP-17 (private DMs) is the modern standard; NIP-44 is the encryption layer
@@ -85,6 +112,8 @@ platform that happens to live on Nostr, not a social feed that happens to suppor
- The current UI is functional but has "amateur web app" feel on some surfaces
- Target bar remains Telegram Desktop — fast, keyboard-navigable, feels native not webby
- Specific surfaces to revisit: note cards, thread view, profile header, modals
- **Windows playtest notes (10 Mar 2026):** install went smoothly, window resize/maximise
feels native; full design review still needed
### Web of Trust (WOT)
- Nostr has a concept of social graph distance for trust scoring
+18 -1
View File
@@ -1807,6 +1807,16 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"log",
"zeroize",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -4983,8 +4993,9 @@ dependencies = [
[[package]]
name = "wrystr"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"keyring",
"serde",
"serde_json",
"tauri",
@@ -5138,6 +5149,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
+1
View File
@@ -22,4 +22,5 @@ tauri = { version = "2", features = ["devtools"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
keyring = "3"
+31 -4
View File
@@ -1,14 +1,41 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use keyring::Entry;
const KEYRING_SERVICE: &str = "wrystr";
/// Store an nsec in the OS keychain, keyed by pubkey (hex).
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
fn store_nsec(pubkey: String, nsec: String) -> Result<(), String> {
let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?;
entry.set_password(&nsec).map_err(|e| e.to_string())
}
/// Load a stored nsec from the OS keychain. Returns None if no entry exists.
#[tauri::command]
fn load_nsec(pubkey: String) -> Result<Option<String>, String> {
let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?;
match entry.get_password() {
Ok(nsec) => Ok(Some(nsec)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
/// Delete a stored nsec from the OS keychain.
#[tauri::command]
fn delete_nsec(pubkey: String) -> Result<(), String> {
let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), // already gone — that's fine
Err(e) => Err(e.to_string()),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![store_nsec, load_nsec, delete_nsec])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+2 -8
View File
@@ -4,14 +4,8 @@ import App from "./App";
import "./index.css";
import { useUserStore } from "./stores/user";
// Restore session from localStorage
const savedPubkey = localStorage.getItem("wrystr_pubkey");
const savedLoginType = localStorage.getItem("wrystr_login_type");
if (savedPubkey && savedLoginType === "pubkey") {
useUserStore.getState().loginWithPubkey(savedPubkey);
}
// Note: nsec is never stored, so nsec sessions can't be auto-restored.
// Future: restore via OS keychain.
// Restore session — pubkey (read-only) or nsec via OS keychain
useUserStore.getState().restoreSession();
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
+34 -1
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { getNDK, publishContactList } from "../lib/nostr";
import { nip19 } from "@nostr-dev-kit/ndk";
import { invoke } from "@tauri-apps/api/core";
interface UserState {
pubkey: string | null;
@@ -14,6 +15,7 @@ interface UserState {
loginWithNsec: (nsec: string) => Promise<void>;
loginWithPubkey: (pubkey: string) => Promise<void>;
logout: () => void;
restoreSession: () => Promise<void>;
fetchOwnProfile: () => Promise<void>;
fetchFollows: () => Promise<void>;
follow: (pubkey: string) => Promise<void>;
@@ -55,10 +57,13 @@ export const useUserStore = create<UserState>((set, get) => ({
set({ pubkey, npub, loggedIn: true, loginError: null });
// Store login (pubkey only, never the nsec)
// Persist pubkey for session restoration
localStorage.setItem("wrystr_pubkey", pubkey);
localStorage.setItem("wrystr_login_type", "nsec");
// Store nsec in OS keychain (best-effort — gracefully ignored if unavailable)
invoke<void>("store_nsec", { pubkey, nsec: nsecInput }).catch(() => {});
// Fetch profile and follows
get().fetchOwnProfile();
get().fetchFollows();
@@ -99,11 +104,39 @@ export const useUserStore = create<UserState>((set, get) => ({
logout: () => {
const ndk = getNDK();
ndk.signer = undefined;
const { pubkey } = get();
if (pubkey) {
invoke<void>("delete_nsec", { pubkey }).catch(() => {});
}
localStorage.removeItem("wrystr_pubkey");
localStorage.removeItem("wrystr_login_type");
set({ pubkey: null, npub: null, profile: null, follows: [], loggedIn: false, loginError: null });
},
restoreSession: async () => {
const savedPubkey = localStorage.getItem("wrystr_pubkey");
const savedLoginType = localStorage.getItem("wrystr_login_type");
if (!savedPubkey) return;
if (savedLoginType === "pubkey") {
await get().loginWithPubkey(savedPubkey);
return;
}
if (savedLoginType === "nsec") {
try {
const nsec = await invoke<string | null>("load_nsec", { pubkey: savedPubkey });
if (nsec) {
await get().loginWithNsec(nsec);
}
// If no keychain entry (first run after feature lands, or keychain unavailable),
// the user will be prompted to log in again — same as before.
} catch {
// Keychain unavailable (e.g. no secret service on this Linux session) — stay logged out.
}
}
},
fetchOwnProfile: async () => {
const { pubkey } = get();
if (!pubkey) return;