Compare commits
51 commits
2e4005d24d
...
f29d4f94a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29d4f94a0 | ||
|
|
fa5ba08578 | ||
|
|
bf10ae383a | ||
|
|
e210f17ddb | ||
|
|
7098e75ca3 | ||
|
|
e6a1efc7f2 | ||
|
|
ec237bf6dd | ||
|
|
6d5063d441 | ||
|
|
40ec9a0dfa | ||
|
|
1c428a9ff2 | ||
|
|
b21678fafb | ||
|
|
64ad94d0b5 | ||
|
|
ae8452602f | ||
|
|
dd50b24ec1 | ||
|
|
0cab88364b | ||
|
|
ecb666bc1d | ||
|
|
1aae81ca04 | ||
|
|
bfe14f0d97 | ||
|
|
db2d22f999 | ||
|
|
a01f258d4b | ||
|
|
c080a8d1c8 | ||
|
|
542f3e5472 | ||
|
|
90d699cff1 | ||
|
|
4722ecdabd | ||
|
|
74072b7d6d | ||
|
|
ef6ea171f8 | ||
|
|
a00c7c9897 | ||
|
|
5f27c4585b | ||
|
|
cbbe779faa | ||
|
|
4d9489b5ac | ||
|
|
8a644afd80 | ||
|
|
48efaad0d9 | ||
|
|
c11ab9f735 | ||
|
|
a1915bb1c1 | ||
|
|
8bce42810a | ||
|
|
3dfd15f9c1 | ||
|
|
55a49b442b | ||
|
|
8f637618bc | ||
|
|
b001b3c7b1 | ||
|
|
2ad054e6e1 | ||
|
|
045fa47476 | ||
|
|
f2c30d397f | ||
|
|
cb23a5e32d | ||
|
|
ad70890efc | ||
|
|
4d70b25628 | ||
|
|
6df690fe8f | ||
|
|
d618a5a4a0 | ||
|
|
808f011876 | ||
|
|
64502c1b14 | ||
|
|
355e07f40f | ||
|
|
653945a5e8 |
42 changed files with 5971 additions and 4714 deletions
File diff suppressed because it is too large
Load diff
534
Cargo.lock
generated
534
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,8 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr = { version = "0.40", features = ["nip44", "nip46"] }
|
nostr = { version = "0.45", features = ["nip44", "nip98"] }
|
||||||
nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] }
|
nostr-sdk = "0.45"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
|
||||||
77
docs/NIP-05.md
Normal file
77
docs/NIP-05.md
Normal 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
BIN
frontend/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -82,6 +82,18 @@ function resolveBackendPath(): string {
|
||||||
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
|
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 {
|
function startBackend(): void {
|
||||||
if (backendStarted && backend && backend.exitCode === null) {
|
if (backendStarted && backend && backend.exitCode === null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -139,10 +151,11 @@ function startBackend(): void {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upper bound for one backend round-trip. Generous on purpose: a publish can
|
* 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
|
* wait on relays and dependency updates run npm/cargo commands that can take
|
||||||
* still gets reaped instead of leaking promises forever.
|
* 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> {
|
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||||
startBackend();
|
startBackend();
|
||||||
|
|
@ -185,6 +198,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'select_profile',
|
'select_profile',
|
||||||
'publish_profile_metadata',
|
'publish_profile_metadata',
|
||||||
'set_profile_picture',
|
'set_profile_picture',
|
||||||
|
'rename_profile',
|
||||||
|
'set_nip05',
|
||||||
'delete_profile',
|
'delete_profile',
|
||||||
'undo_delete',
|
'undo_delete',
|
||||||
'publish_note',
|
'publish_note',
|
||||||
|
|
@ -194,6 +209,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'relay_set_enabled',
|
'relay_set_enabled',
|
||||||
'relay_test',
|
'relay_test',
|
||||||
'settings_update',
|
'settings_update',
|
||||||
|
'update_check',
|
||||||
|
'update_apply',
|
||||||
'backup_now',
|
'backup_now',
|
||||||
'set_vault_password',
|
'set_vault_password',
|
||||||
'unlock_vault',
|
'unlock_vault',
|
||||||
|
|
@ -495,6 +512,7 @@ function createWindow(): void {
|
||||||
minWidth: 920,
|
minWidth: 920,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
title: 'Keynectr',
|
title: 'Keynectr',
|
||||||
|
icon: resolveWindowIcon(),
|
||||||
backgroundColor: '#f6f4f0',
|
backgroundColor: '#f6f4f0',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
protocol.handle('app', (request) => {
|
protocol.handle('app', (request) => {
|
||||||
const { pathname } = new URL(request.url);
|
const { pathname } = new URL(request.url);
|
||||||
|
|
|
||||||
5530
frontend/package-lock.json
generated
5530
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"electron:build": "tsc -p tsconfig.electron.json",
|
"electron:build": "tsc -p tsconfig.electron.json",
|
||||||
"start": "npm run electron:build && electron .",
|
"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"
|
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -32,17 +33,17 @@
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"electron": "^33.2.0",
|
"electron": "^43.4.1",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.15.0",
|
"typescript-eslint": "^8.15.0",
|
||||||
"vite": "^5.4.11",
|
"vite": "^8.2.2",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "dev.nostfeedmanager.desktop",
|
"appId": "dev.nostfeedmanager.desktop",
|
||||||
|
|
@ -65,6 +66,7 @@
|
||||||
"target": [
|
"target": [
|
||||||
"dir"
|
"dir"
|
||||||
],
|
],
|
||||||
|
"icon": "build/icon.png",
|
||||||
"category": "Network",
|
"category": "Network",
|
||||||
"executableName": "keynectr"
|
"executableName": "keynectr"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
frontend/public/icon.png
Normal file
BIN
frontend/public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
42
frontend/scripts/install-desktop-entry.sh
Executable file
42
frontend/scripts/install-desktop-entry.sh
Executable 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."
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -19,7 +19,11 @@ export function Modal({ open, title, onClose, children }: ModalProps) {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
container?.focus();
|
// 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) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { shortenNpub } from '../lib/format';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { CopyButton } from './CopyButton';
|
import { CopyButton } from './CopyButton';
|
||||||
import { Icon, type IconName } from './Icon';
|
import { Icon, type IconName } from './Icon';
|
||||||
|
import logoUrl from '../assets/logo.png';
|
||||||
|
|
||||||
const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
|
||||||
{ id: 'home', label: 'Home', icon: 'home' },
|
{ id: 'home', label: 'Home', icon: 'home' },
|
||||||
|
|
@ -29,7 +30,7 @@ export function Sidebar({ screen, onNavigate }: SidebarProps) {
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
<span className="sidebar-logo" aria-hidden="true">
|
<span className="sidebar-logo" aria-hidden="true">
|
||||||
<Icon name="shield" size={22} />
|
<img src={logoUrl} alt="" draggable={false} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Keynectr</strong>
|
<strong>Keynectr</strong>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import type {
|
||||||
RevealedKey,
|
RevealedKey,
|
||||||
Settings,
|
Settings,
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
|
UpdateApplyReport,
|
||||||
|
UpdateCheckReport,
|
||||||
UploadedImage,
|
UploadedImage,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
|
@ -58,17 +60,30 @@ export const api = {
|
||||||
'set_profile_picture',
|
'set_profile_picture',
|
||||||
{ npub, url },
|
{ 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 }),
|
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
||||||
feedGet: (limit?: number, contactsOnly = false) =>
|
feedGet: (limit?: number, contactsOnly = false, authorNpub?: string) =>
|
||||||
call<FeedItem[]>('feed_get', {
|
call<FeedItem[]>('feed_get', {
|
||||||
...(limit ? { limit } : {}),
|
...(limit ? { limit } : {}),
|
||||||
...(contactsOnly ? { contacts_only: true } : {}),
|
...(contactsOnly ? { contacts_only: true } : {}),
|
||||||
|
...(authorNpub ? { author: authorNpub } : {}),
|
||||||
}),
|
}),
|
||||||
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
|
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
|
||||||
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
|
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
|
||||||
relaySetEnabled: (url: string, enabled: boolean) =>
|
relaySetEnabled: (url: string, enabled: boolean) =>
|
||||||
call<Settings>('relay_set_enabled', { url, enabled }),
|
call<Settings>('relay_set_enabled', { url, enabled }),
|
||||||
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
|
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
|
||||||
|
updateCheck: () => call<UpdateCheckReport>('update_check'),
|
||||||
|
updateApply: () => call<UpdateApplyReport>('update_apply'),
|
||||||
settingsUpdate: (
|
settingsUpdate: (
|
||||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||||
) => call<Settings>('settings_update', patch),
|
) => call<Settings>('settings_update', patch),
|
||||||
|
|
|
||||||
39
frontend/src/lib/format.test.ts
Normal file
39
frontend/src/lib/format.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -63,3 +63,11 @@ export function initialsFor(label: string): string {
|
||||||
}
|
}
|
||||||
return trimmed.charAt(0).toUpperCase();
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export type Theme = 'light' | 'dark' | 'system';
|
export type Theme = 'light' | 'dark' | 'glass' | 'neon';
|
||||||
|
|
||||||
/** Lifecycle of the NIP-46 remote signer. */
|
/** Lifecycle of the NIP-46 remote signer. */
|
||||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
||||||
|
|
@ -36,6 +36,8 @@ export interface ProfileSummary {
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
/** Public URL of the profile picture, when one has been set. */
|
/** Public URL of the profile picture, when one has been set. */
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
|
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
||||||
|
nip05?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RelayConfig {
|
export interface RelayConfig {
|
||||||
|
|
@ -92,6 +94,38 @@ export interface RelayTestResult {
|
||||||
latency_ms?: number | null;
|
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 {
|
export interface AppState {
|
||||||
version: string;
|
version: string;
|
||||||
vault_path: string;
|
vault_path: string;
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,33 @@ interface FeedScreenProps {
|
||||||
onNavigate: (screen: Screen) => void;
|
onNavigate: (screen: Screen) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeedScope = 'everyone' | 'contacts';
|
type FeedScope = 'everyone' | 'contacts' | 'profile';
|
||||||
|
|
||||||
export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
const { state, feedGet } = useApp();
|
const { state, feedGet } = useApp();
|
||||||
const [items, setItems] = useState<FeedItem[]>([]);
|
const [items, setItems] = useState<FeedItem[]>([]);
|
||||||
const [scope, setScope] = useState<FeedScope>('everyone');
|
const [scope, setScope] = useState<FeedScope>('everyone');
|
||||||
|
const [pickedProfile, setPickedProfile] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
|
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
const profiles = state?.profiles ?? [];
|
||||||
const hasActiveProfile = state?.active_profile != null;
|
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 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(
|
const load = useCallback(
|
||||||
async (background: boolean) => {
|
async (background: boolean) => {
|
||||||
|
|
@ -40,7 +52,13 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setItems(await feedGet(undefined, effectiveScope === 'contacts'));
|
setItems(
|
||||||
|
await feedGet(
|
||||||
|
undefined,
|
||||||
|
effectiveScope === 'contacts',
|
||||||
|
effectiveScope === 'profile' ? (selectedProfileNpub ?? undefined) : undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -48,7 +66,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[feedGet, effectiveScope],
|
[feedGet, effectiveScope, selectedProfileNpub],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -122,7 +140,37 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
<Icon name="users" size={16} />
|
<Icon name="users" size={16} />
|
||||||
My contacts
|
My contacts
|
||||||
</button>
|
</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>
|
</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}>
|
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
|
||||||
<Icon name="refresh" size={16} />
|
<Icon name="refresh" size={16} />
|
||||||
Refresh
|
Refresh
|
||||||
|
|
@ -140,12 +188,31 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
<Spinner label="Fetching recent notes…" />
|
<Spinner label="Fetching recent notes…" />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Icon name={effectiveScope === 'contacts' ? 'users' : 'list'} size={26} />}
|
icon={
|
||||||
title={effectiveScope === 'contacts' ? 'No notes from your contacts' : 'No notes found'}
|
<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={
|
description={
|
||||||
effectiveScope === 'contacts'
|
effectiveScope === 'contacts'
|
||||||
? 'No notes were returned from the people the active profile follows in the last 24 hours. Try refreshing, or switch to Everyone.'
|
? 'No notes were returned from the people the active profile follows in the last 24 hours. Try refreshing, or switch to Everyone.'
|
||||||
: 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.'
|
: 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.'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Icon } from '../components/Icon';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||||
import { formatDate, shortenNpub } from '../lib/format';
|
import { formatDate, shortenNpub } from '../lib/format';
|
||||||
|
import type { MetadataPublishReport } from '../lib/types';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
||||||
interface ProfilesScreenProps {
|
interface ProfilesScreenProps {
|
||||||
|
|
@ -24,6 +25,8 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
|
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
|
||||||
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
||||||
const [pictureTarget, setPictureTarget] = useState<PictureTarget | 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 profiles = state?.profiles ?? [];
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
|
@ -132,6 +135,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
<article
|
<article
|
||||||
key={profile.npub}
|
key={profile.npub}
|
||||||
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
|
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">
|
<div className="profile-card-top">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|
@ -142,6 +153,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
/>
|
/>
|
||||||
<div className="profile-card-meta">
|
<div className="profile-card-meta">
|
||||||
<h3>{profile.label}</h3>
|
<h3>{profile.label}</h3>
|
||||||
|
{profile.nip05 && (
|
||||||
|
<span className="nip05-handle" title={profile.nip05}>
|
||||||
|
{profile.nip05}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<code className="mono" title={profile.npub}>
|
<code className="mono" title={profile.npub}>
|
||||||
{shortenNpub(profile.npub, shorten)}
|
{shortenNpub(profile.npub, shorten)}
|
||||||
</code>
|
</code>
|
||||||
|
|
@ -161,6 +177,26 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
>
|
>
|
||||||
<Icon name="publish" size={14} /> Publish name
|
<Icon name="publish" size={14} /> Publish name
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -231,6 +267,32 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
onSavingChange={setPublishing}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -242,6 +304,213 @@ interface PictureTarget {
|
||||||
url: string;
|
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 & 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 & publish
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PictureModal({
|
function PictureModal({
|
||||||
target,
|
target,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -261,22 +530,18 @@ function PictureModal({
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const save = async (nextUrl: string | null) => {
|
const save = async (nextUrl: string | null) => {
|
||||||
onError(null);
|
await runModalSave({
|
||||||
setSaving(true);
|
npub: target.npub,
|
||||||
onSavingChange(target.npub);
|
perform: () => setProfilePicture(target.npub, nextUrl),
|
||||||
try {
|
successMessage: (report) =>
|
||||||
const report = await setProfilePicture(target.npub, nextUrl);
|
|
||||||
onSaved(
|
|
||||||
report.failed.length === 0
|
report.failed.length === 0
|
||||||
? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).`
|
? `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.`,
|
: `Picture saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
|
||||||
);
|
onSaved,
|
||||||
} catch (err) {
|
onError,
|
||||||
onError(err instanceof Error ? err.message : String(err));
|
onSavingChange,
|
||||||
} finally {
|
setSaving,
|
||||||
setSaving(false);
|
});
|
||||||
onSavingChange(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpload = async () => {
|
const onUpload = async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert } from '../components/Alert';
|
import { Alert } from '../components/Alert';
|
||||||
|
import { Badge } from '../components/Badge';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { CopyButton } from '../components/CopyButton';
|
import { CopyButton } from '../components/CopyButton';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
|
import { Spinner } from '../components/Spinner';
|
||||||
import { Toggle } from '../components/Toggle';
|
import { Toggle } from '../components/Toggle';
|
||||||
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal';
|
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';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
||||||
export function SettingsScreen() {
|
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 [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | 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 settings = state?.settings;
|
||||||
const vaultPath = state?.vault_path ?? '';
|
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 (
|
return (
|
||||||
<div className="screen">
|
<div className="screen">
|
||||||
<div className="screen-inner">
|
<div className="screen-inner">
|
||||||
|
|
@ -65,9 +104,13 @@ export function SettingsScreen() {
|
||||||
>
|
>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
<option value="system">System</option>
|
<option value="glass">Aurora</option>
|
||||||
|
<option value="neon">Neon</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -153,6 +196,128 @@ export function SettingsScreen() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
<h2>Advanced</h2>
|
<h2>Advanced</h2>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Badge } from '../components/Badge';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { ErrorText } from '../components/ErrorText';
|
import { ErrorText } from '../components/ErrorText';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
|
import { shortHexId } from '../lib/format';
|
||||||
import type { SignerStatus } from '../lib/types';
|
import type { SignerStatus } from '../lib/types';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
||||||
|
|
@ -15,11 +16,6 @@ const EMPTY_STATUS: SignerStatus = {
|
||||||
pending: [],
|
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() {
|
export function SignerScreen() {
|
||||||
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
|
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
|
||||||
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
||||||
|
|
@ -137,7 +133,7 @@ export function SignerScreen() {
|
||||||
<dd>
|
<dd>
|
||||||
{status.peer ? (
|
{status.peer ? (
|
||||||
<code className="mono" title={status.peer}>
|
<code className="mono" title={status.peer}>
|
||||||
{shortHex(status.peer)}
|
{shortHexId(status.peer)}
|
||||||
</code>
|
</code>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted">None yet</span>
|
<span className="muted">None yet</span>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import type {
|
||||||
Settings,
|
Settings,
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
Theme,
|
Theme,
|
||||||
|
UpdateApplyReport,
|
||||||
|
UpdateCheckReport,
|
||||||
UploadedImage,
|
UploadedImage,
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
|
|
||||||
|
|
@ -41,14 +43,18 @@ interface AppContextValue {
|
||||||
selectProfile: (npub: string) => Promise<void>;
|
selectProfile: (npub: string) => Promise<void>;
|
||||||
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||||
setProfilePicture: (npub: string, url: string | null) => 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>;
|
publishNote: (content: string) => Promise<PublishReport>;
|
||||||
recordPublishFailure: (message: string, details?: string | null) => void;
|
recordPublishFailure: (message: string, details?: string | null) => void;
|
||||||
clearLastPublish: () => void;
|
clearLastPublish: () => void;
|
||||||
feedGet: (limit?: number, contactsOnly?: boolean) => Promise<FeedItem[]>;
|
feedGet: (limit?: number, contactsOnly?: boolean, authorNpub?: string) => Promise<FeedItem[]>;
|
||||||
relayAdd: (url: string) => Promise<Settings>;
|
relayAdd: (url: string) => Promise<Settings>;
|
||||||
relayRemove: (url: string) => Promise<Settings>;
|
relayRemove: (url: string) => Promise<Settings>;
|
||||||
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
|
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
|
||||||
relayTest: (url: string) => Promise<RelayTestResult>;
|
relayTest: (url: string) => Promise<RelayTestResult>;
|
||||||
|
updateCheck: () => Promise<UpdateCheckReport>;
|
||||||
|
updateApply: () => Promise<UpdateApplyReport>;
|
||||||
updateSettings: (
|
updateSettings: (
|
||||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||||
) => Promise<Settings>;
|
) => 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 publishNote = useCallback(async (content: string): Promise<PublishReport> => {
|
||||||
const report = await api.publishNote(content);
|
const report = await api.publishNote(content);
|
||||||
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
||||||
|
|
@ -148,8 +172,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
setLastPublish(null);
|
setLastPublish(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => {
|
const feedGet = useCallback((limit?: number, contactsOnly?: boolean, authorNpub?: string) => {
|
||||||
return api.feedGet(limit, contactsOnly);
|
return api.feedGet(limit, contactsOnly, authorNpub);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applySettings = useCallback((fresh: Settings) => {
|
const applySettings = useCallback((fresh: Settings) => {
|
||||||
|
|
@ -170,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
[applySettings],
|
[applySettings],
|
||||||
);
|
);
|
||||||
const relayTest = useCallback((url: string) => api.relayTest(url), []);
|
const relayTest = useCallback((url: string) => api.relayTest(url), []);
|
||||||
|
const updateCheck = useCallback(() => api.updateCheck(), []);
|
||||||
|
const updateApply = useCallback(() => api.updateApply(), []);
|
||||||
const updateSettings = useCallback(
|
const updateSettings = useCallback(
|
||||||
async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
|
async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
|
||||||
applySettings(await api.settingsUpdate(patch)),
|
applySettings(await api.settingsUpdate(patch)),
|
||||||
|
|
@ -213,7 +239,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
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>(
|
const value = useMemo<AppContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -232,6 +262,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
relayRemove,
|
relayRemove,
|
||||||
relaySetEnabled,
|
relaySetEnabled,
|
||||||
relayTest,
|
relayTest,
|
||||||
|
updateCheck,
|
||||||
|
updateApply,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
backupNow,
|
backupNow,
|
||||||
setVaultPassword,
|
setVaultPassword,
|
||||||
|
|
@ -250,6 +282,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
undoDelete,
|
undoDelete,
|
||||||
publishProfileMetadata,
|
publishProfileMetadata,
|
||||||
setProfilePicture,
|
setProfilePicture,
|
||||||
|
renameProfile,
|
||||||
|
setNip05,
|
||||||
copyText,
|
copyText,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -262,6 +296,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
selectProfile,
|
selectProfile,
|
||||||
publishProfileMetadata,
|
publishProfileMetadata,
|
||||||
setProfilePicture,
|
setProfilePicture,
|
||||||
|
renameProfile,
|
||||||
|
setNip05,
|
||||||
publishNote,
|
publishNote,
|
||||||
deleteProfile,
|
deleteProfile,
|
||||||
undoDelete,
|
undoDelete,
|
||||||
|
|
@ -272,6 +308,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
relayRemove,
|
relayRemove,
|
||||||
relaySetEnabled,
|
relaySetEnabled,
|
||||||
relayTest,
|
relayTest,
|
||||||
|
updateCheck,
|
||||||
|
updateApply,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
backupNow,
|
backupNow,
|
||||||
setVaultPassword,
|
setVaultPassword,
|
||||||
|
|
@ -301,12 +339,13 @@ export function useApp(): AppContextValue {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the requested theme (respecting system preference for `system`). */
|
/** Apply the requested theme. */
|
||||||
export function applyTheme(theme: Theme): void {
|
export function applyTheme(theme: Theme): void {
|
||||||
const prefersDark =
|
if (theme === 'glass' || theme === 'neon') {
|
||||||
theme === 'dark' ||
|
document.documentElement.dataset.theme = theme;
|
||||||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
return;
|
||||||
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';
|
}
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the document theme in sync with settings, watching system changes. */
|
/** Keep the document theme in sync with settings, watching system changes. */
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,113 @@
|
||||||
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6);
|
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Neon" — pure-black Matrix/Cyberpunk palette inspired by the CAJAFUERTE
|
||||||
|
Obsidian theme (MIT): black panels, deep-pink primary (#ff1493), purple
|
||||||
|
secondary (#8b00ff family), yellow highlights (#ffd700). */
|
||||||
|
:root[data-theme='neon'] {
|
||||||
|
--bg: #000000;
|
||||||
|
--surface: #0b0b0e;
|
||||||
|
--surface-2: #131318;
|
||||||
|
--surface-hover: #191921;
|
||||||
|
--border: #2b1224;
|
||||||
|
--border-strong: #4d1c3d;
|
||||||
|
--text: #f5f2f7;
|
||||||
|
--text-muted: #a89bb0;
|
||||||
|
--primary: #ff1493;
|
||||||
|
--primary-hover: #ff4fb0;
|
||||||
|
--primary-soft: #2b0a1e;
|
||||||
|
--on-primary: #ffffff;
|
||||||
|
--danger: #ff3355;
|
||||||
|
--danger-soft: #330810;
|
||||||
|
--warning: #ffd700;
|
||||||
|
--warning-soft: #332b03;
|
||||||
|
--success: #00e68a;
|
||||||
|
--success-soft: #04301f;
|
||||||
|
--info: #b388ff;
|
||||||
|
--info-soft: #1c1035;
|
||||||
|
--focus: #ff1493;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.6), 0 8px 24px rgba(255, 20, 147, 0.07);
|
||||||
|
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.85), 0 0 32px rgba(255, 20, 147, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Aurora" theme (internal id "glass") — frosted-glass aesthetic inspired
|
||||||
|
by the Meridian Obsidian theme (MIT): translucent blurred panels floating
|
||||||
|
over an aurora-lit deep blue-black field, cyan accents, hairline light
|
||||||
|
borders. */
|
||||||
|
:root[data-theme='glass'] {
|
||||||
|
--bg: #0a0d13;
|
||||||
|
--surface: rgba(23, 29, 41, 0.58);
|
||||||
|
--surface-2: rgba(255, 255, 255, 0.05);
|
||||||
|
--surface-hover: rgba(255, 255, 255, 0.09);
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.2);
|
||||||
|
--text: #e8ecf4;
|
||||||
|
--text-muted: #96a2b6;
|
||||||
|
--primary: #7fdbff;
|
||||||
|
--primary-hover: #a3e5ff;
|
||||||
|
--primary-soft: rgba(127, 219, 255, 0.13);
|
||||||
|
--on-primary: #06121c;
|
||||||
|
--danger: #ff8080;
|
||||||
|
--danger-soft: rgba(255, 107, 107, 0.14);
|
||||||
|
--warning: #ffc46b;
|
||||||
|
--warning-soft: rgba(240, 173, 79, 0.15);
|
||||||
|
--success: #7ed99a;
|
||||||
|
--success-soft: rgba(92, 184, 92, 0.16);
|
||||||
|
--info: #82c9ea;
|
||||||
|
--info-soft: rgba(91, 192, 222, 0.14);
|
||||||
|
--focus: #7fdbff;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 36px rgba(0, 0, 0, 0.38);
|
||||||
|
--shadow-modal: 0 18px 56px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.07) inset;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
/* Glass scene: the aurora backdrop lives on <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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +359,25 @@ a {
|
||||||
cursor: not-allowed;
|
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
|
Sidebar
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
|
|
@ -280,8 +406,23 @@ a {
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--primary);
|
background: #fff;
|
||||||
color: var(--on-primary);
|
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 {
|
.sidebar-brand strong {
|
||||||
|
|
@ -1048,6 +1189,14 @@ select {
|
||||||
border-color: var(--success);
|
border-color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-card:not(.is-active) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card:not(.is-active):hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-card-top {
|
.profile-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1080,6 +1229,7 @@ select {
|
||||||
|
|
||||||
.profile-card-actions {
|
.profile-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ describe('FeedScreen', () => {
|
||||||
|
|
||||||
it('disable relays shows an empty state that can navigate to relays', async () => {
|
it('disable relays shows an empty state that can navigate to relays', async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
theme: 'system' as const,
|
theme: 'light' as const,
|
||||||
confirm_before_publish: true,
|
confirm_before_publish: true,
|
||||||
shorten_npub: true,
|
shorten_npub: true,
|
||||||
relays: [
|
relays: [
|
||||||
|
|
@ -107,4 +107,55 @@ describe('FeedScreen', () => {
|
||||||
|
|
||||||
expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('disables Select for the active profile and copies public keys', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,19 @@ describe('SettingsScreen', () => {
|
||||||
expect(document.documentElement.dataset.theme).toBe('dark');
|
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 () => {
|
it('toggles publish confirmation and npub shortening', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
|
|
@ -64,4 +77,70 @@ describe('SettingsScreen', () => {
|
||||||
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument();
|
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Rust (nostr-sdk)')).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
|
||||||
is_active: false,
|
is_active: false,
|
||||||
};
|
};
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
theme: 'system',
|
theme: 'light',
|
||||||
confirm_before_publish: true,
|
confirm_before_publish: true,
|
||||||
shorten_npub: true,
|
shorten_npub: true,
|
||||||
relays: [
|
relays: [
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import type {
|
||||||
RelayTestResult,
|
RelayTestResult,
|
||||||
Settings,
|
Settings,
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
|
UpdateApplyReport,
|
||||||
|
UpdateCheckReport,
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
|
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
|
||||||
|
|
||||||
|
|
@ -43,6 +45,12 @@ export interface FakeBackend {
|
||||||
feedItems: FeedItem[];
|
feedItems: FeedItem[];
|
||||||
/** Notes returned by `feed_get` with `contacts_only: true`. */
|
/** Notes returned by `feed_get` with `contacts_only: true`. */
|
||||||
contactFeedItems: FeedItem[];
|
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 {
|
export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
|
|
@ -126,6 +134,41 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
relays: ['wss://relay.damus.io'],
|
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> {
|
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||||
|
|
@ -163,6 +206,58 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
return next;
|
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': {
|
case 'publish_note': {
|
||||||
if (publishFailure) {
|
if (publishFailure) {
|
||||||
const failure = publishFailure;
|
const failure = publishFailure;
|
||||||
|
|
@ -182,6 +277,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'feed_get': {
|
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);
|
const contactsOnly = Boolean(params.contacts_only);
|
||||||
if (contactsOnly) {
|
if (contactsOnly) {
|
||||||
if (!state.active_profile) {
|
if (!state.active_profile) {
|
||||||
|
|
@ -290,6 +391,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'update_check':
|
||||||
|
return backend.updateReport;
|
||||||
|
|
||||||
|
case 'update_apply':
|
||||||
|
return backend.updateApplyResult;
|
||||||
|
|
||||||
case 'settings_update': {
|
case 'settings_update': {
|
||||||
const nextSettings: Settings = { ...state.settings, ...params };
|
const nextSettings: Settings = { ...state.settings, ...params };
|
||||||
backend.setState({ ...state, settings: nextSettings });
|
backend.setState({ ...state, settings: nextSettings });
|
||||||
|
|
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -113,7 +113,8 @@ impl App {
|
||||||
public_key: restored.npub.clone(),
|
public_key: restored.npub.clone(),
|
||||||
secret_key: "".to_string(),
|
secret_key: "".to_string(),
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: None,
|
picture: restored.picture.clone(),
|
||||||
|
nip05: restored.nip05.clone(),
|
||||||
};
|
};
|
||||||
self.vault.profiles.push(stored);
|
self.vault.profiles.push(stored);
|
||||||
// If no active profile, this restored one becomes active
|
// If no active profile, this restored one becomes active
|
||||||
|
|
|
||||||
114
src/feed.rs
114
src/feed.rs
|
|
@ -77,6 +77,19 @@ pub async fn contact_feed(
|
||||||
aggregate_for(settings, limit, Some(contacts)).await
|
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).
|
/// 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> {
|
async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<PublicKey>, AppError> {
|
||||||
let owner = owner_pubkey(owner_hex)?;
|
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());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = Client::new(Keys::generate());
|
let client =
|
||||||
for url in &relay_urls {
|
crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?;
|
||||||
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 filter = Filter::new().kind(Kind::ContactList).author(owner);
|
let filter = Filter::new().kind(Kind::ContactList).author(owner);
|
||||||
client
|
let mut events = client
|
||||||
.subscribe(filter, None)
|
.stream_events(filter)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::network(format!("Could not subscribe for contacts: {e}")))?;
|
.map_err(|e| AppError::network(format!("Could not subscribe for contacts: {e}")))?;
|
||||||
|
|
||||||
let mut contacts: HashSet<PublicKey> = HashSet::new();
|
let mut contacts: HashSet<PublicKey> = HashSet::new();
|
||||||
let mut notifications = client.notifications();
|
|
||||||
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
match tokio::time::timeout_at(deadline, notifications.recv()).await {
|
match tokio::time::timeout_at(deadline, events.next()).await {
|
||||||
Ok(Ok(RelayPoolNotification::Event { event, .. })) => {
|
Ok(Some((_, Ok(event)))) => {
|
||||||
if event.kind == Kind::ContactList {
|
if event.kind == Kind::ContactList {
|
||||||
for pubkey in event
|
for tag in event.tags.iter() {
|
||||||
.tags
|
if tag.single_letter_tag().map(|s| s.as_char()) == Some('p') {
|
||||||
.iter()
|
if let Some(content) = tag.content() {
|
||||||
.filter_map(|tag| match tag.as_standardized() {
|
if let Ok(pubkey) = PublicKey::parse(content) {
|
||||||
Some(TagStandard::PublicKey { public_key, .. }) => Some(*public_key),
|
contacts.insert(pubkey);
|
||||||
_ => None,
|
}
|
||||||
})
|
}
|
||||||
{
|
}
|
||||||
contacts.insert(pubkey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Ok(_)) => continue,
|
Ok(Some((_, Err(_)))) => continue,
|
||||||
Ok(Err(_)) | Err(_) => break,
|
Ok(None) | Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,15 +149,8 @@ async fn aggregate_for(
|
||||||
let effective_limit = if limit == 0 { DEFAULT_LIMIT } else { limit };
|
let effective_limit = if limit == 0 { DEFAULT_LIMIT } else { limit };
|
||||||
|
|
||||||
// A throwaway identity keeps reading the network completely off the user's keys.
|
// A throwaway identity keeps reading the network completely off the user's keys.
|
||||||
let client = Client::new(Keys::generate());
|
let client =
|
||||||
for url in &relay_urls {
|
crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?;
|
||||||
client
|
|
||||||
.add_relay(url.as_str())
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
|
|
||||||
}
|
|
||||||
client.connect().await;
|
|
||||||
client.wait_for_connection(CONNECT_TIMEOUT).await;
|
|
||||||
|
|
||||||
let since = Timestamp::now() - LOOKBACK;
|
let since = Timestamp::now() - LOOKBACK;
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
|
|
@ -164,25 +161,22 @@ async fn aggregate_for(
|
||||||
Some(authors) => filter.authors(authors.iter().copied()),
|
Some(authors) => filter.authors(authors.iter().copied()),
|
||||||
None => filter,
|
None => filter,
|
||||||
};
|
};
|
||||||
client
|
let mut events = client
|
||||||
.subscribe(filter, None)
|
.stream_events(filter)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::network(format!("Could not subscribe for the feed: {e}")))?;
|
.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 feed = FeedBuilder::new(effective_limit, authors.as_deref());
|
||||||
let mut notifications = client.notifications();
|
|
||||||
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
match tokio::time::timeout_at(deadline, notifications.recv()).await {
|
match tokio::time::timeout_at(deadline, events.next()).await {
|
||||||
Ok(Ok(RelayPoolNotification::Event {
|
Ok(Some((relay_url, Ok(event)))) => {
|
||||||
event, relay_url, ..
|
|
||||||
})) => {
|
|
||||||
if !feed.add(&event, Some(relay_url)) {
|
if !feed.add(&event, Some(relay_url)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Ok(_)) => continue,
|
Ok(Some((_, Err(_)))) => continue,
|
||||||
Ok(Err(_)) | Err(_) => break,
|
Ok(None) | Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +257,7 @@ impl FeedItem {
|
||||||
author: event.pubkey.to_hex(),
|
author: event.pubkey.to_hex(),
|
||||||
author_npub,
|
author_npub,
|
||||||
content: event.content.trim().to_string(),
|
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(),
|
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +271,7 @@ mod tests {
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
EventBuilder::new(Kind::TextNote, content.to_string())
|
EventBuilder::new(Kind::TextNote, content.to_string())
|
||||||
.custom_created_at(Timestamp::from(created_at))
|
.custom_created_at(Timestamp::from(created_at))
|
||||||
.sign(&keys)
|
.finalize_async(&keys)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +328,7 @@ mod tests {
|
||||||
let mut builder = FeedBuilder::new(10, None);
|
let mut builder = FeedBuilder::new(10, None);
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let other = EventBuilder::new(Kind::Metadata, "{}")
|
let other = EventBuilder::new(Kind::Metadata, "{}")
|
||||||
.sign(&keys)
|
.finalize_async(&keys)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
builder.add(&other, None);
|
builder.add(&other, None);
|
||||||
|
|
@ -364,12 +358,12 @@ mod tests {
|
||||||
|
|
||||||
let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string())
|
let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string())
|
||||||
.custom_created_at(Timestamp::from(5))
|
.custom_created_at(Timestamp::from(5))
|
||||||
.sign(&followed)
|
.finalize_async(&followed)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string())
|
let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string())
|
||||||
.custom_created_at(Timestamp::from(6))
|
.custom_created_at(Timestamp::from(6))
|
||||||
.sign(&stranger)
|
.finalize_async(&stranger)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -397,6 +391,30 @@ mod tests {
|
||||||
assert!(feed.is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn contact_feed_with_invalid_owner_errors() {
|
async fn contact_feed_with_invalid_owner_errors() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
|
|
|
||||||
207
src/ipc.rs
207
src/ipc.rs
|
|
@ -1,8 +1,9 @@
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
|
@ -12,6 +13,7 @@ use crate::publish;
|
||||||
use crate::relays;
|
use crate::relays;
|
||||||
use crate::settings::Theme;
|
use crate::settings::Theme;
|
||||||
use crate::signer::Signer;
|
use crate::signer::Signer;
|
||||||
|
use crate::updates;
|
||||||
|
|
||||||
/// How long to wait for a relay connection test.
|
/// How long to wait for a relay connection test.
|
||||||
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||||
|
|
@ -49,6 +51,18 @@ pub enum Request {
|
||||||
npub: String,
|
npub: String,
|
||||||
url: Option<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 {
|
PublishNote {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
|
|
@ -56,9 +70,13 @@ pub enum Request {
|
||||||
FeedGet {
|
FeedGet {
|
||||||
/// Optional cap on how many notes to return; leave `None` for the default.
|
/// Optional cap on how many notes to return; leave `None` for the default.
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
/// When true, only return notes authored by the active profile's
|
/// When true (and no `author` is given), only return notes authored by
|
||||||
/// contacts. When no active profile is selected the request errors.
|
/// the active profile's contacts. When no active profile is selected
|
||||||
|
/// the request errors.
|
||||||
contacts_only: Option<bool>,
|
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 {
|
RelayAdd {
|
||||||
url: String,
|
url: String,
|
||||||
|
|
@ -73,6 +91,11 @@ pub enum Request {
|
||||||
RelayTest {
|
RelayTest {
|
||||||
url: String,
|
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,
|
SettingsGet,
|
||||||
SettingsUpdate {
|
SettingsUpdate {
|
||||||
theme: Option<Theme>,
|
theme: Option<Theme>,
|
||||||
|
|
@ -151,20 +174,26 @@ pub struct ReplyEnvelope {
|
||||||
|
|
||||||
/// Run the JSON-lines IPC server on stdin/stdout.
|
/// Run the JSON-lines IPC server on stdin/stdout.
|
||||||
///
|
///
|
||||||
/// The Electron main process spawns `keynectr serve` and
|
/// The Electron main process spawns `keynectr serve` and exchanges one JSON
|
||||||
/// exchanges one JSON object per line. Requests are processed sequentially so
|
/// object per line. Requests are handled concurrently — replies are
|
||||||
/// the shared state never sees concurrent mutations.
|
/// 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> {
|
pub async fn serve() -> Result<(), AppError> {
|
||||||
use tokio::io::AsyncBufReadExt;
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
// Shared state, so the NIP-46 signer's background task and the request loop
|
// 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.
|
// both see the same vault (including its unlock key) without racing writes.
|
||||||
let app = Arc::new(Mutex::new(App::load()?));
|
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 stdin = tokio::io::stdin();
|
||||||
let mut lines = tokio::io::BufReader::new(stdin).lines();
|
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
|
while let Some(line) = lines
|
||||||
.next_line()
|
.next_line()
|
||||||
|
|
@ -183,29 +212,40 @@ pub async fn serve() -> Result<(), AppError> {
|
||||||
message: "The request could not be understood.".to_string(),
|
message: "The request could not be understood.".to_string(),
|
||||||
details: Some(e.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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let reply = handle(app.clone(), &signer, envelope.request).await;
|
let task_app = app.clone();
|
||||||
write_line(
|
let task_signer = signer.clone();
|
||||||
&mut stdout,
|
let task_stdout = stdout.clone();
|
||||||
ReplyEnvelope {
|
tasks.spawn(async move {
|
||||||
id: envelope.id,
|
let reply = handle(task_app, task_signer, envelope.request).await;
|
||||||
reply,
|
let _ = write_line(
|
||||||
},
|
&task_stdout,
|
||||||
)
|
ReplyEnvelope {
|
||||||
.await?;
|
id: envelope.id,
|
||||||
|
reply,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finish in-flight requests before returning so the GUI never sees the
|
||||||
|
// backend disappear mid-request.
|
||||||
|
while tasks.join_next().await.is_some() {}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_line<W>(writer: &mut W, envelope: ReplyEnvelope) -> Result<(), AppError>
|
async fn write_line(
|
||||||
where
|
writer: &tokio::sync::Mutex<tokio::io::Stdout>,
|
||||||
W: tokio::io::AsyncWriteExt + Unpin,
|
envelope: ReplyEnvelope,
|
||||||
{
|
) -> Result<(), AppError> {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
let mut writer = writer.lock().await;
|
||||||
let mut line = serde_json::to_string(&envelope)
|
let mut line = serde_json::to_string(&envelope)
|
||||||
.map_err(|e| AppError::json("Could not prepare a response", e))?;
|
.map_err(|e| AppError::json("Could not prepare a response", e))?;
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
|
|
@ -222,10 +262,10 @@ where
|
||||||
|
|
||||||
async fn handle(
|
async fn handle(
|
||||||
app: Arc<Mutex<App>>,
|
app: Arc<Mutex<App>>,
|
||||||
signer: &Signer,
|
signer: Arc<Signer>,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Reply<serde_json::Value> {
|
) -> Reply<serde_json::Value> {
|
||||||
let result = run(&app, signer, request).await;
|
let result = run(&app, &signer, request).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(value) => Reply::Ok { data: value },
|
Ok(value) => Reply::Ok { data: value },
|
||||||
Err(err) => Reply::Error {
|
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
|
/// Signer control commands never touch the vault directly, so they take the
|
||||||
/// shared handle (a clone) rather than locking the state. Everything else
|
/// shared handle (a clone) rather than locking the state. Read-only network
|
||||||
/// locks the vault for the duration of the call, mirroring the old
|
/// requests (relay tests, feed reads) grab what they need under a short lock
|
||||||
/// single-threaded model.
|
/// and then run without it, so slow relays cannot delay interactive requests.
|
||||||
#[allow(clippy::await_holding_lock)]
|
/// Everything else locks the state for the duration of the call, so mutations
|
||||||
|
/// remain serialized and never interleave.
|
||||||
async fn run(
|
async fn run(
|
||||||
app: &Arc<Mutex<App>>,
|
app: &Arc<Mutex<App>>,
|
||||||
signer: &Signer,
|
signer: &Signer,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Result<serde_json::Value, AppError> {
|
) -> 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 {
|
match request {
|
||||||
Request::SignerConnect { uri } => {
|
Request::SignerConnect { uri } => {
|
||||||
signer.connect(app.clone(), &uri)?;
|
signer.connect(app.clone(), &uri)?;
|
||||||
|
|
@ -276,16 +313,71 @@ async fn run(
|
||||||
signer.approve(&id, approved)?;
|
signer.approve(&id, approved)?;
|
||||||
Ok(json!(signer.status()))
|
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 => {
|
other => {
|
||||||
let mut guard = app.lock().expect("app mutex poisoned");
|
let mut guard = app.lock().await;
|
||||||
run_with_app(&mut guard, other).await
|
run_with_app(&mut guard, other).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests dispatched to the vault state. The shared mutex guard is held across
|
/// Requests dispatched to the vault state. The shared mutex guard is held across
|
||||||
/// the awaited operation on purpose: requests remain effectively sequential, and
|
/// the awaited operation on purpose: mutations stay serialized against each
|
||||||
/// a concurrent `await` never yields back into a state the loop expects to own.
|
/// other even though requests themselves are handled concurrently.
|
||||||
async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
|
async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
|
||||||
match request {
|
match request {
|
||||||
Request::Init | Request::GetState => Ok(json!(app.state_view())),
|
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() }))
|
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 } => {
|
Request::PublishNote { content } => {
|
||||||
let report =
|
let report =
|
||||||
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
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))
|
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 } => {
|
Request::RelayAdd { url } => {
|
||||||
relays::add_relay(&mut app.settings, &url)?;
|
relays::add_relay(&mut app.settings, &url)?;
|
||||||
app.save_settings()?;
|
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))
|
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::SettingsGet => Ok(json!(app.settings)),
|
||||||
|
|
||||||
Request::BackupNow => {
|
Request::BackupNow => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ pub mod publish;
|
||||||
pub mod relays;
|
pub mod relays;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod signer;
|
pub mod signer;
|
||||||
|
pub mod updates;
|
||||||
pub mod uploads;
|
pub mod uploads;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
|
|
||||||
|
|
|
||||||
111
src/main.rs
111
src/main.rs
|
|
@ -1,5 +1,5 @@
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use keynectr::app::App;
|
use keynectr::app::App;
|
||||||
use keynectr::errors::{AppError, ErrorKind};
|
use keynectr::errors::{AppError, ErrorKind};
|
||||||
|
|
@ -21,6 +21,11 @@ Commands:
|
||||||
publish <npub> <content> Publish a text note as a specific profile
|
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
|
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
|
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);
|
feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50);
|
||||||
--contacts filters to the active profile's contacts
|
--contacts filters to the active profile's contacts
|
||||||
relays list List configured relays
|
relays list List configured relays
|
||||||
|
|
@ -30,7 +35,7 @@ Commands:
|
||||||
relays disable <url> Disable a relay
|
relays disable <url> Disable a relay
|
||||||
relays test <url> Test a relay connection
|
relays test <url> Test a relay connection
|
||||||
settings get Show application settings
|
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 confirm <true|false>
|
||||||
settings set shorten <true|false>
|
settings set shorten <true|false>
|
||||||
delete-profile <npub> Delete a profile (moves it to undo stack)
|
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" => cli_publish(&args).await,
|
||||||
"publish-name" => cli_publish_name(&args),
|
"publish-name" => cli_publish_name(&args),
|
||||||
"set-picture" => cli_set_picture(&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,
|
"feed" => cli_feed(&args).await,
|
||||||
"relays" => cli_relays(&args).await,
|
"relays" => cli_relays(&args).await,
|
||||||
"settings" => cli_settings(&args),
|
"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> {
|
fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
|
||||||
let npub = args
|
let npub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
|
|
@ -343,7 +442,7 @@ fn cli_settings(args: &[String]) -> Result<String, AppError> {
|
||||||
match key.as_str() {
|
match key.as_str() {
|
||||||
"theme" => {
|
"theme" => {
|
||||||
let theme = Theme::parse(value).ok_or_else(|| {
|
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;
|
app.settings.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
@ -486,7 +585,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
|
||||||
let uri = args
|
let uri = args
|
||||||
.get(3)
|
.get(3)
|
||||||
.ok_or_else(|| AppError::config("Usage: signer connect <uri>"))?;
|
.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();
|
let signer = Signer::new();
|
||||||
signer.connect(app, uri)?;
|
signer.connect(app, uri)?;
|
||||||
println!("Connecting to the NIP-46 app… (interrupt with Ctrl-C to stop)");
|
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,
|
created_at: stored.created_at,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
picture: stored.picture,
|
picture: stored.picture,
|
||||||
|
nip05: stored.nip05,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,7 +680,8 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
||||||
public_key: restored.npub.clone(),
|
public_key: restored.npub.clone(),
|
||||||
secret_key: "".to_string(),
|
secret_key: "".to_string(),
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: None,
|
picture: restored.picture,
|
||||||
|
nip05: restored.nip05,
|
||||||
};
|
};
|
||||||
app.vault.profiles.push(stored);
|
app.vault.profiles.push(stored);
|
||||||
if app.vault.active_profile.is_none() {
|
if app.vault.active_profile.is_none() {
|
||||||
|
|
|
||||||
398
src/profiles.rs
398
src/profiles.rs
|
|
@ -1,6 +1,5 @@
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::Duration;
|
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::crypto::VaultKey;
|
use crate::crypto::VaultKey;
|
||||||
|
|
@ -10,11 +9,6 @@ use crate::relays;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::vault::{unix_timestamp, StoredProfile, Vault};
|
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.
|
/// A safe view of a profile that contains no secret key material.
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct ProfileSummary {
|
pub struct ProfileSummary {
|
||||||
|
|
@ -26,6 +20,8 @@ pub struct ProfileSummary {
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
/// Public URL of the profile picture, when one has been set.
|
/// Public URL of the profile picture, when one has been set.
|
||||||
pub picture: Option<String>,
|
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
|
/// 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,
|
secret_key: stored_secret,
|
||||||
created_at,
|
created_at,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = vault.active_profile.is_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.
|
// Best-effort: relay failures here never block profile creation.
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
if !relay_urls.is_empty() {
|
if !relay_urls.is_empty() {
|
||||||
publish_metadata_blocking(&keys, &label, None, relay_urls);
|
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ProfileSummary {
|
Ok(ProfileSummary {
|
||||||
|
|
@ -98,6 +95,7 @@ pub fn create_profile(
|
||||||
created_at,
|
created_at,
|
||||||
is_active,
|
is_active,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +131,7 @@ pub fn publish_profile_metadata(
|
||||||
&keys,
|
&keys,
|
||||||
&stored.label,
|
&stored.label,
|
||||||
stored.picture.clone(),
|
stored.picture.clone(),
|
||||||
|
stored.nip05.clone(),
|
||||||
relay_urls,
|
relay_urls,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -160,12 +159,13 @@ pub fn set_profile_picture(
|
||||||
|
|
||||||
let stored = find_profile_mut(vault, npub)?;
|
let stored = find_profile_mut(vault, npub)?;
|
||||||
stored.picture = url;
|
stored.picture = url;
|
||||||
let (label, npub, created_at, public_key, picture) = (
|
let (label, npub, created_at, public_key, picture, nip05) = (
|
||||||
stored.label.clone(),
|
stored.label.clone(),
|
||||||
stored.public_key.clone(),
|
stored.public_key.clone(),
|
||||||
stored.created_at,
|
stored.created_at,
|
||||||
stored.public_key.clone(),
|
stored.public_key.clone(),
|
||||||
stored.picture.clone(),
|
stored.picture.clone(),
|
||||||
|
stored.nip05.clone(),
|
||||||
);
|
);
|
||||||
drop(stored);
|
drop(stored);
|
||||||
let summary = ProfileSummary {
|
let summary = ProfileSummary {
|
||||||
|
|
@ -174,6 +174,7 @@ pub fn set_profile_picture(
|
||||||
created_at,
|
created_at,
|
||||||
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
||||||
picture,
|
picture,
|
||||||
|
nip05,
|
||||||
};
|
};
|
||||||
|
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
|
|
@ -190,11 +191,173 @@ pub fn set_profile_picture(
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
let report =
|
let report = publish_metadata_blocking(
|
||||||
publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls);
|
&keys,
|
||||||
|
&summary.label,
|
||||||
|
summary.picture.clone(),
|
||||||
|
summary.nip05.clone(),
|
||||||
|
relay_urls,
|
||||||
|
);
|
||||||
Ok((summary, report))
|
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.
|
/// Validate that a picture URL is a well-formed http(s) URL.
|
||||||
fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
||||||
let parsed = Url::parse(url)
|
let parsed = Url::parse(url)
|
||||||
|
|
@ -235,6 +398,7 @@ fn publish_metadata_blocking(
|
||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
label: &str,
|
label: &str,
|
||||||
picture: Option<String>,
|
picture: Option<String>,
|
||||||
|
nip05: Option<String>,
|
||||||
relay_urls: Vec<String>,
|
relay_urls: Vec<String>,
|
||||||
) -> MetadataPublishReport {
|
) -> MetadataPublishReport {
|
||||||
let keys = keys.clone();
|
let keys = keys.clone();
|
||||||
|
|
@ -242,7 +406,9 @@ fn publish_metadata_blocking(
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
tokio::runtime::Runtime::new()
|
tokio::runtime::Runtime::new()
|
||||||
.expect("metadata runtime")
|
.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()
|
.join()
|
||||||
.expect("metadata publish thread panicked")
|
.expect("metadata publish thread panicked")
|
||||||
|
|
@ -252,6 +418,7 @@ async fn publish_metadata_async(
|
||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
label: &str,
|
label: &str,
|
||||||
picture: Option<String>,
|
picture: Option<String>,
|
||||||
|
nip05: Option<String>,
|
||||||
relay_urls: Vec<String>,
|
relay_urls: Vec<String>,
|
||||||
) -> MetadataPublishReport {
|
) -> MetadataPublishReport {
|
||||||
let mut metadata = Metadata::new().name(label).display_name(label);
|
let mut metadata = Metadata::new().name(label).display_name(label);
|
||||||
|
|
@ -260,8 +427,11 @@ async fn publish_metadata_async(
|
||||||
metadata = metadata.picture(parsed);
|
metadata = metadata.picture(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(nip05) = &nip05 {
|
||||||
|
metadata = metadata.nip05(nip05);
|
||||||
|
}
|
||||||
let event = match EventBuilder::new(Kind::Metadata, metadata.as_json())
|
let event = match EventBuilder::new(Kind::Metadata, metadata.as_json())
|
||||||
.sign(keys)
|
.finalize_async(keys)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(event) => event,
|
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 {
|
for url in &relay_urls {
|
||||||
let _ = client.add_relay(url.as_str()).await;
|
let _ = client.add_relay(url.as_str()).await;
|
||||||
}
|
}
|
||||||
client.connect().await;
|
client.connect().await;
|
||||||
let _ = client.wait_for_connection(METADATA_CONNECT_TIMEOUT).await;
|
let (succeeded, failed) =
|
||||||
|
crate::publish::send_to_all_relays(&client, relay_urls, &event, "metadata").await;
|
||||||
let mut succeeded = Vec::new();
|
|
||||||
let mut failed = Vec::new();
|
|
||||||
for url in &relay_urls {
|
|
||||||
match client.relay(url.as_str()).await {
|
|
||||||
Ok(relay) => {
|
|
||||||
match tokio::time::timeout(METADATA_SEND_TIMEOUT, relay.send_event(&event)).await {
|
|
||||||
Ok(Ok(_)) => succeeded.push(url.clone()),
|
|
||||||
Ok(Err(err)) => failed.push(failure_for(url, &err)),
|
|
||||||
Err(_) => failed.push(RelayFailure {
|
|
||||||
url: url.clone(),
|
|
||||||
error: "The relay did not respond in time.".to_string(),
|
|
||||||
details: Some(
|
|
||||||
"Timed out while waiting for the relay to accept the metadata."
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => failed.push(failure_for(url, &err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client.disconnect().await;
|
|
||||||
|
|
||||||
MetadataPublishReport { succeeded, failed }
|
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
|
/// Safe summaries of every stored profile, newest last. Never includes
|
||||||
/// secret keys.
|
/// secret keys.
|
||||||
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
|
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
|
||||||
|
|
@ -349,6 +493,7 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary {
|
||||||
created_at: profile.created_at,
|
created_at: profile.created_at,
|
||||||
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
|
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
|
||||||
picture: profile.picture.clone(),
|
picture: profile.picture.clone(),
|
||||||
|
nip05: profile.nip05.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,6 +609,7 @@ mod tests {
|
||||||
secret_key: "00".repeat(32),
|
secret_key: "00".repeat(32),
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
});
|
});
|
||||||
vault.profiles.push(StoredProfile {
|
vault.profiles.push(StoredProfile {
|
||||||
label: "Bob".to_string(),
|
label: "Bob".to_string(),
|
||||||
|
|
@ -471,6 +617,7 @@ mod tests {
|
||||||
secret_key: "11".repeat(32),
|
secret_key: "11".repeat(32),
|
||||||
created_at: 2,
|
created_at: 2,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
});
|
});
|
||||||
vault
|
vault
|
||||||
}
|
}
|
||||||
|
|
@ -756,6 +903,164 @@ mod tests {
|
||||||
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
|
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.
|
/// Delete a profile by npub, returning the deleted profile for undo.
|
||||||
/// The vault must not be encrypted, or the key must be provided.
|
/// The vault must not be encrypted, or the key must be provided.
|
||||||
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
||||||
|
|
@ -775,6 +1080,7 @@ mod tests {
|
||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
picture: stored.picture,
|
picture: stored.picture,
|
||||||
|
nip05: stored.nip05,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
src/publish.rs
153
src/publish.rs
|
|
@ -11,10 +11,9 @@ use crate::relays;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::vault::Vault;
|
use crate::vault::Vault;
|
||||||
|
|
||||||
/// How long to wait for relays to accept a connection attempt.
|
/// How long to wait for a single relay to accept an event. Relays are sent
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
/// to in parallel, so this caps the whole publish, not each relay.
|
||||||
/// How long to wait for a single relay to accept an event.
|
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(15);
|
|
||||||
|
|
||||||
/// A relay that rejected a published note.
|
/// A relay that rejected a published note.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|
@ -168,7 +167,7 @@ async fn publish_with_keys(
|
||||||
// reported as such rather than as a network error.
|
// reported as such rather than as a network error.
|
||||||
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
|
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
|
||||||
let event = builder
|
let event = builder
|
||||||
.sign(keys)
|
.finalize_async(keys)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
||||||
|
|
||||||
|
|
@ -177,52 +176,8 @@ async fn publish_with_keys(
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
|
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
|
||||||
|
|
||||||
let client = Client::new(keys.clone());
|
let client = relays::open_pool(keys.clone(), &relay_urls, None).await?;
|
||||||
for url in &relay_urls {
|
let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await;
|
||||||
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;
|
|
||||||
|
|
||||||
if succeeded.is_empty() {
|
if succeeded.is_empty() {
|
||||||
return Err(AppError::publish_failed(failed));
|
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
|
/// Build a concise user-facing message plus technical detail from a relay
|
||||||
/// error, without ever including secret material.
|
/// error, without ever including secret material.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,35 @@ pub fn enabled_urls(settings: &Settings) -> Vec<String> {
|
||||||
.collect()
|
.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.
|
/// Result of testing a relay connection.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct RelayTestResult {
|
pub struct RelayTestResult {
|
||||||
|
|
@ -83,7 +112,9 @@ pub struct RelayTestResult {
|
||||||
/// during a connection test.
|
/// during a connection test.
|
||||||
pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestResult, AppError> {
|
pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestResult, AppError> {
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let client = Client::new(keys);
|
let client = Client::builder()
|
||||||
|
.authenticator(SignerAuthenticator::new(keys))
|
||||||
|
.build();
|
||||||
|
|
||||||
client
|
client
|
||||||
.add_relay(url)
|
.add_relay(url)
|
||||||
|
|
@ -93,10 +124,12 @@ pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestRe
|
||||||
let relay = client
|
let relay = client
|
||||||
.relay(url)
|
.relay(url)
|
||||||
.await
|
.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
|
relay
|
||||||
.try_connect(timeout)
|
.try_connect()
|
||||||
|
.timeout(timeout)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::network(format!("Connection failed: {e}")))?;
|
.map_err(|e| AppError::network(format!("Connection failed: {e}")))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ impl RelayConfig {
|
||||||
pub enum Theme {
|
pub enum Theme {
|
||||||
Light,
|
Light,
|
||||||
Dark,
|
Dark,
|
||||||
System,
|
Glass,
|
||||||
|
Neon,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
|
|
@ -31,7 +32,8 @@ impl Theme {
|
||||||
match s {
|
match s {
|
||||||
"light" => Some(Self::Light),
|
"light" => Some(Self::Light),
|
||||||
"dark" => Some(Self::Dark),
|
"dark" => Some(Self::Dark),
|
||||||
"system" => Some(Self::System),
|
"glass" => Some(Self::Glass),
|
||||||
|
"neon" => Some(Self::Neon),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +60,7 @@ fn default_true() -> bool {
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
theme: Theme::System,
|
theme: Theme::Light,
|
||||||
confirm_before_publish: true,
|
confirm_before_publish: true,
|
||||||
shorten_npub: true,
|
shorten_npub: true,
|
||||||
relays: crate::relays::default_relays(),
|
relays: crate::relays::default_relays(),
|
||||||
|
|
|
||||||
146
src/signer.rs
146
src/signer.rs
|
|
@ -17,9 +17,9 @@ use std::time::Duration;
|
||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use getrandom::getrandom;
|
||||||
use nostr::nips::nip44::v2;
|
use nostr::nips::nip44::v2;
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
use nostr::JsonUtil;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
@ -238,7 +238,7 @@ impl Signer {
|
||||||
///
|
///
|
||||||
/// `app` is the shared vault state, so the signer reflects the current unlock
|
/// `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.
|
/// 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://") {
|
if uri.trim().starts_with("bunker://") {
|
||||||
return Err(AppError::config(
|
return Err(AppError::config(
|
||||||
"That is a bunker:// link, which means routing through *another* signer. \
|
"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.
|
/// NIP-44 encrypt with the conversation key, returned base64-encoded.
|
||||||
fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result<String, AppError> {
|
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}")))?;
|
.map_err(|e| AppError::internal(format!("Could not encrypt a message: {e}")))?;
|
||||||
Ok(B64.encode(payload))
|
Ok(B64.encode(payload))
|
||||||
}
|
}
|
||||||
|
|
@ -570,8 +574,8 @@ fn sign_event(keys: &Keys, request: &RawRequest) -> Result<String, String> {
|
||||||
|
|
||||||
let unsigned: UnsignedEvent =
|
let unsigned: UnsignedEvent =
|
||||||
serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?;
|
serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?;
|
||||||
let event = unsigned
|
let event = keys
|
||||||
.sign_with_keys(keys)
|
.sign_event(unsigned)
|
||||||
.map_err(|e| format!("The event could not be signed: {e}"))?;
|
.map_err(|e| format!("The event could not be signed: {e}"))?;
|
||||||
Ok(event.as_json())
|
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,
|
/// The background loop: connect to the client's relays, announce ourselves,
|
||||||
/// subscribe to kind 24133 events, and answer requests until stopped.
|
/// 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.
|
// 1. Resolve the active profile's key under the current vault lock.
|
||||||
let keys = {
|
let keys = {
|
||||||
let guard = match app.lock() {
|
let guard = app.lock().await;
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(_) => {
|
|
||||||
signer.fail("The vault could not be read.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) {
|
let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) {
|
||||||
Ok(hex) => hex,
|
Ok(hex) => hex,
|
||||||
Err(err) => {
|
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.
|
// 3. Connect to the client's relays. The notification stream is opened
|
||||||
let client = Client::new(keys.clone());
|
// 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 {
|
for url in &uri.relays {
|
||||||
if let Err(err) = client.add_relay(url.to_string()).await {
|
if let Err(err) = client.add_relay(url.to_string()).await {
|
||||||
signer.fail(format!("Could not add relay {url}: {err}"));
|
signer.fail(format!("Could not add relay {url}: {err}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
client.connect().await;
|
client.connect().and_wait(CONNECT_TIMEOUT).await;
|
||||||
client.wait_for_connection(CONNECT_TIMEOUT).await;
|
|
||||||
|
|
||||||
// 4. Subscribe to the client's kind 24133 events so we hear its requests.
|
// 4. Subscribe to the client's kind 24133 events so we hear its requests.
|
||||||
|
// `stream_events` opens its internal notification receiver as part of
|
||||||
|
// subscribing, which must happen BEFORE we announce (step 5): the client
|
||||||
|
// acknowledges within milliseconds and a receiver created afterwards would
|
||||||
|
// miss those early messages (tokio broadcast semantics).
|
||||||
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
|
let 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 {
|
||||||
signer.fail(format!("Could not subscribe for messages: {err}"));
|
Ok(events) => events,
|
||||||
return;
|
Err(err) => {
|
||||||
}
|
signer.fail(format!("Could not subscribe for messages: {err}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 5. Announce ourselves: send the connect request with the optional secret.
|
// 5. Announce ourselves: send the connect request with the optional secret.
|
||||||
if let Err(err) = send_connect(&client, &keys, &conversation, &uri).await {
|
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.
|
// 6. Answer requests until the connection goes away or we are stopped.
|
||||||
let mut notifications = client.notifications();
|
|
||||||
loop {
|
loop {
|
||||||
let notification = match notifications.recv().await {
|
let (relay_url, incoming) = match events.next().await {
|
||||||
Ok(notification) => notification,
|
Some(next) => next,
|
||||||
Err(_) => {
|
None => {
|
||||||
signer.fail("The signer connection was closed.");
|
signer.fail("The signer connection was closed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let RelayPoolNotification::Event { event, .. } = notification else {
|
let event = match incoming {
|
||||||
continue;
|
Ok(event) => event,
|
||||||
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
let _ = relay_url;
|
||||||
if event.kind != Kind::NostrConnect || event.pubkey != uri.peer {
|
if event.kind != Kind::NostrConnect || event.pubkey != uri.peer {
|
||||||
continue;
|
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 tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?;
|
||||||
let event = EventBuilder::new(Kind::NostrConnect, content)
|
let event = EventBuilder::new(Kind::NostrConnect, content)
|
||||||
.tags([tag])
|
.tags([tag])
|
||||||
.sign(keys)
|
.finalize_async(keys)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Could not sign a message: {e}"))?;
|
.map_err(|e| format!("Could not sign a message: {e}"))?;
|
||||||
client
|
client
|
||||||
|
|
@ -744,6 +755,80 @@ async fn publish_payload(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn parse_uri_with_relays_and_secret() {
|
fn parse_uri_with_relays_and_secret() {
|
||||||
let uri = parse_connect_uri(
|
let uri = parse_connect_uri(
|
||||||
|
|
@ -764,7 +849,10 @@ mod tests {
|
||||||
fn bunker_link_is_rejected() {
|
fn bunker_link_is_rejected() {
|
||||||
let signer = Signer::new();
|
let signer = Signer::new();
|
||||||
assert!(signer
|
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());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
498
src/updates.rs
Normal file
498
src/updates.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::crypto::VaultKey;
|
use crate::crypto::VaultKey;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ pub struct StoredProfile {
|
||||||
/// profiles stored before pictures were introduced.
|
/// profiles stored before pictures were introduced.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub picture: Option<String>,
|
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
|
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
||||||
|
|
@ -457,6 +462,7 @@ mod tests {
|
||||||
secret_key: "00ff".to_string(),
|
secret_key: "00ff".to_string(),
|
||||||
created_at: 1_700_000_000,
|
created_at: 1_700_000_000,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue