diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index ffbb89c..534c8bf 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,9 +1,9 @@ -# Checkpoint — Release packages with profile metadata fix (2026-09-01) +# Checkpoint — Keynctr branding and Linux installers (2026-08-31) ## Where things are -- 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. +- 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`. ## 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,11 +15,7 @@ 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) -- `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 +- No commit added; packaging metadata and checkpoint updates remain uncommitted. - `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 @@ -32,21 +28,11 @@ - 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 deleted file mode 100644 index 09430fb..0000000 --- a/frontend/src/screens/ImportProfileModal.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 5ecf371..6037e1b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3,8 +3,6 @@ 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; @@ -355,6 +353,8 @@ 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: #f3b407; - --primary-hover: #ffc933; - --primary-soft: rgba(243, 180, 7, 0.15); + --primary: #efbd62; + --primary-hover: #ffdb91; + --primary-soft: rgba(239, 189, 98, 0.15); --on-primary: #171109; - --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; + --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; --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: #f3b407; + color: #efbd62; } html[data-theme='cosmic'] h2 { font-size: 20px; - color: #87e6fb; + color: #74d7cb; } html[data-theme='cosmic'] h3 { font-size: 18px; - color: #f3b407; + color: #f08e7d; } /* Cosmic sidebar - Deep background (darker) */ @@ -422,13 +422,15 @@ 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 77da462..ac55397 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use nostr_sdk::prelude::*; use serde::Serialize; use zeroize::Zeroizing; @@ -101,84 +99,6 @@ 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 { @@ -494,62 +414,6 @@ 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, @@ -816,22 +680,6 @@ 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 56fa85e..0ee5d63 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. - // 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. + // `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). let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); - let mut notifications = client.notifications(); - let subscription = match client.subscribe(filter).await { - Ok(subscription) => subscription, + let mut events = match client.stream_events(filter).await { + Ok(events) => events, Err(err) => { signer.fail(format!("Could not subscribe for messages: {err}")); return; @@ -666,22 +666,20 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C return; } - // 6. Answer requests until stopped. Notifications are persistent and do - // not terminate after EOSE, unlike `stream_events`. + // 6. Answer requests until the connection goes away or we are stopped. loop { - 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 => { + let (relay_url, incoming) = match events.next().await { + Some(next) => next, + None => { signer.fail("The signer connection was closed."); return; } - Some(_) => continue, }; - let event = *incoming; + let event = match incoming { + Ok(event) => event, + Err(_) => continue, + }; + let _ = relay_url; if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { continue; }