mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 14:11:55 -07:00
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:
+37
-8
@@ -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
|
||||
|
||||
Generated
+18
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user