Compare commits

...

51 commits

Author SHA1 Message Date
Avi
f29d4f94a0 Checkpoint: nostr stack 0.45 security migration 2026-08-25 14:24:08 -05:00
Avi
fa5ba08578 Security: upgrade nostr stack 0.40->0.45 clearing 11 RustSec advisories
- nostr 0.40.0 -> 0.45.3, nostr-sdk 0.40.0 -> 0.45.2 (secp256k1 0.30)
- fixes RUSTSEC-2026-0216/0219/0224/0225/0226/0227/0228/0229/0230/0231/0232
  incl. forged-event signature-bypass and NIP-46 credential Debug leak
- NIP-42 auth now explicit: SignerAuthenticator on every client path
- EventBuilder::sign -> finalize_async; nip44 encrypt supplies random nonce
- feed/signer receive loops moved to stream_events (receiver opens before
  the signer announces, preserving the ordering fix)
- relay-pool replaced by in-SDK relay/gossip; try_connect().timeout()
- tests: malformed NIP-44 payload rejection + secret-leak regression
- suite: 115 backend tests green (113 preserved + 2 new); frontend untouched
2026-08-25 14:23:26 -05:00
Avi
bf10ae383a Checkpoint: clarify NIP-42 status (already supported) 2026-08-25 11:52:43 -05:00
Avi
e210f17ddb Checkpoint: DRY modal save lifecycle (R3 complete) 2026-08-25 11:06:13 -05:00
Avi
7098e75ca3 refactor: share modal save lifecycle 2026-08-25 11:04:20 -05:00
Avi
e6a1efc7f2 refactor: share hex-id shortening in lib/format 2026-08-25 10:12:49 -05:00
Avi
ec237bf6dd Checkpoint: relay pool bootstrap 2026-08-24 22:59:05 -05:00
Avi
6d5063d441 refactor: share relay pool bootstrap 2026-08-24 22:58:27 -05:00
Avi
40ec9a0dfa Checkpoint: DRY relay fan-out 2026-08-24 22:12:12 -05:00
Avi
1c428a9ff2 DRY: single parallel relay fan-out for notes and metadata
publish_with_keys and publish_metadata_async carried ~55 identical
lines (JoinSet per-relay sends, 6s timeout, outcome collection,
disconnect, result split) that had to be hand-synced once already.
Extracted into publish::send_to_all_relays; callers keep their own
relay-add policy (note path hard-fails on add errors, metadata path
ignores them), so behavior and user-facing strings are unchanged.
METADATA_SEND_TIMEOUT and profiles::failure_for folded away.
2026-08-24 22:11:22 -05:00
Avi
b21678fafb Checkpoint: NIP-46 signer handshake fix 2026-08-24 20:54:09 -05:00
Avi
64ad94d0b5 Signer: listen before announcing so the handshake reply is not lost
The NIP-46 task created its notification receiver only after
publishing the connect announcement. An automated client answers
within milliseconds, so its ack and first request landed in the
relay pool broadcast channel before a receiver existed and were
dropped silently — the client then waited forever for a reply and
never left its connect/QR screen (seen with Yakihonne).

Open the notification stream right after adding relays, before
connect/subscribe/announce, closing the drop window.

Verified end-to-end with a reference NIP-46 client over live relays:
before the fix get_public_key was never answered; after it the full
round-trip (announce, ack, get_public_key) completes.
2026-08-24 20:53:45 -05:00
Avi
ae8452602f Checkpoint: Aurora rename + dropdown fix 2026-08-24 19:49:52 -05:00
Avi
dd50b24ec1 Rename glass theme display to Aurora; fix unreadable select options
The theme dropdown popup inherited the near-transparent select
background and rendered white-on-white under the glass theme.
Options now get an explicit dark surface, plus color-scheme: dark
so native controls render correctly. User-facing name is Aurora;
the internal 'glass' id is unchanged so saved settings keep working.
2026-08-24 19:49:30 -05:00
Avi
0cab88364b Checkpoint: glass theme look 2026-08-24 19:44:31 -05:00
Avi
ecb666bc1d Glass theme: real frosted-glass look
Translucent surfaces with backdrop blur over an aurora gradient
backdrop, hairline light borders, softened accent tints, frosted
modal scrim, inverted sidebar logo. Scoped entirely to
html[data-theme='glass'] — other themes untouched.
2026-08-24 19:44:11 -05:00
Avi
1aae81ca04 Checkpoint: glass theme fix 2026-08-24 19:40:05 -05:00
Avi
bfe14f0d97 Fix glass theme: repair broken Default impl that blocked all builds
Commit a01f258 removed Theme::System but missed Settings::default(),
so the crate failed to compile and every desktop launch kept spawning
a stale backend binary that rejected 'glass'. Also untangles the
glass palette CSS that was duplicated as nested blocks inside the
neon rule, and re-applies the theme reactively when settings change.
2026-08-24 19:39:32 -05:00
Avi
db2d22f999 Theme: add glass theme (CAJAFUERTE-inspired) alongside light/dark/neon 2026-08-24 14:34:35 -05:00
Avi
a01f258d4b Remove system theme, keep light/dark/neon only 2026-08-24 14:23:15 -05:00
Avi
c080a8d1c8 App icon: remove white border, restore original 512x512 dimensions 2026-08-24 13:52:47 -05:00
Avi
542f3e5472 Checkpoint: app icon 2026-08-24 12:32:39 -05:00
Avi
90d699cff1 App icon: subtle white border around black artwork 2026-08-24 12:30:37 -05:00
Avi
4722ecdabd Checkpoint: neon theme 2026-08-24 12:15:36 -05:00
Avi
74072b7d6d Neon theme: pure-black Matrix palette (CAJAFUERTE-inspired) alongside light/dark/system 2026-08-24 12:14:39 -05:00
Avi
ef6ea171f8 Checkpoint: sidebar brand mark 2026-08-24 11:58:19 -05:00
Avi
a00c7c9897 Sidebar brand mark: hummingbird-key artwork replaces the shield icon 2026-08-24 11:56:16 -05:00
Avi
5f27c4585b Checkpoint: taskbar icon via desktop entry 2026-08-24 11:48:34 -05:00
Avi
cbbe779faa Desktop entry + Wayland app id so the taskbar shows the app icon 2026-08-24 11:46:48 -05:00
Avi
4d9489b5ac Checkpoint: app icon 2026-08-24 11:34:47 -05:00
Avi
8a644afd80 App icon: hummingbird-key mark for window and packaged Linux builds 2026-08-24 11:33:45 -05:00
Avi
48efaad0d9 Checkpoint: security dependency upgrades 2026-08-24 11:01:18 -05:00
Avi
c11ab9f735 Security: major dependency upgrades clearing all npm advisories; show per-advisory fix paths 2026-08-24 11:00:18 -05:00
Avi
a1915bb1c1 Checkpoint: updates card 2026-08-24 09:55:00 -05:00
Avi
8bce42810a Updates card: scan npm/cargo deps, surface security advisories, install compatible updates 2026-08-24 09:54:04 -05:00
Avi
3dfd15f9c1 Checkpoint: feed profile filter 2026-08-24 00:14:23 -05:00
Avi
55a49b442b Feed: 'My notes' scope with per-profile dropdown filter 2026-08-24 00:13:58 -05:00
Avi
8f637618bc Checkpoint: note publish latency trim 2026-08-24 00:04:01 -05:00
Avi
b001b3c7b1 Trim note publish latency: skip global connection wait, parallel sends, 6s cap 2026-08-24 00:03:12 -05:00
Avi
2ad054e6e1 Checkpoint: NIP-05 identifiers 2026-08-23 22:00:13 -05:00
Avi
045fa47476 Add NIP-05 identifiers: store, publish, GUI modal, CLI helpers
- Store an optional nip05 on each profile; publish it in kind 0 metadata
- set-nip05 CLI (+ validation, lower-casing, clear) and nip05-file helper
  that prints the .well-known/nostr.json document for a domain
- Profiles screen: NIP-05 button, handle shown on cards, Nip05Modal with
  client-side validation and Remove action
- Fix Modal stealing focus from autoFocus inputs one frame after open
2026-08-23 21:59:11 -05:00
Avi
f2c30d397f Checkpoint: publish latency trim 2026-08-23 19:10:12 -05:00
Avi
cb23a5e32d Trim metadata publish latency: skip global connection wait, 6s send cap 2026-08-23 19:09:46 -05:00
Avi
ad70890efc Checkpoint: concurrent IPC responsiveness fix 2026-08-23 19:04:11 -05:00
Avi
4d70b25628 Make IPC concurrent so relay checks and feeds never block the UI 2026-08-23 19:03:16 -05:00
Avi
6df690fe8f Checkpoint: profile rename feature 2026-08-23 18:49:50 -05:00
Avi
d618a5a4a0 Add profile rename with metadata republish (GUI + CLI) 2026-08-23 18:48:45 -05:00
Avi
808f011876 Checkpoint: narrow-window button wrap fix 2026-08-23 16:25:30 -05:00
Avi
64502c1b14 Wrap profile card action buttons on narrow windows 2026-08-23 16:25:07 -05:00
Avi
355e07f40f Checkpoint: card-click profile selection 2026-08-23 15:35:00 -05:00
Avi
653945a5e8 Select a profile by clicking anywhere on its card 2026-08-23 15:34:24 -05:00
42 changed files with 5971 additions and 4714 deletions

File diff suppressed because it is too large Load diff

534
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2021"
[dependencies]
nostr = { version = "0.40", features = ["nip44", "nip46"] }
nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] }
nostr = { version = "0.45", features = ["nip44", "nip98"] }
nostr-sdk = "0.45"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

77
docs/NIP-05.md Normal file
View file

@ -0,0 +1,77 @@
# NIP-05 identifiers in Keynectr
A NIP-05 identifier is a human-readable Nostr address that looks like an email
address — for example `boo@l484.com`. When a profile has one, clients such as
Iris, Yakihonne, Amethyst and snort show the handle instead of a raw `npub1…`
key, and users can find and tag you by typing it.
NIP-05 has two halves. Keynectr does the first half; you do the second half
once on your own domain.
## 1. Publish it from Keynectr (the app side)
- **GUI:** Profiles → *NIP-05* button → enter `name@domain.com` → *Save & publish*.
The identifier is stored with the profile and published to your enabled relays
as part of your kind 0 metadata.
- **CLI:**
```bash
B=~/Projects/Nostr_Keynctr/target/release/keynectr
$B set-nip05 <npub> boo@l484.com # set + publish
$B set-nip05 <npub> clear # remove + publish the removal
```
- The special form `_@domain.com` claims the bare domain itself (the whole
domain shows as your handle).
## 2. Serve `.well-known/nostr.json` (the domain side)
Clients verify a NIP-05 claim by fetching:
```
https://<your-domain>/.well-known/nostr.json?name=<local-part>
```
That file must live on the domain in the identifier — publishing alone is not
enough. Keynectr prints the exact document to serve:
```bash
$B nip05-file <npub> boo@l484.com
```
which outputs something like:
```json
{
"names": {
"boo": "3bf0c6…(64-char hex public key)"
},
"relays": {
"3bf0c6…": ["wss://nos.lol", "wss://relay.primal.net"]
}
}
```
Serve it at `https://<domain>/.well-known/nostr.json` with:
- `Content-Type: application/json` (or `application/json; charset=utf-8`)
- CORS header `Access-Control-Allow-Origin: *` (clients fetch it from browsers)
### nginx example
```nginx
location = /.well-known/nostr.json {
include snippets/cors.conf; # or add_header Access-Control-Allow-Origin *;
default_type application/json;
root /var/www/static;
}
```
Then place the printed document at `/var/www/static/.well-known/nostr.json`.
Regenerate it if you switch profiles or change your relay list.
## Notes
- The identifier is lower-cased when stored; validation matches NIP-05's
limited character set (`a-z`, `0-9`, `-`, `_`; `_` for the bare domain).
- Clients cache profiles aggressively — hard-refresh after changing anything.
- Without the well-known file, most clients will not display the handle even
though the metadata was published successfully.

BIN
frontend/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -82,6 +82,18 @@ function resolveBackendPath(): string {
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
}
/**
* The app icon ships inside the bundle: Vite copies `public/icon.png` into
* `dist/`, so the packaged app finds it next to the loaded page. In dev the
* Vite project's `public/` folder is the same file.
*/
function resolveWindowIcon(): string {
const base = app.isPackaged
? path.join(app.getAppPath(), 'dist')
: path.join(app.getAppPath(), 'public');
return path.join(base, 'icon.png');
}
function startBackend(): void {
if (backendStarted && backend && backend.exitCode === null) {
return;
@ -139,10 +151,11 @@ function startBackend(): void {
/**
* Upper bound for one backend round-trip. Generous on purpose: a publish can
* wait for each relay in turn (10s connect + 15s send each). A hung backend
* still gets reaped instead of leaking promises forever.
* wait on relays and dependency updates run npm/cargo commands that can take
* minutes on cold caches. A hung backend still gets reaped instead of leaking
* promises forever.
*/
const BACKEND_TIMEOUT_MS = 120_000;
const BACKEND_TIMEOUT_MS = 300_000;
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
startBackend();
@ -185,6 +198,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'select_profile',
'publish_profile_metadata',
'set_profile_picture',
'rename_profile',
'set_nip05',
'delete_profile',
'undo_delete',
'publish_note',
@ -194,6 +209,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'relay_set_enabled',
'relay_test',
'settings_update',
'update_check',
'update_apply',
'backup_now',
'set_vault_password',
'unlock_vault',
@ -495,6 +512,7 @@ function createWindow(): void {
minWidth: 920,
minHeight: 640,
title: 'Keynectr',
icon: resolveWindowIcon(),
backgroundColor: '#f6f4f0',
autoHideMenuBar: true,
webPreferences: {
@ -545,6 +563,13 @@ function createWindow(): void {
});
}
// Wayland has no per-window icons: the taskbar resolves the running app's id
// through an installed .desktop file instead (see
// scripts/install-desktop-entry.sh). Pin the identity before `ready`.
if (process.platform === 'linux') {
app.setDesktopName('keynectr.desktop');
}
app.whenReady().then(() => {
protocol.handle('app', (request) => {
const { pathname } = new URL(request.url);

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@
"format:check": "prettier --check .",
"electron:build": "tsc -p tsconfig.electron.json",
"start": "npm run electron:build && electron .",
"desktop:install": "bash scripts/install-desktop-entry.sh",
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
},
"dependencies": {
@ -32,17 +33,17 @@
"@types/node": "^26.1.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"@vitejs/plugin-react": "^6.1.0",
"electron": "^43.4.1",
"electron-builder": "^26.15.3",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vitest": "^2.1.8"
"vite": "^8.2.2",
"vitest": "^4.1.11"
},
"build": {
"appId": "dev.nostfeedmanager.desktop",
@ -65,6 +66,7 @@
"target": [
"dir"
],
"icon": "build/icon.png",
"category": "Network",
"executableName": "keynectr"
}

BIN
frontend/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Install the Keynectr .desktop entry and icon for the current user.
#
# On Linux (especially Wayland) the taskbar icon does not come from the
# BrowserWindow icon: the desktop environment resolves the running app's id
# against an installed .desktop file and its Icon= key. This script installs
# both so the hummingbird-key icon shows up in launchers and taskbars.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ICON_SRC="$ROOT/build/icon.png"
ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
if [[ ! -f "$ICON_SRC" ]]; then
echo "Icon not found at $ICON_SRC" >&2
exit 1
fi
mkdir -p "$ICON_DIR" "$DESKTOP_DIR"
cp "$ICON_SRC" "$ICON_DIR/keynectr.png"
cat > "$DESKTOP_DIR/keynectr.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Keynectr
Comment=Manage Nostr profiles and publish notes
Exec=bash -lc "cd '$ROOT' && npm start"
Icon=keynectr
Terminal=false
Categories=Network;Utility;
StartupWMClass=keynectr
EOF
# Refresh the desktop database so the new entry is picked up without a logout.
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
command -v kbuildsycoca6 >/dev/null 2>&1 && kbuildsycoca6 --noincremental >/dev/null 2>&1 || true
echo "Installed:"
echo " $ICON_DIR/keynectr.png"
echo " $DESKTOP_DIR/keynectr.desktop"
echo "Restart Keynectr to see the icon in the taskbar."

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -19,7 +19,11 @@ export function Modal({ open, title, onClose, children }: ModalProps) {
const container = containerRef.current;
const frame = requestAnimationFrame(() => {
// Keep keyboard focus where the user (or autoFocus) put it; only pull
// focus to the dialog itself as a fallback for content without inputs.
if (!container?.contains(document.activeElement)) {
container?.focus();
}
});
const onKeyDown = (event: KeyboardEvent) => {

View file

@ -4,6 +4,7 @@ import { shortenNpub } from '../lib/format';
import { Avatar } from './Avatar';
import { CopyButton } from './CopyButton';
import { Icon, type IconName } from './Icon';
import logoUrl from '../assets/logo.png';
const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'home', label: 'Home', icon: 'home' },
@ -29,7 +30,7 @@ export function Sidebar({ screen, onNavigate }: SidebarProps) {
<aside className="sidebar">
<div className="sidebar-brand">
<span className="sidebar-logo" aria-hidden="true">
<Icon name="shield" size={22} />
<img src={logoUrl} alt="" draggable={false} />
</span>
<div>
<strong>Keynectr</strong>

View file

@ -11,6 +11,8 @@ import type {
RevealedKey,
Settings,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
UploadedImage,
} from './types';
@ -58,17 +60,30 @@ export const api = {
'set_profile_picture',
{ npub, url },
),
renameProfile: (npub: string, label: string) =>
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>(
'rename_profile',
{ npub, label },
),
setNip05: (npub: string, nip05: string | null) =>
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>('set_nip05', {
npub,
nip05,
}),
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
feedGet: (limit?: number, contactsOnly = false) =>
feedGet: (limit?: number, contactsOnly = false, authorNpub?: string) =>
call<FeedItem[]>('feed_get', {
...(limit ? { limit } : {}),
...(contactsOnly ? { contacts_only: true } : {}),
...(authorNpub ? { author: authorNpub } : {}),
}),
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
relaySetEnabled: (url: string, enabled: boolean) =>
call<Settings>('relay_set_enabled', { url, enabled }),
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
updateCheck: () => call<UpdateCheckReport>('update_check'),
updateApply: () => call<UpdateApplyReport>('update_apply'),
settingsUpdate: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch),

View file

@ -0,0 +1,39 @@
import { shortHexId, shortenNpub } from './format';
describe('shortHexId', () => {
it('returns an empty string unchanged', () => {
expect(shortHexId('')).toBe('');
});
it('returns a string of exactly 16 characters unchanged', () => {
const value = 'a'.repeat(16);
expect(shortHexId(value)).toBe(value);
});
it('shortens a string of 17 characters to first8…last8', () => {
// 17 chars: 9 leading a's then 8 b's.
const value = 'aaaaaaaaabbbbbbbb';
expect(shortHexId(value)).toBe('aaaaaaaa…bbbbbbbb');
});
it('shortens a 64-character hexadecimal id to first8…last8', () => {
const hex = 'a47921f7247f6f70391d9563ce0f7bbd4d73922e13cbac6ca57f712e68cdc9f0';
expect(shortHexId(hex)).toBe('a47921f7…68cdc9f0');
});
});
describe('shortenNpub', () => {
it('returns the full value when shortening is disabled', () => {
const npub = 'npub153ujraey0ahhqwgaj43uurmmh4xh8y3wz096cm990acju6xde8cqynjxwv';
expect(shortenNpub(npub, false)).toBe(npub);
});
it('keeps short values even when shortening is enabled', () => {
expect(shortenNpub('npub1short', true)).toBe('npub1short');
});
it('shortens with a 10-character prefix and 8-character suffix when enabled', () => {
const npub = 'npub153ujraey0ahhqwgaj43uurmmh4xh8y3wz096cm990acju6xde8cqynjxwv';
expect(shortenNpub(npub, true)).toBe('npub153ujr...cqynjxwv');
});
});

View file

@ -63,3 +63,11 @@ export function initialsFor(label: string): string {
}
return trimmed.charAt(0).toUpperCase();
}
/**
* Shorten a long hexadecimal id for display: `a47921f7…68cdc9f0`.
* Keeps the full value when the string is short enough to read as-is.
*/
export function shortHexId(value: string): string {
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
}

View file

@ -1,4 +1,4 @@
export type Theme = 'light' | 'dark' | 'system';
export type Theme = 'light' | 'dark' | 'glass' | 'neon';
/** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
@ -36,6 +36,8 @@ export interface ProfileSummary {
is_active: boolean;
/** Public URL of the profile picture, when one has been set. */
picture?: string | null;
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
nip05?: string | null;
}
export interface RelayConfig {
@ -92,6 +94,38 @@ export interface RelayTestResult {
latency_ms?: number | null;
}
/** One dependency with a newer compatible version available. */
export interface PackageUpdate {
name: string;
current: string;
available: string;
}
/** A known security advisory affecting an npm dependency. */
export interface SecurityAdvisory {
package: string;
/** npm severity label: critical / high / moderate / low / info. */
severity: string;
title: string | null;
/** What is needed to clear it; null when a compatible fix exists. */
fix: string | null;
}
/** Result of scanning both dependency sets for updates. */
export interface UpdateCheckReport {
outdated_npm: PackageUpdate[];
advisories: SecurityAdvisory[];
outdated_cargo: PackageUpdate[];
notes: string[];
}
/** Result of applying dependency updates. */
export interface UpdateApplyReport {
applied: string[];
failed: string[];
restart_required: boolean;
}
export interface AppState {
version: string;
vault_path: string;

View file

@ -15,21 +15,33 @@ interface FeedScreenProps {
onNavigate: (screen: Screen) => void;
}
type FeedScope = 'everyone' | 'contacts';
type FeedScope = 'everyone' | 'contacts' | 'profile';
export function FeedScreen({ onNavigate }: FeedScreenProps) {
const { state, feedGet } = useApp();
const [items, setItems] = useState<FeedItem[]>([]);
const [scope, setScope] = useState<FeedScope>('everyone');
const [pickedProfile, setPickedProfile] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
const shorten = state?.settings.shorten_npub ?? true;
const profiles = state?.profiles ?? [];
const hasActiveProfile = state?.active_profile != null;
const hasProfiles = profiles.length > 0;
// Default the author filter to the active profile until one is picked.
const selectedProfileNpub =
pickedProfile ?? state?.active_profile?.npub ?? profiles[0]?.npub ?? null;
const contactsScope = scope === 'contacts';
const effectiveScope: FeedScope = contactsScope && !hasActiveProfile ? 'everyone' : scope;
const profileScope = scope === 'profile';
const effectiveScope: FeedScope =
contactsScope && !hasActiveProfile
? 'everyone'
: profileScope && !hasProfiles
? 'everyone'
: scope;
const load = useCallback(
async (background: boolean) => {
@ -40,7 +52,13 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
setLoading(true);
}
try {
setItems(await feedGet(undefined, effectiveScope === 'contacts'));
setItems(
await feedGet(
undefined,
effectiveScope === 'contacts',
effectiveScope === 'profile' ? (selectedProfileNpub ?? undefined) : undefined,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@ -48,7 +66,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
setRefreshing(false);
}
},
[feedGet, effectiveScope],
[feedGet, effectiveScope, selectedProfileNpub],
);
useEffect(() => {
@ -122,7 +140,37 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<Icon name="users" size={16} />
My contacts
</button>
<button
type="button"
className={`segmented-btn${effectiveScope === 'profile' ? ' is-active' : ''}`}
onClick={() => setScope('profile')}
disabled={!hasProfiles}
title={
hasProfiles
? 'Only notes published by one of your profiles'
: 'Create a profile to filter by it'
}
aria-pressed={effectiveScope === 'profile'}
>
<Icon name="edit" size={16} />
My notes
</button>
</div>
{effectiveScope === 'profile' && (
<select
className="feed-profile-select"
aria-label="Show notes from profile"
value={selectedProfileNpub ?? ''}
onChange={(event) => setPickedProfile(event.target.value)}
>
{profiles.map((profile) => (
<option key={profile.npub} value={profile.npub}>
{profile.label}
{profile.is_active ? ' (active)' : ''}
</option>
))}
</select>
)}
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
<Icon name="refresh" size={16} />
Refresh
@ -140,11 +188,30 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<Spinner label="Fetching recent notes…" />
) : items.length === 0 ? (
<EmptyState
icon={<Icon name={effectiveScope === 'contacts' ? 'users' : 'list'} size={26} />}
title={effectiveScope === 'contacts' ? 'No notes from your contacts' : 'No notes found'}
icon={
<Icon
name={
effectiveScope === 'contacts'
? 'users'
: effectiveScope === 'profile'
? 'edit'
: 'list'
}
size={26}
/>
}
title={
effectiveScope === 'contacts'
? 'No notes from your contacts'
: effectiveScope === 'profile'
? 'No notes from this profile'
: 'No notes found'
}
description={
effectiveScope === 'contacts'
? 'No notes were returned from the people the active profile follows in the last 24 hours. Try refreshing, or switch to Everyone.'
: effectiveScope === 'profile'
? 'This profile has not published any notes on your enabled relays in the last 24 hours. Notes older than a day, or notes only on other relays, will not appear here.'
: 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.'
}
/>

View file

@ -9,6 +9,7 @@ import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal';
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
import { formatDate, shortenNpub } from '../lib/format';
import type { MetadataPublishReport } from '../lib/types';
import { useApp } from '../state/AppProvider';
interface ProfilesScreenProps {
@ -24,6 +25,8 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null);
const [nip05Target, setNip05Target] = useState<Nip05Target | null>(null);
const profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true;
@ -132,6 +135,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<article
key={profile.npub}
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
onClick={
profile.is_active
? undefined
: (event) => {
if ((event.target as HTMLElement).closest('button, a, input')) return;
void onSelect(profile.npub);
}
}
>
<div className="profile-card-top">
<Avatar
@ -142,6 +153,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
/>
<div className="profile-card-meta">
<h3>{profile.label}</h3>
{profile.nip05 && (
<span className="nip05-handle" title={profile.nip05}>
{profile.nip05}
</span>
)}
<code className="mono" title={profile.npub}>
{shortenNpub(profile.npub, shorten)}
</code>
@ -161,6 +177,26 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
>
<Icon name="publish" size={14} /> Publish name
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setRenameTarget({ npub: profile.npub, label: profile.label })}
>
<Icon name="edit" size={14} /> Edit name
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
setNip05Target({
npub: profile.npub,
label: profile.label,
nip05: profile.nip05 ?? '',
})
}
>
<Icon name="edit" size={14} /> NIP-05
</Button>
<Button
variant="ghost"
size="sm"
@ -231,6 +267,32 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onSavingChange={setPublishing}
/>
)}
{renameTarget && (
<RenameModal
target={renameTarget}
onClose={() => setRenameTarget(null)}
onSaved={(message) => {
setNotice(message);
setRenameTarget(null);
}}
onError={setError}
onSavingChange={setPublishing}
/>
)}
{nip05Target && (
<Nip05Modal
target={nip05Target}
onClose={() => setNip05Target(null)}
onSaved={(message) => {
setNotice(message);
setNip05Target(null);
}}
onError={setError}
onSavingChange={setPublishing}
/>
)}
</div>
</div>
);
@ -242,6 +304,213 @@ interface PictureTarget {
url: string;
}
interface Nip05Target {
npub: string;
label: string;
nip05: string;
}
/** Client-side mirror of the backend's NIP-05 validation, for fast feedback. */
function nip05Problem(value: string): string | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null; // Empty clears the identifier.
}
const at = trimmed.indexOf('@');
if (at <= 0 || at === trimmed.length - 1) {
return 'Use the form name@domain.com.';
}
const [local, domain] = [trimmed.slice(0, at), trimmed.slice(at + 1)];
if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) {
return 'The part before @ may only use letters, numbers, dashes and underscores.';
}
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {
return 'The part after @ must be a domain like example.com.';
}
return null;
}
async function runModalSave(options: {
npub: string;
perform: () => Promise<MetadataPublishReport>;
successMessage: (report: MetadataPublishReport) => string;
onSaved: (message: string) => void;
onError: (message: string | null) => void;
onSavingChange: (npub: string | null) => void;
setSaving: (saving: boolean) => void;
}): Promise<void> {
options.onError(null);
options.setSaving(true);
options.onSavingChange(options.npub);
try {
const report = await options.perform();
options.onSaved(options.successMessage(report));
} catch (err) {
options.onError(err instanceof Error ? err.message : String(err));
} finally {
options.setSaving(false);
options.onSavingChange(null);
}
}
function Nip05Modal({
target,
onClose,
onSaved,
onError,
onSavingChange,
}: {
target: Nip05Target;
onClose: () => void;
onSaved: (message: string) => void;
onError: (message: string | null) => void;
onSavingChange: (npub: string | null) => void;
}) {
const { setNip05 } = useApp();
const [nip05, setNip05Value] = useState(target.nip05);
const [saving, setSaving] = useState(false);
const trimmed = nip05.trim();
const changed = trimmed !== target.nip05;
const problem = nip05Problem(trimmed);
const canSave = changed && !problem;
const save = async (next: string | null) => {
await runModalSave({
npub: target.npub,
perform: () => setNip05(target.npub, next),
successMessage: (report) =>
report.failed.length === 0
? next
? `NIP-05 "${next}" published to ${report.succeeded.length} relay(s). Remember it must also be served from your domain (see docs/NIP-05.md).`
: 'NIP-05 removed and the change published.'
: `NIP-05 saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
onSaved,
onError,
onSavingChange,
setSaving,
});
};
return (
<Modal open title={`NIP-05 address — ${target.label}`} onClose={onClose}>
<div className="picture-modal">
<div className="field">
<label htmlFor="profile-nip05">NIP-05 address</label>
<input
id="profile-nip05"
type="text"
value={nip05}
onChange={(event) => setNip05Value(event.target.value)}
placeholder="name@domain.com"
autoComplete="off"
autoFocus
/>
{problem && trimmed && <ErrorText>{problem}</ErrorText>}
</div>
<p className="muted">
A NIP-05 address (like an email handle) makes clients show a proper username instead of a
raw key. It is published to your relays and must also be served from your domain as
<code> /.well-known/nostr.json</code> — see <code>docs/NIP-05.md</code>.
</p>
<div className="modal-actions">
{target.nip05 && (
<Button variant="danger" onClick={() => void save(null)} disabled={saving}>
Remove
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void save(trimmed ? trimmed.toLowerCase() : null)}
loading={saving}
disabled={!canSave}
>
Save &amp; publish
</Button>
</div>
</div>
</Modal>
);
}
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 (
<Modal open title={`Edit profile name — ${target.label}`} onClose={onClose}>
<div className="picture-modal">
<div className="field">
<label htmlFor="profile-name">Profile name</label>
<input
id="profile-name"
type="text"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="My Profile"
autoComplete="off"
autoFocus
/>
</div>
<p className="muted">
The name is stored on this computer and published to your enabled relays so other Nostr
clients show it.
</p>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void save()}
loading={saving}
disabled={!canSave}
>
Save &amp; publish
</Button>
</div>
</div>
</Modal>
);
}
function PictureModal({
target,
onClose,
@ -261,22 +530,18 @@ function PictureModal({
const [saving, setSaving] = useState(false);
const save = async (nextUrl: string | null) => {
onError(null);
setSaving(true);
onSavingChange(target.npub);
try {
const report = await setProfilePicture(target.npub, nextUrl);
onSaved(
await runModalSave({
npub: target.npub,
perform: () => setProfilePicture(target.npub, nextUrl),
successMessage: (report) =>
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.`,
);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
onSavingChange(null);
}
onSaved,
onError,
onSavingChange,
setSaving,
});
};
const onUpload = async () => {

View file

@ -1,17 +1,24 @@
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 } from '../lib/types';
import type { Theme, UpdateCheckReport } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SettingsScreen() {
const { state, updateSettings, backupNow } = useApp();
const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
const [updateReport, setUpdateReport] = useState<UpdateCheckReport | null>(null);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [applyMessage, setApplyMessage] = useState<string[] | null>(null);
const settings = state?.settings;
const vaultPath = state?.vault_path ?? '';
@ -44,6 +51,38 @@ 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 (
<div className="screen">
<div className="screen-inner">
@ -65,9 +104,13 @@ export function SettingsScreen() {
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
<option value="glass">Aurora</option>
<option value="neon">Neon</option>
</select>
<p className="hint">“System” follows your desktop's light or dark preference.</p>
<p className="hint">
“Light” and “Dark” follow your manual selection. “Aurora” and “Neon” are aesthetic
themes with distinctive color palettes.
</p>
</div>
</div>
</section>
@ -153,6 +196,128 @@ export function SettingsScreen() {
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Updates</h2>
</header>
<div className="card-body settings-block">
<p className="hint">
Scans the JavaScript packages and Rust crates this app is built on. Known security
advisories are always listed first; installing applies compatible updates only.
</p>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onCheckUpdates()} loading={checking}>
<Icon name="refresh" size={16} />
Check for updates
</Button>
<Button
variant="primary"
onClick={() => void onInstallUpdates()}
loading={installing}
disabled={checking}
>
<Icon name="shield" size={16} />
Install updates
</Button>
</div>
{updateError && (
<Alert tone="error" title="Update problem">
{updateError}
</Alert>
)}
{checking && <Spinner label="Scanning dependencies…" />}
{applyMessage && (
<Alert tone="info" title="Updates">
<ul className="update-summary">
{applyMessage.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Alert>
)}
{updateReport && !checking && (
<>
{updateReport.advisories.length > 0 ? (
<Alert
tone="warning"
title={`${updateReport.advisories.length} security issue(s) found`}
>
<ul className="update-list">
{updateReport.advisories.map((advisory) => (
<li key={advisory.package}>
<Badge
tone={
advisory.severity === 'critical' || advisory.severity === 'high'
? 'danger'
: advisory.severity === 'moderate'
? 'warning'
: 'neutral'
}
>
{advisory.severity}
</Badge>{' '}
<code className="mono">{advisory.package}</code>
{advisory.title ? ` — ${advisory.title}` : ''}
{advisory.fix && <span className="hint"> {advisory.fix}</span>}
</li>
))}
</ul>
</Alert>
) : (
<Alert
tone="success"
title="No known security advisories in the npm dependencies."
/>
)}
{updateReport.outdated_npm.length > 0 && (
<div>
<span className="field-label">JavaScript packages</span>
<ul className="update-list">
{updateReport.outdated_npm.map((pkg) => (
<li key={pkg.name}>
<code className="mono">{pkg.name}</code> {pkg.current} →{' '}
<strong>{pkg.available}</strong>
</li>
))}
</ul>
</div>
)}
{updateReport.outdated_cargo.length > 0 && (
<div>
<span className="field-label">Rust crates</span>
<ul className="update-list">
{updateReport.outdated_cargo.map((crateName) => (
<li key={crateName.name}>
<code className="mono">{crateName.name}</code> {crateName.current} →{' '}
<strong>{crateName.available}</strong>
</li>
))}
</ul>
</div>
)}
{updateReport.notes.map((note) => (
<p className="hint" key={note}>
{note}
</p>
))}
{updateReport.advisories.length === 0 &&
updateReport.outdated_npm.length === 0 &&
updateReport.outdated_cargo.length === 0 && (
<Alert tone="success">Everything is up to date.</Alert>
)}
</>
)}
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Advanced</h2>

View file

@ -4,6 +4,7 @@ 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';
@ -15,11 +16,6 @@ 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<SignerStatus>(EMPTY_STATUS);
@ -137,7 +133,7 @@ export function SignerScreen() {
<dd>
{status.peer ? (
<code className="mono" title={status.peer}>
{shortHex(status.peer)}
{shortHexId(status.peer)}
</code>
) : (
<span className="muted">None yet</span>

View file

@ -21,6 +21,8 @@ import type {
Settings,
SignerStatus,
Theme,
UpdateApplyReport,
UpdateCheckReport,
UploadedImage,
} from '../lib/types';
@ -41,14 +43,18 @@ interface AppContextValue {
selectProfile: (npub: string) => Promise<void>;
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
renameProfile: (npub: string, label: string) => Promise<MetadataPublishReport>;
setNip05: (npub: string, nip05: string | null) => Promise<MetadataPublishReport>;
publishNote: (content: string) => Promise<PublishReport>;
recordPublishFailure: (message: string, details?: string | null) => void;
clearLastPublish: () => void;
feedGet: (limit?: number, contactsOnly?: boolean) => Promise<FeedItem[]>;
feedGet: (limit?: number, contactsOnly?: boolean, authorNpub?: string) => Promise<FeedItem[]>;
relayAdd: (url: string) => Promise<Settings>;
relayRemove: (url: string) => Promise<Settings>;
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
relayTest: (url: string) => Promise<RelayTestResult>;
updateCheck: () => Promise<UpdateCheckReport>;
updateApply: () => Promise<UpdateApplyReport>;
updateSettings: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>;
@ -134,6 +140,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
[],
);
const renameProfile = useCallback(
async (npub: string, label: string): Promise<MetadataPublishReport> => {
const result = await api.renameProfile(npub, label);
setState(result.state);
return result.report;
},
[],
);
const setNip05 = useCallback(
async (npub: string, nip05: string | null): Promise<MetadataPublishReport> => {
const result = await api.setNip05(npub, nip05);
setState(result.state);
return result.report;
},
[],
);
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
const report = await api.publishNote(content);
setLastPublish({ report, error: null, details: null, at: Date.now() });
@ -148,8 +172,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
setLastPublish(null);
}, []);
const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => {
return api.feedGet(limit, contactsOnly);
const feedGet = useCallback((limit?: number, contactsOnly?: boolean, authorNpub?: string) => {
return api.feedGet(limit, contactsOnly, authorNpub);
}, []);
const applySettings = useCallback((fresh: Settings) => {
@ -170,6 +194,8 @@ 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<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
applySettings(await api.settingsUpdate(patch)),
@ -213,7 +239,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
const copyText = useCallback((text: string) => api.copyText(text), []);
useThemeSync(state?.settings.theme);
useEffect(() => {
if (state?.settings.theme) {
applyTheme(state.settings.theme);
}
}, [state?.settings.theme]);
const value = useMemo<AppContextValue>(
() => ({
@ -232,6 +262,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayRemove,
relaySetEnabled,
relayTest,
updateCheck,
updateApply,
updateSettings,
backupNow,
setVaultPassword,
@ -250,6 +282,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
undoDelete,
publishProfileMetadata,
setProfilePicture,
renameProfile,
setNip05,
copyText,
}),
[
@ -262,6 +296,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
selectProfile,
publishProfileMetadata,
setProfilePicture,
renameProfile,
setNip05,
publishNote,
deleteProfile,
undoDelete,
@ -272,6 +308,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayRemove,
relaySetEnabled,
relayTest,
updateCheck,
updateApply,
updateSettings,
backupNow,
setVaultPassword,
@ -301,12 +339,13 @@ export function useApp(): AppContextValue {
return context;
}
/** Apply the requested theme (respecting system preference for `system`). */
/** Apply the requested theme. */
export function applyTheme(theme: Theme): void {
const prefersDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';
if (theme === 'glass' || theme === 'neon') {
document.documentElement.dataset.theme = theme;
return;
}
document.documentElement.dataset.theme = theme;
}
/** Keep the document theme in sync with settings, watching system changes. */

View file

@ -58,6 +58,113 @@
--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 <body>; 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;
}
@ -252,6 +359,25 @@ 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
------------------------------------------------------------------------- */
@ -280,8 +406,23 @@ a {
border-radius: 11px;
display: grid;
place-items: center;
background: var(--primary);
color: var(--on-primary);
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);
}
.sidebar-brand strong {
@ -1048,6 +1189,14 @@ 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;
@ -1080,6 +1229,7 @@ select {
.profile-card-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: auto;

View file

@ -24,7 +24,7 @@ describe('FeedScreen', () => {
it('disable relays shows an empty state that can navigate to relays', async () => {
const settings = {
theme: 'system' as const,
theme: 'light' as const,
confirm_before_publish: true,
shorten_npub: true,
relays: [
@ -107,4 +107,55 @@ 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(<FeedScreen onNavigate={vi.fn()} />);
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(<FeedScreen onNavigate={vi.fn()} />);
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(<FeedScreen onNavigate={vi.fn()} />);
const myNotesButton = await screen.findByRole('button', { name: /My notes/i });
expect(myNotesButton).toBeDisabled();
});
});

View file

@ -37,6 +37,118 @@ describe('ProfilesScreen', () => {
});
});
it('selects a profile when its card is clicked', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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);

View file

@ -29,6 +29,19 @@ describe('SettingsScreen', () => {
expect(document.documentElement.dataset.theme).toBe('dark');
});
it('applies the neon theme to the document', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
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);
@ -64,4 +77,70 @@ 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(<SettingsScreen />);
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(<SettingsScreen />);
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(<SettingsScreen />);
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(<SettingsScreen />);
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();
});
});

View file

@ -25,7 +25,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
is_active: false,
};
const settings: Settings = {
theme: 'system',
theme: 'light',
confirm_before_publish: true,
shorten_npub: true,
relays: [

View file

@ -7,6 +7,8 @@ import type {
RelayTestResult,
Settings,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
} from '../lib/types';
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
@ -43,6 +45,12 @@ 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 {
@ -126,6 +134,41 @@ 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<string, unknown>): Promise<unknown> {
@ -163,6 +206,58 @@ 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;
@ -182,6 +277,12 @@ 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) {
@ -290,6 +391,12 @@ 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 });

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -113,7 +113,8 @@ impl App {
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
picture: None,
picture: restored.picture.clone(),
nip05: restored.nip05.clone(),
};
self.vault.profiles.push(stored);
// If no active profile, this restored one becomes active

View file

@ -77,6 +77,19 @@ 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<Vec<FeedItem>, 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<Vec<PublicKey>, AppError> {
let owner = owner_pubkey(owner_hex)?;
@ -85,43 +98,34 @@ async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<Pub
return Ok(Vec::new());
}
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 client =
crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?;
let filter = Filter::new().kind(Kind::ContactList).author(owner);
client
.subscribe(filter, None)
let mut events = client
.stream_events(filter)
.await
.map_err(|e| AppError::network(format!("Could not subscribe for contacts: {e}")))?;
let mut contacts: HashSet<PublicKey> = HashSet::new();
let mut notifications = client.notifications();
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
loop {
match tokio::time::timeout_at(deadline, notifications.recv()).await {
Ok(Ok(RelayPoolNotification::Event { event, .. })) => {
match tokio::time::timeout_at(deadline, events.next()).await {
Ok(Some((_, Ok(event)))) => {
if event.kind == Kind::ContactList {
for pubkey in event
.tags
.iter()
.filter_map(|tag| match tag.as_standardized() {
Some(TagStandard::PublicKey { public_key, .. }) => Some(*public_key),
_ => None,
})
{
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);
}
}
}
Ok(Ok(_)) => continue,
Ok(Err(_)) | Err(_) => break,
}
}
}
Ok(Some((_, Err(_)))) => continue,
Ok(None) | Err(_) => break,
}
}
@ -145,15 +149,8 @@ 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 = 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 client =
crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?;
let since = Timestamp::now() - LOOKBACK;
let filter = Filter::new()
@ -164,25 +161,22 @@ async fn aggregate_for(
Some(authors) => filter.authors(authors.iter().copied()),
None => filter,
};
client
.subscribe(filter, None)
let mut events = client
.stream_events(filter)
.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, notifications.recv()).await {
Ok(Ok(RelayPoolNotification::Event {
event, relay_url, ..
})) => {
match tokio::time::timeout_at(deadline, events.next()).await {
Ok(Some((relay_url, Ok(event)))) => {
if !feed.add(&event, Some(relay_url)) {
break;
}
}
Ok(Ok(_)) => continue,
Ok(Err(_)) | Err(_) => break,
Ok(Some((_, Err(_)))) => continue,
Ok(None) | Err(_) => break,
}
}
@ -263,7 +257,7 @@ impl FeedItem {
author: event.pubkey.to_hex(),
author_npub,
content: event.content.trim().to_string(),
created_at: event.created_at.as_u64(),
created_at: event.created_at.as_secs(),
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
})
}
@ -277,7 +271,7 @@ mod tests {
let keys = Keys::generate();
EventBuilder::new(Kind::TextNote, content.to_string())
.custom_created_at(Timestamp::from(created_at))
.sign(&keys)
.finalize_async(&keys)
.await
.unwrap()
}
@ -334,7 +328,7 @@ mod tests {
let mut builder = FeedBuilder::new(10, None);
let keys = Keys::generate();
let other = EventBuilder::new(Kind::Metadata, "{}")
.sign(&keys)
.finalize_async(&keys)
.await
.unwrap();
builder.add(&other, None);
@ -364,12 +358,12 @@ mod tests {
let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string())
.custom_created_at(Timestamp::from(5))
.sign(&followed)
.finalize_async(&followed)
.await
.unwrap();
let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string())
.custom_created_at(Timestamp::from(6))
.sign(&stranger)
.finalize_async(&stranger)
.await
.unwrap();
@ -397,6 +391,30 @@ 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 {

View file

@ -1,8 +1,9 @@
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::Mutex;
use crate::app::App;
use crate::errors::AppError;
@ -12,6 +13,7 @@ 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);
@ -49,6 +51,18 @@ pub enum Request {
npub: String,
url: Option<String>,
},
/// 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<String>,
},
PublishNote {
content: String,
},
@ -56,9 +70,13 @@ pub enum Request {
FeedGet {
/// Optional cap on how many notes to return; leave `None` for the default.
limit: Option<usize>,
/// When true, only return notes authored by the active profile's
/// contacts. When no active profile is selected the request errors.
/// 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.
contacts_only: Option<bool>,
/// When set (npub or hex), only return notes authored by that account.
/// Takes precedence over `contacts_only`.
author: Option<String>,
},
RelayAdd {
url: String,
@ -73,6 +91,11 @@ 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<Theme>,
@ -151,20 +174,26 @@ 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 processed sequentially so
/// the shared state never sees concurrent mutations.
/// 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.
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 = Signer::new();
let signer = Arc::new(Signer::new());
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
let stdin = tokio::io::stdin();
let mut lines = tokio::io::BufReader::new(stdin).lines();
let mut stdout = tokio::io::stdout();
let mut tasks = JoinSet::new();
while let Some(line) = lines
.next_line()
@ -183,29 +212,40 @@ pub async fn serve() -> Result<(), AppError> {
message: "The request could not be understood.".to_string(),
details: Some(e.to_string()),
};
write_line(&mut stdout, ReplyEnvelope { id: 0, reply }).await?;
write_line(&stdout, ReplyEnvelope { id: 0, reply }).await?;
continue;
}
};
let reply = handle(app.clone(), &signer, envelope.request).await;
write_line(
&mut stdout,
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?;
.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<W>(writer: &mut W, envelope: ReplyEnvelope) -> Result<(), AppError>
where
W: tokio::io::AsyncWriteExt + Unpin,
{
async fn write_line(
writer: &tokio::sync::Mutex<tokio::io::Stdout>,
envelope: ReplyEnvelope,
) -> Result<(), AppError> {
use tokio::io::AsyncWriteExt;
let mut writer = writer.lock().await;
let mut line = serde_json::to_string(&envelope)
.map_err(|e| AppError::json("Could not prepare a response", e))?;
line.push('\n');
@ -222,10 +262,10 @@ where
async fn handle(
app: Arc<Mutex<App>>,
signer: &Signer,
signer: Arc<Signer>,
request: Request,
) -> Reply<serde_json::Value> {
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 {
@ -249,19 +289,16 @@ 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. Everything else
/// locks the vault for the duration of the call, mirroring the old
/// single-threaded model.
#[allow(clippy::await_holding_lock)]
/// 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.
async fn run(
app: &Arc<Mutex<App>>,
signer: &Signer,
request: Request,
) -> Result<serde_json::Value, AppError> {
// 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)?;
@ -276,16 +313,71 @@ 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().expect("app mutex poisoned");
let mut guard = app.lock().await;
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: requests remain effectively sequential, and
/// a concurrent `await` never yields back into a state the loop expects to own.
/// the awaited operation on purpose: mutations stay serialized against each
/// other even though requests themselves are handled concurrently.
async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
match request {
Request::Init | Request::GetState => Ok(json!(app.state_view())),
@ -325,6 +417,27 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
}
Request::RenameProfile { npub, label } => {
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())
@ -332,25 +445,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(report))
}
Request::FeedGet {
limit,
contacts_only,
} => {
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()?;
@ -369,11 +463,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(app.settings))
}
Request::RelayTest { url } => {
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
Ok(json!(result))
}
Request::SettingsGet => Ok(json!(app.settings)),
Request::BackupNow => {

View file

@ -8,6 +8,7 @@ pub mod publish;
pub mod relays;
pub mod settings;
pub mod signer;
pub mod updates;
pub mod uploads;
pub mod vault;

View file

@ -1,5 +1,5 @@
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use keynectr::app::App;
use keynectr::errors::{AppError, ErrorKind};
@ -21,6 +21,11 @@ Commands:
publish <npub> <content> Publish a text note as a specific profile
publish-name <npub> Publish the profile's stored name so other clients show it
set-picture <npub> <url> Set the profile picture (http(s) URL) and publish it
rename <npub> <new-name> Rename a profile and publish the new name
set-nip05 <npub> <id|clear> Set the NIP-05 address (name@domain) and publish it;
pass 'clear' to remove it
nip05-file <npub> <id> 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
@ -30,7 +35,7 @@ Commands:
relays disable <url> Disable a relay
relays test <url> Test a relay connection
settings get Show application settings
settings set theme <light|dark|system>
settings set theme <light|dark|glass|neon>
settings set confirm <true|false>
settings set shorten <true|false>
delete-profile <npub> Delete a profile (moves it to undo stack)
@ -70,6 +75,9 @@ 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),
@ -187,6 +195,97 @@ fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
))
}
fn cli_rename(args: &[String]) -> Result<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: keynectr rename <npub> <new-name>"))?;
let label = args[3..].join(" ");
if label.trim().is_empty() {
return Err(AppError::config("Usage: keynectr rename <npub> <new-name>"));
}
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<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: keynectr nip05-file <npub> <name@domain>"))?;
let identifier = args
.get(3)
.ok_or_else(|| AppError::config("Usage: keynectr nip05-file <npub> <name@domain>"))?;
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<serde_json::Value> =
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://<your-domain>/.well-known/nostr.json (Content-Type: application/json):\n\n{pretty}\n"
))
}
fn cli_set_nip05(args: &[String]) -> Result<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: keynectr set-nip05 <npub> <name@domain|clear>"))?;
let raw = args
.get(3)
.ok_or_else(|| AppError::config("Usage: keynectr set-nip05 <npub> <name@domain|clear>"))?;
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<String, AppError> {
let npub = args
.get(2)
@ -343,7 +442,7 @@ fn cli_settings(args: &[String]) -> Result<String, AppError> {
match key.as_str() {
"theme" => {
let theme = Theme::parse(value).ok_or_else(|| {
AppError::config("Theme must be one of: light, dark, system")
AppError::config("Theme must be one of: light, dark, glass, neon")
})?;
app.settings.theme = theme;
}
@ -486,7 +585,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
let uri = args
.get(3)
.ok_or_else(|| AppError::config("Usage: signer connect <uri>"))?;
let app = Arc::new(Mutex::new(load_app_with_unlock()?));
let app = Arc::new(tokio::sync::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)");
@ -548,6 +647,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary
created_at: stored.created_at,
is_active: false,
picture: stored.picture,
nip05: stored.nip05,
})
}
@ -580,7 +680,8 @@ fn cli_undo_delete() -> Result<String, AppError> {
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
picture: None,
picture: restored.picture,
nip05: restored.nip05,
};
app.vault.profiles.push(stored);
if app.vault.active_profile.is_none() {

View file

@ -1,6 +1,5 @@
use nostr_sdk::prelude::*;
use serde::Serialize;
use std::time::Duration;
use zeroize::Zeroizing;
use crate::crypto::VaultKey;
@ -10,11 +9,6 @@ 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 {
@ -26,6 +20,8 @@ pub struct ProfileSummary {
pub is_active: bool,
/// Public URL of the profile picture, when one has been set.
pub picture: Option<String>,
/// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set.
pub nip05: Option<String>,
}
/// A secret key revealed after the vault is unlocked, in both the raw hex and
@ -76,6 +72,7 @@ pub fn create_profile(
secret_key: stored_secret,
created_at,
picture: None,
nip05: None,
};
let is_active = vault.active_profile.is_none();
@ -89,7 +86,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, relay_urls);
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
}
Ok(ProfileSummary {
@ -98,6 +95,7 @@ pub fn create_profile(
created_at,
is_active,
picture: None,
nip05: None,
})
}
@ -133,6 +131,7 @@ pub fn publish_profile_metadata(
&keys,
&stored.label,
stored.picture.clone(),
stored.nip05.clone(),
relay_urls,
))
}
@ -160,12 +159,13 @@ pub fn set_profile_picture(
let stored = find_profile_mut(vault, npub)?;
stored.picture = url;
let (label, npub, created_at, public_key, picture) = (
let (label, npub, created_at, public_key, picture, nip05) = (
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,6 +174,7 @@ 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);
@ -190,11 +191,173 @@ pub fn set_profile_picture(
}
let keys = Keys::new(secret_key);
let report =
publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls);
let report = publish_metadata_blocking(
&keys,
&summary.label,
summary.picture.clone(),
summary.nip05.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<String>,
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 (`<local-part>@<domain>`), 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<String, AppError> {
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)
@ -235,6 +398,7 @@ fn publish_metadata_blocking(
keys: &Keys,
label: &str,
picture: Option<String>,
nip05: Option<String>,
relay_urls: Vec<String>,
) -> MetadataPublishReport {
let keys = keys.clone();
@ -242,7 +406,9 @@ fn publish_metadata_blocking(
std::thread::spawn(move || {
tokio::runtime::Runtime::new()
.expect("metadata runtime")
.block_on(publish_metadata_async(&keys, &label, picture, relay_urls))
.block_on(publish_metadata_async(
&keys, &label, picture, nip05, relay_urls,
))
})
.join()
.expect("metadata publish thread panicked")
@ -252,6 +418,7 @@ async fn publish_metadata_async(
keys: &Keys,
label: &str,
picture: Option<String>,
nip05: Option<String>,
relay_urls: Vec<String>,
) -> MetadataPublishReport {
let mut metadata = Metadata::new().name(label).display_name(label);
@ -260,8 +427,11 @@ 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())
.sign(keys)
.finalize_async(keys)
.await
{
Ok(event) => event,
@ -280,48 +450,22 @@ async fn publish_metadata_async(
}
};
let client = Client::new(keys.clone());
// 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();
for url in &relay_urls {
let _ = client.add_relay(url.as_str()).await;
}
client.connect().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;
let (succeeded, failed) =
crate::publish::send_to_all_relays(&client, relay_urls, &event, "metadata").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<ProfileSummary> {
@ -349,6 +493,7 @@ 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(),
}
}
@ -464,6 +609,7 @@ mod tests {
secret_key: "00".repeat(32),
created_at: 1,
picture: None,
nip05: None,
});
vault.profiles.push(StoredProfile {
label: "Bob".to_string(),
@ -471,6 +617,7 @@ mod tests {
secret_key: "11".repeat(32),
created_at: 2,
picture: None,
nip05: None,
});
vault
}
@ -756,6 +903,164 @@ 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<ProfileSummary, AppError> {
@ -775,6 +1080,7 @@ mod tests {
created_at: stored.created_at,
is_active: false,
picture: stored.picture,
nip05: stored.nip05,
})
}
}

View file

@ -11,10 +11,9 @@ use crate::relays;
use crate::settings::Settings;
use crate::vault::Vault;
/// 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);
/// 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);
/// A relay that rejected a published note.
#[derive(Debug, Clone, Serialize)]
@ -168,7 +167,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
.sign(keys)
.finalize_async(keys)
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
@ -177,52 +176,8 @@ async fn publish_with_keys(
.to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
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<String> = Vec::new();
let mut failed: Vec<RelayFailure> = 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;
let client = relays::open_pool(keys.clone(), &relay_urls, None).await?;
let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await;
if succeeded.is_empty() {
return Err(AppError::publish_failed(failed));
@ -235,6 +190,102 @@ 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<String>,
event: &Event,
noun: &str,
) -> (Vec<String>, Vec<RelayFailure>) {
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<Option<Result<String, RelayFailure>>> =
(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.
///

View file

@ -69,6 +69,35 @@ pub fn enabled_urls(settings: &Settings) -> Vec<String> {
.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<Duration>,
) -> Result<Client, AppError> {
// 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 {
@ -83,7 +112,9 @@ pub struct RelayTestResult {
/// during a connection test.
pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestResult, AppError> {
let keys = Keys::generate();
let client = Client::new(keys);
let client = Client::builder()
.authenticator(SignerAuthenticator::new(keys))
.build();
client
.add_relay(url)
@ -93,10 +124,12 @@ pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestRe
let relay = client
.relay(url)
.await
.map_err(|e| AppError::network(format!("Could not look up relay {url}: {e}")))?;
.map_err(|e| AppError::network(format!("Could not look up relay {url}: {e}")))?
.ok_or_else(|| AppError::network(format!("Relay {url} is not in the pool")))?;
relay
.try_connect(timeout)
.try_connect()
.timeout(timeout)
.await
.map_err(|e| AppError::network(format!("Connection failed: {e}")))?;

View file

@ -22,7 +22,8 @@ impl RelayConfig {
pub enum Theme {
Light,
Dark,
System,
Glass,
Neon,
}
impl Theme {
@ -31,7 +32,8 @@ impl Theme {
match s {
"light" => Some(Self::Light),
"dark" => Some(Self::Dark),
"system" => Some(Self::System),
"glass" => Some(Self::Glass),
"neon" => Some(Self::Neon),
_ => None,
}
}
@ -58,7 +60,7 @@ fn default_true() -> bool {
impl Default for Settings {
fn default() -> Self {
Self {
theme: Theme::System,
theme: Theme::Light,
confirm_before_publish: true,
shorten_npub: true,
relays: crate::relays::default_relays(),

View file

@ -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<Mutex<App>>, uri: &str) -> Result<(), AppError> {
pub fn connect(&self, app: Arc<tokio::sync::Mutex<App>>, 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,7 +363,11 @@ fn percent_decode(raw: &str) -> Option<String> {
/// NIP-44 encrypt with the conversation key, returned base64-encoded.
fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result<String, AppError> {
let payload = v2::encrypt_to_bytes(conversation, plaintext.as_bytes())
// 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)
.map_err(|e| AppError::internal(format!("Could not encrypt a message: {e}")))?;
Ok(B64.encode(payload))
}
@ -570,8 +574,8 @@ fn sign_event(keys: &Keys, request: &RawRequest) -> Result<String, String> {
let unsigned: UnsignedEvent =
serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?;
let event = unsigned
.sign_with_keys(keys)
let event = keys
.sign_event(unsigned)
.map_err(|e| format!("The event could not be signed: {e}"))?;
Ok(event.as_json())
}
@ -596,16 +600,10 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result<String, String> {
/// 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<Mutex<App>>, uri: ConnectUri) {
async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: ConnectUri) {
// 1. Resolve the active profile's key under the current vault lock.
let keys = {
let guard = match app.lock() {
Ok(guard) => guard,
Err(_) => {
signer.fail("The vault could not be read.");
return;
}
};
let guard = app.lock().await;
let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) {
Ok(hex) => hex,
Err(err) => {
@ -632,23 +630,35 @@ async fn run_sign_task(signer: Signer, app: Arc<Mutex<App>>, uri: ConnectUri) {
}
};
// 3. Connect to the client's relays.
let client = Client::new(keys.clone());
// 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();
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().await;
client.wait_for_connection(CONNECT_TIMEOUT).await;
client.connect().and_wait(CONNECT_TIMEOUT).await;
// 4. Subscribe to the client's kind 24133 events so we hear its requests.
// `stream_events` opens its internal notification receiver as part of
// subscribing, which must happen BEFORE we announce (step 5): the client
// acknowledges within milliseconds and a receiver created afterwards would
// miss those early messages (tokio broadcast semantics).
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
if let Err(err) = client.subscribe(filter, None).await {
let mut events = match client.stream_events(filter).await {
Ok(events) => events,
Err(err) => {
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 {
@ -657,18 +667,19 @@ async fn run_sign_task(signer: Signer, app: Arc<Mutex<App>>, uri: ConnectUri) {
}
// 6. Answer requests until the connection goes away or we are stopped.
let mut notifications = client.notifications();
loop {
let notification = match notifications.recv().await {
Ok(notification) => notification,
Err(_) => {
let (relay_url, incoming) = match events.next().await {
Some(next) => next,
None => {
signer.fail("The signer connection was closed.");
return;
}
};
let RelayPoolNotification::Event { event, .. } = notification else {
continue;
let event = match incoming {
Ok(event) => event,
Err(_) => continue,
};
let _ = relay_url;
if event.kind != Kind::NostrConnect || event.pubkey != uri.peer {
continue;
}
@ -730,7 +741,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])
.sign(keys)
.finalize_async(keys)
.await
.map_err(|e| format!("Could not sign a message: {e}"))?;
client
@ -744,6 +755,80 @@ 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(
@ -764,7 +849,10 @@ mod tests {
fn bunker_link_is_rejected() {
let signer = Signer::new();
assert!(signer
.connect(Arc::new(Mutex::new(App::load().unwrap())), "bunker://abc")
.connect(
Arc::new(tokio::sync::Mutex::new(App::load().unwrap())),
"bunker://abc"
)
.is_err());
}

498
src/updates.rs Normal file
View file

@ -0,0 +1,498 @@
//! 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<String>,
/// What npm says is needed to clear it. `None` means a compatible fix
/// exists that "Install updates" can apply.
pub fix: Option<String>,
}
/// Everything the scan found, ready to show in one screen.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateCheckReport {
pub outdated_npm: Vec<PackageUpdate>,
pub advisories: Vec<SecurityAdvisory>,
pub outdated_cargo: Vec<PackageUpdate>,
/// Hints about optional tooling or skipped parts of the scan.
pub notes: Vec<String>,
}
/// Result of applying updates.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateApplyReport {
pub applied: Vec<String>,
pub failed: Vec<String>,
/// 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<std::process::Output, AppError> {
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<PackageUpdate> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(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<SecurityAdvisory> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(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<PackageUpdate> {
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<UpdateCheckReport, AppError> {
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<UpdateApplyReport, AppError> {
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::<Vec<_>>()
.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());
}
}

View file

@ -1,3 +1,4 @@
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr_sdk::prelude::*;
use crate::crypto::VaultKey;

View file

@ -39,6 +39,11 @@ pub struct StoredProfile {
/// profiles stored before pictures were introduced.
#[serde(default)]
pub picture: Option<String>,
/// 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<String>,
}
/// KDF parameters that encrypted a vault. Stored so future key-derivation
@ -457,6 +462,7 @@ mod tests {
secret_key: "00ff".to_string(),
created_at: 1_700_000_000,
picture: None,
nip05: None,
}
}