diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 534c8bf..ffbb89c 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,9 +1,9 @@ -# Checkpoint — Keynctr branding and Linux installers (2026-08-31) +# Checkpoint — Release packages with profile metadata fix (2026-09-01) ## Where things are -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `76deca6` ("feat: add Cosmic theme + motion system"). -- Working tree: uncommitted Keynctr branding, packaging metadata, and Cosmic palette/branding updates, plus untracked `.directory`, `.opencode/`, `COSMIC_THEME.md`, and `KeynectrAppIconPossibility02.jpeg`. +- Project: `/home/avi/Projects/Keynctr` +- Git repo: `master` @ `3107508` ("fix: query imported metadata by relay"). +- Working tree: intended profile metadata fix is committed; unrelated UI/branding changes remain uncommitted and untracked. ## What was completed 1. **Cosmic branding update.** The Cosmic theme now uses Gold `#F3B407` and Light Blue `#87E6FB`; Cosmic’s dark sidebar presents the logo in white while retaining black-on-white artwork elsewhere. The visible brand is `SOLARPUNK SUMMIT` with `KITCHEN 484`. @@ -15,7 +15,11 @@ 6. **Keynctr branding.** Replaced the visible `SOLARPUNK SUMMIT` / `KITCHEN 484` labels with `Keynctr` in the window, page title, sidebar, home screen, settings, and tests. Rebuilt artifacts: `frontend/release/Keynctr-0.1.0.AppImage` and `frontend/release/keynectr_0.1.0_amd64.deb`. ## Commits added in this session (newest first) -- No commit added; packaging metadata and checkpoint updates remain uncommitted. +- `3107508` fix: query imported metadata by relay +- `da33652` fix: query imported metadata by public key +- `bde35bc` fix: import existing profile metadata +- `d2d773a` fix: remove Stardust background dots +- `7605f51` fix: keep NIP-46 signer subscription open - `76deca6` feat: add Cosmic theme + motion system (palette/branding refinements currently uncommitted) - `8366af7` feat: add Impeccable themes (light + dark) + unified ProfileEditModal - `832e114` checkpoint: fix delete/undo + themed auto-dismiss bar @@ -28,11 +32,21 @@ - Branding verification: `npm test`, `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run electron:build`, `npm run build`, and `npx electron-builder --linux AppImage deb` passed. - Frontend build no longer reports the Cosmic font `@import` ordering warning; standard Vite/ESM and ESLint module warnings remain. - Wayland `--ozone-platform` / `has no handler` messages on `electron:build` are harmless. +- NIP-46 fix: use a persistent subscription instead of the auto-closing `stream_events` helper, so clients can send requests after EOSE. +- Stardust verification: `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` still reports existing issues in three unrelated frontend files. +- Existing-account import verification: `cargo test` (115 passed), `cargo clippy --all-targets`, `cargo fmt --check`, `cargo build --release`, `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` reports existing issues in three frontend files. +- Imported account naming: the name field is no longer required; kind-0 `display_name`/`name` is used automatically, with a shortened npub fallback. Verification: `cargo test` (116 passed), `cargo clippy --all-targets`, `cargo fmt --check`, `cargo build --release`, `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` still reports existing issues in three frontend files. +- Corrected metadata lookup to query with the derived public-key type directly, preventing silent fallback when importing accounts. +- Release verification: Rust test suite (116 passed), clippy, fmt, release build, frontend tests (99 passed), typecheck, lint, Electron build, production build, and AppImage/Debian packaging passed. Frontend format check retains three existing warnings. ## How to resume / reproduce GUI (Cosmic): `cargo build --release && cd frontend && npm run build && npm run electron:build && npm start` (or dev: `npm run dev` in one terminal + `NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` in second). Settings → Appearance → `Cosmic — Stardust`. CLI: `cargo run -- settings set theme cosmic`. - Build installers: `cd frontend && npm run build && npm run electron:build && npx electron-builder --linux AppImage deb`. Install the `.deb` with `sudo apt install ./release/keynectr_0.1.0_amd64.deb`, or run the AppImage with `./release/SOLARPUNK\ SUMMIT-0.1.0.AppImage`. - Current installers: `sudo apt install ./release/keynectr_0.1.0_amd64.deb`, or `./release/Keynctr-0.1.0.AppImage`. +- Signer GUI: unlock vault, open `Signer`, select remote signer in the client, paste its `nostrconnect://` URI, then approve requests. CLI: `cargo run --release -- signer connect `. +- Stardust GUI: select `Settings -> Appearance -> Cosmic - Stardust`, then restart the frontend to load the updated CSS bundle. +- Import GUI: restart after rebuilding, open `Profiles -> Add existing account`, enter only the private key, and Keynctr will derive the profile name and metadata from the network. +- Release artifacts: `frontend/release/Keynctr-0.1.0.AppImage` and `frontend/release/keynectr_0.1.0_amd64.deb`, verified with `file`. ## Outstanding / next-step items - Undo restores with empty `secret_key` (stores `ProfileSummary`); needs `StoredProfile` in `undo_history` for full secret recovery. diff --git a/frontend/src/screens/ImportProfileModal.tsx b/frontend/src/screens/ImportProfileModal.tsx new file mode 100644 index 0000000..09430fb --- /dev/null +++ b/frontend/src/screens/ImportProfileModal.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { Button } from '../components/Button'; +import { ErrorText } from '../components/ErrorText'; +import { Modal } from '../components/Modal'; +import { useApp } from '../state/AppProvider'; + +export function ImportProfileModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const { importProfile } = useApp(); + const [secret, setSecret] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const labelRef = useRef(null); + + useEffect(() => { + if (open) { + setSecret(''); setError(null); setSaving(false); + requestAnimationFrame(() => labelRef.current?.focus()); + } + }, [open]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!secret.trim() || saving) return; + setSaving(true); setError(null); + try { await importProfile('', secret.trim()); onClose(); } + catch (err) { setError(err instanceof Error ? err.message : String(err)); setSaving(false); } + }; + + return +
+

Import an account using its private key. The key stays in your local vault and is never displayed.

+
+ + setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" /> + {error && {error}} +
+
+ + +
+
+
; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6037e1b..5ecf371 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3,6 +3,8 @@ Light/dark/system themes via `data-theme` on . ========================================================================= */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,500;0,8..60,600;0,8..60,700;1,8..60,400;1,8..60,500;1,8..60,600;1,8..60,700&display=swap'); + :root, :root[data-theme='light'] { --bg: #f6f4f0; @@ -353,8 +355,6 @@ html[data-theme='impeccable-dark'] .sidebar { Fonts: Source Serif 4 for body, Cormorant Garamond for headings. Sidebar: Deep (darker background). Workspace: Deep Space (dramatic vignette). */ -@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,500;0,8..60,600;0,8..60,700;1,8..60,400;1,8..60,500;1,8..60,600;1,8..60,700&display=swap'); - :root[data-theme='cosmic'] { /* Stardust dark mode colors */ --bg: #171109; @@ -365,19 +365,19 @@ html[data-theme='impeccable-dark'] .sidebar { --border-strong: #3d3220; --text: #fff5dc; --text-muted: #d5bd8d; - --primary: #efbd62; - --primary-hover: #ffdb91; - --primary-soft: rgba(239, 189, 98, 0.15); + --primary: #f3b407; + --primary-hover: #ffc933; + --primary-soft: rgba(243, 180, 7, 0.15); --on-primary: #171109; - --danger: #f08e7d; - --danger-soft: rgba(240, 142, 125, 0.15); - --warning: #f08e7d; - --warning-soft: rgba(240, 142, 125, 0.15); - --success: #74d7cb; - --success-soft: rgba(116, 215, 203, 0.15); - --info: #74d7cb; - --info-soft: rgba(116, 215, 203, 0.15); - --focus: #efbd62; + --danger: #f3b407; + --danger-soft: rgba(243, 180, 7, 0.15); + --warning: #f3b407; + --warning-soft: rgba(243, 180, 7, 0.15); + --success: #87e6fb; + --success-soft: rgba(135, 230, 251, 0.15); + --info: #87e6fb; + --info-soft: rgba(135, 230, 251, 0.15); + --focus: #f3b407; --shadow: 0 2px 8px rgba(0, 0, 0, 0.4); --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6); --radius: 8px; @@ -403,17 +403,17 @@ html[data-theme='cosmic'] h4 { html[data-theme='cosmic'] h1 { font-size: 28px; - color: #efbd62; + color: #f3b407; } html[data-theme='cosmic'] h2 { font-size: 20px; - color: #74d7cb; + color: #87e6fb; } html[data-theme='cosmic'] h3 { font-size: 18px; - color: #f08e7d; + color: #f3b407; } /* Cosmic sidebar - Deep background (darker) */ @@ -422,15 +422,13 @@ html[data-theme='cosmic'] .sidebar { border-right-color: var(--border); } +html[data-theme='cosmic'] .sidebar-logo img { + filter: invert(1); +} + /* Cosmic workspace - Deep Space background with vignette effect */ html[data-theme='cosmic'] .app-shell { background: - radial-gradient(circle at 8% 13%, rgba(255, 245, 220, 0.72) 0 1px, transparent 1.7px), - radial-gradient(circle at 27% 7%, rgba(116, 215, 203, 0.82) 0 1px, transparent 1.8px), - radial-gradient(circle at 73% 19%, rgba(255, 245, 220, 0.68) 0 1.2px, transparent 1.9px), - radial-gradient(circle at 91% 42%, rgba(240, 142, 125, 0.78) 0 1px, transparent 1.8px), - radial-gradient(circle at 61% 78%, rgba(255, 245, 220, 0.64) 0 1px, transparent 1.7px), - radial-gradient(circle at 18% 84%, rgba(239, 189, 98, 0.82) 0 1.2px, transparent 2px), radial-gradient(circle at 12% 4%, rgba(239, 189, 98, 0.52), transparent 58rem), radial-gradient(circle at 88% 12%, rgba(116, 215, 203, 0.42), transparent 46rem), radial-gradient(circle at 52% 100%, rgba(240, 142, 125, 0.36), transparent 50rem), diff --git a/src/profiles.rs b/src/profiles.rs index ac55397..77da462 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use nostr_sdk::prelude::*; use serde::Serialize; use zeroize::Zeroizing; @@ -99,6 +101,84 @@ pub fn create_profile( }) } +/// Add an existing profile from a hex or bech32 secret key. +pub fn import_profile( + vault: &mut Vault, + label: String, + secret: &str, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result { + if vault.is_encrypted() && key.is_none() { + return Err(AppError::vault_locked()); + } + + let secret_key = SecretKey::from_bech32(secret.trim()) + .or_else(|_| parse_secret_key(secret.trim())) + .map_err(|e| AppError::invalid_secret(format!("Could not import that secret key: {e}")))?; + let keys = Keys::new(secret_key); + let public_key = keys + .public_key() + .to_bech32() + .map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?; + if vault + .profiles + .iter() + .any(|profile| profile.public_key == public_key) + { + return Err(AppError::config("This account is already added.")); + } + + let secret_hex = Zeroizing::new(hex::encode(keys.secret_key().to_secret_bytes())); + let stored_secret = match &vault.crypto { + Some(_) => crate::crypto::encrypt_secret(key.expect("guarded above"), &secret_hex)?, + None => secret_hex.to_string(), + }; + let created_at = unix_timestamp()?; + let metadata = fetch_profile_metadata(&keys.public_key(), &relays::enabled_urls(settings)); + let label = if label.trim().is_empty() { + metadata + .as_ref() + .and_then(|m| m.display_name.as_deref().or(m.name.as_deref())) + .filter(|name| !name.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| shorten_npub(&keys.public_key())) + } else { + label + }; + let is_active = vault.active_profile.is_none(); + if is_active { + vault.active_profile = Some(public_key.clone()); + } + vault.profiles.push(StoredProfile { + label: label.clone(), + public_key: public_key.clone(), + secret_key: stored_secret, + created_at, + picture: metadata.as_ref().and_then(|m| m.picture.clone()), + nip05: metadata.as_ref().and_then(|m| m.nip05.clone()), + }); + + let relay_urls = relays::enabled_urls(settings); + if !relay_urls.is_empty() { + publish_metadata_blocking( + &keys, + &label, + metadata.as_ref().and_then(|m| m.picture.clone()), + metadata.as_ref().and_then(|m| m.nip05.clone()), + relay_urls, + ); + } + Ok(ProfileSummary { + label, + npub: public_key, + created_at, + is_active, + picture: metadata.as_ref().and_then(|m| m.picture.clone()), + nip05: metadata.and_then(|m| m.nip05), + }) +} + /// The per-relay outcome of publishing a profile metadata event. #[derive(Debug, Clone, Serialize)] pub struct MetadataPublishReport { @@ -414,6 +494,62 @@ fn publish_metadata_blocking( .expect("metadata publish thread panicked") } +/// Best-effort lookup of the account's latest kind-0 metadata. Import must +/// still succeed when relays are unavailable, so lookup failures are ignored. +fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option { + if relay_urls.is_empty() { + return None; + } + let public_key = *public_key; + let relay_urls = relay_urls.to_vec(); + std::thread::spawn(move || { + tokio::runtime::Runtime::new().ok()?.block_on(async move { + let client = Client::builder() + .authenticator(SignerAuthenticator::new(Keys::generate())) + .build(); + for url in &relay_urls { + let _ = client.add_relay(url.as_str()).await; + } + client.connect().and_wait(Duration::from_secs(10)).await; + let mut best = None; + for _url in relay_urls { + let events = client + .fetch_events( + Filter::new() + .kind(Kind::Metadata) + .author(public_key) + .limit(10), + ) + .timeout(Duration::from_secs(5)) + .await + .ok(); + if let Some(event) = events + .and_then(|events| events.into_iter().max_by_key(|event| event.created_at)) + { + if best + .as_ref() + .is_none_or(|current: &Event| event.created_at > current.created_at) + { + best = Some(event); + } + } + } + client.disconnect().await; + best.and_then(|event| serde_json::from_str::(&event.content).ok()) + }) + }) + .join() + .ok() + .flatten() +} + +fn shorten_npub(public_key: &PublicKey) -> String { + public_key + .to_bech32() + .map(|npub| format!("{}…{}", &npub[..10], &npub[npub.len() - 6..])) + .unwrap_or_else(|_| "Imported account".to_string()) +} + async fn publish_metadata_async( keys: &Keys, label: &str, @@ -680,6 +816,22 @@ mod tests { assert_eq!(vault.active_profile.as_deref(), Some("npub1alice")); } + #[test] + fn import_profile_allows_empty_label() { + let mut vault = Vault::empty(); + let keys = Keys::generate(); + let summary = import_profile( + &mut vault, + String::new(), + &keys.secret_key().to_bech32().unwrap(), + None, + &offline_settings(), + ) + .unwrap(); + assert!(!summary.label.is_empty()); + assert_eq!(summary.npub, keys.public_key().to_bech32().unwrap()); + } + #[test] fn summaries_never_include_secret_keys() { let vault = populated_vault(); diff --git a/src/signer.rs b/src/signer.rs index 0ee5d63..56fa85e 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -647,13 +647,13 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C client.connect().and_wait(CONNECT_TIMEOUT).await; // 4. Subscribe to the client's kind 24133 events so we hear its requests. - // `stream_events` opens its internal notification receiver as part of - // subscribing, which must happen BEFORE we announce (step 5): the client - // acknowledges within milliseconds and a receiver created afterwards would - // miss those early messages (tokio broadcast semantics). + // Do not use `stream_events` here: it is an auto-closing historical-event + // helper and ends at EOSE. NIP-46 needs a long-lived subscription because + // the app sends requests after the initial handshake. let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); - let mut events = match client.stream_events(filter).await { - Ok(events) => events, + let mut notifications = client.notifications(); + let subscription = match client.subscribe(filter).await { + Ok(subscription) => subscription, Err(err) => { signer.fail(format!("Could not subscribe for messages: {err}")); return; @@ -666,20 +666,22 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C return; } - // 6. Answer requests until the connection goes away or we are stopped. + // 6. Answer requests until stopped. Notifications are persistent and do + // not terminate after EOSE, unlike `stream_events`. loop { - let (relay_url, incoming) = match events.next().await { - Some(next) => next, - None => { + let incoming = match notifications.next().await { + Some(nostr_sdk::client::ClientNotification::Event { + subscription_id, + event, + .. + }) if subscription_id == *subscription.id() => event, + Some(nostr_sdk::client::ClientNotification::Shutdown) | None => { signer.fail("The signer connection was closed."); return; } + Some(_) => continue, }; - let event = match incoming { - Ok(event) => event, - Err(_) => continue, - }; - let _ = relay_url; + let event = *incoming; if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { continue; }