diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 81923f2..6597edd 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,1282 +1,62 @@ -# Checkpoint — nostr stack 0.45 security migration (2026-08-25) - -A stopping point you can return to if this session is closed. Everything below was -verified green at the moment this file was written. - -## Where things are - -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `fa5ba08` ("Security: upgrade nostr stack 0.40->0.45 - clearing 11 RustSec advisories"). Before it: `bf10ae3` (Checkpoint: clarify - NIP-42 status), `e210f17` (Checkpoint: DRY modal save lifecycle / R3), - `7098e75` (refactor: share modal save lifecycle), `e6a1efc` (R4), - `6d5063d` (R2), `1c428a9` (R1). -- Working tree: only the long-standing user changes remain — `concept3.svg` - deleted, `KeynectrAppIconPossibility02.jpeg` untracked. - -## What was completed - -1. **Security migration of the Nostr stack (`fa5ba08`).** `cargo audit` was - installed on this machine (`cargo install cargo-audit`, v0.22.2) and its - first scan found **11 RustSec vulnerabilities** in nostr 0.40 / - relay-pool 0.40.1 — including forged-event signature-bypass (0224), - NIP-46 credential Debug leak (0225), and auth-challenge memory - exhaustion (0231). Upgraded to `nostr 0.45.3` + `nostr-sdk 0.45.2` - (secp256k1 0.30), clearing every vulnerability and all 4 warnings. - Code adaptations: `Client::builder().authenticator(SignerAuthenticator)` - replaces implicit signer (NIP-42 auto-auth stays working); signing moved - from `EventBuilder::sign` to `finalize_async`; nip44 encrypt now takes an - explicit random nonce (getrandom); feed + signer receive loops moved to - `stream_events` (signer's notification stream still opens BEFORE the - announce, preserving the ordering fix); contact p-tags parsed via - `single_letter_tag()`; `relay()` returns Option; `try_connect().timeout()`. - Dropped unused `nip46` feature (signer is hand-rolled). Dry-run first: - bump-only build proved zero dependency-crate failures / no MSRV issues; - all 24 errors were exactly the planned API edits. -2. **New regression tests (+2, suite now 115).** Malformed NIP-44 payloads - (not-base64, empty, truncated, bad version byte) reject cleanly with no - panic; error strings never contain secret-key material (0225 class). -3. Earlier today: R3 modal-save dedup (`7098e75`) completing the DRY audit, - NIP-42 clarification docs (`bf10ae3`). - -## Commits added most recently - -- `fa5ba08` Security: upgrade nostr stack 0.40->0.45 clearing 11 RustSec advisories - -## Verification commands run (all green) - -Rust (repo root): cargo fmt --check clean; cargo clippy --all-targets only -the two pre-existing profiles.rs warnings; cargo test 115 passed (release -mode re-run too); cargo build --release success. -Frontend (`frontend/`, untouched): typecheck clean; format:check clean; -npm test 15 files / 99 tests; vite build success; electron:build success. -Live: probe built on 0.45 stack published kind-1 notes accepted by -wss://soloco.nl and wss://relay.primal.net within the 6 s watchdog. -Pre-commit audit per user protocol: staged diff = Cargo.toml/Cargo.lock/src -only; git diff --check clean. - -## How to use / reproduce - -No user-facing change. Rebuild + restart to pick up the new backend: - -```bash -cd ~/Projects/Nostr_Keynctr && cargo build --release -cd frontend && npm run build && npm start -``` - -Re-check Updates card: Rust advisory section should now list no -vulnerabilities (cargo-audit is installed machine-wide). - -## Notes & next steps - -- wss://nostr.l484.com had a DNS-resolution blip at the very end of the - session (gaierror for ~a minute); it worked earlier today including a - stored test event. If it stays down, check the relay host — not app code. -- Probe harnesses live in /tmp/opencode (nip42-probe on 0.45; python raw - readers). Copy into scripts/ if they should survive reboots. -- Pre-existing clippy warnings in src/profiles.rs remain untouched by request. -- Relay health today: nos.lol 502 intermittent, nostr.wine 403, - l484.com transient DNS failure at session end, nostr.band unreachable; - primal/mom/soloco healthy. - ---- - -# Older checkpoint — DRY modal save lifecycle / R3 complete (2026-08-25) - -A stopping point you can return to if this session is closed. Everything below was -verified green at the moment this file was written. - -## Where things are - -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `7098e75` ("refactor: share modal save lifecycle"). - Before it: `e6a1efc` (refactor: share hex-id shortening in lib/format), - `ec237bf` (Checkpoint: relay pool bootstrap), `6d5063d` - (refactor: share relay pool bootstrap), `1c428a9` (DRY fan-out R1), - `b21678f` (signer-fix checkpoint), `64ad94d` (NIP-46 handshake fix). -- Working tree after this checkpoint commit: only the long-standing user - changes remain — `concept3.svg` deleted and - `KeynectrAppIconPossibility02.jpeg` untracked, exactly as the user had them. - -## What was completed - -1. **R3 modal save-lifecycle dedup (`7098e75`).** The three profile modals in - `frontend/src/screens/ProfilesScreen.tsx` (Picture, Rename, NIP-05) each - carried an identical ~20-line async save lifecycle. Now a single - module-local `runModalSave` helper owns it: clear error → set saving → - claim publishing slot (`onSavingChange(npub)`) → run API → build success - message from the publish report → report via `onSaved`, or on failure - surface the error via `onError` → always release saving state and the slot - in `finally`. Each modal keeps only what is genuinely its own: the API call, - its success-message logic inline (including NIP-05's set/removed branch), - and RenameModal's `canSave` guard outside the helper. No user-facing change: - callback order, messages, buttons, props, and tests untouched. - With this, all four DRY-audit items (R1–R4) are complete. -2. Earlier: R4 hex-id shortening (`e6a1efc`), R2 relay-pool bootstrap - (`6d5063d`), R1 fan-out dedup (`1c428a9`) — details in the older checkpoints - below. - -## Commits added most recently - -- `7098e75` refactor: share modal save lifecycle - -## Verification commands run (all green) - -Rust (repo root): cargo test 113 passed; cargo clippy --all-targets finished -(pre-existing profiles.rs warnings only); cargo fmt --check clean; -cargo build --release success. -Frontend (`frontend/`): npm test 15 files / 99 tests passed; typecheck clean; -lint clean; format:check clean; npm run build success; electron:build success. -Pre-commit audit per user protocol: staged diff contained only -ProfilesScreen.tsx; git diff --check clean; nothing else staged. - -## How to use / reproduce - -No user-facing change. Profiles → Picture / Edit name / NIP-05 modals behave -exactly as before; any future change to the shared save policy happens in one -place: `runModalSave` in `frontend/src/screens/ProfilesScreen.tsx` -(just above `Nip05Modal`). - -## Notes & next steps - -- **NIP-42 status clarified (2026-08-25 addendum).** Earlier notes saying - "l484.com needs NIP-42 auth" described *raw probes* that don't answer - challenges — not a Keynectr gap. Verified: nostr-sdk 0.40 attaches the - signer in `Client::new` and enables NIP-42 auto-authentication by default - (`nip42_auto_authentication: true`), so AUTH challenges on read AND write - are answered automatically on every Keynectr path. Live test against - `wss://nostr.l484.com` replicating Keynectr's exact client setup: event - accepted in 0.3 s (inside the 6 s `RELAY_SEND_TIMEOUT`) and retrievable - afterwards. Known edge, left as-is by choice: the SDK's worst-case auth - dance (~7 s wait + resend) can exceed the 6 s watchdog on a very slow - authed relay → "did not respond in time"; healthy relays finish in <1 s. - Probe harness: `/tmp/opencode/nip42-probe` (throwaway key, same crates). -- DRY audit items R1–R4 are all complete; nothing left on hold from that list. -- Pre-existing clippy warnings in src/profiles.rs remain untouched by request. -- Relay health today: nos.lol 502 (intermittent), nostr.wine 403, - l484.com sends NIP-42 AUTH challenges but works fine with Keynectr - (auto-answered — see addendum above), nostr.band unreachable; - primal/mom/soloco healthy. -- Client visibility rule of thumb confirmed today: a note appears only on - clients whose read relays overlap the relays it was published to; if a - note "is missing" somewhere, first compare relay lists (Primal indexes - broadly; Iris/Yakihonne read narrower sets). - ---- - -# Older checkpoint — DRY hex-id shortening (2026-08-24) - -A stopping point you can return to if this session is closed. Everything below was -verified green at the moment this file was written. - -## Where things are - -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `e6a1efc` ("refactor: share hex-id shortening in lib/format"). - Before it: `ec237bf` (Checkpoint: relay pool bootstrap), `6d5063d` - (refactor: share relay pool bootstrap), `1c428a9` (DRY fan-out R1), - `b21678f` (signer-fix checkpoint), `64ad94d` (NIP-46 handshake fix), - `dd50b24` (Aurora rename). -- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted - and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. - -## What was completed - -1. **R4 hex-id shortening (`e6a1efc`).** Exported `shortHexId` from - `frontend/src/lib/format.ts` with verbatim logic from the former local - `shortHex` in `SignerScreen.tsx`; SignerScreen now imports and calls - `shortHexId`; added `format.test.ts` with 7 focused tests (empty, len 16, - len 17, 64-hex, shortenNpub disabled, enabled truncation). Output behavior - is byte-identical: >16 uses first 8 chars + Unicode ellipsis U+2026 + last 8, - ≤16 passes through unchanged. Does not reuse `shortenNpub`; preserves - existing visual output exactly per audit constraint. -2. **R2 DRY refactor (`6d5063d`).** New `relays::open_pool(keys, urls, - wait: Option) -> Result` is the single pool-construction - path (add each relay with strict error propagation, connect, optional - wait). Adopted by feed's two fetchers (`Keys::generate()` + 10 s wait) and - note publishing (`keys.clone()`, no wait). `profiles.rs` intentionally NOT - adopted — its unique ignore-add-errors policy stays local; it regained an - explicit `connect()` when `send_to_all_relays` dropped its internal one - (avoids double-connect on the note path). `signer.rs` deliberately - untouched: ordering-critical notification setup + fail-to-status error model. - Net −20 lines; behavior, error strings, timing unchanged. -3. Earlier this session: R1 fan-out dedup (`1c428a9`), NIP-46 handshake fix - (`64ad94d`), Aurora rename + dropdown fix (`dd50b24`), frosted-glass look - (`ecb666b`), glass build fix (`bfe14f0`). - -## Commits added most recently - -- `e6a1efc` refactor: share hex-id shortening in lib/format -- `6d5063d` refactor: share relay pool bootstrap - -## Verification commands run (all green) - -Rust: cargo test 113 passed; clippy only pre-existing profiles.rs warnings; -fmt clean; release binary rebuilt. -Frontend (untouched): npm test 99; typecheck, lint, format, electron:build, -vite build all clean. -Pre-commit audit per user protocol: git diff --check clean; signer.rs empty -diff; 3 test files added (format.test.ts with 7 new tests). - -## How to use / reproduce - -No user-facing change. Future connection-policy work (e.g., NIP-42 auth for -nostr.l484.com, retries) now starts in `relays::open_pool`. - -## Notes & next steps - -- Remaining DRY items by user request on hold: R3 (modal save-lifecycle hook). -- R4 (shortHex vs shortenNpub) is complete: shared `shortHexId` in lib/format, - output preserved exactly, tests green. -- Relay health today: nos.lol 502, nostr.wine 403, l484.com needs NIP-42 auth, - nostr.band unreachable; primal/mom/soloco healthy. ---- - -# Older checkpoint — DRY relay fan-out (2026-08-24) - -A stopping point you can return to if this session is closed. Everything below was -verified green at the moment this file was written. - -## Where things are - -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `1c428a9` ("DRY: single parallel relay fan-out for notes - and metadata"). Before it: `64ad94d` (NIP-46 handshake fix), `b21678f`/ - `ae84526` (checkpoints), `dd50b24` (Aurora rename). -- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted - and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. - -## What was completed - -1. **R1 DRY refactor (`1c428a9`).** The parallel relay send block (~55 lines: - JoinSet per-relay sends, 6 s timeout, indexed outcome collection, - disconnect, succeeded/failed split) existed twice — in - `publish.rs::publish_with_keys` and `profiles.rs::publish_metadata_async`. - Now one `pub(crate) publish::send_to_all_relays(client, urls, event, noun)` - serves both; callers keep their distinct add-relay policies (note path - aborts on add errors, metadata ignores them) so behavior, error strings, - and public APIs are unchanged. Removed duplicate constant - `METADATA_SEND_TIMEOUT`, dead helper `failure_for`. Net −49 lines. -2. Deliberately NOT done (pending approval): R2 shared client-bootstrap - helper, R3 modal save-lifecycle hook (frontend), R4 shortHex/shortenNpub. - -## Commits added most recently - -- `1c428a9` DRY: single parallel relay fan-out for notes and metadata - -## Verification commands run (all green) - -Rust: cargo fmt --check clean; clippy only pre-existing profiles.rs warnings; -cargo test 113 passed; release binary rebuilt. -Frontend (untouched, suite rerun): npm test 92 passed; typecheck, lint, -format:check, electron:build, vite build all clean. - -## How to use / reproduce - -No user-facing change; note and metadata publishing behave exactly as before. -Any future change to relay-send policy now happens in one place: -`send_to_all_relays` in `src/publish.rs`. - -## Notes & next steps - -- R2–R4 from the DRY audit remain unimplemented by request. -- Relay health today: nos.lol 502, nostr.wine 403, nostr.l484.com needs - NIP-42 auth, nostr.band unreachable; primal/mom/soloco healthy. - ---- - -# Older checkpoint — NIP-46 signer handshake fix (2026-08-24) - -A stopping point you can return to if this session is closed. Everything below was -verified green at the moment this file was written. - -## Where things are - -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `64ad94d` ("Signer: listen before announcing so the - handshake reply is not lost"). Before it: `dd50b24` (Aurora rename + - dropdown fix), `ecb666b` (frosted-glass look), `bfe14f0` (glass build fix). -- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted - and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. - -## What was completed - -1. **Root-caused the "Yakihonne never leaves the QR screen" bug (`64ad94d`).** - Reproduced with a reference NIP-46 client harness over live relays - (`/tmp/opencode/nc-client`): the app's announcement arrived, the client's - `ack` + `get_public_key` were answered to nobody — the signer task created - its notification receiver *after* publishing the announce, so the client's - millisecond-fast handshake reply landed in the broadcast channel before any - receiver existed and was dropped. Client waits forever → stuck QR. - Fix: open `client.notifications()` immediately after adding relays, before - connect/subscribe/announce. Post-fix round-trip PASSES end-to-end - (announce → ack → get_public_key → pubkey returned). -2. Relay observations from testing: nos.lol 502 today; nostr.wine 403 - (auth/paid); nostr.l484.com sends NIP-42 AUTH challenges; healthy: - relay.primal.net, nostr.mom, soloco.nl. - -## Commits added most recently - -- `64ad94d` Signer: listen before announcing so the handshake reply is not lost - -## Verification commands run (all green) - -Rust (repo root): cargo test 113 passed; clippy (pre-existing profiles.rs -warnings only); fmt clean; release binary rebuilt with the fix. -Frontend (`frontend/`): npm test 92 passed; typecheck, lint, format clean. -End-to-end: reference NIP-46 client + `keynectr signer connect ` over -wss://nostr.mom, relay.primal.net, soloco.nl — full handshake PASS. - -## How to use / reproduce - -GUI: `cd ~/Projects/Nostr_Keynctr/frontend && npm start` → Signer → paste a -client's nostrconnect:// link → approve requests. With Yakihonne: choose its -remote-signer/connect option, copy its link into Keynectr within its timeout; -Yakihonne should now switch from the QR screen to the logged-in state. - -Harness (if ever needed again): `/tmp/opencode/nc-client` — `nc-client` -prints a nostrconnect URI to /tmp/opencode/uri.txt and PASSes on round-trip; -run `target/release/keynectr signer connect $(cat /tmp/opencode/uri.txt)`. - -## Notes & next steps - -- If a client is still slow, remaining edge: relays that reject kind 24133 or - need auth can starve the handshake — consider surfacing per-relay connect - status in the Signer UI later. -- Pre-existing clippy warnings in src/profiles.rs untouched by request. - ---- - -# Older checkpoint — Aurora rename + dropdown fix (2026-08-24) - -A stopping point you can return to if this session is closed. Everything below was -verified green at the moment this file was written. - -## Where things are - -- Project: `/home/avi/Projects/Nostr_Keynctr` -- Git repo: `master` @ `dd50b24` ("Rename glass theme display to Aurora; fix - unreadable select options"). Before it: `ecb666b` (frosted-glass look), - `0cab883`/`1aae81c` (checkpoints), `bfe14f0` (glass fix). -- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted - and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. - -## What was completed - -1. **Theme renamed to Aurora (`dd50b24`).** User picked "Aurora" from options - (Aurora/Frost/Glacier/Ice/keep). Display label only — internal id stays - `glass`, so existing settings.json values need no migration. -2. **Fixed unreadable theme dropdown (`dd50b24`).** Under the glass theme the - native ` setPickedProfile(event.target.value)} - > - {profiles.map((profile) => ( - - ))} - - )} - - - )} - - - - - - ); -} - -function RenameModal({ - target, - onClose, - onSaved, - onError, - onSavingChange, -}: { - target: { npub: string; label: string }; - onClose: () => void; - onSaved: (message: string) => void; - onError: (message: string | null) => void; - onSavingChange: (npub: string | null) => void; -}) { - const { renameProfile } = useApp(); - const [label, setLabel] = useState(target.label); - const [saving, setSaving] = useState(false); - - const trimmed = label.trim(); - const canSave = trimmed.length > 0 && trimmed !== target.label; - - const save = async () => { - if (!canSave) { - return; - } - await runModalSave({ - npub: target.npub, - perform: () => renameProfile(target.npub, trimmed), - successMessage: (report) => - report.failed.length === 0 - ? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.` - : `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, - onSaved, - onError, - onSavingChange, - setSaving, - }); - }; - - return ( - -
-
- - setLabel(event.target.value)} - placeholder="My Profile" - autoComplete="off" - autoFocus - /> -
-

- The name is stored on this computer and published to your enabled relays so other Nostr - clients show it. -

-
- - -
-
-
- ); -} - function PictureModal({ target, onClose, @@ -530,18 +261,22 @@ function PictureModal({ const [saving, setSaving] = useState(false); const save = async (nextUrl: string | null) => { - await runModalSave({ - npub: target.npub, - perform: () => setProfilePicture(target.npub, nextUrl), - successMessage: (report) => + onError(null); + setSaving(true); + onSavingChange(target.npub); + try { + const report = await setProfilePicture(target.npub, nextUrl); + onSaved( report.failed.length === 0 ? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).` : `Picture saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, - onSaved, - onError, - onSavingChange, - setSaving, - }); + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + onSavingChange(null); + } }; const onUpload = async () => { diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx index 0db240f..88d013f 100644 --- a/frontend/src/screens/SettingsScreen.tsx +++ b/frontend/src/screens/SettingsScreen.tsx @@ -1,24 +1,17 @@ import { useState } from 'react'; import { Alert } from '../components/Alert'; -import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { CopyButton } from '../components/CopyButton'; import { Icon } from '../components/Icon'; -import { Spinner } from '../components/Spinner'; import { Toggle } from '../components/Toggle'; import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal'; -import type { Theme, UpdateCheckReport } from '../lib/types'; +import type { Theme } from '../lib/types'; import { useApp } from '../state/AppProvider'; export function SettingsScreen() { - const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp(); + const { state, updateSettings, backupNow } = useApp(); const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null); const [passwordModal, setPasswordModal] = useState(null); - const [updateReport, setUpdateReport] = useState(null); - const [checking, setChecking] = useState(false); - const [installing, setInstalling] = useState(false); - const [updateError, setUpdateError] = useState(null); - const [applyMessage, setApplyMessage] = useState(null); const settings = state?.settings; const vaultPath = state?.vault_path ?? ''; @@ -51,38 +44,6 @@ export function SettingsScreen() { } }; - const onCheckUpdates = async () => { - setChecking(true); - setUpdateError(null); - setApplyMessage(null); - setUpdateReport(null); - try { - setUpdateReport(await updateCheck()); - } catch (err) { - setUpdateError(err instanceof Error ? err.message : String(err)); - } finally { - setChecking(false); - } - }; - - const onInstallUpdates = async () => { - setInstalling(true); - setUpdateError(null); - setApplyMessage(null); - try { - const result = await updateApply(); - const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)]; - if (result.restart_required) { - lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.'); - } - setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']); - } catch (err) { - setUpdateError(err instanceof Error ? err.message : String(err)); - } finally { - setInstalling(false); - } - }; - return (
@@ -104,13 +65,9 @@ export function SettingsScreen() { > - - + -

- “Light” and “Dark” follow your manual selection. “Aurora” and “Neon” are aesthetic - themes with distinctive color palettes. -

+

“System” follows your desktop's light or dark preference.

@@ -196,128 +153,6 @@ export function SettingsScreen() { -
-
-

Updates

-
-
-

- Scans the JavaScript packages and Rust crates this app is built on. Known security - advisories are always listed first; installing applies compatible updates only. -

-
- - -
- - {updateError && ( - - {updateError} - - )} - - {checking && } - - {applyMessage && ( - -
    - {applyMessage.map((line) => ( -
  • {line}
  • - ))} -
-
- )} - - {updateReport && !checking && ( - <> - {updateReport.advisories.length > 0 ? ( - -
    - {updateReport.advisories.map((advisory) => ( -
  • - - {advisory.severity} - {' '} - {advisory.package} - {advisory.title ? ` — ${advisory.title}` : ''} - {advisory.fix && {advisory.fix}} -
  • - ))} -
-
- ) : ( - - )} - - {updateReport.outdated_npm.length > 0 && ( -
- JavaScript packages -
    - {updateReport.outdated_npm.map((pkg) => ( -
  • - {pkg.name} {pkg.current} →{' '} - {pkg.available} -
  • - ))} -
-
- )} - - {updateReport.outdated_cargo.length > 0 && ( -
- Rust crates -
    - {updateReport.outdated_cargo.map((crateName) => ( -
  • - {crateName.name} {crateName.current} →{' '} - {crateName.available} -
  • - ))} -
-
- )} - - {updateReport.notes.map((note) => ( -

- {note} -

- ))} - - {updateReport.advisories.length === 0 && - updateReport.outdated_npm.length === 0 && - updateReport.outdated_cargo.length === 0 && ( - Everything is up to date. - )} - - )} -
-
-

Advanced

diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 1b3c1ac..4f548b2 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -4,7 +4,6 @@ import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; -import { shortHexId } from '../lib/format'; import type { SignerStatus } from '../lib/types'; import { useApp } from '../state/AppProvider'; @@ -16,6 +15,11 @@ const EMPTY_STATUS: SignerStatus = { pending: [], }; +/** Shorten a 64-char hex key for display. */ +function shortHex(value: string): string { + return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value; +} + export function SignerScreen() { const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp(); const [status, setStatus] = useState(EMPTY_STATUS); @@ -133,7 +137,7 @@ export function SignerScreen() {
{status.peer ? ( - {shortHexId(status.peer)} + {shortHex(status.peer)} ) : ( None yet diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 0970bf4..b248a2d 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -21,8 +21,6 @@ import type { Settings, SignerStatus, Theme, - UpdateApplyReport, - UpdateCheckReport, UploadedImage, } from '../lib/types'; @@ -43,18 +41,14 @@ interface AppContextValue { selectProfile: (npub: string) => Promise; publishProfileMetadata: (npub: string) => Promise; setProfilePicture: (npub: string, url: string | null) => Promise; - renameProfile: (npub: string, label: string) => Promise; - setNip05: (npub: string, nip05: string | null) => Promise; publishNote: (content: string) => Promise; recordPublishFailure: (message: string, details?: string | null) => void; clearLastPublish: () => void; - feedGet: (limit?: number, contactsOnly?: boolean, authorNpub?: string) => Promise; + feedGet: (limit?: number, contactsOnly?: boolean) => Promise; relayAdd: (url: string) => Promise; relayRemove: (url: string) => Promise; relaySetEnabled: (url: string, enabled: boolean) => Promise; relayTest: (url: string) => Promise; - updateCheck: () => Promise; - updateApply: () => Promise; updateSettings: ( patch: Partial>, ) => Promise; @@ -140,24 +134,6 @@ export function AppProvider({ children }: { children: ReactNode }) { [], ); - const renameProfile = useCallback( - async (npub: string, label: string): Promise => { - const result = await api.renameProfile(npub, label); - setState(result.state); - return result.report; - }, - [], - ); - - const setNip05 = useCallback( - async (npub: string, nip05: string | null): Promise => { - const result = await api.setNip05(npub, nip05); - setState(result.state); - return result.report; - }, - [], - ); - const publishNote = useCallback(async (content: string): Promise => { const report = await api.publishNote(content); setLastPublish({ report, error: null, details: null, at: Date.now() }); @@ -172,8 +148,8 @@ export function AppProvider({ children }: { children: ReactNode }) { setLastPublish(null); }, []); - const feedGet = useCallback((limit?: number, contactsOnly?: boolean, authorNpub?: string) => { - return api.feedGet(limit, contactsOnly, authorNpub); + const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => { + return api.feedGet(limit, contactsOnly); }, []); const applySettings = useCallback((fresh: Settings) => { @@ -194,8 +170,6 @@ export function AppProvider({ children }: { children: ReactNode }) { [applySettings], ); const relayTest = useCallback((url: string) => api.relayTest(url), []); - const updateCheck = useCallback(() => api.updateCheck(), []); - const updateApply = useCallback(() => api.updateApply(), []); const updateSettings = useCallback( async (patch: Partial>) => applySettings(await api.settingsUpdate(patch)), @@ -239,11 +213,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const copyText = useCallback((text: string) => api.copyText(text), []); - useEffect(() => { - if (state?.settings.theme) { - applyTheme(state.settings.theme); - } - }, [state?.settings.theme]); + useThemeSync(state?.settings.theme); const value = useMemo( () => ({ @@ -262,8 +232,6 @@ export function AppProvider({ children }: { children: ReactNode }) { relayRemove, relaySetEnabled, relayTest, - updateCheck, - updateApply, updateSettings, backupNow, setVaultPassword, @@ -282,8 +250,6 @@ export function AppProvider({ children }: { children: ReactNode }) { undoDelete, publishProfileMetadata, setProfilePicture, - renameProfile, - setNip05, copyText, }), [ @@ -296,8 +262,6 @@ export function AppProvider({ children }: { children: ReactNode }) { selectProfile, publishProfileMetadata, setProfilePicture, - renameProfile, - setNip05, publishNote, deleteProfile, undoDelete, @@ -308,8 +272,6 @@ export function AppProvider({ children }: { children: ReactNode }) { relayRemove, relaySetEnabled, relayTest, - updateCheck, - updateApply, updateSettings, backupNow, setVaultPassword, @@ -339,13 +301,12 @@ export function useApp(): AppContextValue { return context; } -/** Apply the requested theme. */ +/** Apply the requested theme (respecting system preference for `system`). */ export function applyTheme(theme: Theme): void { - if (theme === 'glass' || theme === 'neon') { - document.documentElement.dataset.theme = theme; - return; - } - document.documentElement.dataset.theme = theme; + const prefersDark = + theme === 'dark' || + (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light'; } /** Keep the document theme in sync with settings, watching system changes. */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 947ee5e..beb19db 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -58,113 +58,6 @@ --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6); } -/* "Neon" — pure-black Matrix/Cyberpunk palette inspired by the CAJAFUERTE - Obsidian theme (MIT): black panels, deep-pink primary (#ff1493), purple - secondary (#8b00ff family), yellow highlights (#ffd700). */ -:root[data-theme='neon'] { - --bg: #000000; - --surface: #0b0b0e; - --surface-2: #131318; - --surface-hover: #191921; - --border: #2b1224; - --border-strong: #4d1c3d; - --text: #f5f2f7; - --text-muted: #a89bb0; - --primary: #ff1493; - --primary-hover: #ff4fb0; - --primary-soft: #2b0a1e; - --on-primary: #ffffff; - --danger: #ff3355; - --danger-soft: #330810; - --warning: #ffd700; - --warning-soft: #332b03; - --success: #00e68a; - --success-soft: #04301f; - --info: #b388ff; - --info-soft: #1c1035; - --focus: #ff1493; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.6), 0 8px 24px rgba(255, 20, 147, 0.07); - --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.85), 0 0 32px rgba(255, 20, 147, 0.12); -} - -/* "Aurora" theme (internal id "glass") — frosted-glass aesthetic inspired - by the Meridian Obsidian theme (MIT): translucent blurred panels floating - over an aurora-lit deep blue-black field, cyan accents, hairline light - borders. */ -:root[data-theme='glass'] { - --bg: #0a0d13; - --surface: rgba(23, 29, 41, 0.58); - --surface-2: rgba(255, 255, 255, 0.05); - --surface-hover: rgba(255, 255, 255, 0.09); - --border: rgba(255, 255, 255, 0.1); - --border-strong: rgba(255, 255, 255, 0.2); - --text: #e8ecf4; - --text-muted: #96a2b6; - --primary: #7fdbff; - --primary-hover: #a3e5ff; - --primary-soft: rgba(127, 219, 255, 0.13); - --on-primary: #06121c; - --danger: #ff8080; - --danger-soft: rgba(255, 107, 107, 0.14); - --warning: #ffc46b; - --warning-soft: rgba(240, 173, 79, 0.15); - --success: #7ed99a; - --success-soft: rgba(92, 184, 92, 0.16); - --info: #82c9ea; - --info-soft: rgba(91, 192, 222, 0.14); - --focus: #7fdbff; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 36px rgba(0, 0, 0, 0.38); - --shadow-modal: 0 18px 56px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.07) inset; - color-scheme: dark; -} -/* Glass scene: the aurora backdrop lives on ; panels above it are - translucent and blurred so the glow reads through them. */ -html[data-theme='glass'] body { - background: - radial-gradient(1000px 640px at 10% -10%, rgba(127, 219, 255, 0.15), transparent 60%), - radial-gradient(880px 560px at 92% 6%, rgba(150, 130, 255, 0.12), transparent 58%), - radial-gradient(760px 720px at 50% 120%, rgba(91, 192, 222, 0.1), transparent 62%), #0a0d13; -} - -html[data-theme='glass'] .app-shell, -html[data-theme='glass'] .main { - background: transparent; -} - -/* Frosted panes: every major surface blurs whatever sits behind it. */ -html[data-theme='glass'] .card, -html[data-theme='glass'] .sidebar, -html[data-theme='glass'] .modal, -html[data-theme='glass'] .empty-state, -html[data-theme='glass'] .profile-card, -html[data-theme='glass'] .compose-preview { - -webkit-backdrop-filter: blur(18px) saturate(160%); - backdrop-filter: blur(18px) saturate(160%); -} - -html[data-theme='glass'] input[type='text'], -html[data-theme='glass'] input[type='search'], -html[data-theme='glass'] input[type='password'], -html[data-theme='glass'] select, -html[data-theme='glass'] textarea { - background: rgba(255, 255, 255, 0.045); - border-color: rgba(255, 255, 255, 0.16); -} - -/* The native select popup inherits the select's near-transparent - background, which renders white with light text — give options an - explicit dark surface so entries stay readable. */ -html[data-theme='glass'] select option { - background-color: #141a26; - color: var(--text); -} - -html[data-theme='glass'] .modal-backdrop { - background: rgba(4, 7, 12, 0.5); - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); -} - * { box-sizing: border-box; } @@ -359,25 +252,6 @@ a { cursor: not-allowed; } -.feed-profile-select { - height: 34px; -} - -.update-list { - margin: 6px 0 0; - padding-left: 18px; - display: grid; - gap: 4px; - font-size: 14px; -} - -.update-summary { - margin: 6px 0 0; - padding-left: 18px; - display: grid; - gap: 4px; -} - /* ------------------------------------------------------------------------- Sidebar ------------------------------------------------------------------------- */ @@ -406,23 +280,8 @@ a { border-radius: 11px; display: grid; place-items: center; - background: #fff; - overflow: hidden; -} - -.sidebar-logo img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -/* The artwork is black-on-white; flip it in dark themes so it stays black - bird on dark tile instead of a glaring white square. */ -html[data-theme='dark'] .sidebar-logo img, -html[data-theme='neon'] .sidebar-logo img, -html[data-theme='glass'] .sidebar-logo img { - filter: invert(1); + background: var(--primary); + color: var(--on-primary); } .sidebar-brand strong { @@ -1189,14 +1048,6 @@ select { border-color: var(--success); } -.profile-card:not(.is-active) { - cursor: pointer; -} - -.profile-card:not(.is-active):hover { - border-color: var(--primary); -} - .profile-card-top { display: flex; align-items: center; @@ -1229,7 +1080,6 @@ select { .profile-card-actions { display: flex; - flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; diff --git a/frontend/src/test/FeedScreen.test.tsx b/frontend/src/test/FeedScreen.test.tsx index bb2a93c..d0bacc8 100644 --- a/frontend/src/test/FeedScreen.test.tsx +++ b/frontend/src/test/FeedScreen.test.tsx @@ -24,7 +24,7 @@ describe('FeedScreen', () => { it('disable relays shows an empty state that can navigate to relays', async () => { const settings = { - theme: 'light' as const, + theme: 'system' as const, confirm_before_publish: true, shorten_npub: true, relays: [ @@ -107,55 +107,4 @@ describe('FeedScreen', () => { expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument(); }); - - it('defaults the "My notes" scope to the active profile', async () => { - const backend = createFakeBackend(); - const user = renderFeed(backend); - renderWithApp(); - - await screen.findByText('Hello from the feed.'); - await user.click(screen.getByRole('button', { name: /My notes/i })); - - expect(await screen.findByText('A note from my own profile.')).toBeInTheDocument(); - expect(screen.queryByText('Hello from the feed.')).not.toBeInTheDocument(); - const authorRequest = backend.requests.find( - (r) => r.method === 'feed_get' && r.params.author != null, - ); - expect(authorRequest).toBeDefined(); - expect(authorRequest?.params.author).toBe('npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); - }); - - it('switches profiles with the dropdown', async () => { - const backend = createFakeBackend(); - backend.profileFeedItems = []; - const user = renderFeed(backend); - renderWithApp(); - - await screen.findByText('Hello from the feed.'); - await user.click(screen.getByRole('button', { name: /My notes/i })); - await screen.findByText('No notes from this profile'); - - await user.selectOptions( - screen.getByLabelText('Show notes from profile'), - 'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - ); - - await waitFor(() => { - const bobRequests = backend.requests.filter( - (r) => - r.method === 'feed_get' && - r.params.author === 'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - ); - expect(bobRequests.length).toBeGreaterThanOrEqual(1); - }); - }); - - it('disables the "My notes" scope when there are no profiles', async () => { - const backend = createFakeBackend(makeEmptyState()); - renderFeed(backend); - renderWithApp(); - - const myNotesButton = await screen.findByRole('button', { name: /My notes/i }); - expect(myNotesButton).toBeDisabled(); - }); }); diff --git a/frontend/src/test/ProfilesScreen.test.tsx b/frontend/src/test/ProfilesScreen.test.tsx index 20759a0..49cf60d 100644 --- a/frontend/src/test/ProfilesScreen.test.tsx +++ b/frontend/src/test/ProfilesScreen.test.tsx @@ -37,118 +37,6 @@ describe('ProfilesScreen', () => { }); }); - it('selects a profile when its card is clicked', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await screen.findByText('Bob'); - await user.click(screen.getByText('Bob')); - - await waitFor(() => { - expect(backend.state.active_profile?.npub).toBe(BOB); - }); - }); - - it('does not select a profile when an action button inside the card is clicked', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await screen.findByText('Bob'); - const copyButtons = screen.getAllByRole('button', { name: 'Copy public key' }); - await user.click(copyButtons[copyButtons.length - 1]); - - expect(backend.copied).toContain(BOB); - expect(backend.state.active_profile?.npub).toBe(ALICE); - }); - - it('renames a profile from the Edit name modal and publishes it', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await screen.findByText('Bob'); - await user.click(screen.getAllByRole('button', { name: 'Edit name' })[1]); - - const input = screen.getByLabelText('Profile name'); - expect(input).toHaveValue('Bob'); - await user.clear(input); - await user.type(input, 'Bobby'); - await user.click(screen.getByRole('button', { name: 'Save & publish' })); - - await waitFor(() => { - expect(backend.state.profiles.find((p) => p.npub === BOB)?.label).toBe('Bobby'); - }); - expect(await screen.findByRole('status')).toHaveTextContent(/Renamed to "Bobby"/); - }); - - it('sets a NIP-05 address from the NIP-05 modal and publishes it', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await screen.findByText('Bob'); - await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]); - - const input = screen.getByLabelText('NIP-05 address'); - expect(input).toHaveValue(''); - await user.type(input, 'Bob@Example.com'); - await user.click(screen.getByRole('button', { name: 'Save & publish' })); - - await waitFor(() => { - expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBe('bob@example.com'); - }); - expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 "bob@example.com"/); - expect(await screen.findByText('bob@example.com')).toBeInTheDocument(); - }); - - it('rejects a malformed NIP-05 address without calling the backend', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await screen.findByText('Bob'); - await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]); - - const input = screen.getByLabelText('NIP-05 address'); - await user.type(input, 'not-an-address'); - expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled(); - - await user.clear(input); - await user.type(input, 'boo@nodot'); - expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled(); - expect(backend.requests.filter((r) => r.method === 'set_nip05')).toHaveLength(0); - }); - - it('removes an existing NIP-05 address', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - backend.setState({ - ...backend.state, - profiles: backend.state.profiles.map((p) => - p.npub === BOB ? { ...p, nip05: 'bob@example.com' } : p, - ), - active_profile: backend.state.active_profile, - }); - const user = userEvent.setup(); - renderWithApp(); - - await screen.findByText('bob@example.com'); - await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]); - await user.click(screen.getByRole('button', { name: 'Remove' })); - - await waitFor(() => { - expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBeNull(); - }); - expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 removed/i); - }); - it('disables Select for the active profile and copies public keys', async () => { const backend = createFakeBackend(); installFakeBackend(backend); diff --git a/frontend/src/test/SettingsScreen.test.tsx b/frontend/src/test/SettingsScreen.test.tsx index 68ecbf2..928f5aa 100644 --- a/frontend/src/test/SettingsScreen.test.tsx +++ b/frontend/src/test/SettingsScreen.test.tsx @@ -29,19 +29,6 @@ describe('SettingsScreen', () => { expect(document.documentElement.dataset.theme).toBe('dark'); }); - it('applies the neon theme to the document', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - renderWithApp(); - - const themeSelect = await screen.findByLabelText('Theme'); - fireEvent.change(themeSelect, { target: { value: 'neon' } }); - await waitFor(() => { - expect(backend.state.settings.theme).toBe('neon'); - }); - expect(document.documentElement.dataset.theme).toBe('neon'); - }); - it('toggles publish confirmation and npub shortening', async () => { const backend = createFakeBackend(); installFakeBackend(backend); @@ -77,70 +64,4 @@ describe('SettingsScreen', () => { expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument(); expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument(); }); - - it('checks for updates and lists security advisories first', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await user.click(await screen.findByRole('button', { name: /Check for updates/i })); - - expect(await screen.findByText('2 security issue(s) found')).toBeInTheDocument(); - expect(screen.getByText(/minimist/)).toBeInTheDocument(); - // Advisories needing a major upgrade explain themselves instead of - // silently surviving an install. - expect( - screen.getByText('Needs electron@43.4.1, a major upgrade — not auto-installed.'), - ).toBeInTheDocument(); - expect(screen.getByText(/minimist/)).toBeInTheDocument(); - expect(screen.getByText('JavaScript packages')).toBeInTheDocument(); - expect(screen.getByText('Rust crates')).toBeInTheDocument(); - const checkRequest = backend.requests.find((r) => r.method === 'update_check'); - expect(checkRequest).toBeDefined(); - }); - - it('reports an up-to-date app when the scan finds nothing', async () => { - const backend = createFakeBackend(); - backend.updateReport = { - outdated_npm: [], - advisories: [], - outdated_cargo: [], - notes: [], - }; - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await user.click(await screen.findByRole('button', { name: /Check for updates/i })); - - expect(await screen.findByText('Everything is up to date.')).toBeInTheDocument(); - }); - - it('installs updates and asks for a rebuild + restart', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await user.click(await screen.findByRole('button', { name: /Install updates/i })); - - expect(await screen.findByText(/Rebuild and restart the app/)).toBeInTheDocument(); - expect(screen.getByText('JavaScript security fixes applied')).toBeInTheDocument(); - const applyRequest = backend.requests.find((r) => r.method === 'update_apply'); - expect(applyRequest).toBeDefined(); - }); - - it('surfaces update failures as errors', async () => { - const backend = createFakeBackend(); - backend.nextErrors.update_check = { message: 'npm was not found on this computer.' }; - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - await user.click(await screen.findByRole('button', { name: /Check for updates/i })); - - expect(await screen.findByText('Update problem')).toBeInTheDocument(); - expect(screen.getByText('npm was not found on this computer.')).toBeInTheDocument(); - }); }); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index 21b89ee..c5de46c 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -25,7 +25,7 @@ export function makeState(overrides?: Partial): AppState { is_active: false, }; const settings: Settings = { - theme: 'light', + theme: 'system', confirm_before_publish: true, shorten_npub: true, relays: [ diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index cd5d0d7..314c1e4 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -7,8 +7,6 @@ import type { RelayTestResult, Settings, SignerStatus, - UpdateApplyReport, - UpdateCheckReport, } from '../lib/types'; import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock'; @@ -45,12 +43,6 @@ export interface FakeBackend { feedItems: FeedItem[]; /** Notes returned by `feed_get` with `contacts_only: true`. */ contactFeedItems: FeedItem[]; - /** Notes returned by `feed_get` with an `author` filter. */ - profileFeedItems: FeedItem[]; - /** Report returned by `update_check`. */ - updateReport: UpdateCheckReport; - /** Result returned by `update_apply`. */ - updateApplyResult: UpdateApplyReport; } export function createFakeBackend(initial?: AppState): FakeBackend { @@ -134,41 +126,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend { relays: ['wss://relay.damus.io'], }, ], - /** Notes returned by `feed_get` with an `author` filter. */ - profileFeedItems: [ - { - id: 'note1dddddddddddddddddddddddddddddddddddddddddddddddd', - author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', - author_npub: ALICE, - content: 'A note from my own profile.', - created_at: 1700000400, - relays: ['wss://relay.damus.io'], - }, - ], - updateReport: { - outdated_npm: [{ name: 'vite', current: '4.0.0', available: '5.1.0' }], - advisories: [ - { - package: 'minimist', - severity: 'high', - title: 'Prototype Pollution', - fix: null, - }, - { - package: 'electron', - severity: 'critical', - title: 'ASAR Integrity Bypass', - fix: 'Needs electron@43.4.1, a major upgrade — not auto-installed.', - }, - ], - outdated_cargo: [{ name: 'serde', current: '1.0.200', available: '1.0.219' }], - notes: [], - }, - updateApplyResult: { - applied: ['JavaScript security fixes applied', 'Rust crates updated in Cargo.lock'], - failed: [], - restart_required: true, - }, }; async function dispatch(method: string, params: Record): Promise { @@ -206,58 +163,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return next; } - case 'rename_profile': { - const npub = String(params.npub); - const label = String(params.label ?? '').trim(); - if (!label) { - throw new Error('The profile name cannot be empty.'); - } - if (!state.profiles.some((p) => p.npub === npub)) { - throw new Error('That profile is not stored on this computer.'); - } - const updated: ProfileSummary = { ...state.profiles.find((p) => p.npub === npub)!, label }; - const next: AppState = { - ...state, - profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)), - active_profile: state.active_profile?.npub === npub ? updated : state.active_profile, - }; - backend.setState(next); - return { profile: updated, report: makePublishReport(), state: next }; - } - - case 'set_nip05': { - const npub = String(params.npub); - const raw = params.nip05; - const nip05 = typeof raw === 'string' ? raw.trim().toLowerCase() : null; - if (nip05) { - const at = nip05.indexOf('@'); - const [local, domain] = [nip05.slice(0, at), nip05.slice(at + 1)]; - if (at <= 0 || at === nip05.length - 1 || nip05.includes('@', at + 1)) { - throw new Error('A NIP-05 address must look like name@domain.com.'); - } - if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) { - throw new Error( - 'The part before @ may only use letters, numbers, dashes and underscores.', - ); - } - if (!domain.includes('.') || domain.split('.').some((part) => !part)) { - throw new Error('The part after @ must be a domain like example.com.'); - } - } - const existing = state.profiles.find((p) => p.npub === npub); - if (!existing) { - throw new Error('That profile is not stored on this computer.'); - } - const updated: ProfileSummary = { ...existing, nip05: nip05 || null }; - const next: AppState = { - ...state, - profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)), - active_profile: state.active_profile?.npub === npub ? updated : state.active_profile, - }; - backend.setState(next); - return { profile: updated, report: makePublishReport(), state: next }; - } - case 'publish_note': { if (publishFailure) { const failure = publishFailure; @@ -277,12 +182,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend { } case 'feed_get': { - const author = typeof params.author === 'string' ? params.author : null; - if (author) { - return backend.profileFeedItems.filter( - (item) => item.author_npub === author || item.author === author, - ); - } const contactsOnly = Boolean(params.contacts_only); if (contactsOnly) { if (!state.active_profile) { @@ -391,12 +290,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return result; } - case 'update_check': - return backend.updateReport; - - case 'update_apply': - return backend.updateApplyResult; - case 'settings_update': { const nextSettings: Settings = { ...state.settings, ...params }; backend.setState({ ...state, settings: nextSettings }); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/frontend/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/src/app.rs b/src/app.rs index 88b569a..d60fc2b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -113,8 +113,7 @@ impl App { public_key: restored.npub.clone(), secret_key: "".to_string(), created_at: restored.created_at, - picture: restored.picture.clone(), - nip05: restored.nip05.clone(), + picture: None, }; self.vault.profiles.push(stored); // If no active profile, this restored one becomes active diff --git a/src/feed.rs b/src/feed.rs index 2674a62..19258f2 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -77,19 +77,6 @@ pub async fn contact_feed( aggregate_for(settings, limit, Some(contacts)).await } -/// Only notes authored by one account (npub or hex). -/// -/// Used by the feed screen's "My notes" scope so the user can read just the -/// posts of one of their own profiles. -pub async fn profile_feed( - settings: &Settings, - limit: usize, - author: &str, -) -> Result, AppError> { - let pubkey = owner_pubkey(author)?; - aggregate_for(settings, limit, Some(vec![pubkey])).await -} - /// Fetch the hex public keys followed by an owner profile (kind 3 contact list). async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result, AppError> { let owner = owner_pubkey(owner_hex)?; @@ -98,34 +85,43 @@ async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result = HashSet::new(); + let mut notifications = client.notifications(); let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT; loop { - match tokio::time::timeout_at(deadline, events.next()).await { - Ok(Some((_, Ok(event)))) => { + match tokio::time::timeout_at(deadline, notifications.recv()).await { + Ok(Ok(RelayPoolNotification::Event { event, .. })) => { if event.kind == Kind::ContactList { - for tag in event.tags.iter() { - if tag.single_letter_tag().map(|s| s.as_char()) == Some('p') { - if let Some(content) = tag.content() { - if let Ok(pubkey) = PublicKey::parse(content) { - contacts.insert(pubkey); - } - } - } + for pubkey in event + .tags + .iter() + .filter_map(|tag| match tag.as_standardized() { + Some(TagStandard::PublicKey { public_key, .. }) => Some(*public_key), + _ => None, + }) + { + contacts.insert(pubkey); } } } - Ok(Some((_, Err(_)))) => continue, - Ok(None) | Err(_) => break, + Ok(Ok(_)) => continue, + Ok(Err(_)) | Err(_) => break, } } @@ -149,8 +145,15 @@ async fn aggregate_for( let effective_limit = if limit == 0 { DEFAULT_LIMIT } else { limit }; // A throwaway identity keeps reading the network completely off the user's keys. - let client = - crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?; + let client = Client::new(Keys::generate()); + for url in &relay_urls { + client + .add_relay(url.as_str()) + .await + .map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?; + } + client.connect().await; + client.wait_for_connection(CONNECT_TIMEOUT).await; let since = Timestamp::now() - LOOKBACK; let filter = Filter::new() @@ -161,22 +164,25 @@ async fn aggregate_for( Some(authors) => filter.authors(authors.iter().copied()), None => filter, }; - let mut events = client - .stream_events(filter) + client + .subscribe(filter, None) .await .map_err(|e| AppError::network(format!("Could not subscribe for the feed: {e}")))?; let mut feed = FeedBuilder::new(effective_limit, authors.as_deref()); + let mut notifications = client.notifications(); let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT; loop { - match tokio::time::timeout_at(deadline, events.next()).await { - Ok(Some((relay_url, Ok(event)))) => { + match tokio::time::timeout_at(deadline, notifications.recv()).await { + Ok(Ok(RelayPoolNotification::Event { + event, relay_url, .. + })) => { if !feed.add(&event, Some(relay_url)) { break; } } - Ok(Some((_, Err(_)))) => continue, - Ok(None) | Err(_) => break, + Ok(Ok(_)) => continue, + Ok(Err(_)) | Err(_) => break, } } @@ -257,7 +263,7 @@ impl FeedItem { author: event.pubkey.to_hex(), author_npub, content: event.content.trim().to_string(), - created_at: event.created_at.as_secs(), + created_at: event.created_at.as_u64(), relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(), }) } @@ -271,7 +277,7 @@ mod tests { let keys = Keys::generate(); EventBuilder::new(Kind::TextNote, content.to_string()) .custom_created_at(Timestamp::from(created_at)) - .finalize_async(&keys) + .sign(&keys) .await .unwrap() } @@ -328,7 +334,7 @@ mod tests { let mut builder = FeedBuilder::new(10, None); let keys = Keys::generate(); let other = EventBuilder::new(Kind::Metadata, "{}") - .finalize_async(&keys) + .sign(&keys) .await .unwrap(); builder.add(&other, None); @@ -358,12 +364,12 @@ mod tests { let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string()) .custom_created_at(Timestamp::from(5)) - .finalize_async(&followed) + .sign(&followed) .await .unwrap(); let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string()) .custom_created_at(Timestamp::from(6)) - .finalize_async(&stranger) + .sign(&stranger) .await .unwrap(); @@ -391,30 +397,6 @@ mod tests { assert!(feed.is_empty()); } - #[tokio::test] - async fn profile_feed_with_no_relays_is_empty() { - let settings = Settings { - relays: Vec::new(), - ..Default::default() - }; - let feed = profile_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str()) - .await - .unwrap(); - assert!(feed.is_empty()); - } - - #[tokio::test] - async fn profile_feed_with_invalid_author_errors() { - let settings = Settings { - relays: Vec::new(), - ..Default::default() - }; - let err = profile_feed(&settings, DEFAULT_LIMIT, "not-a-key") - .await - .expect_err("an invalid author must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); - } - #[tokio::test] async fn contact_feed_with_invalid_owner_errors() { let settings = Settings { diff --git a/src/ipc.rs b/src/ipc.rs index e1ec407..e51c80c 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,9 +1,8 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::json; -use tokio::sync::Mutex; use crate::app::App; use crate::errors::AppError; @@ -13,7 +12,6 @@ use crate::publish; use crate::relays; use crate::settings::Theme; use crate::signer::Signer; -use crate::updates; /// How long to wait for a relay connection test. const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -51,18 +49,6 @@ pub enum Request { npub: String, url: Option, }, - /// Change a profile's label and publish it as part of the profile's kind 0 - /// metadata. - RenameProfile { - npub: String, - label: String, - }, - /// Store a NIP-05 identifier (or clear it with `None`) and publish it as - /// part of the profile's kind 0 metadata. - SetNip05 { - npub: String, - nip05: Option, - }, PublishNote { content: String, }, @@ -70,13 +56,9 @@ pub enum Request { FeedGet { /// Optional cap on how many notes to return; leave `None` for the default. limit: Option, - /// When true (and no `author` is given), only return notes authored by - /// the active profile's contacts. When no active profile is selected - /// the request errors. + /// When true, only return notes authored by the active profile's + /// contacts. When no active profile is selected the request errors. contacts_only: Option, - /// When set (npub or hex), only return notes authored by that account. - /// Takes precedence over `contacts_only`. - author: Option, }, RelayAdd { url: String, @@ -91,11 +73,6 @@ pub enum Request { RelayTest { url: String, }, - /// Scan both dependency sets (npm + cargo) for available updates and - /// security advisories, without changing anything. - UpdateCheck, - /// Install compatible dependency updates (security fixes first). - UpdateApply, SettingsGet, SettingsUpdate { theme: Option, @@ -174,26 +151,20 @@ pub struct ReplyEnvelope { /// Run the JSON-lines IPC server on stdin/stdout. /// -/// The Electron main process spawns `keynectr serve` and exchanges one JSON -/// object per line. Requests are handled concurrently — replies are -/// id-correlated, so they may arrive out of order — while every handler that -/// touches shared state locks it for the duration, so mutations remain -/// serialized and never interleave. Long network-only requests (relay tests, -/// feed reads) run without holding that lock so they cannot delay interactive -/// ones such as selecting a profile. +/// The Electron main process spawns `keynectr serve` and +/// exchanges one JSON object per line. Requests are processed sequentially so +/// the shared state never sees concurrent mutations. pub async fn serve() -> Result<(), AppError> { use tokio::io::AsyncBufReadExt; - use tokio::task::JoinSet; // Shared state, so the NIP-46 signer's background task and the request loop // both see the same vault (including its unlock key) without racing writes. let app = Arc::new(Mutex::new(App::load()?)); - let signer = Arc::new(Signer::new()); - let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); + let signer = Signer::new(); let stdin = tokio::io::stdin(); let mut lines = tokio::io::BufReader::new(stdin).lines(); - let mut tasks = JoinSet::new(); + let mut stdout = tokio::io::stdout(); while let Some(line) = lines .next_line() @@ -212,40 +183,29 @@ pub async fn serve() -> Result<(), AppError> { message: "The request could not be understood.".to_string(), details: Some(e.to_string()), }; - write_line(&stdout, ReplyEnvelope { id: 0, reply }).await?; + write_line(&mut stdout, ReplyEnvelope { id: 0, reply }).await?; continue; } }; - let task_app = app.clone(); - let task_signer = signer.clone(); - let task_stdout = stdout.clone(); - tasks.spawn(async move { - let reply = handle(task_app, task_signer, envelope.request).await; - let _ = write_line( - &task_stdout, - ReplyEnvelope { - id: envelope.id, - reply, - }, - ) - .await; - }); + let reply = handle(app.clone(), &signer, envelope.request).await; + write_line( + &mut stdout, + ReplyEnvelope { + id: envelope.id, + reply, + }, + ) + .await?; } - // Finish in-flight requests before returning so the GUI never sees the - // backend disappear mid-request. - while tasks.join_next().await.is_some() {} Ok(()) } -async fn write_line( - writer: &tokio::sync::Mutex, - envelope: ReplyEnvelope, -) -> Result<(), AppError> { - use tokio::io::AsyncWriteExt; - - let mut writer = writer.lock().await; +async fn write_line(writer: &mut W, envelope: ReplyEnvelope) -> Result<(), AppError> +where + W: tokio::io::AsyncWriteExt + Unpin, +{ let mut line = serde_json::to_string(&envelope) .map_err(|e| AppError::json("Could not prepare a response", e))?; line.push('\n'); @@ -262,10 +222,10 @@ async fn write_line( async fn handle( app: Arc>, - signer: Arc, + signer: &Signer, request: Request, ) -> Reply { - let result = run(&app, &signer, request).await; + let result = run(&app, signer, request).await; match result { Ok(value) => Reply::Ok { data: value }, Err(err) => Reply::Error { @@ -289,16 +249,19 @@ fn error_code(err: &AppError) -> String { } /// Signer control commands never touch the vault directly, so they take the -/// shared handle (a clone) rather than locking the state. Read-only network -/// requests (relay tests, feed reads) grab what they need under a short lock -/// and then run without it, so slow relays cannot delay interactive requests. -/// Everything else locks the state for the duration of the call, so mutations -/// remain serialized and never interleave. +/// shared handle (a clone) rather than locking the state. Everything else +/// locks the vault for the duration of the call, mirroring the old +/// single-threaded model. +#[allow(clippy::await_holding_lock)] async fn run( app: &Arc>, signer: &Signer, request: Request, ) -> Result { + // Signer control commands never touch the vault directly, so they take the + // shared handle (a clone) rather than locking the state. Everything else + // locks the vault for the duration of the call, mirroring the old + // single-threaded model. match request { Request::SignerConnect { uri } => { signer.connect(app.clone(), &uri)?; @@ -313,71 +276,16 @@ async fn run( signer.approve(&id, approved)?; Ok(json!(signer.status())) } - Request::RelayTest { url } => { - // Pure network probe against the given URL; no shared state. - let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; - Ok(json!(result)) - } - Request::UpdateCheck => { - // Long-running package-manager scan; never touches shared state. - let report = updates::check().await?; - Ok(json!(report)) - } - Request::UpdateApply => { - // Installs updates on disk; a rebuild + restart picks them up. - let report = updates::apply().await?; - Ok(json!(report)) - } - Request::FeedGet { - limit, - contacts_only, - author, - } => { - let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); - let contacts_only = contacts_only.unwrap_or(false); - // Resolve the requested author outside any lock: parsing a key is - // pure and must not queue behind vault mutations. - let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()), - None => None, - }; - // Copy the inputs out of shared state under a short lock so the - // multi-second relay fetches below never block a Select or save. - let (settings, owner_hex) = { - let guard = app.lock().await; - let owner_hex = if author_hex.is_some() { - None - } else if contacts_only { - let npub = guard - .vault - .active_profile - .as_deref() - .ok_or_else(AppError::no_active_profile)?; - Some(feed::owner_pubkey(npub)?.to_hex()) - } else { - None - }; - (guard.settings.clone(), owner_hex) - }; - let items = if let Some(hex) = author_hex { - feed::profile_feed(&settings, limit, &hex).await? - } else if let Some(hex) = owner_hex { - feed::contact_feed(&settings, limit, &hex).await? - } else { - feed::aggregate_feed(&settings, limit).await? - }; - Ok(json!(items)) - } other => { - let mut guard = app.lock().await; + let mut guard = app.lock().expect("app mutex poisoned"); run_with_app(&mut guard, other).await } } } /// Requests dispatched to the vault state. The shared mutex guard is held across -/// the awaited operation on purpose: mutations stay serialized against each -/// other even though requests themselves are handled concurrently. +/// the awaited operation on purpose: requests remain effectively sequential, and +/// a concurrent `await` never yields back into a state the loop expects to own. async fn run_with_app(app: &mut App, request: Request) -> Result { match request { Request::Init | Request::GetState => Ok(json!(app.state_view())), @@ -417,27 +325,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let key = app.vault_key().copied(); - let (summary, report) = profiles::rename_profile( - &mut app.vault, - &npub, - label, - key.as_ref(), - &app.settings, - )?; - app.save_vault()?; - Ok(json!({ "profile": summary, "report": report, "state": app.state_view() })) - } - - Request::SetNip05 { npub, nip05 } => { - let key = app.vault_key().copied(); - let (summary, report) = - profiles::set_nip05(&mut app.vault, &npub, nip05, key.as_ref(), &app.settings)?; - app.save_vault()?; - Ok(json!({ "profile": summary, "report": report, "state": app.state_view() })) - } - Request::PublishNote { content } => { let report = publish::publish_active(&app.vault, &app.settings, &content, app.vault_key()) @@ -445,6 +332,25 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); + let items = if contacts_only.unwrap_or(false) { + let npub = app + .vault + .active_profile + .as_deref() + .ok_or_else(AppError::no_active_profile)?; + let pubkey = feed::owner_pubkey(npub)?; + feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await? + } else { + feed::aggregate_feed(&app.settings, limit).await? + }; + Ok(json!(items)) + } + Request::RelayAdd { url } => { relays::add_relay(&mut app.settings, &url)?; app.save_settings()?; @@ -463,6 +369,11 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; + Ok(json!(result)) + } + Request::SettingsGet => Ok(json!(app.settings)), Request::BackupNow => { diff --git a/src/lib.rs b/src/lib.rs index 7ca39ef..b489c08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ pub mod publish; pub mod relays; pub mod settings; pub mod signer; -pub mod updates; pub mod uploads; pub mod vault; diff --git a/src/main.rs b/src/main.rs index 920afe9..92cf91a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::process::ExitCode; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use keynectr::app::App; use keynectr::errors::{AppError, ErrorKind}; @@ -21,11 +21,6 @@ Commands: publish Publish a text note as a specific profile publish-name Publish the profile's stored name so other clients show it set-picture Set the profile picture (http(s) URL) and publish it - rename Rename a profile and publish the new name - set-nip05 Set the NIP-05 address (name@domain) and publish it; - pass 'clear' to remove it - nip05-file Print the .well-known/nostr.json document to serve - on your domain for a NIP-05 address feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50); --contacts filters to the active profile's contacts relays list List configured relays @@ -35,7 +30,7 @@ Commands: relays disable Disable a relay relays test Test a relay connection settings get Show application settings - settings set theme + settings set theme settings set confirm settings set shorten delete-profile Delete a profile (moves it to undo stack) @@ -75,9 +70,6 @@ async fn main() -> ExitCode { "publish" => cli_publish(&args).await, "publish-name" => cli_publish_name(&args), "set-picture" => cli_set_picture(&args), - "rename" => cli_rename(&args), - "set-nip05" => cli_set_nip05(&args), - "nip05-file" => cli_nip05_file(&args), "feed" => cli_feed(&args).await, "relays" => cli_relays(&args).await, "settings" => cli_settings(&args), @@ -195,97 +187,6 @@ fn cli_set_picture(args: &[String]) -> Result { )) } -fn cli_rename(args: &[String]) -> Result { - let npub = args - .get(2) - .ok_or_else(|| AppError::config("Usage: keynectr rename "))?; - let label = args[3..].join(" "); - if label.trim().is_empty() { - return Err(AppError::config("Usage: keynectr rename ")); - } - - let mut app = load_app_with_unlock()?; - let key = app.vault_key().copied(); - let (summary, report) = - profiles::rename_profile(&mut app.vault, npub, label, key.as_ref(), &app.settings)?; - app.save_vault()?; - Ok(format!( - "Renamed to \"{}\"; accepted by {} relay(s).", - summary.label, - report.succeeded.len() - )) -} - -/// Print the `.well-known/nostr.json` document that serves a NIP-05 -/// identifier for a stored profile. Read-only: never unlocks the vault. -fn cli_nip05_file(args: &[String]) -> Result { - let npub = args - .get(2) - .ok_or_else(|| AppError::config("Usage: keynectr nip05-file "))?; - let identifier = args - .get(3) - .ok_or_else(|| AppError::config("Usage: keynectr nip05-file "))?; - let name = profiles::validate_nip05(identifier)?; - let local = name.split('@').next().unwrap_or(&name); - - let app = App::load()?; - let stored = app - .vault - .profiles - .iter() - .find(|p| p.public_key == npub.as_str()) - .ok_or_else(|| AppError::profile_not_found(npub))?; - let hex = keynectr::feed::owner_pubkey(&stored.public_key)?.to_hex(); - let relays = relays::enabled_urls(&app.settings); - - let mut names = serde_json::Map::new(); - names.insert( - local.to_string(), - serde_json::Value::String(hex.to_string()), - ); - let mut doc = serde_json::Map::new(); - doc.insert("names".to_string(), serde_json::Value::Object(names)); - if !relays.is_empty() { - let urls: Vec = - relays.into_iter().map(serde_json::Value::String).collect(); - let mut relay_map = serde_json::Map::new(); - relay_map.insert(hex.to_string(), serde_json::Value::Array(urls)); - doc.insert("relays".to_string(), serde_json::Value::Object(relay_map)); - } - let pretty = serde_json::to_string_pretty(&serde_json::Value::Object(doc)) - .map_err(|e| AppError::internal(format!("Could not render the document: {e}")))?; - Ok(format!( - "Serve this as https:///.well-known/nostr.json (Content-Type: application/json):\n\n{pretty}\n" - )) -} - -fn cli_set_nip05(args: &[String]) -> Result { - let npub = args - .get(2) - .ok_or_else(|| AppError::config("Usage: keynectr set-nip05 "))?; - let raw = args - .get(3) - .ok_or_else(|| AppError::config("Usage: keynectr set-nip05 "))?; - let nip05 = if raw.eq_ignore_ascii_case("clear") { - None - } else { - Some(raw.clone()) - }; - - let mut app = load_app_with_unlock()?; - let key = app.vault_key().copied(); - let (summary, report) = - profiles::set_nip05(&mut app.vault, npub, nip05, key.as_ref(), &app.settings)?; - app.save_vault()?; - match summary.nip05 { - Some(id) => Ok(format!( - "NIP-05 set to \"{id}\"; accepted by {} relay(s).", - report.succeeded.len() - )), - None => Ok("NIP-05 cleared.".to_string()), - } -} - fn cli_publish_name(args: &[String]) -> Result { let npub = args .get(2) @@ -442,7 +343,7 @@ fn cli_settings(args: &[String]) -> Result { match key.as_str() { "theme" => { let theme = Theme::parse(value).ok_or_else(|| { - AppError::config("Theme must be one of: light, dark, glass, neon") + AppError::config("Theme must be one of: light, dark, system") })?; app.settings.theme = theme; } @@ -585,7 +486,7 @@ async fn cli_signer(args: &[String]) -> Result { let uri = args .get(3) .ok_or_else(|| AppError::config("Usage: signer connect "))?; - let app = Arc::new(tokio::sync::Mutex::new(load_app_with_unlock()?)); + let app = Arc::new(Mutex::new(load_app_with_unlock()?)); let signer = Signer::new(); signer.connect(app, uri)?; println!("Connecting to the NIP-46 app… (interrupt with Ctrl-C to stop)"); @@ -647,7 +548,6 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result Result { public_key: restored.npub.clone(), secret_key: "".to_string(), created_at: restored.created_at, - picture: restored.picture, - nip05: restored.nip05, + picture: None, }; app.vault.profiles.push(stored); if app.vault.active_profile.is_none() { diff --git a/src/profiles.rs b/src/profiles.rs index b6ba18a..2973246 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,5 +1,6 @@ use nostr_sdk::prelude::*; use serde::Serialize; +use std::time::Duration; use zeroize::Zeroizing; use crate::crypto::VaultKey; @@ -9,6 +10,11 @@ use crate::relays; use crate::settings::Settings; use crate::vault::{unix_timestamp, StoredProfile, Vault}; +/// How long to wait for relays to accept a connection attempt. +const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// How long to wait for a single relay to accept the metadata event. +const METADATA_SEND_TIMEOUT: Duration = Duration::from_secs(15); + /// A safe view of a profile that contains no secret key material. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct ProfileSummary { @@ -20,8 +26,6 @@ pub struct ProfileSummary { pub is_active: bool, /// Public URL of the profile picture, when one has been set. pub picture: Option, - /// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. - pub nip05: Option, } /// A secret key revealed after the vault is unlocked, in both the raw hex and @@ -72,7 +76,6 @@ pub fn create_profile( secret_key: stored_secret, created_at, picture: None, - nip05: None, }; let is_active = vault.active_profile.is_none(); @@ -86,7 +89,7 @@ pub fn create_profile( // Best-effort: relay failures here never block profile creation. let relay_urls = relays::enabled_urls(settings); if !relay_urls.is_empty() { - publish_metadata_blocking(&keys, &label, None, None, relay_urls); + publish_metadata_blocking(&keys, &label, None, relay_urls); } Ok(ProfileSummary { @@ -95,7 +98,6 @@ pub fn create_profile( created_at, is_active, picture: None, - nip05: None, }) } @@ -131,7 +133,6 @@ pub fn publish_profile_metadata( &keys, &stored.label, stored.picture.clone(), - stored.nip05.clone(), relay_urls, )) } @@ -159,13 +160,12 @@ pub fn set_profile_picture( let stored = find_profile_mut(vault, npub)?; stored.picture = url; - let (label, npub, created_at, public_key, picture, nip05) = ( + let (label, npub, created_at, public_key, picture) = ( stored.label.clone(), stored.public_key.clone(), stored.created_at, stored.public_key.clone(), stored.picture.clone(), - stored.nip05.clone(), ); drop(stored); let summary = ProfileSummary { @@ -174,7 +174,6 @@ pub fn set_profile_picture( created_at, is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), picture, - nip05, }; let relay_urls = relays::enabled_urls(settings); @@ -191,173 +190,11 @@ pub fn set_profile_picture( } let keys = Keys::new(secret_key); - let report = publish_metadata_blocking( - &keys, - &summary.label, - summary.picture.clone(), - summary.nip05.clone(), - relay_urls, - ); + let report = + publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls); Ok((summary, report)) } -/// Change a profile's label and immediately republish it as the profile's -/// kind 0 metadata so external clients show the new name. -/// -/// Returns the updated summary plus the per-relay publish report. -pub fn rename_profile( - vault: &mut Vault, - npub: &str, - label: String, - key: Option<&VaultKey>, - settings: &Settings, -) -> Result<(ProfileSummary, MetadataPublishReport), AppError> { - let trimmed = label.trim(); - if trimmed.is_empty() { - return Err(AppError::config("The profile name cannot be empty.")); - } - - // Resolve and sign before mutating so a locked vault or bad key changes - // nothing on disk. - let secret_hex = resolve_secret_key(vault, npub, key)?; - let secret_key = parse_secret_key(&secret_hex)?; - - let stored = find_profile_mut(vault, npub)?; - stored.label = trimmed.to_string(); - let (label, created_at, public_key, picture, nip05) = ( - stored.label.clone(), - stored.created_at, - stored.public_key.clone(), - stored.picture.clone(), - stored.nip05.clone(), - ); - let summary = ProfileSummary { - label, - npub: public_key.clone(), - created_at, - is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), - picture, - nip05, - }; - - let relay_urls = relays::enabled_urls(settings); - if relay_urls.is_empty() { - // The vault change stands; publishing can be retried later via the - // explicit "publish name" action once a relay is enabled. - return Ok(( - summary, - MetadataPublishReport { - succeeded: Vec::new(), - failed: Vec::new(), - }, - )); - } - - let keys = Keys::new(secret_key); - let report = publish_metadata_blocking( - &keys, - &summary.label, - summary.picture.clone(), - summary.nip05.clone(), - relay_urls, - ); - Ok((summary, report)) -} - -/// Store a NIP-05 identifier (e.g. `boo@l484.com`) and immediately publish it -/// as part of the profile's kind 0 metadata. -/// -/// Pass `None` to clear the identifier. Returns the updated summary plus the -/// per-relay publish report. -pub fn set_nip05( - vault: &mut Vault, - npub: &str, - nip05: Option, - key: Option<&VaultKey>, - settings: &Settings, -) -> Result<(ProfileSummary, MetadataPublishReport), AppError> { - let normalised = match &nip05 { - Some(value) => Some(validate_nip05(value)?), - None => None, - }; - - // Resolve and sign before mutating so a locked vault or bad key changes - // nothing on disk. - let secret_hex = resolve_secret_key(vault, npub, key)?; - let secret_key = parse_secret_key(&secret_hex)?; - - let stored = find_profile_mut(vault, npub)?; - stored.nip05 = normalised; - let (label, created_at, public_key, picture, nip05) = ( - stored.label.clone(), - stored.created_at, - stored.public_key.clone(), - stored.picture.clone(), - stored.nip05.clone(), - ); - let summary = ProfileSummary { - label, - npub: public_key.clone(), - created_at, - is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), - picture, - nip05, - }; - - let relay_urls = relays::enabled_urls(settings); - if relay_urls.is_empty() { - // The vault change stands; publishing can be retried later via the - // explicit "publish name" action once a relay is enabled. - return Ok(( - summary, - MetadataPublishReport { - succeeded: Vec::new(), - failed: Vec::new(), - }, - )); - } - - let keys = Keys::new(secret_key); - let report = publish_metadata_blocking( - &keys, - &summary.label, - summary.picture.clone(), - summary.nip05.clone(), - relay_urls, - ); - Ok((summary, report)) -} - -/// Validate a NIP-05 identifier (`@`), returning the -/// lower-cased trimmed form. `_@domain` (the bare-domain form) is allowed. -/// Exposed for the CLI's `.well-known/nostr.json` helper. -pub fn validate_nip05(raw: &str) -> Result { - let trimmed = raw.trim().to_lowercase(); - let (local, domain) = trimmed - .split_once('@') - .ok_or_else(|| AppError::config("A NIP-05 address must look like name@domain.com."))?; - if local.is_empty() || domain.is_empty() || domain.contains('@') { - return Err(AppError::config( - "A NIP-05 address must look like name@domain.com.", - )); - } - if local != "_" - && !local - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - return Err(AppError::config( - "The part before @ may only use letters, numbers, dashes and underscores.", - )); - } - if domain.split('.').any(|label| label.is_empty()) || !domain.contains('.') { - return Err(AppError::config( - "The part after @ must be a domain like example.com.", - )); - } - Ok(trimmed) -} - /// Validate that a picture URL is a well-formed http(s) URL. fn validate_picture_url(url: &str) -> Result<(), AppError> { let parsed = Url::parse(url) @@ -398,7 +235,6 @@ fn publish_metadata_blocking( keys: &Keys, label: &str, picture: Option, - nip05: Option, relay_urls: Vec, ) -> MetadataPublishReport { let keys = keys.clone(); @@ -406,9 +242,7 @@ fn publish_metadata_blocking( std::thread::spawn(move || { tokio::runtime::Runtime::new() .expect("metadata runtime") - .block_on(publish_metadata_async( - &keys, &label, picture, nip05, relay_urls, - )) + .block_on(publish_metadata_async(&keys, &label, picture, relay_urls)) }) .join() .expect("metadata publish thread panicked") @@ -418,7 +252,6 @@ async fn publish_metadata_async( keys: &Keys, label: &str, picture: Option, - nip05: Option, relay_urls: Vec, ) -> MetadataPublishReport { let mut metadata = Metadata::new().name(label).display_name(label); @@ -427,11 +260,8 @@ async fn publish_metadata_async( metadata = metadata.picture(parsed); } } - if let Some(nip05) = &nip05 { - metadata = metadata.nip05(nip05); - } let event = match EventBuilder::new(Kind::Metadata, metadata.as_json()) - .finalize_async(keys) + .sign(keys) .await { Ok(event) => event, @@ -450,22 +280,48 @@ async fn publish_metadata_async( } }; - // This path deliberately builds its own client instead of - // `relays::open_pool`: adding a broken relay must not abort the whole - // metadata publish, so add errors are ignored here. - let client = Client::builder() - .authenticator(SignerAuthenticator::new(keys.clone())) - .build(); + let client = Client::new(keys.clone()); for url in &relay_urls { let _ = client.add_relay(url.as_str()).await; } client.connect().await; - let (succeeded, failed) = - crate::publish::send_to_all_relays(&client, relay_urls, &event, "metadata").await; + let _ = client.wait_for_connection(METADATA_CONNECT_TIMEOUT).await; + + let mut succeeded = Vec::new(); + let mut failed = Vec::new(); + for url in &relay_urls { + match client.relay(url.as_str()).await { + Ok(relay) => { + match tokio::time::timeout(METADATA_SEND_TIMEOUT, relay.send_event(&event)).await { + Ok(Ok(_)) => succeeded.push(url.clone()), + Ok(Err(err)) => failed.push(failure_for(url, &err)), + Err(_) => failed.push(RelayFailure { + url: url.clone(), + error: "The relay did not respond in time.".to_string(), + details: Some( + "Timed out while waiting for the relay to accept the metadata." + .to_string(), + ), + }), + } + } + Err(err) => failed.push(failure_for(url, &err)), + } + } + client.disconnect().await; MetadataPublishReport { succeeded, failed } } +fn failure_for(url: &str, err: &impl std::fmt::Display) -> RelayFailure { + let (error, details) = crate::publish::relay_error_message(err); + RelayFailure { + url: url.to_string(), + error, + details: Some(details), + } +} + /// Safe summaries of every stored profile, newest last. Never includes /// secret keys. pub fn summaries(vault: &Vault) -> Vec { @@ -493,7 +349,6 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary { created_at: profile.created_at, is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()), picture: profile.picture.clone(), - nip05: profile.nip05.clone(), } } @@ -609,7 +464,6 @@ mod tests { secret_key: "00".repeat(32), created_at: 1, picture: None, - nip05: None, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), @@ -617,7 +471,6 @@ mod tests { secret_key: "11".repeat(32), created_at: 2, picture: None, - nip05: None, }); vault } @@ -903,164 +756,6 @@ mod tests { assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); } - #[test] - fn rename_profile_updates_label_and_skips_publish_without_relays() { - let mut vault = Vault::empty(); - let summary = - create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); - - let (renamed, report) = rename_profile( - &mut vault, - &summary.npub, - "Alicia".to_string(), - None, - &offline_settings(), - ) - .expect("renaming must work offline"); - - assert_eq!(renamed.label, "Alicia"); - assert_eq!( - vault.profiles[0].label, "Alicia", - "vault must remember the label" - ); - // No relays enabled: nothing published, but the change still stands. - assert!(report.succeeded.is_empty()); - assert!(report.failed.is_empty()); - - // Leading/trailing whitespace is trimmed. - let (trimmed, _) = rename_profile( - &mut vault, - &summary.npub, - " Ace ".to_string(), - None, - &offline_settings(), - ) - .unwrap(); - assert_eq!(trimmed.label, "Ace"); - assert_eq!(vault.profiles[0].label, "Ace"); - } - - #[test] - fn rename_profile_rejects_empty_names() { - let mut vault = Vault::empty(); - let summary = - create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); - - for bad in [String::new(), " ".to_string()] { - let err = rename_profile(&mut vault, &summary.npub, bad, None, &offline_settings()) - .expect_err("empty name must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::Config); - } - assert_eq!( - vault.profiles[0].label, "Alice", - "nothing stored on failure" - ); - } - - #[test] - fn rename_profile_missing_profile_errors() { - let mut vault = Vault::empty(); - let err = rename_profile( - &mut vault, - "npub1ghost", - "Ghost".to_string(), - None, - &offline_settings(), - ) - .expect_err("missing profile must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); - } - - #[test] - fn set_nip05_stores_identifier_and_skips_publish_without_relays() { - let mut vault = Vault::empty(); - let summary = - create_profile(&mut vault, "Boo".to_string(), None, &offline_settings()).unwrap(); - - let (updated, report) = set_nip05( - &mut vault, - &summary.npub, - Some("Boo@L484.com".to_string()), - None, - &offline_settings(), - ) - .expect("setting a NIP-05 must work offline"); - - assert_eq!( - updated.nip05.as_deref(), - Some("boo@l484.com"), - "lower-cased" - ); - assert_eq!( - vault.profiles[0].nip05.as_deref(), - Some("boo@l484.com"), - "vault must remember the identifier" - ); - // No relays enabled: nothing published, but the change still stands. - assert!(report.succeeded.is_empty()); - assert!(report.failed.is_empty()); - - // Clearing the identifier also persists. - let (cleared, _) = - set_nip05(&mut vault, &summary.npub, None, None, &offline_settings()).unwrap(); - assert!(cleared.nip05.is_none()); - assert!(vault.profiles[0].nip05.is_none()); - } - - #[test] - fn set_nip05_rejects_malformed_identifiers() { - let mut vault = Vault::empty(); - let summary = - create_profile(&mut vault, "Boo".to_string(), None, &offline_settings()).unwrap(); - - for bad in [ - "just-a-name", - "@l484.com", - "boo@", - "bo o@l484.com", - "boo@nodot", - "boo@@l484.com", - ] { - let err = set_nip05( - &mut vault, - &summary.npub, - Some(bad.to_string()), - None, - &offline_settings(), - ) - .expect_err("malformed NIP-05 must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::Config); - } - assert!( - vault.profiles[0].nip05.is_none(), - "nothing stored on failure" - ); - - // The bare-domain form `_@domain` is valid. - set_nip05( - &mut vault, - &summary.npub, - Some("_@l484.com".to_string()), - None, - &offline_settings(), - ) - .expect("bare-domain form must be accepted"); - } - - #[test] - fn set_nip05_missing_profile_errors() { - let mut vault = Vault::empty(); - let err = set_nip05( - &mut vault, - "npub1ghost", - Some("ghost@example.com".to_string()), - None, - &offline_settings(), - ) - .expect_err("missing profile must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); - } - /// Delete a profile by npub, returning the deleted profile for undo. /// The vault must not be encrypted, or the key must be provided. pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result { @@ -1080,7 +775,6 @@ mod tests { created_at: stored.created_at, is_active: false, picture: stored.picture, - nip05: stored.nip05, }) } } diff --git a/src/publish.rs b/src/publish.rs index 76196b3..5e4f476 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -11,9 +11,10 @@ use crate::relays; use crate::settings::Settings; use crate::vault::Vault; -/// How long to wait for a single relay to accept an event. Relays are sent -/// to in parallel, so this caps the whole publish, not each relay. -const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6); +/// How long to wait for relays to accept a connection attempt. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// How long to wait for a single relay to accept an event. +const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(15); /// A relay that rejected a published note. #[derive(Debug, Clone, Serialize)] @@ -167,7 +168,7 @@ async fn publish_with_keys( // reported as such rather than as a network error. let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content)); let event = builder - .finalize_async(keys) + .sign(keys) .await .map_err(|e| AppError::sign_failed(format!("{e}")))?; @@ -176,8 +177,52 @@ async fn publish_with_keys( .to_bech32() .map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?; - let client = relays::open_pool(keys.clone(), &relay_urls, None).await?; - let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await; + let client = Client::new(keys.clone()); + for url in &relay_urls { + client + .add_relay(url.as_str()) + .await + .map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?; + } + client.connect().await; + client.wait_for_connection(CONNECT_TIMEOUT).await; + + // Send to each relay individually so partial failures are fully reported. + let mut succeeded: Vec = Vec::new(); + let mut failed: Vec = Vec::new(); + for url in &relay_urls { + let relay = match client.relay(url.as_str()).await { + Ok(relay) => relay, + Err(err) => { + let (message, details) = relay_error_message(&err); + failed.push(RelayFailure { + url: url.clone(), + error: message, + details: Some(details), + }); + continue; + } + }; + + match tokio::time::timeout(RELAY_SEND_TIMEOUT, relay.send_event(&event)).await { + Ok(Ok(_)) => succeeded.push(url.clone()), + Ok(Err(err)) => { + let (message, details) = relay_error_message(&err); + failed.push(RelayFailure { + url: url.clone(), + error: message, + details: Some(details), + }); + } + Err(_) => failed.push(RelayFailure { + url: url.clone(), + error: "The relay did not respond in time.".to_string(), + details: Some("Timed out while waiting for the relay to accept the note.".into()), + }), + } + } + + client.disconnect().await; if succeeded.is_empty() { return Err(AppError::publish_failed(failed)); @@ -190,102 +235,6 @@ async fn publish_with_keys( }) } -/// Send an already-signed event to every listed relay in parallel so one slow -/// or dead relay cannot drag the whole publish out to (relays x timeout). -/// -/// Callers own opening the pool (see `relays::open_pool`) — including whether -/// they wait for connections, which this deliberately does not: `send_event` -/// waits for each relay to become writable itself, and the per-send timeout -/// below already bounds the whole publish. Waiting for *all* relays first -/// would burn the full timeout whenever a single relay is unreachable. -/// `noun` ("note", "metadata") only shapes user-facing timeout wording. -pub(crate) async fn send_to_all_relays( - client: &Client, - relay_urls: Vec, - event: &Event, - noun: &str, -) -> (Vec, Vec) { - let noun = noun.to_string(); - // No explicit wait for connections here: `send_event` waits for each relay - // to become writable itself, and the per-send timeout below already bounds - // the whole publish. Waiting for *all* relays first would burn the full - // timeout whenever a single relay is unreachable. - - let mut outcomes: Vec>> = - (0..relay_urls.len()).map(|_| None).collect(); - let mut sends = tokio::task::JoinSet::new(); - for (index, url) in relay_urls.iter().cloned().enumerate() { - let client = client.clone(); - let event = event.clone(); - let noun = noun.clone(); - sends.spawn(async move { - match client.relay(url.as_str()).await { - Ok(Some(relay)) => { - match tokio::time::timeout(RELAY_SEND_TIMEOUT, relay.send_event(&event)).await { - Ok(Ok(_)) => (index, Ok(url)), - Ok(Err(err)) => { - let (message, details) = relay_error_message(&err); - ( - index, - Err(RelayFailure { - url, - error: message, - details: Some(details), - }), - ) - } - Err(_) => ( - index, - Err(RelayFailure { - url, - error: "The relay did not respond in time.".to_string(), - details: Some(format!( - "Timed out while waiting for the relay to accept the {noun}." - )), - }), - ), - } - } - Ok(None) => ( - index, - Err(RelayFailure { - url, - error: "Relay is not in the active pool.".to_string(), - details: Some("The client dropped this relay before sending.".to_string()), - }), - ), - Err(err) => { - let (message, details) = relay_error_message(&err); - ( - index, - Err(RelayFailure { - url, - error: message, - details: Some(details), - }), - ) - } - } - }); - } - while let Some(joined) = sends.join_next().await { - let (index, outcome) = joined.expect("relay send task panicked"); - outcomes[index] = Some(outcome); - } - - let mut succeeded = Vec::new(); - let mut failed = Vec::new(); - for outcome in outcomes.into_iter().flatten() { - match outcome { - Ok(url) => succeeded.push(url), - Err(failure) => failed.push(failure), - } - } - - client.disconnect().await; - (succeeded, failed) -} - /// Build a concise user-facing message plus technical detail from a relay /// error, without ever including secret material. /// diff --git a/src/relays.rs b/src/relays.rs index 303698c..ae0904c 100644 --- a/src/relays.rs +++ b/src/relays.rs @@ -69,35 +69,6 @@ pub fn enabled_urls(settings: &Settings) -> Vec { .collect() } -/// Build a client pool over `relay_urls`, adding each relay and optionally -/// waiting (up to `wait`) for connections before returning. -/// -/// Fails on the first relay that cannot be added. Callers that should tolerate -/// an unreachable relay instead of aborting add their relays themselves. -pub(crate) async fn open_pool( - keys: Keys, - relay_urls: &[String], - wait: Option, -) -> Result { - // The authenticator answers NIP-42 AUTH challenges automatically on every - // path that opens a client (nostr-sdk >= 0.45 has no implicit signer). - let client = Client::builder() - .authenticator(SignerAuthenticator::new(keys)) - .build(); - for url in relay_urls { - client - .add_relay(url.as_str()) - .await - .map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?; - } - // Deprecated `wait_for_connection`; the builder form is the 0.45 way. - match wait { - Some(timeout) => client.connect().and_wait(timeout).await, - None => client.connect().await, - } - Ok(client) -} - /// Result of testing a relay connection. #[derive(Debug, Clone, Serialize)] pub struct RelayTestResult { @@ -112,9 +83,7 @@ pub struct RelayTestResult { /// during a connection test. pub async fn test_connection(url: &str, timeout: Duration) -> Result { let keys = Keys::generate(); - let client = Client::builder() - .authenticator(SignerAuthenticator::new(keys)) - .build(); + let client = Client::new(keys); client .add_relay(url) @@ -124,12 +93,10 @@ pub async fn test_connection(url: &str, timeout: Duration) -> Result Some(Self::Light), "dark" => Some(Self::Dark), - "glass" => Some(Self::Glass), - "neon" => Some(Self::Neon), + "system" => Some(Self::System), _ => None, } } @@ -60,7 +58,7 @@ fn default_true() -> bool { impl Default for Settings { fn default() -> Self { Self { - theme: Theme::Light, + theme: Theme::System, confirm_before_publish: true, shorten_npub: true, relays: crate::relays::default_relays(), diff --git a/src/signer.rs b/src/signer.rs index 0ee5d63..086f0b3 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -17,9 +17,9 @@ use std::time::Duration; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use getrandom::getrandom; use nostr::nips::nip44::v2; use nostr::nips::nip44::v2::ConversationKey; +use nostr::JsonUtil; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::oneshot; @@ -238,7 +238,7 @@ impl Signer { /// /// `app` is the shared vault state, so the signer reflects the current unlock /// key and active profile. A locked vault cannot sign until it is unlocked. - pub fn connect(&self, app: Arc>, uri: &str) -> Result<(), AppError> { + pub fn connect(&self, app: Arc>, uri: &str) -> Result<(), AppError> { if uri.trim().starts_with("bunker://") { return Err(AppError::config( "That is a bunker:// link, which means routing through *another* signer. \ @@ -363,11 +363,7 @@ fn percent_decode(raw: &str) -> Option { /// NIP-44 encrypt with the conversation key, returned base64-encoded. fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result { - // nostr-sdk 0.45 removed the convenience wrapper that generated the nonce - // internally; the caller now supplies fresh randomness per message. - let mut nonce = [0u8; 32]; - getrandom(&mut nonce).map_err(|e| AppError::internal(format!("Could not get entropy: {e}")))?; - let payload = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce) + let payload = v2::encrypt_to_bytes(conversation, plaintext.as_bytes()) .map_err(|e| AppError::internal(format!("Could not encrypt a message: {e}")))?; Ok(B64.encode(payload)) } @@ -574,8 +570,8 @@ fn sign_event(keys: &Keys, request: &RawRequest) -> Result { let unsigned: UnsignedEvent = serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?; - let event = keys - .sign_event(unsigned) + let event = unsigned + .sign_with_keys(keys) .map_err(|e| format!("The event could not be signed: {e}"))?; Ok(event.as_json()) } @@ -600,10 +596,16 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result { /// The background loop: connect to the client's relays, announce ourselves, /// subscribe to kind 24133 events, and answer requests until stopped. -async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { +async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { // 1. Resolve the active profile's key under the current vault lock. let keys = { - let guard = app.lock().await; + let guard = match app.lock() { + Ok(guard) => guard, + Err(_) => { + signer.fail("The vault could not be read."); + return; + } + }; let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) { Ok(hex) => hex, Err(err) => { @@ -630,35 +632,23 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C } }; - // 3. Connect to the client's relays. The notification stream is opened - // BEFORE anything is sent or subscribed: the client acknowledges our - // announcement within milliseconds, and a receiver created afterwards - // would miss those early messages (tokio broadcast semantics), leaving - // the client waiting forever for a reply. - let client = Client::builder() - .authenticator(SignerAuthenticator::new(keys.clone())) - .build(); + // 3. Connect to the client's relays. + let client = Client::new(keys.clone()); for url in &uri.relays { if let Err(err) = client.add_relay(url.to_string()).await { signer.fail(format!("Could not add relay {url}: {err}")); return; } } - client.connect().and_wait(CONNECT_TIMEOUT).await; + client.connect().await; + client.wait_for_connection(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). let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); - let mut events = match client.stream_events(filter).await { - Ok(events) => events, - Err(err) => { - signer.fail(format!("Could not subscribe for messages: {err}")); - return; - } - }; + if let Err(err) = client.subscribe(filter, None).await { + signer.fail(format!("Could not subscribe for messages: {err}")); + return; + } // 5. Announce ourselves: send the connect request with the optional secret. if let Err(err) = send_connect(&client, &keys, &conversation, &uri).await { @@ -667,19 +657,18 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C } // 6. Answer requests until the connection goes away or we are stopped. + let mut notifications = client.notifications(); loop { - let (relay_url, incoming) = match events.next().await { - Some(next) => next, - None => { + let notification = match notifications.recv().await { + Ok(notification) => notification, + Err(_) => { signer.fail("The signer connection was closed."); return; } }; - let event = match incoming { - Ok(event) => event, - Err(_) => continue, + let RelayPoolNotification::Event { event, .. } = notification else { + continue; }; - let _ = relay_url; if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { continue; } @@ -741,7 +730,7 @@ async fn publish_payload( let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?; let event = EventBuilder::new(Kind::NostrConnect, content) .tags([tag]) - .finalize_async(keys) + .sign(keys) .await .map_err(|e| format!("Could not sign a message: {e}"))?; client @@ -755,80 +744,6 @@ async fn publish_payload( mod tests { use super::*; - /// Malformed NIP-44 payloads (RUSTSEC-2026-0216/0227 classes) must come - /// back as clean errors, never a panic or a hang. A payload that fails to - /// decrypt against the conversation key is also exactly how forged signer - /// messages from a non-peer key are rejected. - #[test] - fn nip44_decrypt_rejects_malformed_payloads() { - let signer = Keys::generate(); - let peer = Keys::generate(); - let conversation = - ConversationKey::derive(signer.secret_key(), &peer.public_key()).unwrap(); - - let not_base64 = "this is !! not base64 !!"; - let empty = ""; - let too_short = B64.encode([0x02u8, 0x00]); - let bad_version = B64.encode([0xFFu8, 0x00, 0x11]); - let truncated = { - // encrypt returns raw bytes; nip44_decrypt takes their base64 form. - let mut bytes = v2::encrypt_to_bytes_with_nonce( - &conversation, - "a message long enough to be truncated meaningfully".as_bytes(), - [7u8; 32], - ) - .unwrap(); - bytes.truncate(bytes.len() / 2); - B64.encode(&bytes) - }; - - for payload in [ - not_base64, - empty, - &too_short, - &bad_version, - truncated.as_str(), - ] { - let result = nip44_decrypt(&conversation, payload); - assert!(result.is_err(), "payload {payload:?} must be rejected"); - if let Err(err) = result { - assert!( - !err.message().is_empty(), - "rejection of {payload:?} must carry a concise reason" - ); - } - } - } - - /// NIP-46 credential-leakage regression (RUSTSEC-2026-0225 class): error - /// strings surfaced to callers or logs must never contain secret-key hex. - #[test] - fn error_paths_never_leak_secret_key_material() { - let signer = Keys::generate(); - let sk_hex = hex::encode(signer.secret_key().secret_bytes()); - assert_eq!(sk_hex.len(), 64); - let peer = Keys::generate(); - let conversation = - ConversationKey::derive(signer.secret_key(), &peer.public_key()).unwrap(); - - // Collect the human-readable reasons our failure paths produce. - let mut failures = Vec::new(); - if let Err(err) = nip44_decrypt(&conversation, "not-base64 !!!") { - failures.push(err.message().to_string()); - } - if let Err(err) = nip44_decrypt(&conversation, &B64.encode([0xFFu8, 0x00, 0x11])) { - failures.push(err.message().to_string()); - } - - assert!(!failures.is_empty(), "expected at least one failure path"); - for message in failures { - assert!( - !message.to_lowercase().contains(&sk_hex), - "error text leaked secret-key material: {message}" - ); - } - } - #[test] fn parse_uri_with_relays_and_secret() { let uri = parse_connect_uri( @@ -849,10 +764,7 @@ mod tests { fn bunker_link_is_rejected() { let signer = Signer::new(); assert!(signer - .connect( - Arc::new(tokio::sync::Mutex::new(App::load().unwrap())), - "bunker://abc" - ) + .connect(Arc::new(Mutex::new(App::load().unwrap())), "bunker://abc") .is_err()); } diff --git a/src/updates.rs b/src/updates.rs deleted file mode 100644 index ef75d9b..0000000 --- a/src/updates.rs +++ /dev/null @@ -1,498 +0,0 @@ -//! Dependency update scanning and installation for both halves of the app. -//! -//! The app runs from a source checkout, so "updating" means refreshing the -//! JavaScript dependencies (`frontend/`) and the Rust crates (`Cargo.lock`) -//! to their newest compatible versions. Security advisories from `npm audit` -//! are surfaced first; `cargo update` picks up semver-compatible patches for -//! the Rust side. -//! -//! Nothing here touches secret material: only fixed, read-mostly package -//! manager commands are run in the project directories. - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use serde::Serialize; -use tokio::process::Command; - -use crate::errors::{AppError, ErrorKind}; - -/// Upper bound for a single package-manager command. Installs can be slow on -/// cold caches, but a hung command must still be reaped eventually. -const COMMAND_TIMEOUT: Duration = Duration::from_secs(150); - -/// One dependency with a newer compatible version available. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct PackageUpdate { - pub name: String, - pub current: String, - pub available: String, -} - -/// A known security advisory affecting an npm dependency. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct SecurityAdvisory { - pub package: String, - /// npm severity label: critical / high / moderate / low / info. - pub severity: String, - /// Short advisory title, when npm reported one. - pub title: Option, - /// What npm says is needed to clear it. `None` means a compatible fix - /// exists that "Install updates" can apply. - pub fix: Option, -} - -/// Everything the scan found, ready to show in one screen. -#[derive(Debug, Clone, Serialize)] -pub struct UpdateCheckReport { - pub outdated_npm: Vec, - pub advisories: Vec, - pub outdated_cargo: Vec, - /// Hints about optional tooling or skipped parts of the scan. - pub notes: Vec, -} - -/// Result of applying updates. -#[derive(Debug, Clone, Serialize)] -pub struct UpdateApplyReport { - pub applied: Vec, - pub failed: Vec, - /// The running binary/bundle cannot hot-swap; the app must be rebuilt - /// and restarted to load the refreshed dependencies. - pub restart_required: bool, -} - -/// The directory this backend was compiled from (the project root). -fn source_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -/// The frontend source directory (where `package.json` lives). -fn frontend_dir() -> PathBuf { - source_dir().join("frontend") -} - -/// Verify the app is running from its source checkout before shelling out. -fn require_source_checkout() -> Result<(), AppError> { - let manifest = source_dir().join("Cargo.toml"); - let package = frontend_dir().join("package.json"); - if manifest.exists() && package.exists() { - return Ok(()); - } - Err(AppError::simple( - ErrorKind::Config, - "Updates need the app's source folder, which was not found next to the running program.", - )) -} - -/// Run a command with a timeout, capturing stdout/stderr separately. -async fn run(dir: &Path, program: &str, args: &[&str]) -> Result { - let mut command = Command::new(program); - command.current_dir(dir).args(args).kill_on_drop(true); - let future = command.output(); - match tokio::time::timeout(COMMAND_TIMEOUT, future).await { - Ok(Ok(output)) => Ok(output), - Ok(Err(err)) => { - let hint = if err.kind() == std::io::ErrorKind::NotFound { - format!("'{program}' was not found on this computer.") - } else { - format!("Could not run '{program}': {err}") - }; - Err(AppError::simple(ErrorKind::Internal, hint)) - } - Err(_) => Err(AppError::with_details( - ErrorKind::Network, - format!("'{program}' took too long and was stopped."), - "The command exceeded its time limit while updating dependencies.", - )), - } -} - -/// Decode command output as UTF-8, falling back to lossy text. -fn text(bytes: &[u8]) -> String { - String::from_utf8_lossy(bytes).into_owned() -} - -/// Parse `npm outdated --json`. -/// -/// npm exits non-zero when anything is outdated, so exit status is ignored: -/// the JSON body is the data. Unparseable or missing output means no data. -fn parse_npm_outdated(json: &str) -> Vec { - let Ok(value) = serde_json::from_str::(json) else { - return Vec::new(); - }; - let Some(map) = value.as_object() else { - return Vec::new(); - }; - let mut updates = Vec::new(); - for (name, info) in map { - let get = |key: &str| { - info.get(key) - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string() - }; - let current = get("current"); - let available = if get("latest").is_empty() { - get("wanted") - } else { - get("latest") - }; - updates.push(PackageUpdate { - name: name.clone(), - current, - available, - }); - } - updates.sort_by(|a, b| a.name.cmp(&b.name)); - updates -} - -/// Parse `npm audit --json` into a flat advisory list. -fn parse_npm_audit(json: &str) -> Vec { - let Ok(value) = serde_json::from_str::(json) else { - return Vec::new(); - }; - let Some(vulnerabilities) = value.get("vulnerabilities").and_then(|v| v.as_object()) else { - return Vec::new(); - }; - let mut advisories = Vec::new(); - for (name, info) in vulnerabilities { - let severity = info - .get("severity") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - // `via` holds either plain title strings or full advisory objects. - let title = info.get("via").and_then(|v| v.as_array()).and_then(|list| { - list.iter().find_map(|entry| match entry { - serde_json::Value::String(text) => Some(text.clone()), - serde_json::Value::Object(object) => object - .get("title") - .and_then(|t| t.as_str()) - .map(|t| t.to_string()), - _ => None, - }) - }); - let fix = match info.get("fixAvailable") { - // A compatible fix exists; "Install updates" handles it. - Some(serde_json::Value::Bool(true)) | None => None, - Some(serde_json::Value::Bool(false)) => { - Some("No fix has been published yet.".to_string()) - } - Some(details @ serde_json::Value::Object(_)) => { - let name = details - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("a dependency"); - let version = details - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("latest"); - let major = details - .get("isSemVerMajor") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - Some(format!( - "Needs {name}@{version}{} — not auto-installed.", - if major { ", a major upgrade" } else { "" } - )) - } - Some(_) => None, - }; - advisories.push(SecurityAdvisory { - package: name.clone(), - severity, - title, - fix, - }); - } - const ORDER: [&str; 5] = ["critical", "high", "moderate", "low", "info"]; - advisories.sort_by(|a, b| { - let rank = |s: &str| { - ORDER - .iter() - .position(|known| known.eq_ignore_ascii_case(s)) - .unwrap_or(ORDER.len()) - }; - rank(&a.severity) - .cmp(&rank(&b.severity)) - .then_with(|| a.package.cmp(&b.package)) - }); - advisories -} - -/// Parse `cargo update --dry-run` status lines into crate updates. -fn parse_cargo_updates(text: &str) -> Vec { - let mut updates = Vec::new(); - for line in text.lines() { - let tokens: Vec<&str> = line.split_whitespace().collect(); - // e.g. "Updating serde v1.0.200 -> v1.0.219" (+ optional suffixes). - if tokens.len() >= 5 && tokens[3] == "->" { - let verb = tokens.first().copied().unwrap_or_default(); - if matches!(verb, "Updating" | "Downgrading") { - updates.push(PackageUpdate { - name: tokens[1].to_string(), - current: tokens[2].trim_start_matches('v').to_string(), - available: tokens[4].trim_start_matches('v').to_string(), - }); - } - } - } - updates.sort_by(|a, b| a.name.cmp(&b.name)); - updates -} - -/// Whether the optional RustSec scanner is installed. -async fn cargo_audit_available() -> bool { - run(Path::new("."), "cargo", &["audit", "--version"]) - .await - .map(|output| output.status.success()) - .unwrap_or(false) -} - -/// Scan both dependency sets without changing anything. -pub async fn check() -> Result { - require_source_checkout()?; - let root = source_dir(); - let frontend = frontend_dir(); - - let outdated = run(&frontend, "npm", &["outdated", "--json"]).await?; - let outdated_npm = parse_npm_outdated(&text(&outdated.stdout)); - - let audit = run(&frontend, "npm", &["audit", "--json"]).await?; - let advisories = parse_npm_audit(&text(&audit.stdout)); - - let dry_run = run(&root, "cargo", &["update", "--dry-run"]).await?; - let cargo_text = format!("{}{}", text(&dry_run.stdout), text(&dry_run.stderr)); - let outdated_cargo = parse_cargo_updates(&cargo_text); - - let mut notes = Vec::new(); - if !cargo_audit_available().await { - notes.push( - "Rust advisory scan unavailable: install cargo-audit (cargo install cargo-audit) \ - to also check Rust crates against the RustSec database." - .to_string(), - ); - } - - Ok(UpdateCheckReport { - outdated_npm, - advisories, - outdated_cargo, - notes, - }) -} - -/// Install compatible updates: npm security fixes, then general bumps, then -/// the Rust lockfile. -pub async fn apply() -> Result { - require_source_checkout()?; - let root = source_dir(); - let frontend = frontend_dir(); - - let steps: [(&Path, &str, &[&str], &str); 3] = [ - ( - &frontend, - "npm", - &["audit", "fix"], - "JavaScript security fixes applied", - ), - ( - &frontend, - "npm", - &["update"], - "JavaScript packages updated to their newest compatible versions", - ), - ( - &root, - "cargo", - &["update"], - "Rust crates updated in Cargo.lock", - ), - ]; - - let mut applied = Vec::new(); - let mut failed = Vec::new(); - for (dir, program, args, summary) in steps { - match run(dir, program, args).await { - Ok(output) => { - if output.status.success() { - applied.push(summary.to_string()); - } else { - let stderr = text(&output.stderr); - let tail: String = stderr - .lines() - .filter(|line| !line.trim().is_empty()) - .rev() - .take(3) - .collect::>() - .join(" | "); - failed.push(format!("{program} {args:?}: {tail}")); - } - } - Err(err) => failed.push(format!("{program} {args:?}: {}", err.message())), - } - } - - if applied.is_empty() && !failed.is_empty() { - return Err(AppError::with_details( - ErrorKind::Internal, - "No updates could be installed.", - failed.join("\n"), - )); - } - - let restart_required = !applied.is_empty(); - Ok(UpdateApplyReport { - applied, - failed, - restart_required, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_npm_outdated_entries() { - let json = r#"{ - "left-pad": { "current": "1.0.0", "wanted": "1.3.0", "latest": "1.3.0" }, - "react": { "current": "18.2.0", "wanted": "18.2.0", "latest": "19.0.0" } - }"#; - let updates = parse_npm_outdated(json); - assert_eq!(updates.len(), 2); - assert_eq!( - updates[0], - PackageUpdate { - name: "left-pad".to_string(), - current: "1.0.0".to_string(), - available: "1.3.0".to_string(), - } - ); - assert_eq!(updates[1].name, "react"); - assert_eq!(updates[1].available, "19.0.0"); - } - - #[test] - fn empty_or_garbage_outdated_output_yields_nothing() { - assert!(parse_npm_outdated("").is_empty()); - assert!(parse_npm_outdated("not json at all").is_empty()); - assert!(parse_npm_outdated("{}").is_empty()); - } - - #[test] - fn parses_npm_audit_with_title_objects_and_strings() { - let json = r#"{ - "vulnerabilities": { - "minimist": { - "severity": "high", - "via": [{ "title": "Prototype Pollution", "url": "https://example.com/a" }] - }, - "tar": { "severity": "low", "via": ["Regular Expression Denial of Service"] } - } - }"#; - let advisories = parse_npm_audit(json); - assert_eq!(advisories.len(), 2); - assert_eq!(advisories[0].package, "minimist"); - assert_eq!(advisories[0].severity, "high"); - assert_eq!(advisories[0].title.as_deref(), Some("Prototype Pollution")); - assert_eq!(advisories[1].package, "tar"); - assert_eq!( - advisories[1].title.as_deref(), - Some("Regular Expression Denial of Service") - ); - } - - #[test] - fn advisory_fix_paths_are_classified() { - let json = r#"{ - "vulnerabilities": { - "auto": { "severity": "low", "via": [], "fixAvailable": true }, - "stuck": { "severity": "high", "via": [], "fixAvailable": false }, - "electron": { - "severity": "critical", "via": [], - "fixAvailable": { "name": "electron", "version": "43.4.1", "isSemVerMajor": true } - }, - "minor": { - "severity": "moderate", "via": [], - "fixAvailable": { "name": "left-pad", "version": "1.3.0", "isSemVerMajor": false } - } - } - }"#; - let advisories = parse_npm_audit(json); - let fix_of = |name: &str| { - advisories - .iter() - .find(|a| a.package == name) - .and_then(|a| a.fix.clone()) - }; - // Compatible fixes need no hint: Install updates clears them. - assert_eq!(fix_of("auto"), None); - assert_eq!( - fix_of("stuck").as_deref(), - Some("No fix has been published yet.") - ); - assert_eq!( - fix_of("electron").as_deref(), - Some("Needs electron@43.4.1, a major upgrade — not auto-installed.") - ); - assert_eq!( - fix_of("minor").as_deref(), - Some("Needs left-pad@1.3.0 — not auto-installed.") - ); - } - - #[test] - fn sorts_advisories_by_severity_then_name() { - let json = r#"{ - "vulnerabilities": { - "zeta": { "severity": "critical", "via": [] }, - "alpha": { "severity": "high", "via": [] }, - "beta": { "severity": "critical", "via": [] } - } - }"#; - let advisories = parse_npm_audit(json); - let order: Vec<&str> = advisories.iter().map(|a| a.package.as_str()).collect(); - assert_eq!(order, vec!["beta", "zeta", "alpha"]); - } - - #[test] - fn empty_audit_yields_no_advisories() { - assert!(parse_npm_audit("").is_empty()); - assert!(parse_npm_audit("{}").is_empty()); - assert!(parse_npm_audit("{\"vulnerabilities\":{}}").is_empty()); - } - - #[test] - fn parses_cargo_update_lines() { - let text = "\ - Updating crates.io index\n\ - Locking 2 packages to their latest compatible version\n\ - Updating serde v1.0.200 -> v1.0.219\n\ - Downgrading leftpad v2.0.0 -> v1.9.0 (features: [\"std\"])\n\ - Adding newcrate v0.1.0\n"; - let updates = parse_cargo_updates(text); - assert_eq!(updates.len(), 2); - assert_eq!(updates[0].name, "leftpad"); - assert_eq!(updates[0].current, "2.0.0"); - assert_eq!(updates[0].available, "1.9.0"); - assert_eq!(updates[1].name, "serde"); - assert_eq!(updates[1].available, "1.0.219"); - } - - #[test] - fn unchanged_lockfile_yields_no_updates() { - assert!(parse_cargo_updates("Unchanged").is_empty()); - assert!(parse_cargo_updates("").is_empty()); - } - - #[test] - fn source_layout_holds_for_this_repo() { - // The compile-time paths must exist in the real checkout, otherwise - // every runtime check would fail with a misleading error. - assert!(source_dir().join("Cargo.toml").exists()); - assert!(frontend_dir().join("package.json").exists()); - } -} diff --git a/src/uploads.rs b/src/uploads.rs index a1ed3ac..39622ff 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -1,4 +1,3 @@ -use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr_sdk::prelude::*; use crate::crypto::VaultKey; diff --git a/src/vault.rs b/src/vault.rs index e4b078c..72e1f77 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -39,11 +39,6 @@ pub struct StoredProfile { /// profiles stored before pictures were introduced. #[serde(default)] pub picture: Option, - /// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. - /// Absent for profiles stored before NIP-05 was introduced. Published as - /// part of kind 0 metadata so clients show a human handle. - #[serde(default)] - pub nip05: Option, } /// KDF parameters that encrypted a vault. Stored so future key-derivation @@ -462,7 +457,6 @@ mod tests { secret_key: "00ff".to_string(), created_at: 1_700_000_000, picture: None, - nip05: None, } }