Compare commits
No commits in common. "2604cf9d1e3c50d66dde60051d9f5098309fa4e2" and "a3b509cb09304151296e07beb616e339b8edaeda" have entirely different histories.
2604cf9d1e
...
a3b509cb09
5 changed files with 43 additions and 252 deletions
|
|
@ -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 <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.
|
||||
|
|
|
|||
|
|
@ -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<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>;
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@
|
|||
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;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
152
src/profiles.rs
152
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<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 {
|
||||
|
|
@ -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<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,
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// 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<tokio::sync::Mutex<App>>, 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue