Compare commits

...

12 commits

5 changed files with 252 additions and 43 deletions

View file

@ -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 <nostrconnect://...>`.
- 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.

View file

@ -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<string | null>(null);
const labelRef = useRef<HTMLInputElement>(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 <Modal open={open} title="Add an existing account" onClose={onClose}>
<form onSubmit={submit} noValidate>
<p className="muted">Import an account using its private key. The key stays in your local vault and is never displayed.</p>
<div className="field">
<label htmlFor="import-profile-secret">Private key</label>
<input id="import-profile-secret" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" />
{error && <ErrorText>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>{saving ? 'Adding…' : 'Add account'}</Button>
</div>
</form>
</Modal>;
}

View file

@ -3,6 +3,8 @@
Light/dark/system themes via `data-theme` on <html>.
========================================================================= */
@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),

View file

@ -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<ProfileSummary, AppError> {
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<Metadata> {
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::<Metadata>(&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();

View file

@ -647,13 +647,13 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, 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<tokio::sync::Mutex<App>>, 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;
}