Compare commits
No commits in common. "f29d4f94a05eab9ee694130cff4e1c1ddd46efa1" and "2e4005d24dbb6e566270fe454d29636639b3ea86" have entirely different histories.
f29d4f94a0
...
2e4005d24d
42 changed files with 4723 additions and 5980 deletions
File diff suppressed because it is too large
Load diff
532
Cargo.lock
generated
532
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,8 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nostr = { version = "0.45", features = ["nip44", "nip98"] }
|
||||
nostr-sdk = "0.45"
|
||||
nostr = { version = "0.40", features = ["nip44", "nip46"] }
|
||||
nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
# 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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -82,18 +82,6 @@ function resolveBackendPath(): string {
|
|||
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
|
||||
}
|
||||
|
||||
/**
|
||||
* The app icon ships inside the bundle: Vite copies `public/icon.png` into
|
||||
* `dist/`, so the packaged app finds it next to the loaded page. In dev the
|
||||
* Vite project's `public/` folder is the same file.
|
||||
*/
|
||||
function resolveWindowIcon(): string {
|
||||
const base = app.isPackaged
|
||||
? path.join(app.getAppPath(), 'dist')
|
||||
: path.join(app.getAppPath(), 'public');
|
||||
return path.join(base, 'icon.png');
|
||||
}
|
||||
|
||||
function startBackend(): void {
|
||||
if (backendStarted && backend && backend.exitCode === null) {
|
||||
return;
|
||||
|
|
@ -151,11 +139,10 @@ function startBackend(): void {
|
|||
|
||||
/**
|
||||
* Upper bound for one backend round-trip. Generous on purpose: a publish can
|
||||
* wait on relays and dependency updates run npm/cargo commands that can take
|
||||
* minutes on cold caches. A hung backend still gets reaped instead of leaking
|
||||
* promises forever.
|
||||
* wait for each relay in turn (10s connect + 15s send each). A hung backend
|
||||
* still gets reaped instead of leaking promises forever.
|
||||
*/
|
||||
const BACKEND_TIMEOUT_MS = 300_000;
|
||||
const BACKEND_TIMEOUT_MS = 120_000;
|
||||
|
||||
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
startBackend();
|
||||
|
|
@ -198,8 +185,6 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
|||
'select_profile',
|
||||
'publish_profile_metadata',
|
||||
'set_profile_picture',
|
||||
'rename_profile',
|
||||
'set_nip05',
|
||||
'delete_profile',
|
||||
'undo_delete',
|
||||
'publish_note',
|
||||
|
|
@ -209,8 +194,6 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
|||
'relay_set_enabled',
|
||||
'relay_test',
|
||||
'settings_update',
|
||||
'update_check',
|
||||
'update_apply',
|
||||
'backup_now',
|
||||
'set_vault_password',
|
||||
'unlock_vault',
|
||||
|
|
@ -512,7 +495,6 @@ function createWindow(): void {
|
|||
minWidth: 920,
|
||||
minHeight: 640,
|
||||
title: 'Keynectr',
|
||||
icon: resolveWindowIcon(),
|
||||
backgroundColor: '#f6f4f0',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
|
|
@ -563,13 +545,6 @@ function createWindow(): void {
|
|||
});
|
||||
}
|
||||
|
||||
// Wayland has no per-window icons: the taskbar resolves the running app's id
|
||||
// through an installed .desktop file instead (see
|
||||
// scripts/install-desktop-entry.sh). Pin the identity before `ready`.
|
||||
if (process.platform === 'linux') {
|
||||
app.setDesktopName('keynectr.desktop');
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
protocol.handle('app', (request) => {
|
||||
const { pathname } = new URL(request.url);
|
||||
|
|
|
|||
5554
frontend/package-lock.json
generated
5554
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,7 +17,6 @@
|
|||
"format:check": "prettier --check .",
|
||||
"electron:build": "tsc -p tsconfig.electron.json",
|
||||
"start": "npm run electron:build && electron .",
|
||||
"desktop:install": "bash scripts/install-desktop-entry.sh",
|
||||
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -33,17 +32,17 @@
|
|||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"electron": "^43.4.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"build": {
|
||||
"appId": "dev.nostfeedmanager.desktop",
|
||||
|
|
@ -66,7 +65,6 @@
|
|||
"target": [
|
||||
"dir"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "Network",
|
||||
"executableName": "keynectr"
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,42 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install the Keynectr .desktop entry and icon for the current user.
|
||||
#
|
||||
# On Linux (especially Wayland) the taskbar icon does not come from the
|
||||
# BrowserWindow icon: the desktop environment resolves the running app's id
|
||||
# against an installed .desktop file and its Icon= key. This script installs
|
||||
# both so the hummingbird-key icon shows up in launchers and taskbars.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ICON_SRC="$ROOT/build/icon.png"
|
||||
ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps"
|
||||
DESKTOP_DIR="$HOME/.local/share/applications"
|
||||
|
||||
if [[ ! -f "$ICON_SRC" ]]; then
|
||||
echo "Icon not found at $ICON_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ICON_DIR" "$DESKTOP_DIR"
|
||||
cp "$ICON_SRC" "$ICON_DIR/keynectr.png"
|
||||
|
||||
cat > "$DESKTOP_DIR/keynectr.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Keynectr
|
||||
Comment=Manage Nostr profiles and publish notes
|
||||
Exec=bash -lc "cd '$ROOT' && npm start"
|
||||
Icon=keynectr
|
||||
Terminal=false
|
||||
Categories=Network;Utility;
|
||||
StartupWMClass=keynectr
|
||||
EOF
|
||||
|
||||
# Refresh the desktop database so the new entry is picked up without a logout.
|
||||
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
|
||||
command -v kbuildsycoca6 >/dev/null 2>&1 && kbuildsycoca6 --noincremental >/dev/null 2>&1 || true
|
||||
|
||||
echo "Installed:"
|
||||
echo " $ICON_DIR/keynectr.png"
|
||||
echo " $DESKTOP_DIR/keynectr.desktop"
|
||||
echo "Restart Keynectr to see the icon in the taskbar."
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
|
|
@ -19,11 +19,7 @@ export function Modal({ open, title, onClose, children }: ModalProps) {
|
|||
const container = containerRef.current;
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
// Keep keyboard focus where the user (or autoFocus) put it; only pull
|
||||
// focus to the dialog itself as a fallback for content without inputs.
|
||||
if (!container?.contains(document.activeElement)) {
|
||||
container?.focus();
|
||||
}
|
||||
container?.focus();
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { shortenNpub } from '../lib/format';
|
|||
import { Avatar } from './Avatar';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { Icon, type IconName } from './Icon';
|
||||
import logoUrl from '../assets/logo.png';
|
||||
|
||||
const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
|
||||
{ id: 'home', label: 'Home', icon: 'home' },
|
||||
|
|
@ -30,7 +29,7 @@ export function Sidebar({ screen, onNavigate }: SidebarProps) {
|
|||
<aside className="sidebar">
|
||||
<div className="sidebar-brand">
|
||||
<span className="sidebar-logo" aria-hidden="true">
|
||||
<img src={logoUrl} alt="" draggable={false} />
|
||||
<Icon name="shield" size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>Keynectr</strong>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ import type {
|
|||
RevealedKey,
|
||||
Settings,
|
||||
SignerStatus,
|
||||
UpdateApplyReport,
|
||||
UpdateCheckReport,
|
||||
UploadedImage,
|
||||
} from './types';
|
||||
|
||||
|
|
@ -60,30 +58,17 @@ export const api = {
|
|||
'set_profile_picture',
|
||||
{ npub, url },
|
||||
),
|
||||
renameProfile: (npub: string, label: string) =>
|
||||
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>(
|
||||
'rename_profile',
|
||||
{ npub, label },
|
||||
),
|
||||
setNip05: (npub: string, nip05: string | null) =>
|
||||
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>('set_nip05', {
|
||||
npub,
|
||||
nip05,
|
||||
}),
|
||||
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
||||
feedGet: (limit?: number, contactsOnly = false, authorNpub?: string) =>
|
||||
feedGet: (limit?: number, contactsOnly = false) =>
|
||||
call<FeedItem[]>('feed_get', {
|
||||
...(limit ? { limit } : {}),
|
||||
...(contactsOnly ? { contacts_only: true } : {}),
|
||||
...(authorNpub ? { author: authorNpub } : {}),
|
||||
}),
|
||||
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
|
||||
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
|
||||
relaySetEnabled: (url: string, enabled: boolean) =>
|
||||
call<Settings>('relay_set_enabled', { url, enabled }),
|
||||
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
|
||||
updateCheck: () => call<UpdateCheckReport>('update_check'),
|
||||
updateApply: () => call<UpdateApplyReport>('update_apply'),
|
||||
settingsUpdate: (
|
||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||
) => call<Settings>('settings_update', patch),
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
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,11 +63,3 @@ export function initialsFor(label: string): string {
|
|||
}
|
||||
return trimmed.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten a long hexadecimal id for display: `a47921f7…68cdc9f0`.
|
||||
* Keeps the full value when the string is short enough to read as-is.
|
||||
*/
|
||||
export function shortHexId(value: string): string {
|
||||
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type Theme = 'light' | 'dark' | 'glass' | 'neon';
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
/** Lifecycle of the NIP-46 remote signer. */
|
||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
||||
|
|
@ -36,8 +36,6 @@ export interface ProfileSummary {
|
|||
is_active: boolean;
|
||||
/** Public URL of the profile picture, when one has been set. */
|
||||
picture?: string | null;
|
||||
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
||||
nip05?: string | null;
|
||||
}
|
||||
|
||||
export interface RelayConfig {
|
||||
|
|
@ -94,38 +92,6 @@ export interface RelayTestResult {
|
|||
latency_ms?: number | null;
|
||||
}
|
||||
|
||||
/** One dependency with a newer compatible version available. */
|
||||
export interface PackageUpdate {
|
||||
name: string;
|
||||
current: string;
|
||||
available: string;
|
||||
}
|
||||
|
||||
/** A known security advisory affecting an npm dependency. */
|
||||
export interface SecurityAdvisory {
|
||||
package: string;
|
||||
/** npm severity label: critical / high / moderate / low / info. */
|
||||
severity: string;
|
||||
title: string | null;
|
||||
/** What is needed to clear it; null when a compatible fix exists. */
|
||||
fix: string | null;
|
||||
}
|
||||
|
||||
/** Result of scanning both dependency sets for updates. */
|
||||
export interface UpdateCheckReport {
|
||||
outdated_npm: PackageUpdate[];
|
||||
advisories: SecurityAdvisory[];
|
||||
outdated_cargo: PackageUpdate[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** Result of applying dependency updates. */
|
||||
export interface UpdateApplyReport {
|
||||
applied: string[];
|
||||
failed: string[];
|
||||
restart_required: boolean;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
version: string;
|
||||
vault_path: string;
|
||||
|
|
|
|||
|
|
@ -15,33 +15,21 @@ interface FeedScreenProps {
|
|||
onNavigate: (screen: Screen) => void;
|
||||
}
|
||||
|
||||
type FeedScope = 'everyone' | 'contacts' | 'profile';
|
||||
type FeedScope = 'everyone' | 'contacts';
|
||||
|
||||
export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||
const { state, feedGet } = useApp();
|
||||
const [items, setItems] = useState<FeedItem[]>([]);
|
||||
const [scope, setScope] = useState<FeedScope>('everyone');
|
||||
const [pickedProfile, setPickedProfile] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
|
||||
const shorten = state?.settings.shorten_npub ?? true;
|
||||
const profiles = state?.profiles ?? [];
|
||||
const hasActiveProfile = state?.active_profile != null;
|
||||
const hasProfiles = profiles.length > 0;
|
||||
// Default the author filter to the active profile until one is picked.
|
||||
const selectedProfileNpub =
|
||||
pickedProfile ?? state?.active_profile?.npub ?? profiles[0]?.npub ?? null;
|
||||
const contactsScope = scope === 'contacts';
|
||||
const profileScope = scope === 'profile';
|
||||
const effectiveScope: FeedScope =
|
||||
contactsScope && !hasActiveProfile
|
||||
? 'everyone'
|
||||
: profileScope && !hasProfiles
|
||||
? 'everyone'
|
||||
: scope;
|
||||
const effectiveScope: FeedScope = contactsScope && !hasActiveProfile ? 'everyone' : scope;
|
||||
|
||||
const load = useCallback(
|
||||
async (background: boolean) => {
|
||||
|
|
@ -52,13 +40,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
|||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
setItems(
|
||||
await feedGet(
|
||||
undefined,
|
||||
effectiveScope === 'contacts',
|
||||
effectiveScope === 'profile' ? (selectedProfileNpub ?? undefined) : undefined,
|
||||
),
|
||||
);
|
||||
setItems(await feedGet(undefined, effectiveScope === 'contacts'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
|
@ -66,7 +48,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
|||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[feedGet, effectiveScope, selectedProfileNpub],
|
||||
[feedGet, effectiveScope],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -140,37 +122,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
|||
<Icon name="users" size={16} />
|
||||
My contacts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`segmented-btn${effectiveScope === 'profile' ? ' is-active' : ''}`}
|
||||
onClick={() => setScope('profile')}
|
||||
disabled={!hasProfiles}
|
||||
title={
|
||||
hasProfiles
|
||||
? 'Only notes published by one of your profiles'
|
||||
: 'Create a profile to filter by it'
|
||||
}
|
||||
aria-pressed={effectiveScope === 'profile'}
|
||||
>
|
||||
<Icon name="edit" size={16} />
|
||||
My notes
|
||||
</button>
|
||||
</div>
|
||||
{effectiveScope === 'profile' && (
|
||||
<select
|
||||
className="feed-profile-select"
|
||||
aria-label="Show notes from profile"
|
||||
value={selectedProfileNpub ?? ''}
|
||||
onChange={(event) => setPickedProfile(event.target.value)}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.npub} value={profile.npub}>
|
||||
{profile.label}
|
||||
{profile.is_active ? ' (active)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
|
||||
<Icon name="refresh" size={16} />
|
||||
Refresh
|
||||
|
|
@ -188,31 +140,12 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
|||
<Spinner label="Fetching recent notes…" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<Icon
|
||||
name={
|
||||
effectiveScope === 'contacts'
|
||||
? 'users'
|
||||
: effectiveScope === 'profile'
|
||||
? 'edit'
|
||||
: 'list'
|
||||
}
|
||||
size={26}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
effectiveScope === 'contacts'
|
||||
? 'No notes from your contacts'
|
||||
: effectiveScope === 'profile'
|
||||
? 'No notes from this profile'
|
||||
: 'No notes found'
|
||||
}
|
||||
icon={<Icon name={effectiveScope === 'contacts' ? 'users' : 'list'} size={26} />}
|
||||
title={effectiveScope === 'contacts' ? 'No notes from your contacts' : 'No notes found'}
|
||||
description={
|
||||
effectiveScope === 'contacts'
|
||||
? 'No notes were returned from the people the active profile follows in the last 24 hours. Try refreshing, or switch to Everyone.'
|
||||
: effectiveScope === 'profile'
|
||||
? 'This profile has not published any notes on your enabled relays in the last 24 hours. Notes older than a day, or notes only on other relays, will not appear here.'
|
||||
: 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.'
|
||||
: 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Icon } from '../components/Icon';
|
|||
import { Modal } from '../components/Modal';
|
||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||
import { formatDate, shortenNpub } from '../lib/format';
|
||||
import type { MetadataPublishReport } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
interface ProfilesScreenProps {
|
||||
|
|
@ -25,8 +24,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
|
||||
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
||||
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
|
||||
const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null);
|
||||
const [nip05Target, setNip05Target] = useState<Nip05Target | null>(null);
|
||||
|
||||
const profiles = state?.profiles ?? [];
|
||||
const shorten = state?.settings.shorten_npub ?? true;
|
||||
|
|
@ -135,14 +132,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
<article
|
||||
key={profile.npub}
|
||||
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
|
||||
onClick={
|
||||
profile.is_active
|
||||
? undefined
|
||||
: (event) => {
|
||||
if ((event.target as HTMLElement).closest('button, a, input')) return;
|
||||
void onSelect(profile.npub);
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="profile-card-top">
|
||||
<Avatar
|
||||
|
|
@ -153,11 +142,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
/>
|
||||
<div className="profile-card-meta">
|
||||
<h3>{profile.label}</h3>
|
||||
{profile.nip05 && (
|
||||
<span className="nip05-handle" title={profile.nip05}>
|
||||
{profile.nip05}
|
||||
</span>
|
||||
)}
|
||||
<code className="mono" title={profile.npub}>
|
||||
{shortenNpub(profile.npub, shorten)}
|
||||
</code>
|
||||
|
|
@ -177,26 +161,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
>
|
||||
<Icon name="publish" size={14} /> Publish name
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRenameTarget({ npub: profile.npub, label: profile.label })}
|
||||
>
|
||||
<Icon name="edit" size={14} /> Edit name
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setNip05Target({
|
||||
npub: profile.npub,
|
||||
label: profile.label,
|
||||
nip05: profile.nip05 ?? '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon name="edit" size={14} /> NIP-05
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -267,32 +231,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
onSavingChange={setPublishing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renameTarget && (
|
||||
<RenameModal
|
||||
target={renameTarget}
|
||||
onClose={() => setRenameTarget(null)}
|
||||
onSaved={(message) => {
|
||||
setNotice(message);
|
||||
setRenameTarget(null);
|
||||
}}
|
||||
onError={setError}
|
||||
onSavingChange={setPublishing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nip05Target && (
|
||||
<Nip05Modal
|
||||
target={nip05Target}
|
||||
onClose={() => setNip05Target(null)}
|
||||
onSaved={(message) => {
|
||||
setNotice(message);
|
||||
setNip05Target(null);
|
||||
}}
|
||||
onError={setError}
|
||||
onSavingChange={setPublishing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -304,213 +242,6 @@ interface PictureTarget {
|
|||
url: string;
|
||||
}
|
||||
|
||||
interface Nip05Target {
|
||||
npub: string;
|
||||
label: string;
|
||||
nip05: string;
|
||||
}
|
||||
|
||||
/** Client-side mirror of the backend's NIP-05 validation, for fast feedback. */
|
||||
function nip05Problem(value: string): string | null {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return null; // Empty clears the identifier.
|
||||
}
|
||||
const at = trimmed.indexOf('@');
|
||||
if (at <= 0 || at === trimmed.length - 1) {
|
||||
return 'Use the form name@domain.com.';
|
||||
}
|
||||
const [local, domain] = [trimmed.slice(0, at), trimmed.slice(at + 1)];
|
||||
if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) {
|
||||
return 'The part before @ may only use letters, numbers, dashes and underscores.';
|
||||
}
|
||||
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {
|
||||
return 'The part after @ must be a domain like example.com.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runModalSave(options: {
|
||||
npub: string;
|
||||
perform: () => Promise<MetadataPublishReport>;
|
||||
successMessage: (report: MetadataPublishReport) => string;
|
||||
onSaved: (message: string) => void;
|
||||
onError: (message: string | null) => void;
|
||||
onSavingChange: (npub: string | null) => void;
|
||||
setSaving: (saving: boolean) => void;
|
||||
}): Promise<void> {
|
||||
options.onError(null);
|
||||
options.setSaving(true);
|
||||
options.onSavingChange(options.npub);
|
||||
try {
|
||||
const report = await options.perform();
|
||||
options.onSaved(options.successMessage(report));
|
||||
} catch (err) {
|
||||
options.onError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
options.setSaving(false);
|
||||
options.onSavingChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
function Nip05Modal({
|
||||
target,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
onSavingChange,
|
||||
}: {
|
||||
target: Nip05Target;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
onError: (message: string | null) => void;
|
||||
onSavingChange: (npub: string | null) => void;
|
||||
}) {
|
||||
const { setNip05 } = useApp();
|
||||
const [nip05, setNip05Value] = useState(target.nip05);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const trimmed = nip05.trim();
|
||||
const changed = trimmed !== target.nip05;
|
||||
const problem = nip05Problem(trimmed);
|
||||
const canSave = changed && !problem;
|
||||
|
||||
const save = async (next: string | null) => {
|
||||
await runModalSave({
|
||||
npub: target.npub,
|
||||
perform: () => setNip05(target.npub, next),
|
||||
successMessage: (report) =>
|
||||
report.failed.length === 0
|
||||
? next
|
||||
? `NIP-05 "${next}" published to ${report.succeeded.length} relay(s). Remember it must also be served from your domain (see docs/NIP-05.md).`
|
||||
: 'NIP-05 removed and the change published.'
|
||||
: `NIP-05 saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
|
||||
onSaved,
|
||||
onError,
|
||||
onSavingChange,
|
||||
setSaving,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open title={`NIP-05 address — ${target.label}`} onClose={onClose}>
|
||||
<div className="picture-modal">
|
||||
<div className="field">
|
||||
<label htmlFor="profile-nip05">NIP-05 address</label>
|
||||
<input
|
||||
id="profile-nip05"
|
||||
type="text"
|
||||
value={nip05}
|
||||
onChange={(event) => setNip05Value(event.target.value)}
|
||||
placeholder="name@domain.com"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{problem && trimmed && <ErrorText>{problem}</ErrorText>}
|
||||
</div>
|
||||
<p className="muted">
|
||||
A NIP-05 address (like an email handle) makes clients show a proper username instead of a
|
||||
raw key. It is published to your relays and must also be served from your domain as
|
||||
<code> /.well-known/nostr.json</code> — see <code>docs/NIP-05.md</code>.
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
{target.nip05 && (
|
||||
<Button variant="danger" onClick={() => void save(null)} disabled={saving}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void save(trimmed ? trimmed.toLowerCase() : null)}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save & 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({
|
||||
target,
|
||||
onClose,
|
||||
|
|
@ -530,18 +261,22 @@ function PictureModal({
|
|||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const save = async (nextUrl: string | null) => {
|
||||
await runModalSave({
|
||||
npub: target.npub,
|
||||
perform: () => setProfilePicture(target.npub, nextUrl),
|
||||
successMessage: (report) =>
|
||||
onError(null);
|
||||
setSaving(true);
|
||||
onSavingChange(target.npub);
|
||||
try {
|
||||
const report = await setProfilePicture(target.npub, nextUrl);
|
||||
onSaved(
|
||||
report.failed.length === 0
|
||||
? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).`
|
||||
: `Picture saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
|
||||
onSaved,
|
||||
onError,
|
||||
onSavingChange,
|
||||
setSaving,
|
||||
});
|
||||
);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
onSavingChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpload = async () => {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,17 @@
|
|||
import { useState } from 'react';
|
||||
import { Alert } from '../components/Alert';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { Button } from '../components/Button';
|
||||
import { CopyButton } from '../components/CopyButton';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { Toggle } from '../components/Toggle';
|
||||
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal';
|
||||
import type { Theme, UpdateCheckReport } from '../lib/types';
|
||||
import type { Theme } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp();
|
||||
const { state, updateSettings, backupNow } = useApp();
|
||||
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
|
||||
const [updateReport, setUpdateReport] = useState<UpdateCheckReport | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [applyMessage, setApplyMessage] = useState<string[] | null>(null);
|
||||
|
||||
const settings = state?.settings;
|
||||
const vaultPath = state?.vault_path ?? '';
|
||||
|
|
@ -51,38 +44,6 @@ export function SettingsScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const onCheckUpdates = async () => {
|
||||
setChecking(true);
|
||||
setUpdateError(null);
|
||||
setApplyMessage(null);
|
||||
setUpdateReport(null);
|
||||
try {
|
||||
setUpdateReport(await updateCheck());
|
||||
} catch (err) {
|
||||
setUpdateError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onInstallUpdates = async () => {
|
||||
setInstalling(true);
|
||||
setUpdateError(null);
|
||||
setApplyMessage(null);
|
||||
try {
|
||||
const result = await updateApply();
|
||||
const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)];
|
||||
if (result.restart_required) {
|
||||
lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.');
|
||||
}
|
||||
setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']);
|
||||
} catch (err) {
|
||||
setUpdateError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<div className="screen-inner">
|
||||
|
|
@ -104,13 +65,9 @@ export function SettingsScreen() {
|
|||
>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="glass">Aurora</option>
|
||||
<option value="neon">Neon</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
<p className="hint">
|
||||
“Light” and “Dark” follow your manual selection. “Aurora” and “Neon” are aesthetic
|
||||
themes with distinctive color palettes.
|
||||
</p>
|
||||
<p className="hint">“System” follows your desktop's light or dark preference.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -196,128 +153,6 @@ export function SettingsScreen() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header className="card-header">
|
||||
<h2>Updates</h2>
|
||||
</header>
|
||||
<div className="card-body settings-block">
|
||||
<p className="hint">
|
||||
Scans the JavaScript packages and Rust crates this app is built on. Known security
|
||||
advisories are always listed first; installing applies compatible updates only.
|
||||
</p>
|
||||
<div className="settings-inline">
|
||||
<Button variant="secondary" onClick={() => void onCheckUpdates()} loading={checking}>
|
||||
<Icon name="refresh" size={16} />
|
||||
Check for updates
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void onInstallUpdates()}
|
||||
loading={installing}
|
||||
disabled={checking}
|
||||
>
|
||||
<Icon name="shield" size={16} />
|
||||
Install updates
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{updateError && (
|
||||
<Alert tone="error" title="Update problem">
|
||||
{updateError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{checking && <Spinner label="Scanning dependencies…" />}
|
||||
|
||||
{applyMessage && (
|
||||
<Alert tone="info" title="Updates">
|
||||
<ul className="update-summary">
|
||||
{applyMessage.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{updateReport && !checking && (
|
||||
<>
|
||||
{updateReport.advisories.length > 0 ? (
|
||||
<Alert
|
||||
tone="warning"
|
||||
title={`${updateReport.advisories.length} security issue(s) found`}
|
||||
>
|
||||
<ul className="update-list">
|
||||
{updateReport.advisories.map((advisory) => (
|
||||
<li key={advisory.package}>
|
||||
<Badge
|
||||
tone={
|
||||
advisory.severity === 'critical' || advisory.severity === 'high'
|
||||
? 'danger'
|
||||
: advisory.severity === 'moderate'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{advisory.severity}
|
||||
</Badge>{' '}
|
||||
<code className="mono">{advisory.package}</code>
|
||||
{advisory.title ? ` — ${advisory.title}` : ''}
|
||||
{advisory.fix && <span className="hint"> {advisory.fix}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
tone="success"
|
||||
title="No known security advisories in the npm dependencies."
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateReport.outdated_npm.length > 0 && (
|
||||
<div>
|
||||
<span className="field-label">JavaScript packages</span>
|
||||
<ul className="update-list">
|
||||
{updateReport.outdated_npm.map((pkg) => (
|
||||
<li key={pkg.name}>
|
||||
<code className="mono">{pkg.name}</code> {pkg.current} →{' '}
|
||||
<strong>{pkg.available}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateReport.outdated_cargo.length > 0 && (
|
||||
<div>
|
||||
<span className="field-label">Rust crates</span>
|
||||
<ul className="update-list">
|
||||
{updateReport.outdated_cargo.map((crateName) => (
|
||||
<li key={crateName.name}>
|
||||
<code className="mono">{crateName.name}</code> {crateName.current} →{' '}
|
||||
<strong>{crateName.available}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateReport.notes.map((note) => (
|
||||
<p className="hint" key={note}>
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{updateReport.advisories.length === 0 &&
|
||||
updateReport.outdated_npm.length === 0 &&
|
||||
updateReport.outdated_cargo.length === 0 && (
|
||||
<Alert tone="success">Everything is up to date.</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header className="card-header">
|
||||
<h2>Advanced</h2>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Badge } from '../components/Badge';
|
|||
import { Button } from '../components/Button';
|
||||
import { ErrorText } from '../components/ErrorText';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { shortHexId } from '../lib/format';
|
||||
import type { SignerStatus } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
|
|
@ -16,6 +15,11 @@ const EMPTY_STATUS: SignerStatus = {
|
|||
pending: [],
|
||||
};
|
||||
|
||||
/** Shorten a 64-char hex key for display. */
|
||||
function shortHex(value: string): string {
|
||||
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
export function SignerScreen() {
|
||||
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
|
||||
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
||||
|
|
@ -133,7 +137,7 @@ export function SignerScreen() {
|
|||
<dd>
|
||||
{status.peer ? (
|
||||
<code className="mono" title={status.peer}>
|
||||
{shortHexId(status.peer)}
|
||||
{shortHex(status.peer)}
|
||||
</code>
|
||||
) : (
|
||||
<span className="muted">None yet</span>
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import type {
|
|||
Settings,
|
||||
SignerStatus,
|
||||
Theme,
|
||||
UpdateApplyReport,
|
||||
UpdateCheckReport,
|
||||
UploadedImage,
|
||||
} from '../lib/types';
|
||||
|
||||
|
|
@ -43,18 +41,14 @@ interface AppContextValue {
|
|||
selectProfile: (npub: string) => Promise<void>;
|
||||
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
||||
renameProfile: (npub: string, label: string) => Promise<MetadataPublishReport>;
|
||||
setNip05: (npub: string, nip05: string | null) => Promise<MetadataPublishReport>;
|
||||
publishNote: (content: string) => Promise<PublishReport>;
|
||||
recordPublishFailure: (message: string, details?: string | null) => void;
|
||||
clearLastPublish: () => void;
|
||||
feedGet: (limit?: number, contactsOnly?: boolean, authorNpub?: string) => Promise<FeedItem[]>;
|
||||
feedGet: (limit?: number, contactsOnly?: boolean) => Promise<FeedItem[]>;
|
||||
relayAdd: (url: string) => Promise<Settings>;
|
||||
relayRemove: (url: string) => Promise<Settings>;
|
||||
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
|
||||
relayTest: (url: string) => Promise<RelayTestResult>;
|
||||
updateCheck: () => Promise<UpdateCheckReport>;
|
||||
updateApply: () => Promise<UpdateApplyReport>;
|
||||
updateSettings: (
|
||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||
) => Promise<Settings>;
|
||||
|
|
@ -140,24 +134,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
[],
|
||||
);
|
||||
|
||||
const renameProfile = useCallback(
|
||||
async (npub: string, label: string): Promise<MetadataPublishReport> => {
|
||||
const result = await api.renameProfile(npub, label);
|
||||
setState(result.state);
|
||||
return result.report;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setNip05 = useCallback(
|
||||
async (npub: string, nip05: string | null): Promise<MetadataPublishReport> => {
|
||||
const result = await api.setNip05(npub, nip05);
|
||||
setState(result.state);
|
||||
return result.report;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
|
||||
const report = await api.publishNote(content);
|
||||
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
||||
|
|
@ -172,8 +148,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
setLastPublish(null);
|
||||
}, []);
|
||||
|
||||
const feedGet = useCallback((limit?: number, contactsOnly?: boolean, authorNpub?: string) => {
|
||||
return api.feedGet(limit, contactsOnly, authorNpub);
|
||||
const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => {
|
||||
return api.feedGet(limit, contactsOnly);
|
||||
}, []);
|
||||
|
||||
const applySettings = useCallback((fresh: Settings) => {
|
||||
|
|
@ -194,8 +170,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
[applySettings],
|
||||
);
|
||||
const relayTest = useCallback((url: string) => api.relayTest(url), []);
|
||||
const updateCheck = useCallback(() => api.updateCheck(), []);
|
||||
const updateApply = useCallback(() => api.updateApply(), []);
|
||||
const updateSettings = useCallback(
|
||||
async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
|
||||
applySettings(await api.settingsUpdate(patch)),
|
||||
|
|
@ -239,11 +213,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.settings.theme) {
|
||||
applyTheme(state.settings.theme);
|
||||
}
|
||||
}, [state?.settings.theme]);
|
||||
useThemeSync(state?.settings.theme);
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
|
|
@ -262,8 +232,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
relayRemove,
|
||||
relaySetEnabled,
|
||||
relayTest,
|
||||
updateCheck,
|
||||
updateApply,
|
||||
updateSettings,
|
||||
backupNow,
|
||||
setVaultPassword,
|
||||
|
|
@ -282,8 +250,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
undoDelete,
|
||||
publishProfileMetadata,
|
||||
setProfilePicture,
|
||||
renameProfile,
|
||||
setNip05,
|
||||
copyText,
|
||||
}),
|
||||
[
|
||||
|
|
@ -296,8 +262,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
selectProfile,
|
||||
publishProfileMetadata,
|
||||
setProfilePicture,
|
||||
renameProfile,
|
||||
setNip05,
|
||||
publishNote,
|
||||
deleteProfile,
|
||||
undoDelete,
|
||||
|
|
@ -308,8 +272,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
relayRemove,
|
||||
relaySetEnabled,
|
||||
relayTest,
|
||||
updateCheck,
|
||||
updateApply,
|
||||
updateSettings,
|
||||
backupNow,
|
||||
setVaultPassword,
|
||||
|
|
@ -339,13 +301,12 @@ export function useApp(): AppContextValue {
|
|||
return context;
|
||||
}
|
||||
|
||||
/** Apply the requested theme. */
|
||||
/** Apply the requested theme (respecting system preference for `system`). */
|
||||
export function applyTheme(theme: Theme): void {
|
||||
if (theme === 'glass' || theme === 'neon') {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
return;
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
const prefersDark =
|
||||
theme === 'dark' ||
|
||||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/** Keep the document theme in sync with settings, watching system changes. */
|
||||
|
|
|
|||
|
|
@ -58,113 +58,6 @@
|
|||
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* "Neon" — pure-black Matrix/Cyberpunk palette inspired by the CAJAFUERTE
|
||||
Obsidian theme (MIT): black panels, deep-pink primary (#ff1493), purple
|
||||
secondary (#8b00ff family), yellow highlights (#ffd700). */
|
||||
:root[data-theme='neon'] {
|
||||
--bg: #000000;
|
||||
--surface: #0b0b0e;
|
||||
--surface-2: #131318;
|
||||
--surface-hover: #191921;
|
||||
--border: #2b1224;
|
||||
--border-strong: #4d1c3d;
|
||||
--text: #f5f2f7;
|
||||
--text-muted: #a89bb0;
|
||||
--primary: #ff1493;
|
||||
--primary-hover: #ff4fb0;
|
||||
--primary-soft: #2b0a1e;
|
||||
--on-primary: #ffffff;
|
||||
--danger: #ff3355;
|
||||
--danger-soft: #330810;
|
||||
--warning: #ffd700;
|
||||
--warning-soft: #332b03;
|
||||
--success: #00e68a;
|
||||
--success-soft: #04301f;
|
||||
--info: #b388ff;
|
||||
--info-soft: #1c1035;
|
||||
--focus: #ff1493;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.6), 0 8px 24px rgba(255, 20, 147, 0.07);
|
||||
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.85), 0 0 32px rgba(255, 20, 147, 0.12);
|
||||
}
|
||||
|
||||
/* "Aurora" theme (internal id "glass") — frosted-glass aesthetic inspired
|
||||
by the Meridian Obsidian theme (MIT): translucent blurred panels floating
|
||||
over an aurora-lit deep blue-black field, cyan accents, hairline light
|
||||
borders. */
|
||||
:root[data-theme='glass'] {
|
||||
--bg: #0a0d13;
|
||||
--surface: rgba(23, 29, 41, 0.58);
|
||||
--surface-2: rgba(255, 255, 255, 0.05);
|
||||
--surface-hover: rgba(255, 255, 255, 0.09);
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--border-strong: rgba(255, 255, 255, 0.2);
|
||||
--text: #e8ecf4;
|
||||
--text-muted: #96a2b6;
|
||||
--primary: #7fdbff;
|
||||
--primary-hover: #a3e5ff;
|
||||
--primary-soft: rgba(127, 219, 255, 0.13);
|
||||
--on-primary: #06121c;
|
||||
--danger: #ff8080;
|
||||
--danger-soft: rgba(255, 107, 107, 0.14);
|
||||
--warning: #ffc46b;
|
||||
--warning-soft: rgba(240, 173, 79, 0.15);
|
||||
--success: #7ed99a;
|
||||
--success-soft: rgba(92, 184, 92, 0.16);
|
||||
--info: #82c9ea;
|
||||
--info-soft: rgba(91, 192, 222, 0.14);
|
||||
--focus: #7fdbff;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 36px rgba(0, 0, 0, 0.38);
|
||||
--shadow-modal: 0 18px 56px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.07) inset;
|
||||
color-scheme: dark;
|
||||
}
|
||||
/* Glass scene: the aurora backdrop lives on <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;
|
||||
}
|
||||
|
|
@ -359,25 +252,6 @@ a {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.feed-profile-select {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.update-list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.update-summary {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Sidebar
|
||||
------------------------------------------------------------------------- */
|
||||
|
|
@ -406,23 +280,8 @@ a {
|
|||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The artwork is black-on-white; flip it in dark themes so it stays black
|
||||
bird on dark tile instead of a glaring white square. */
|
||||
html[data-theme='dark'] .sidebar-logo img,
|
||||
html[data-theme='neon'] .sidebar-logo img,
|
||||
html[data-theme='glass'] .sidebar-logo img {
|
||||
filter: invert(1);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.sidebar-brand strong {
|
||||
|
|
@ -1189,14 +1048,6 @@ select {
|
|||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.profile-card:not(.is-active) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-card:not(.is-active):hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.profile-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1229,7 +1080,6 @@ select {
|
|||
|
||||
.profile-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ describe('FeedScreen', () => {
|
|||
|
||||
it('disable relays shows an empty state that can navigate to relays', async () => {
|
||||
const settings = {
|
||||
theme: 'light' as const,
|
||||
theme: 'system' as const,
|
||||
confirm_before_publish: true,
|
||||
shorten_npub: true,
|
||||
relays: [
|
||||
|
|
@ -107,55 +107,4 @@ describe('FeedScreen', () => {
|
|||
|
||||
expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults the "My notes" scope to the active profile', async () => {
|
||||
const backend = createFakeBackend();
|
||||
const user = renderFeed(backend);
|
||||
renderWithApp(<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,118 +37,6 @@ describe('ProfilesScreen', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('selects a profile when its card is clicked', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
await screen.findByText('Bob');
|
||||
await user.click(screen.getByText('Bob'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backend.state.active_profile?.npub).toBe(BOB);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not select a profile when an action button inside the card is clicked', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
await screen.findByText('Bob');
|
||||
const copyButtons = screen.getAllByRole('button', { name: 'Copy public key' });
|
||||
await user.click(copyButtons[copyButtons.length - 1]);
|
||||
|
||||
expect(backend.copied).toContain(BOB);
|
||||
expect(backend.state.active_profile?.npub).toBe(ALICE);
|
||||
});
|
||||
|
||||
it('renames a profile from the Edit name modal and publishes it', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
await screen.findByText('Bob');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit name' })[1]);
|
||||
|
||||
const input = screen.getByLabelText('Profile name');
|
||||
expect(input).toHaveValue('Bob');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Bobby');
|
||||
await user.click(screen.getByRole('button', { name: 'Save & publish' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backend.state.profiles.find((p) => p.npub === BOB)?.label).toBe('Bobby');
|
||||
});
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/Renamed to "Bobby"/);
|
||||
});
|
||||
|
||||
it('sets a NIP-05 address from the NIP-05 modal and publishes it', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
await screen.findByText('Bob');
|
||||
await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]);
|
||||
|
||||
const input = screen.getByLabelText('NIP-05 address');
|
||||
expect(input).toHaveValue('');
|
||||
await user.type(input, 'Bob@Example.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Save & publish' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBe('bob@example.com');
|
||||
});
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 "bob@example.com"/);
|
||||
expect(await screen.findByText('bob@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a malformed NIP-05 address without calling the backend', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
await screen.findByText('Bob');
|
||||
await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]);
|
||||
|
||||
const input = screen.getByLabelText('NIP-05 address');
|
||||
await user.type(input, 'not-an-address');
|
||||
expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'boo@nodot');
|
||||
expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled();
|
||||
expect(backend.requests.filter((r) => r.method === 'set_nip05')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('removes an existing NIP-05 address', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
backend.setState({
|
||||
...backend.state,
|
||||
profiles: backend.state.profiles.map((p) =>
|
||||
p.npub === BOB ? { ...p, nip05: 'bob@example.com' } : p,
|
||||
),
|
||||
active_profile: backend.state.active_profile,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
await screen.findByText('bob@example.com');
|
||||
await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]);
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBeNull();
|
||||
});
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 removed/i);
|
||||
});
|
||||
|
||||
it('disables Select for the active profile and copies public keys', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
|
|
|
|||
|
|
@ -29,19 +29,6 @@ describe('SettingsScreen', () => {
|
|||
expect(document.documentElement.dataset.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('applies the neon theme to the document', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
renderWithApp(<SettingsScreen />);
|
||||
|
||||
const themeSelect = await screen.findByLabelText('Theme');
|
||||
fireEvent.change(themeSelect, { target: { value: 'neon' } });
|
||||
await waitFor(() => {
|
||||
expect(backend.state.settings.theme).toBe('neon');
|
||||
});
|
||||
expect(document.documentElement.dataset.theme).toBe('neon');
|
||||
});
|
||||
|
||||
it('toggles publish confirmation and npub shortening', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
|
|
@ -77,70 +64,4 @@ describe('SettingsScreen', () => {
|
|||
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks for updates and lists security advisories first', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<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,
|
||||
};
|
||||
const settings: Settings = {
|
||||
theme: 'light',
|
||||
theme: 'system',
|
||||
confirm_before_publish: true,
|
||||
shorten_npub: true,
|
||||
relays: [
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import type {
|
|||
RelayTestResult,
|
||||
Settings,
|
||||
SignerStatus,
|
||||
UpdateApplyReport,
|
||||
UpdateCheckReport,
|
||||
} from '../lib/types';
|
||||
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
|
||||
|
||||
|
|
@ -45,12 +43,6 @@ export interface FakeBackend {
|
|||
feedItems: FeedItem[];
|
||||
/** Notes returned by `feed_get` with `contacts_only: true`. */
|
||||
contactFeedItems: FeedItem[];
|
||||
/** Notes returned by `feed_get` with an `author` filter. */
|
||||
profileFeedItems: FeedItem[];
|
||||
/** Report returned by `update_check`. */
|
||||
updateReport: UpdateCheckReport;
|
||||
/** Result returned by `update_apply`. */
|
||||
updateApplyResult: UpdateApplyReport;
|
||||
}
|
||||
|
||||
export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||
|
|
@ -134,41 +126,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
relays: ['wss://relay.damus.io'],
|
||||
},
|
||||
],
|
||||
/** Notes returned by `feed_get` with an `author` filter. */
|
||||
profileFeedItems: [
|
||||
{
|
||||
id: 'note1dddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
author_npub: ALICE,
|
||||
content: 'A note from my own profile.',
|
||||
created_at: 1700000400,
|
||||
relays: ['wss://relay.damus.io'],
|
||||
},
|
||||
],
|
||||
updateReport: {
|
||||
outdated_npm: [{ name: 'vite', current: '4.0.0', available: '5.1.0' }],
|
||||
advisories: [
|
||||
{
|
||||
package: 'minimist',
|
||||
severity: 'high',
|
||||
title: 'Prototype Pollution',
|
||||
fix: null,
|
||||
},
|
||||
{
|
||||
package: 'electron',
|
||||
severity: 'critical',
|
||||
title: 'ASAR Integrity Bypass',
|
||||
fix: 'Needs electron@43.4.1, a major upgrade — not auto-installed.',
|
||||
},
|
||||
],
|
||||
outdated_cargo: [{ name: 'serde', current: '1.0.200', available: '1.0.219' }],
|
||||
notes: [],
|
||||
},
|
||||
updateApplyResult: {
|
||||
applied: ['JavaScript security fixes applied', 'Rust crates updated in Cargo.lock'],
|
||||
failed: [],
|
||||
restart_required: true,
|
||||
},
|
||||
};
|
||||
|
||||
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -206,58 +163,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
return next;
|
||||
}
|
||||
|
||||
case 'rename_profile': {
|
||||
const npub = String(params.npub);
|
||||
const label = String(params.label ?? '').trim();
|
||||
if (!label) {
|
||||
throw new Error('The profile name cannot be empty.');
|
||||
}
|
||||
if (!state.profiles.some((p) => p.npub === npub)) {
|
||||
throw new Error('That profile is not stored on this computer.');
|
||||
}
|
||||
const updated: ProfileSummary = { ...state.profiles.find((p) => p.npub === npub)!, label };
|
||||
const next: AppState = {
|
||||
...state,
|
||||
profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)),
|
||||
active_profile: state.active_profile?.npub === npub ? updated : state.active_profile,
|
||||
};
|
||||
backend.setState(next);
|
||||
return { profile: updated, report: makePublishReport(), state: next };
|
||||
}
|
||||
|
||||
case 'set_nip05': {
|
||||
const npub = String(params.npub);
|
||||
const raw = params.nip05;
|
||||
const nip05 = typeof raw === 'string' ? raw.trim().toLowerCase() : null;
|
||||
if (nip05) {
|
||||
const at = nip05.indexOf('@');
|
||||
const [local, domain] = [nip05.slice(0, at), nip05.slice(at + 1)];
|
||||
if (at <= 0 || at === nip05.length - 1 || nip05.includes('@', at + 1)) {
|
||||
throw new Error('A NIP-05 address must look like name@domain.com.');
|
||||
}
|
||||
if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) {
|
||||
throw new Error(
|
||||
'The part before @ may only use letters, numbers, dashes and underscores.',
|
||||
);
|
||||
}
|
||||
if (!domain.includes('.') || domain.split('.').some((part) => !part)) {
|
||||
throw new Error('The part after @ must be a domain like example.com.');
|
||||
}
|
||||
}
|
||||
const existing = state.profiles.find((p) => p.npub === npub);
|
||||
if (!existing) {
|
||||
throw new Error('That profile is not stored on this computer.');
|
||||
}
|
||||
const updated: ProfileSummary = { ...existing, nip05: nip05 || null };
|
||||
const next: AppState = {
|
||||
...state,
|
||||
profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)),
|
||||
active_profile: state.active_profile?.npub === npub ? updated : state.active_profile,
|
||||
};
|
||||
backend.setState(next);
|
||||
return { profile: updated, report: makePublishReport(), state: next };
|
||||
}
|
||||
|
||||
case 'publish_note': {
|
||||
if (publishFailure) {
|
||||
const failure = publishFailure;
|
||||
|
|
@ -277,12 +182,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
}
|
||||
|
||||
case 'feed_get': {
|
||||
const author = typeof params.author === 'string' ? params.author : null;
|
||||
if (author) {
|
||||
return backend.profileFeedItems.filter(
|
||||
(item) => item.author_npub === author || item.author === author,
|
||||
);
|
||||
}
|
||||
const contactsOnly = Boolean(params.contacts_only);
|
||||
if (contactsOnly) {
|
||||
if (!state.active_profile) {
|
||||
|
|
@ -391,12 +290,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
return result;
|
||||
}
|
||||
|
||||
case 'update_check':
|
||||
return backend.updateReport;
|
||||
|
||||
case 'update_apply':
|
||||
return backend.updateApplyResult;
|
||||
|
||||
case 'settings_update': {
|
||||
const nextSettings: Settings = { ...state.settings, ...params };
|
||||
backend.setState({ ...state, settings: nextSettings });
|
||||
|
|
|
|||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -113,8 +113,7 @@ impl App {
|
|||
public_key: restored.npub.clone(),
|
||||
secret_key: "".to_string(),
|
||||
created_at: restored.created_at,
|
||||
picture: restored.picture.clone(),
|
||||
nip05: restored.nip05.clone(),
|
||||
picture: None,
|
||||
};
|
||||
self.vault.profiles.push(stored);
|
||||
// If no active profile, this restored one becomes active
|
||||
|
|
|
|||
114
src/feed.rs
114
src/feed.rs
|
|
@ -77,19 +77,6 @@ pub async fn contact_feed(
|
|||
aggregate_for(settings, limit, Some(contacts)).await
|
||||
}
|
||||
|
||||
/// Only notes authored by one account (npub or hex).
|
||||
///
|
||||
/// Used by the feed screen's "My notes" scope so the user can read just the
|
||||
/// posts of one of their own profiles.
|
||||
pub async fn profile_feed(
|
||||
settings: &Settings,
|
||||
limit: usize,
|
||||
author: &str,
|
||||
) -> Result<Vec<FeedItem>, AppError> {
|
||||
let pubkey = owner_pubkey(author)?;
|
||||
aggregate_for(settings, limit, Some(vec![pubkey])).await
|
||||
}
|
||||
|
||||
/// Fetch the hex public keys followed by an owner profile (kind 3 contact list).
|
||||
async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<PublicKey>, AppError> {
|
||||
let owner = owner_pubkey(owner_hex)?;
|
||||
|
|
@ -98,34 +85,43 @@ async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<Pub
|
|||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let client =
|
||||
crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?;
|
||||
let client = Client::new(Keys::generate());
|
||||
for url in &relay_urls {
|
||||
client
|
||||
.add_relay(url.as_str())
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
|
||||
}
|
||||
client.connect().await;
|
||||
client.wait_for_connection(CONNECT_TIMEOUT).await;
|
||||
|
||||
let filter = Filter::new().kind(Kind::ContactList).author(owner);
|
||||
let mut events = client
|
||||
.stream_events(filter)
|
||||
client
|
||||
.subscribe(filter, None)
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Could not subscribe for contacts: {e}")))?;
|
||||
|
||||
let mut contacts: HashSet<PublicKey> = HashSet::new();
|
||||
let mut notifications = client.notifications();
|
||||
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, events.next()).await {
|
||||
Ok(Some((_, Ok(event)))) => {
|
||||
match tokio::time::timeout_at(deadline, notifications.recv()).await {
|
||||
Ok(Ok(RelayPoolNotification::Event { event, .. })) => {
|
||||
if event.kind == Kind::ContactList {
|
||||
for tag in event.tags.iter() {
|
||||
if tag.single_letter_tag().map(|s| s.as_char()) == Some('p') {
|
||||
if let Some(content) = tag.content() {
|
||||
if let Ok(pubkey) = PublicKey::parse(content) {
|
||||
contacts.insert(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
for pubkey in event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| match tag.as_standardized() {
|
||||
Some(TagStandard::PublicKey { public_key, .. }) => Some(*public_key),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
contacts.insert(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some((_, Err(_)))) => continue,
|
||||
Ok(None) | Err(_) => break,
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,8 +145,15 @@ async fn aggregate_for(
|
|||
let effective_limit = if limit == 0 { DEFAULT_LIMIT } else { limit };
|
||||
|
||||
// A throwaway identity keeps reading the network completely off the user's keys.
|
||||
let client =
|
||||
crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?;
|
||||
let client = Client::new(Keys::generate());
|
||||
for url in &relay_urls {
|
||||
client
|
||||
.add_relay(url.as_str())
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
|
||||
}
|
||||
client.connect().await;
|
||||
client.wait_for_connection(CONNECT_TIMEOUT).await;
|
||||
|
||||
let since = Timestamp::now() - LOOKBACK;
|
||||
let filter = Filter::new()
|
||||
|
|
@ -161,22 +164,25 @@ async fn aggregate_for(
|
|||
Some(authors) => filter.authors(authors.iter().copied()),
|
||||
None => filter,
|
||||
};
|
||||
let mut events = client
|
||||
.stream_events(filter)
|
||||
client
|
||||
.subscribe(filter, None)
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Could not subscribe for the feed: {e}")))?;
|
||||
|
||||
let mut feed = FeedBuilder::new(effective_limit, authors.as_deref());
|
||||
let mut notifications = client.notifications();
|
||||
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, events.next()).await {
|
||||
Ok(Some((relay_url, Ok(event)))) => {
|
||||
match tokio::time::timeout_at(deadline, notifications.recv()).await {
|
||||
Ok(Ok(RelayPoolNotification::Event {
|
||||
event, relay_url, ..
|
||||
})) => {
|
||||
if !feed.add(&event, Some(relay_url)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some((_, Err(_)))) => continue,
|
||||
Ok(None) | Err(_) => break,
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +263,7 @@ impl FeedItem {
|
|||
author: event.pubkey.to_hex(),
|
||||
author_npub,
|
||||
content: event.content.trim().to_string(),
|
||||
created_at: event.created_at.as_secs(),
|
||||
created_at: event.created_at.as_u64(),
|
||||
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
|
@ -271,7 +277,7 @@ mod tests {
|
|||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::TextNote, content.to_string())
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize_async(&keys)
|
||||
.sign(&keys)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
|
@ -328,7 +334,7 @@ mod tests {
|
|||
let mut builder = FeedBuilder::new(10, None);
|
||||
let keys = Keys::generate();
|
||||
let other = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.finalize_async(&keys)
|
||||
.sign(&keys)
|
||||
.await
|
||||
.unwrap();
|
||||
builder.add(&other, None);
|
||||
|
|
@ -358,12 +364,12 @@ mod tests {
|
|||
|
||||
let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string())
|
||||
.custom_created_at(Timestamp::from(5))
|
||||
.finalize_async(&followed)
|
||||
.sign(&followed)
|
||||
.await
|
||||
.unwrap();
|
||||
let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string())
|
||||
.custom_created_at(Timestamp::from(6))
|
||||
.finalize_async(&stranger)
|
||||
.sign(&stranger)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -391,30 +397,6 @@ mod tests {
|
|||
assert!(feed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_feed_with_no_relays_is_empty() {
|
||||
let settings = Settings {
|
||||
relays: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let feed = profile_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(feed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_feed_with_invalid_author_errors() {
|
||||
let settings = Settings {
|
||||
relays: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let err = profile_feed(&settings, DEFAULT_LIMIT, "not-a-key")
|
||||
.await
|
||||
.expect_err("an invalid author must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_feed_with_invalid_owner_errors() {
|
||||
let settings = Settings {
|
||||
|
|
|
|||
207
src/ipc.rs
207
src/ipc.rs
|
|
@ -1,9 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::errors::AppError;
|
||||
|
|
@ -13,7 +12,6 @@ use crate::publish;
|
|||
use crate::relays;
|
||||
use crate::settings::Theme;
|
||||
use crate::signer::Signer;
|
||||
use crate::updates;
|
||||
|
||||
/// How long to wait for a relay connection test.
|
||||
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
|
@ -51,18 +49,6 @@ pub enum Request {
|
|||
npub: String,
|
||||
url: Option<String>,
|
||||
},
|
||||
/// Change a profile's label and publish it as part of the profile's kind 0
|
||||
/// metadata.
|
||||
RenameProfile {
|
||||
npub: String,
|
||||
label: String,
|
||||
},
|
||||
/// Store a NIP-05 identifier (or clear it with `None`) and publish it as
|
||||
/// part of the profile's kind 0 metadata.
|
||||
SetNip05 {
|
||||
npub: String,
|
||||
nip05: Option<String>,
|
||||
},
|
||||
PublishNote {
|
||||
content: String,
|
||||
},
|
||||
|
|
@ -70,13 +56,9 @@ pub enum Request {
|
|||
FeedGet {
|
||||
/// Optional cap on how many notes to return; leave `None` for the default.
|
||||
limit: Option<usize>,
|
||||
/// When true (and no `author` is given), only return notes authored by
|
||||
/// the active profile's contacts. When no active profile is selected
|
||||
/// the request errors.
|
||||
/// When true, only return notes authored by the active profile's
|
||||
/// contacts. When no active profile is selected the request errors.
|
||||
contacts_only: Option<bool>,
|
||||
/// When set (npub or hex), only return notes authored by that account.
|
||||
/// Takes precedence over `contacts_only`.
|
||||
author: Option<String>,
|
||||
},
|
||||
RelayAdd {
|
||||
url: String,
|
||||
|
|
@ -91,11 +73,6 @@ pub enum Request {
|
|||
RelayTest {
|
||||
url: String,
|
||||
},
|
||||
/// Scan both dependency sets (npm + cargo) for available updates and
|
||||
/// security advisories, without changing anything.
|
||||
UpdateCheck,
|
||||
/// Install compatible dependency updates (security fixes first).
|
||||
UpdateApply,
|
||||
SettingsGet,
|
||||
SettingsUpdate {
|
||||
theme: Option<Theme>,
|
||||
|
|
@ -174,26 +151,20 @@ pub struct ReplyEnvelope {
|
|||
|
||||
/// Run the JSON-lines IPC server on stdin/stdout.
|
||||
///
|
||||
/// The Electron main process spawns `keynectr serve` and exchanges one JSON
|
||||
/// object per line. Requests are handled concurrently — replies are
|
||||
/// id-correlated, so they may arrive out of order — while every handler that
|
||||
/// touches shared state locks it for the duration, so mutations remain
|
||||
/// serialized and never interleave. Long network-only requests (relay tests,
|
||||
/// feed reads) run without holding that lock so they cannot delay interactive
|
||||
/// ones such as selecting a profile.
|
||||
/// The Electron main process spawns `keynectr serve` and
|
||||
/// exchanges one JSON object per line. Requests are processed sequentially so
|
||||
/// the shared state never sees concurrent mutations.
|
||||
pub async fn serve() -> Result<(), AppError> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
// Shared state, so the NIP-46 signer's background task and the request loop
|
||||
// both see the same vault (including its unlock key) without racing writes.
|
||||
let app = Arc::new(Mutex::new(App::load()?));
|
||||
let signer = Arc::new(Signer::new());
|
||||
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
|
||||
let signer = Signer::new();
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut lines = tokio::io::BufReader::new(stdin).lines();
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut stdout = tokio::io::stdout();
|
||||
|
||||
while let Some(line) = lines
|
||||
.next_line()
|
||||
|
|
@ -212,40 +183,29 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
message: "The request could not be understood.".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
};
|
||||
write_line(&stdout, ReplyEnvelope { id: 0, reply }).await?;
|
||||
write_line(&mut stdout, ReplyEnvelope { id: 0, reply }).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let task_app = app.clone();
|
||||
let task_signer = signer.clone();
|
||||
let task_stdout = stdout.clone();
|
||||
tasks.spawn(async move {
|
||||
let reply = handle(task_app, task_signer, envelope.request).await;
|
||||
let _ = write_line(
|
||||
&task_stdout,
|
||||
ReplyEnvelope {
|
||||
id: envelope.id,
|
||||
reply,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
let reply = handle(app.clone(), &signer, envelope.request).await;
|
||||
write_line(
|
||||
&mut stdout,
|
||||
ReplyEnvelope {
|
||||
id: envelope.id,
|
||||
reply,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Finish in-flight requests before returning so the GUI never sees the
|
||||
// backend disappear mid-request.
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_line(
|
||||
writer: &tokio::sync::Mutex<tokio::io::Stdout>,
|
||||
envelope: ReplyEnvelope,
|
||||
) -> Result<(), AppError> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut writer = writer.lock().await;
|
||||
async fn write_line<W>(writer: &mut W, envelope: ReplyEnvelope) -> Result<(), AppError>
|
||||
where
|
||||
W: tokio::io::AsyncWriteExt + Unpin,
|
||||
{
|
||||
let mut line = serde_json::to_string(&envelope)
|
||||
.map_err(|e| AppError::json("Could not prepare a response", e))?;
|
||||
line.push('\n');
|
||||
|
|
@ -262,10 +222,10 @@ async fn write_line(
|
|||
|
||||
async fn handle(
|
||||
app: Arc<Mutex<App>>,
|
||||
signer: Arc<Signer>,
|
||||
signer: &Signer,
|
||||
request: Request,
|
||||
) -> Reply<serde_json::Value> {
|
||||
let result = run(&app, &signer, request).await;
|
||||
let result = run(&app, signer, request).await;
|
||||
match result {
|
||||
Ok(value) => Reply::Ok { data: value },
|
||||
Err(err) => Reply::Error {
|
||||
|
|
@ -289,16 +249,19 @@ fn error_code(err: &AppError) -> String {
|
|||
}
|
||||
|
||||
/// Signer control commands never touch the vault directly, so they take the
|
||||
/// shared handle (a clone) rather than locking the state. Read-only network
|
||||
/// requests (relay tests, feed reads) grab what they need under a short lock
|
||||
/// and then run without it, so slow relays cannot delay interactive requests.
|
||||
/// Everything else locks the state for the duration of the call, so mutations
|
||||
/// remain serialized and never interleave.
|
||||
/// shared handle (a clone) rather than locking the state. Everything else
|
||||
/// locks the vault for the duration of the call, mirroring the old
|
||||
/// single-threaded model.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn run(
|
||||
app: &Arc<Mutex<App>>,
|
||||
signer: &Signer,
|
||||
request: Request,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
// Signer control commands never touch the vault directly, so they take the
|
||||
// shared handle (a clone) rather than locking the state. Everything else
|
||||
// locks the vault for the duration of the call, mirroring the old
|
||||
// single-threaded model.
|
||||
match request {
|
||||
Request::SignerConnect { uri } => {
|
||||
signer.connect(app.clone(), &uri)?;
|
||||
|
|
@ -313,71 +276,16 @@ async fn run(
|
|||
signer.approve(&id, approved)?;
|
||||
Ok(json!(signer.status()))
|
||||
}
|
||||
Request::RelayTest { url } => {
|
||||
// Pure network probe against the given URL; no shared state.
|
||||
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
|
||||
Ok(json!(result))
|
||||
}
|
||||
Request::UpdateCheck => {
|
||||
// Long-running package-manager scan; never touches shared state.
|
||||
let report = updates::check().await?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
Request::UpdateApply => {
|
||||
// Installs updates on disk; a rebuild + restart picks them up.
|
||||
let report = updates::apply().await?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
Request::FeedGet {
|
||||
limit,
|
||||
contacts_only,
|
||||
author,
|
||||
} => {
|
||||
let limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
|
||||
let contacts_only = contacts_only.unwrap_or(false);
|
||||
// Resolve the requested author outside any lock: parsing a key is
|
||||
// pure and must not queue behind vault mutations.
|
||||
let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
|
||||
None => None,
|
||||
};
|
||||
// Copy the inputs out of shared state under a short lock so the
|
||||
// multi-second relay fetches below never block a Select or save.
|
||||
let (settings, owner_hex) = {
|
||||
let guard = app.lock().await;
|
||||
let owner_hex = if author_hex.is_some() {
|
||||
None
|
||||
} else if contacts_only {
|
||||
let npub = guard
|
||||
.vault
|
||||
.active_profile
|
||||
.as_deref()
|
||||
.ok_or_else(AppError::no_active_profile)?;
|
||||
Some(feed::owner_pubkey(npub)?.to_hex())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(guard.settings.clone(), owner_hex)
|
||||
};
|
||||
let items = if let Some(hex) = author_hex {
|
||||
feed::profile_feed(&settings, limit, &hex).await?
|
||||
} else if let Some(hex) = owner_hex {
|
||||
feed::contact_feed(&settings, limit, &hex).await?
|
||||
} else {
|
||||
feed::aggregate_feed(&settings, limit).await?
|
||||
};
|
||||
Ok(json!(items))
|
||||
}
|
||||
other => {
|
||||
let mut guard = app.lock().await;
|
||||
let mut guard = app.lock().expect("app mutex poisoned");
|
||||
run_with_app(&mut guard, other).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests dispatched to the vault state. The shared mutex guard is held across
|
||||
/// the awaited operation on purpose: mutations stay serialized against each
|
||||
/// other even though requests themselves are handled concurrently.
|
||||
/// the awaited operation on purpose: requests remain effectively sequential, and
|
||||
/// a concurrent `await` never yields back into a state the loop expects to own.
|
||||
async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
|
||||
match request {
|
||||
Request::Init | Request::GetState => Ok(json!(app.state_view())),
|
||||
|
|
@ -417,27 +325,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
|
||||
}
|
||||
|
||||
Request::RenameProfile { npub, label } => {
|
||||
let key = app.vault_key().copied();
|
||||
let (summary, report) = profiles::rename_profile(
|
||||
&mut app.vault,
|
||||
&npub,
|
||||
label,
|
||||
key.as_ref(),
|
||||
&app.settings,
|
||||
)?;
|
||||
app.save_vault()?;
|
||||
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
|
||||
}
|
||||
|
||||
Request::SetNip05 { npub, nip05 } => {
|
||||
let key = app.vault_key().copied();
|
||||
let (summary, report) =
|
||||
profiles::set_nip05(&mut app.vault, &npub, nip05, key.as_ref(), &app.settings)?;
|
||||
app.save_vault()?;
|
||||
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
|
||||
}
|
||||
|
||||
Request::PublishNote { content } => {
|
||||
let report =
|
||||
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
||||
|
|
@ -445,6 +332,25 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
Ok(json!(report))
|
||||
}
|
||||
|
||||
Request::FeedGet {
|
||||
limit,
|
||||
contacts_only,
|
||||
} => {
|
||||
let limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
|
||||
let items = if contacts_only.unwrap_or(false) {
|
||||
let npub = app
|
||||
.vault
|
||||
.active_profile
|
||||
.as_deref()
|
||||
.ok_or_else(AppError::no_active_profile)?;
|
||||
let pubkey = feed::owner_pubkey(npub)?;
|
||||
feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await?
|
||||
} else {
|
||||
feed::aggregate_feed(&app.settings, limit).await?
|
||||
};
|
||||
Ok(json!(items))
|
||||
}
|
||||
|
||||
Request::RelayAdd { url } => {
|
||||
relays::add_relay(&mut app.settings, &url)?;
|
||||
app.save_settings()?;
|
||||
|
|
@ -463,6 +369,11 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
Ok(json!(app.settings))
|
||||
}
|
||||
|
||||
Request::RelayTest { url } => {
|
||||
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
|
||||
Ok(json!(result))
|
||||
}
|
||||
|
||||
Request::SettingsGet => Ok(json!(app.settings)),
|
||||
|
||||
Request::BackupNow => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ pub mod publish;
|
|||
pub mod relays;
|
||||
pub mod settings;
|
||||
pub mod signer;
|
||||
pub mod updates;
|
||||
pub mod uploads;
|
||||
pub mod vault;
|
||||
|
||||
|
|
|
|||
111
src/main.rs
111
src/main.rs
|
|
@ -1,5 +1,5 @@
|
|||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use keynectr::app::App;
|
||||
use keynectr::errors::{AppError, ErrorKind};
|
||||
|
|
@ -21,11 +21,6 @@ Commands:
|
|||
publish <npub> <content> Publish a text note as a specific profile
|
||||
publish-name <npub> Publish the profile's stored name so other clients show it
|
||||
set-picture <npub> <url> Set the profile picture (http(s) URL) and publish it
|
||||
rename <npub> <new-name> Rename a profile and publish the new name
|
||||
set-nip05 <npub> <id|clear> Set the NIP-05 address (name@domain) and publish it;
|
||||
pass 'clear' to remove it
|
||||
nip05-file <npub> <id> Print the .well-known/nostr.json document to serve
|
||||
on your domain for a NIP-05 address
|
||||
feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50);
|
||||
--contacts filters to the active profile's contacts
|
||||
relays list List configured relays
|
||||
|
|
@ -35,7 +30,7 @@ Commands:
|
|||
relays disable <url> Disable a relay
|
||||
relays test <url> Test a relay connection
|
||||
settings get Show application settings
|
||||
settings set theme <light|dark|glass|neon>
|
||||
settings set theme <light|dark|system>
|
||||
settings set confirm <true|false>
|
||||
settings set shorten <true|false>
|
||||
delete-profile <npub> Delete a profile (moves it to undo stack)
|
||||
|
|
@ -75,9 +70,6 @@ async fn main() -> ExitCode {
|
|||
"publish" => cli_publish(&args).await,
|
||||
"publish-name" => cli_publish_name(&args),
|
||||
"set-picture" => cli_set_picture(&args),
|
||||
"rename" => cli_rename(&args),
|
||||
"set-nip05" => cli_set_nip05(&args),
|
||||
"nip05-file" => cli_nip05_file(&args),
|
||||
"feed" => cli_feed(&args).await,
|
||||
"relays" => cli_relays(&args).await,
|
||||
"settings" => cli_settings(&args),
|
||||
|
|
@ -195,97 +187,6 @@ fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
|
|||
))
|
||||
}
|
||||
|
||||
fn cli_rename(args: &[String]) -> Result<String, AppError> {
|
||||
let npub = args
|
||||
.get(2)
|
||||
.ok_or_else(|| AppError::config("Usage: keynectr rename <npub> <new-name>"))?;
|
||||
let label = args[3..].join(" ");
|
||||
if label.trim().is_empty() {
|
||||
return Err(AppError::config("Usage: keynectr rename <npub> <new-name>"));
|
||||
}
|
||||
|
||||
let mut app = load_app_with_unlock()?;
|
||||
let key = app.vault_key().copied();
|
||||
let (summary, report) =
|
||||
profiles::rename_profile(&mut app.vault, npub, label, key.as_ref(), &app.settings)?;
|
||||
app.save_vault()?;
|
||||
Ok(format!(
|
||||
"Renamed to \"{}\"; accepted by {} relay(s).",
|
||||
summary.label,
|
||||
report.succeeded.len()
|
||||
))
|
||||
}
|
||||
|
||||
/// Print the `.well-known/nostr.json` document that serves a NIP-05
|
||||
/// identifier for a stored profile. Read-only: never unlocks the vault.
|
||||
fn cli_nip05_file(args: &[String]) -> Result<String, AppError> {
|
||||
let npub = args
|
||||
.get(2)
|
||||
.ok_or_else(|| AppError::config("Usage: keynectr nip05-file <npub> <name@domain>"))?;
|
||||
let identifier = args
|
||||
.get(3)
|
||||
.ok_or_else(|| AppError::config("Usage: keynectr nip05-file <npub> <name@domain>"))?;
|
||||
let name = profiles::validate_nip05(identifier)?;
|
||||
let local = name.split('@').next().unwrap_or(&name);
|
||||
|
||||
let app = App::load()?;
|
||||
let stored = app
|
||||
.vault
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| p.public_key == npub.as_str())
|
||||
.ok_or_else(|| AppError::profile_not_found(npub))?;
|
||||
let hex = keynectr::feed::owner_pubkey(&stored.public_key)?.to_hex();
|
||||
let relays = relays::enabled_urls(&app.settings);
|
||||
|
||||
let mut names = serde_json::Map::new();
|
||||
names.insert(
|
||||
local.to_string(),
|
||||
serde_json::Value::String(hex.to_string()),
|
||||
);
|
||||
let mut doc = serde_json::Map::new();
|
||||
doc.insert("names".to_string(), serde_json::Value::Object(names));
|
||||
if !relays.is_empty() {
|
||||
let urls: Vec<serde_json::Value> =
|
||||
relays.into_iter().map(serde_json::Value::String).collect();
|
||||
let mut relay_map = serde_json::Map::new();
|
||||
relay_map.insert(hex.to_string(), serde_json::Value::Array(urls));
|
||||
doc.insert("relays".to_string(), serde_json::Value::Object(relay_map));
|
||||
}
|
||||
let pretty = serde_json::to_string_pretty(&serde_json::Value::Object(doc))
|
||||
.map_err(|e| AppError::internal(format!("Could not render the document: {e}")))?;
|
||||
Ok(format!(
|
||||
"Serve this as https://<your-domain>/.well-known/nostr.json (Content-Type: application/json):\n\n{pretty}\n"
|
||||
))
|
||||
}
|
||||
|
||||
fn cli_set_nip05(args: &[String]) -> Result<String, AppError> {
|
||||
let npub = args
|
||||
.get(2)
|
||||
.ok_or_else(|| AppError::config("Usage: keynectr set-nip05 <npub> <name@domain|clear>"))?;
|
||||
let raw = args
|
||||
.get(3)
|
||||
.ok_or_else(|| AppError::config("Usage: keynectr set-nip05 <npub> <name@domain|clear>"))?;
|
||||
let nip05 = if raw.eq_ignore_ascii_case("clear") {
|
||||
None
|
||||
} else {
|
||||
Some(raw.clone())
|
||||
};
|
||||
|
||||
let mut app = load_app_with_unlock()?;
|
||||
let key = app.vault_key().copied();
|
||||
let (summary, report) =
|
||||
profiles::set_nip05(&mut app.vault, npub, nip05, key.as_ref(), &app.settings)?;
|
||||
app.save_vault()?;
|
||||
match summary.nip05 {
|
||||
Some(id) => Ok(format!(
|
||||
"NIP-05 set to \"{id}\"; accepted by {} relay(s).",
|
||||
report.succeeded.len()
|
||||
)),
|
||||
None => Ok("NIP-05 cleared.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
|
||||
let npub = args
|
||||
.get(2)
|
||||
|
|
@ -442,7 +343,7 @@ fn cli_settings(args: &[String]) -> Result<String, AppError> {
|
|||
match key.as_str() {
|
||||
"theme" => {
|
||||
let theme = Theme::parse(value).ok_or_else(|| {
|
||||
AppError::config("Theme must be one of: light, dark, glass, neon")
|
||||
AppError::config("Theme must be one of: light, dark, system")
|
||||
})?;
|
||||
app.settings.theme = theme;
|
||||
}
|
||||
|
|
@ -585,7 +486,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
|
|||
let uri = args
|
||||
.get(3)
|
||||
.ok_or_else(|| AppError::config("Usage: signer connect <uri>"))?;
|
||||
let app = Arc::new(tokio::sync::Mutex::new(load_app_with_unlock()?));
|
||||
let app = Arc::new(Mutex::new(load_app_with_unlock()?));
|
||||
let signer = Signer::new();
|
||||
signer.connect(app, uri)?;
|
||||
println!("Connecting to the NIP-46 app… (interrupt with Ctrl-C to stop)");
|
||||
|
|
@ -647,7 +548,6 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary
|
|||
created_at: stored.created_at,
|
||||
is_active: false,
|
||||
picture: stored.picture,
|
||||
nip05: stored.nip05,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -680,8 +580,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
|||
public_key: restored.npub.clone(),
|
||||
secret_key: "".to_string(),
|
||||
created_at: restored.created_at,
|
||||
picture: restored.picture,
|
||||
nip05: restored.nip05,
|
||||
picture: None,
|
||||
};
|
||||
app.vault.profiles.push(stored);
|
||||
if app.vault.active_profile.is_none() {
|
||||
|
|
|
|||
398
src/profiles.rs
398
src/profiles.rs
|
|
@ -1,5 +1,6 @@
|
|||
use nostr_sdk::prelude::*;
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::VaultKey;
|
||||
|
|
@ -9,6 +10,11 @@ use crate::relays;
|
|||
use crate::settings::Settings;
|
||||
use crate::vault::{unix_timestamp, StoredProfile, Vault};
|
||||
|
||||
/// How long to wait for relays to accept a connection attempt.
|
||||
const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How long to wait for a single relay to accept the metadata event.
|
||||
const METADATA_SEND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// A safe view of a profile that contains no secret key material.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ProfileSummary {
|
||||
|
|
@ -20,8 +26,6 @@ pub struct ProfileSummary {
|
|||
pub is_active: bool,
|
||||
/// Public URL of the profile picture, when one has been set.
|
||||
pub picture: Option<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
|
||||
|
|
@ -72,7 +76,6 @@ pub fn create_profile(
|
|||
secret_key: stored_secret,
|
||||
created_at,
|
||||
picture: None,
|
||||
nip05: None,
|
||||
};
|
||||
|
||||
let is_active = vault.active_profile.is_none();
|
||||
|
|
@ -86,7 +89,7 @@ pub fn create_profile(
|
|||
// Best-effort: relay failures here never block profile creation.
|
||||
let relay_urls = relays::enabled_urls(settings);
|
||||
if !relay_urls.is_empty() {
|
||||
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
|
||||
publish_metadata_blocking(&keys, &label, None, relay_urls);
|
||||
}
|
||||
|
||||
Ok(ProfileSummary {
|
||||
|
|
@ -95,7 +98,6 @@ pub fn create_profile(
|
|||
created_at,
|
||||
is_active,
|
||||
picture: None,
|
||||
nip05: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +133,6 @@ pub fn publish_profile_metadata(
|
|||
&keys,
|
||||
&stored.label,
|
||||
stored.picture.clone(),
|
||||
stored.nip05.clone(),
|
||||
relay_urls,
|
||||
))
|
||||
}
|
||||
|
|
@ -159,13 +160,12 @@ pub fn set_profile_picture(
|
|||
|
||||
let stored = find_profile_mut(vault, npub)?;
|
||||
stored.picture = url;
|
||||
let (label, npub, created_at, public_key, picture, nip05) = (
|
||||
let (label, npub, created_at, public_key, picture) = (
|
||||
stored.label.clone(),
|
||||
stored.public_key.clone(),
|
||||
stored.created_at,
|
||||
stored.public_key.clone(),
|
||||
stored.picture.clone(),
|
||||
stored.nip05.clone(),
|
||||
);
|
||||
drop(stored);
|
||||
let summary = ProfileSummary {
|
||||
|
|
@ -174,7 +174,6 @@ pub fn set_profile_picture(
|
|||
created_at,
|
||||
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
||||
picture,
|
||||
nip05,
|
||||
};
|
||||
|
||||
let relay_urls = relays::enabled_urls(settings);
|
||||
|
|
@ -191,173 +190,11 @@ pub fn set_profile_picture(
|
|||
}
|
||||
|
||||
let keys = Keys::new(secret_key);
|
||||
let report = publish_metadata_blocking(
|
||||
&keys,
|
||||
&summary.label,
|
||||
summary.picture.clone(),
|
||||
summary.nip05.clone(),
|
||||
relay_urls,
|
||||
);
|
||||
let report =
|
||||
publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls);
|
||||
Ok((summary, report))
|
||||
}
|
||||
|
||||
/// Change a profile's label and immediately republish it as the profile's
|
||||
/// kind 0 metadata so external clients show the new name.
|
||||
///
|
||||
/// Returns the updated summary plus the per-relay publish report.
|
||||
pub fn rename_profile(
|
||||
vault: &mut Vault,
|
||||
npub: &str,
|
||||
label: String,
|
||||
key: Option<&VaultKey>,
|
||||
settings: &Settings,
|
||||
) -> Result<(ProfileSummary, MetadataPublishReport), AppError> {
|
||||
let trimmed = label.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::config("The profile name cannot be empty."));
|
||||
}
|
||||
|
||||
// Resolve and sign before mutating so a locked vault or bad key changes
|
||||
// nothing on disk.
|
||||
let secret_hex = resolve_secret_key(vault, npub, key)?;
|
||||
let secret_key = parse_secret_key(&secret_hex)?;
|
||||
|
||||
let stored = find_profile_mut(vault, npub)?;
|
||||
stored.label = trimmed.to_string();
|
||||
let (label, created_at, public_key, picture, nip05) = (
|
||||
stored.label.clone(),
|
||||
stored.created_at,
|
||||
stored.public_key.clone(),
|
||||
stored.picture.clone(),
|
||||
stored.nip05.clone(),
|
||||
);
|
||||
let summary = ProfileSummary {
|
||||
label,
|
||||
npub: public_key.clone(),
|
||||
created_at,
|
||||
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
||||
picture,
|
||||
nip05,
|
||||
};
|
||||
|
||||
let relay_urls = relays::enabled_urls(settings);
|
||||
if relay_urls.is_empty() {
|
||||
// The vault change stands; publishing can be retried later via the
|
||||
// explicit "publish name" action once a relay is enabled.
|
||||
return Ok((
|
||||
summary,
|
||||
MetadataPublishReport {
|
||||
succeeded: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let keys = Keys::new(secret_key);
|
||||
let report = publish_metadata_blocking(
|
||||
&keys,
|
||||
&summary.label,
|
||||
summary.picture.clone(),
|
||||
summary.nip05.clone(),
|
||||
relay_urls,
|
||||
);
|
||||
Ok((summary, report))
|
||||
}
|
||||
|
||||
/// Store a NIP-05 identifier (e.g. `boo@l484.com`) and immediately publish it
|
||||
/// as part of the profile's kind 0 metadata.
|
||||
///
|
||||
/// Pass `None` to clear the identifier. Returns the updated summary plus the
|
||||
/// per-relay publish report.
|
||||
pub fn set_nip05(
|
||||
vault: &mut Vault,
|
||||
npub: &str,
|
||||
nip05: Option<String>,
|
||||
key: Option<&VaultKey>,
|
||||
settings: &Settings,
|
||||
) -> Result<(ProfileSummary, MetadataPublishReport), AppError> {
|
||||
let normalised = match &nip05 {
|
||||
Some(value) => Some(validate_nip05(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Resolve and sign before mutating so a locked vault or bad key changes
|
||||
// nothing on disk.
|
||||
let secret_hex = resolve_secret_key(vault, npub, key)?;
|
||||
let secret_key = parse_secret_key(&secret_hex)?;
|
||||
|
||||
let stored = find_profile_mut(vault, npub)?;
|
||||
stored.nip05 = normalised;
|
||||
let (label, created_at, public_key, picture, nip05) = (
|
||||
stored.label.clone(),
|
||||
stored.created_at,
|
||||
stored.public_key.clone(),
|
||||
stored.picture.clone(),
|
||||
stored.nip05.clone(),
|
||||
);
|
||||
let summary = ProfileSummary {
|
||||
label,
|
||||
npub: public_key.clone(),
|
||||
created_at,
|
||||
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
||||
picture,
|
||||
nip05,
|
||||
};
|
||||
|
||||
let relay_urls = relays::enabled_urls(settings);
|
||||
if relay_urls.is_empty() {
|
||||
// The vault change stands; publishing can be retried later via the
|
||||
// explicit "publish name" action once a relay is enabled.
|
||||
return Ok((
|
||||
summary,
|
||||
MetadataPublishReport {
|
||||
succeeded: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let keys = Keys::new(secret_key);
|
||||
let report = publish_metadata_blocking(
|
||||
&keys,
|
||||
&summary.label,
|
||||
summary.picture.clone(),
|
||||
summary.nip05.clone(),
|
||||
relay_urls,
|
||||
);
|
||||
Ok((summary, report))
|
||||
}
|
||||
|
||||
/// Validate a NIP-05 identifier (`<local-part>@<domain>`), returning the
|
||||
/// lower-cased trimmed form. `_@domain` (the bare-domain form) is allowed.
|
||||
/// Exposed for the CLI's `.well-known/nostr.json` helper.
|
||||
pub fn validate_nip05(raw: &str) -> Result<String, AppError> {
|
||||
let trimmed = raw.trim().to_lowercase();
|
||||
let (local, domain) = trimmed
|
||||
.split_once('@')
|
||||
.ok_or_else(|| AppError::config("A NIP-05 address must look like name@domain.com."))?;
|
||||
if local.is_empty() || domain.is_empty() || domain.contains('@') {
|
||||
return Err(AppError::config(
|
||||
"A NIP-05 address must look like name@domain.com.",
|
||||
));
|
||||
}
|
||||
if local != "_"
|
||||
&& !local
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(AppError::config(
|
||||
"The part before @ may only use letters, numbers, dashes and underscores.",
|
||||
));
|
||||
}
|
||||
if domain.split('.').any(|label| label.is_empty()) || !domain.contains('.') {
|
||||
return Err(AppError::config(
|
||||
"The part after @ must be a domain like example.com.",
|
||||
));
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// Validate that a picture URL is a well-formed http(s) URL.
|
||||
fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
||||
let parsed = Url::parse(url)
|
||||
|
|
@ -398,7 +235,6 @@ fn publish_metadata_blocking(
|
|||
keys: &Keys,
|
||||
label: &str,
|
||||
picture: Option<String>,
|
||||
nip05: Option<String>,
|
||||
relay_urls: Vec<String>,
|
||||
) -> MetadataPublishReport {
|
||||
let keys = keys.clone();
|
||||
|
|
@ -406,9 +242,7 @@ fn publish_metadata_blocking(
|
|||
std::thread::spawn(move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.expect("metadata runtime")
|
||||
.block_on(publish_metadata_async(
|
||||
&keys, &label, picture, nip05, relay_urls,
|
||||
))
|
||||
.block_on(publish_metadata_async(&keys, &label, picture, relay_urls))
|
||||
})
|
||||
.join()
|
||||
.expect("metadata publish thread panicked")
|
||||
|
|
@ -418,7 +252,6 @@ async fn publish_metadata_async(
|
|||
keys: &Keys,
|
||||
label: &str,
|
||||
picture: Option<String>,
|
||||
nip05: Option<String>,
|
||||
relay_urls: Vec<String>,
|
||||
) -> MetadataPublishReport {
|
||||
let mut metadata = Metadata::new().name(label).display_name(label);
|
||||
|
|
@ -427,11 +260,8 @@ async fn publish_metadata_async(
|
|||
metadata = metadata.picture(parsed);
|
||||
}
|
||||
}
|
||||
if let Some(nip05) = &nip05 {
|
||||
metadata = metadata.nip05(nip05);
|
||||
}
|
||||
let event = match EventBuilder::new(Kind::Metadata, metadata.as_json())
|
||||
.finalize_async(keys)
|
||||
.sign(keys)
|
||||
.await
|
||||
{
|
||||
Ok(event) => event,
|
||||
|
|
@ -450,22 +280,48 @@ async fn publish_metadata_async(
|
|||
}
|
||||
};
|
||||
|
||||
// This path deliberately builds its own client instead of
|
||||
// `relays::open_pool`: adding a broken relay must not abort the whole
|
||||
// metadata publish, so add errors are ignored here.
|
||||
let client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys.clone()))
|
||||
.build();
|
||||
let client = Client::new(keys.clone());
|
||||
for url in &relay_urls {
|
||||
let _ = client.add_relay(url.as_str()).await;
|
||||
}
|
||||
client.connect().await;
|
||||
let (succeeded, failed) =
|
||||
crate::publish::send_to_all_relays(&client, relay_urls, &event, "metadata").await;
|
||||
let _ = client.wait_for_connection(METADATA_CONNECT_TIMEOUT).await;
|
||||
|
||||
let mut succeeded = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
for url in &relay_urls {
|
||||
match client.relay(url.as_str()).await {
|
||||
Ok(relay) => {
|
||||
match tokio::time::timeout(METADATA_SEND_TIMEOUT, relay.send_event(&event)).await {
|
||||
Ok(Ok(_)) => succeeded.push(url.clone()),
|
||||
Ok(Err(err)) => failed.push(failure_for(url, &err)),
|
||||
Err(_) => failed.push(RelayFailure {
|
||||
url: url.clone(),
|
||||
error: "The relay did not respond in time.".to_string(),
|
||||
details: Some(
|
||||
"Timed out while waiting for the relay to accept the metadata."
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(err) => failed.push(failure_for(url, &err)),
|
||||
}
|
||||
}
|
||||
client.disconnect().await;
|
||||
|
||||
MetadataPublishReport { succeeded, failed }
|
||||
}
|
||||
|
||||
fn failure_for(url: &str, err: &impl std::fmt::Display) -> RelayFailure {
|
||||
let (error, details) = crate::publish::relay_error_message(err);
|
||||
RelayFailure {
|
||||
url: url.to_string(),
|
||||
error,
|
||||
details: Some(details),
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe summaries of every stored profile, newest last. Never includes
|
||||
/// secret keys.
|
||||
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
|
||||
|
|
@ -493,7 +349,6 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary {
|
|||
created_at: profile.created_at,
|
||||
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
|
||||
picture: profile.picture.clone(),
|
||||
nip05: profile.nip05.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -609,7 +464,6 @@ mod tests {
|
|||
secret_key: "00".repeat(32),
|
||||
created_at: 1,
|
||||
picture: None,
|
||||
nip05: None,
|
||||
});
|
||||
vault.profiles.push(StoredProfile {
|
||||
label: "Bob".to_string(),
|
||||
|
|
@ -617,7 +471,6 @@ mod tests {
|
|||
secret_key: "11".repeat(32),
|
||||
created_at: 2,
|
||||
picture: None,
|
||||
nip05: None,
|
||||
});
|
||||
vault
|
||||
}
|
||||
|
|
@ -903,164 +756,6 @@ mod tests {
|
|||
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_profile_updates_label_and_skips_publish_without_relays() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary =
|
||||
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
|
||||
|
||||
let (renamed, report) = rename_profile(
|
||||
&mut vault,
|
||||
&summary.npub,
|
||||
"Alicia".to_string(),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.expect("renaming must work offline");
|
||||
|
||||
assert_eq!(renamed.label, "Alicia");
|
||||
assert_eq!(
|
||||
vault.profiles[0].label, "Alicia",
|
||||
"vault must remember the label"
|
||||
);
|
||||
// No relays enabled: nothing published, but the change still stands.
|
||||
assert!(report.succeeded.is_empty());
|
||||
assert!(report.failed.is_empty());
|
||||
|
||||
// Leading/trailing whitespace is trimmed.
|
||||
let (trimmed, _) = rename_profile(
|
||||
&mut vault,
|
||||
&summary.npub,
|
||||
" Ace ".to_string(),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(trimmed.label, "Ace");
|
||||
assert_eq!(vault.profiles[0].label, "Ace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_profile_rejects_empty_names() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary =
|
||||
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
|
||||
|
||||
for bad in [String::new(), " ".to_string()] {
|
||||
let err = rename_profile(&mut vault, &summary.npub, bad, None, &offline_settings())
|
||||
.expect_err("empty name must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::Config);
|
||||
}
|
||||
assert_eq!(
|
||||
vault.profiles[0].label, "Alice",
|
||||
"nothing stored on failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_profile_missing_profile_errors() {
|
||||
let mut vault = Vault::empty();
|
||||
let err = rename_profile(
|
||||
&mut vault,
|
||||
"npub1ghost",
|
||||
"Ghost".to_string(),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.expect_err("missing profile must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_nip05_stores_identifier_and_skips_publish_without_relays() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary =
|
||||
create_profile(&mut vault, "Boo".to_string(), None, &offline_settings()).unwrap();
|
||||
|
||||
let (updated, report) = set_nip05(
|
||||
&mut vault,
|
||||
&summary.npub,
|
||||
Some("Boo@L484.com".to_string()),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.expect("setting a NIP-05 must work offline");
|
||||
|
||||
assert_eq!(
|
||||
updated.nip05.as_deref(),
|
||||
Some("boo@l484.com"),
|
||||
"lower-cased"
|
||||
);
|
||||
assert_eq!(
|
||||
vault.profiles[0].nip05.as_deref(),
|
||||
Some("boo@l484.com"),
|
||||
"vault must remember the identifier"
|
||||
);
|
||||
// No relays enabled: nothing published, but the change still stands.
|
||||
assert!(report.succeeded.is_empty());
|
||||
assert!(report.failed.is_empty());
|
||||
|
||||
// Clearing the identifier also persists.
|
||||
let (cleared, _) =
|
||||
set_nip05(&mut vault, &summary.npub, None, None, &offline_settings()).unwrap();
|
||||
assert!(cleared.nip05.is_none());
|
||||
assert!(vault.profiles[0].nip05.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_nip05_rejects_malformed_identifiers() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary =
|
||||
create_profile(&mut vault, "Boo".to_string(), None, &offline_settings()).unwrap();
|
||||
|
||||
for bad in [
|
||||
"just-a-name",
|
||||
"@l484.com",
|
||||
"boo@",
|
||||
"bo o@l484.com",
|
||||
"boo@nodot",
|
||||
"boo@@l484.com",
|
||||
] {
|
||||
let err = set_nip05(
|
||||
&mut vault,
|
||||
&summary.npub,
|
||||
Some(bad.to_string()),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.expect_err("malformed NIP-05 must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::Config);
|
||||
}
|
||||
assert!(
|
||||
vault.profiles[0].nip05.is_none(),
|
||||
"nothing stored on failure"
|
||||
);
|
||||
|
||||
// The bare-domain form `_@domain` is valid.
|
||||
set_nip05(
|
||||
&mut vault,
|
||||
&summary.npub,
|
||||
Some("_@l484.com".to_string()),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.expect("bare-domain form must be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_nip05_missing_profile_errors() {
|
||||
let mut vault = Vault::empty();
|
||||
let err = set_nip05(
|
||||
&mut vault,
|
||||
"npub1ghost",
|
||||
Some("ghost@example.com".to_string()),
|
||||
None,
|
||||
&offline_settings(),
|
||||
)
|
||||
.expect_err("missing profile must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
|
||||
}
|
||||
|
||||
/// Delete a profile by npub, returning the deleted profile for undo.
|
||||
/// The vault must not be encrypted, or the key must be provided.
|
||||
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
||||
|
|
@ -1080,7 +775,6 @@ mod tests {
|
|||
created_at: stored.created_at,
|
||||
is_active: false,
|
||||
picture: stored.picture,
|
||||
nip05: stored.nip05,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
src/publish.rs
153
src/publish.rs
|
|
@ -11,9 +11,10 @@ use crate::relays;
|
|||
use crate::settings::Settings;
|
||||
use crate::vault::Vault;
|
||||
|
||||
/// How long to wait for a single relay to accept an event. Relays are sent
|
||||
/// to in parallel, so this caps the whole publish, not each relay.
|
||||
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
/// How long to wait for relays to accept a connection attempt.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How long to wait for a single relay to accept an event.
|
||||
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// A relay that rejected a published note.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -167,7 +168,7 @@ async fn publish_with_keys(
|
|||
// reported as such rather than as a network error.
|
||||
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
|
||||
let event = builder
|
||||
.finalize_async(keys)
|
||||
.sign(keys)
|
||||
.await
|
||||
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
||||
|
||||
|
|
@ -176,8 +177,52 @@ async fn publish_with_keys(
|
|||
.to_bech32()
|
||||
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
|
||||
|
||||
let client = relays::open_pool(keys.clone(), &relay_urls, None).await?;
|
||||
let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await;
|
||||
let client = Client::new(keys.clone());
|
||||
for url in &relay_urls {
|
||||
client
|
||||
.add_relay(url.as_str())
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
|
||||
}
|
||||
client.connect().await;
|
||||
client.wait_for_connection(CONNECT_TIMEOUT).await;
|
||||
|
||||
// Send to each relay individually so partial failures are fully reported.
|
||||
let mut succeeded: Vec<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() {
|
||||
return Err(AppError::publish_failed(failed));
|
||||
|
|
@ -190,102 +235,6 @@ async fn publish_with_keys(
|
|||
})
|
||||
}
|
||||
|
||||
/// Send an already-signed event to every listed relay in parallel so one slow
|
||||
/// or dead relay cannot drag the whole publish out to (relays x timeout).
|
||||
///
|
||||
/// Callers own opening the pool (see `relays::open_pool`) — including whether
|
||||
/// they wait for connections, which this deliberately does not: `send_event`
|
||||
/// waits for each relay to become writable itself, and the per-send timeout
|
||||
/// below already bounds the whole publish. Waiting for *all* relays first
|
||||
/// would burn the full timeout whenever a single relay is unreachable.
|
||||
/// `noun` ("note", "metadata") only shapes user-facing timeout wording.
|
||||
pub(crate) async fn send_to_all_relays(
|
||||
client: &Client,
|
||||
relay_urls: Vec<String>,
|
||||
event: &Event,
|
||||
noun: &str,
|
||||
) -> (Vec<String>, Vec<RelayFailure>) {
|
||||
let noun = noun.to_string();
|
||||
// No explicit wait for connections here: `send_event` waits for each relay
|
||||
// to become writable itself, and the per-send timeout below already bounds
|
||||
// the whole publish. Waiting for *all* relays first would burn the full
|
||||
// timeout whenever a single relay is unreachable.
|
||||
|
||||
let mut outcomes: Vec<Option<Result<String, RelayFailure>>> =
|
||||
(0..relay_urls.len()).map(|_| None).collect();
|
||||
let mut sends = tokio::task::JoinSet::new();
|
||||
for (index, url) in relay_urls.iter().cloned().enumerate() {
|
||||
let client = client.clone();
|
||||
let event = event.clone();
|
||||
let noun = noun.clone();
|
||||
sends.spawn(async move {
|
||||
match client.relay(url.as_str()).await {
|
||||
Ok(Some(relay)) => {
|
||||
match tokio::time::timeout(RELAY_SEND_TIMEOUT, relay.send_event(&event)).await {
|
||||
Ok(Ok(_)) => (index, Ok(url)),
|
||||
Ok(Err(err)) => {
|
||||
let (message, details) = relay_error_message(&err);
|
||||
(
|
||||
index,
|
||||
Err(RelayFailure {
|
||||
url,
|
||||
error: message,
|
||||
details: Some(details),
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(_) => (
|
||||
index,
|
||||
Err(RelayFailure {
|
||||
url,
|
||||
error: "The relay did not respond in time.".to_string(),
|
||||
details: Some(format!(
|
||||
"Timed out while waiting for the relay to accept the {noun}."
|
||||
)),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(None) => (
|
||||
index,
|
||||
Err(RelayFailure {
|
||||
url,
|
||||
error: "Relay is not in the active pool.".to_string(),
|
||||
details: Some("The client dropped this relay before sending.".to_string()),
|
||||
}),
|
||||
),
|
||||
Err(err) => {
|
||||
let (message, details) = relay_error_message(&err);
|
||||
(
|
||||
index,
|
||||
Err(RelayFailure {
|
||||
url,
|
||||
error: message,
|
||||
details: Some(details),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(joined) = sends.join_next().await {
|
||||
let (index, outcome) = joined.expect("relay send task panicked");
|
||||
outcomes[index] = Some(outcome);
|
||||
}
|
||||
|
||||
let mut succeeded = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
for outcome in outcomes.into_iter().flatten() {
|
||||
match outcome {
|
||||
Ok(url) => succeeded.push(url),
|
||||
Err(failure) => failed.push(failure),
|
||||
}
|
||||
}
|
||||
|
||||
client.disconnect().await;
|
||||
(succeeded, failed)
|
||||
}
|
||||
|
||||
/// Build a concise user-facing message plus technical detail from a relay
|
||||
/// error, without ever including secret material.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -69,35 +69,6 @@ pub fn enabled_urls(settings: &Settings) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Build a client pool over `relay_urls`, adding each relay and optionally
|
||||
/// waiting (up to `wait`) for connections before returning.
|
||||
///
|
||||
/// Fails on the first relay that cannot be added. Callers that should tolerate
|
||||
/// an unreachable relay instead of aborting add their relays themselves.
|
||||
pub(crate) async fn open_pool(
|
||||
keys: Keys,
|
||||
relay_urls: &[String],
|
||||
wait: Option<Duration>,
|
||||
) -> Result<Client, AppError> {
|
||||
// The authenticator answers NIP-42 AUTH challenges automatically on every
|
||||
// path that opens a client (nostr-sdk >= 0.45 has no implicit signer).
|
||||
let client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys))
|
||||
.build();
|
||||
for url in relay_urls {
|
||||
client
|
||||
.add_relay(url.as_str())
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
|
||||
}
|
||||
// Deprecated `wait_for_connection`; the builder form is the 0.45 way.
|
||||
match wait {
|
||||
Some(timeout) => client.connect().and_wait(timeout).await,
|
||||
None => client.connect().await,
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Result of testing a relay connection.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelayTestResult {
|
||||
|
|
@ -112,9 +83,7 @@ pub struct RelayTestResult {
|
|||
/// during a connection test.
|
||||
pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestResult, AppError> {
|
||||
let keys = Keys::generate();
|
||||
let client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys))
|
||||
.build();
|
||||
let client = Client::new(keys);
|
||||
|
||||
client
|
||||
.add_relay(url)
|
||||
|
|
@ -124,12 +93,10 @@ pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestRe
|
|||
let relay = client
|
||||
.relay(url)
|
||||
.await
|
||||
.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")))?;
|
||||
.map_err(|e| AppError::network(format!("Could not look up relay {url}: {e}")))?;
|
||||
|
||||
relay
|
||||
.try_connect()
|
||||
.timeout(timeout)
|
||||
.try_connect(timeout)
|
||||
.await
|
||||
.map_err(|e| AppError::network(format!("Connection failed: {e}")))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ impl RelayConfig {
|
|||
pub enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
Glass,
|
||||
Neon,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
|
|
@ -32,8 +31,7 @@ impl Theme {
|
|||
match s {
|
||||
"light" => Some(Self::Light),
|
||||
"dark" => Some(Self::Dark),
|
||||
"glass" => Some(Self::Glass),
|
||||
"neon" => Some(Self::Neon),
|
||||
"system" => Some(Self::System),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +58,7 @@ fn default_true() -> bool {
|
|||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: Theme::Light,
|
||||
theme: Theme::System,
|
||||
confirm_before_publish: true,
|
||||
shorten_npub: true,
|
||||
relays: crate::relays::default_relays(),
|
||||
|
|
|
|||
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;
|
||||
use getrandom::getrandom;
|
||||
use nostr::nips::nip44::v2;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr::JsonUtil;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio::sync::oneshot;
|
||||
|
|
@ -238,7 +238,7 @@ impl Signer {
|
|||
///
|
||||
/// `app` is the shared vault state, so the signer reflects the current unlock
|
||||
/// key and active profile. A locked vault cannot sign until it is unlocked.
|
||||
pub fn connect(&self, app: Arc<tokio::sync::Mutex<App>>, uri: &str) -> Result<(), AppError> {
|
||||
pub fn connect(&self, app: Arc<Mutex<App>>, uri: &str) -> Result<(), AppError> {
|
||||
if uri.trim().starts_with("bunker://") {
|
||||
return Err(AppError::config(
|
||||
"That is a bunker:// link, which means routing through *another* signer. \
|
||||
|
|
@ -363,11 +363,7 @@ fn percent_decode(raw: &str) -> Option<String> {
|
|||
|
||||
/// NIP-44 encrypt with the conversation key, returned base64-encoded.
|
||||
fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result<String, AppError> {
|
||||
// nostr-sdk 0.45 removed the convenience wrapper that generated the nonce
|
||||
// internally; the caller now supplies fresh randomness per message.
|
||||
let mut nonce = [0u8; 32];
|
||||
getrandom(&mut nonce).map_err(|e| AppError::internal(format!("Could not get entropy: {e}")))?;
|
||||
let payload = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce)
|
||||
let payload = v2::encrypt_to_bytes(conversation, plaintext.as_bytes())
|
||||
.map_err(|e| AppError::internal(format!("Could not encrypt a message: {e}")))?;
|
||||
Ok(B64.encode(payload))
|
||||
}
|
||||
|
|
@ -574,8 +570,8 @@ fn sign_event(keys: &Keys, request: &RawRequest) -> Result<String, String> {
|
|||
|
||||
let unsigned: UnsignedEvent =
|
||||
serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?;
|
||||
let event = keys
|
||||
.sign_event(unsigned)
|
||||
let event = unsigned
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| format!("The event could not be signed: {e}"))?;
|
||||
Ok(event.as_json())
|
||||
}
|
||||
|
|
@ -600,10 +596,16 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result<String, String> {
|
|||
|
||||
/// The background loop: connect to the client's relays, announce ourselves,
|
||||
/// subscribe to kind 24133 events, and answer requests until stopped.
|
||||
async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: ConnectUri) {
|
||||
async fn run_sign_task(signer: Signer, app: Arc<Mutex<App>>, uri: ConnectUri) {
|
||||
// 1. Resolve the active profile's key under the current vault lock.
|
||||
let keys = {
|
||||
let guard = app.lock().await;
|
||||
let guard = match app.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => {
|
||||
signer.fail("The vault could not be read.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) {
|
||||
Ok(hex) => hex,
|
||||
Err(err) => {
|
||||
|
|
@ -630,35 +632,23 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
|
|||
}
|
||||
};
|
||||
|
||||
// 3. Connect to the client's relays. The notification stream is opened
|
||||
// BEFORE anything is sent or subscribed: the client acknowledges our
|
||||
// announcement within milliseconds, and a receiver created afterwards
|
||||
// would miss those early messages (tokio broadcast semantics), leaving
|
||||
// the client waiting forever for a reply.
|
||||
let client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys.clone()))
|
||||
.build();
|
||||
// 3. Connect to the client's relays.
|
||||
let client = Client::new(keys.clone());
|
||||
for url in &uri.relays {
|
||||
if let Err(err) = client.add_relay(url.to_string()).await {
|
||||
signer.fail(format!("Could not add relay {url}: {err}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
client.connect().and_wait(CONNECT_TIMEOUT).await;
|
||||
client.connect().await;
|
||||
client.wait_for_connection(CONNECT_TIMEOUT).await;
|
||||
|
||||
// 4. Subscribe to the client's kind 24133 events so we hear its requests.
|
||||
// `stream_events` opens its internal notification receiver as part of
|
||||
// subscribing, which must happen BEFORE we announce (step 5): the client
|
||||
// acknowledges within milliseconds and a receiver created afterwards would
|
||||
// miss those early messages (tokio broadcast semantics).
|
||||
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
|
||||
let mut events = match client.stream_events(filter).await {
|
||||
Ok(events) => events,
|
||||
Err(err) => {
|
||||
signer.fail(format!("Could not subscribe for messages: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = client.subscribe(filter, None).await {
|
||||
signer.fail(format!("Could not subscribe for messages: {err}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Announce ourselves: send the connect request with the optional secret.
|
||||
if let Err(err) = send_connect(&client, &keys, &conversation, &uri).await {
|
||||
|
|
@ -667,19 +657,18 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
|
|||
}
|
||||
|
||||
// 6. Answer requests until the connection goes away or we are stopped.
|
||||
let mut notifications = client.notifications();
|
||||
loop {
|
||||
let (relay_url, incoming) = match events.next().await {
|
||||
Some(next) => next,
|
||||
None => {
|
||||
let notification = match notifications.recv().await {
|
||||
Ok(notification) => notification,
|
||||
Err(_) => {
|
||||
signer.fail("The signer connection was closed.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let event = match incoming {
|
||||
Ok(event) => event,
|
||||
Err(_) => continue,
|
||||
let RelayPoolNotification::Event { event, .. } = notification else {
|
||||
continue;
|
||||
};
|
||||
let _ = relay_url;
|
||||
if event.kind != Kind::NostrConnect || event.pubkey != uri.peer {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -741,7 +730,7 @@ async fn publish_payload(
|
|||
let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?;
|
||||
let event = EventBuilder::new(Kind::NostrConnect, content)
|
||||
.tags([tag])
|
||||
.finalize_async(keys)
|
||||
.sign(keys)
|
||||
.await
|
||||
.map_err(|e| format!("Could not sign a message: {e}"))?;
|
||||
client
|
||||
|
|
@ -755,80 +744,6 @@ async fn publish_payload(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Malformed NIP-44 payloads (RUSTSEC-2026-0216/0227 classes) must come
|
||||
/// back as clean errors, never a panic or a hang. A payload that fails to
|
||||
/// decrypt against the conversation key is also exactly how forged signer
|
||||
/// messages from a non-peer key are rejected.
|
||||
#[test]
|
||||
fn nip44_decrypt_rejects_malformed_payloads() {
|
||||
let signer = Keys::generate();
|
||||
let peer = Keys::generate();
|
||||
let conversation =
|
||||
ConversationKey::derive(signer.secret_key(), &peer.public_key()).unwrap();
|
||||
|
||||
let not_base64 = "this is !! not base64 !!";
|
||||
let empty = "";
|
||||
let too_short = B64.encode([0x02u8, 0x00]);
|
||||
let bad_version = B64.encode([0xFFu8, 0x00, 0x11]);
|
||||
let truncated = {
|
||||
// encrypt returns raw bytes; nip44_decrypt takes their base64 form.
|
||||
let mut bytes = v2::encrypt_to_bytes_with_nonce(
|
||||
&conversation,
|
||||
"a message long enough to be truncated meaningfully".as_bytes(),
|
||||
[7u8; 32],
|
||||
)
|
||||
.unwrap();
|
||||
bytes.truncate(bytes.len() / 2);
|
||||
B64.encode(&bytes)
|
||||
};
|
||||
|
||||
for payload in [
|
||||
not_base64,
|
||||
empty,
|
||||
&too_short,
|
||||
&bad_version,
|
||||
truncated.as_str(),
|
||||
] {
|
||||
let result = nip44_decrypt(&conversation, payload);
|
||||
assert!(result.is_err(), "payload {payload:?} must be rejected");
|
||||
if let Err(err) = result {
|
||||
assert!(
|
||||
!err.message().is_empty(),
|
||||
"rejection of {payload:?} must carry a concise reason"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NIP-46 credential-leakage regression (RUSTSEC-2026-0225 class): error
|
||||
/// strings surfaced to callers or logs must never contain secret-key hex.
|
||||
#[test]
|
||||
fn error_paths_never_leak_secret_key_material() {
|
||||
let signer = Keys::generate();
|
||||
let sk_hex = hex::encode(signer.secret_key().secret_bytes());
|
||||
assert_eq!(sk_hex.len(), 64);
|
||||
let peer = Keys::generate();
|
||||
let conversation =
|
||||
ConversationKey::derive(signer.secret_key(), &peer.public_key()).unwrap();
|
||||
|
||||
// Collect the human-readable reasons our failure paths produce.
|
||||
let mut failures = Vec::new();
|
||||
if let Err(err) = nip44_decrypt(&conversation, "not-base64 !!!") {
|
||||
failures.push(err.message().to_string());
|
||||
}
|
||||
if let Err(err) = nip44_decrypt(&conversation, &B64.encode([0xFFu8, 0x00, 0x11])) {
|
||||
failures.push(err.message().to_string());
|
||||
}
|
||||
|
||||
assert!(!failures.is_empty(), "expected at least one failure path");
|
||||
for message in failures {
|
||||
assert!(
|
||||
!message.to_lowercase().contains(&sk_hex),
|
||||
"error text leaked secret-key material: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_uri_with_relays_and_secret() {
|
||||
let uri = parse_connect_uri(
|
||||
|
|
@ -849,10 +764,7 @@ mod tests {
|
|||
fn bunker_link_is_rejected() {
|
||||
let signer = Signer::new();
|
||||
assert!(signer
|
||||
.connect(
|
||||
Arc::new(tokio::sync::Mutex::new(App::load().unwrap())),
|
||||
"bunker://abc"
|
||||
)
|
||||
.connect(Arc::new(Mutex::new(App::load().unwrap())), "bunker://abc")
|
||||
.is_err());
|
||||
}
|
||||
|
||||
|
|
|
|||
498
src/updates.rs
498
src/updates.rs
|
|
@ -1,498 +0,0 @@
|
|||
//! Dependency update scanning and installation for both halves of the app.
|
||||
//!
|
||||
//! The app runs from a source checkout, so "updating" means refreshing the
|
||||
//! JavaScript dependencies (`frontend/`) and the Rust crates (`Cargo.lock`)
|
||||
//! to their newest compatible versions. Security advisories from `npm audit`
|
||||
//! are surfaced first; `cargo update` picks up semver-compatible patches for
|
||||
//! the Rust side.
|
||||
//!
|
||||
//! Nothing here touches secret material: only fixed, read-mostly package
|
||||
//! manager commands are run in the project directories.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::errors::{AppError, ErrorKind};
|
||||
|
||||
/// Upper bound for a single package-manager command. Installs can be slow on
|
||||
/// cold caches, but a hung command must still be reaped eventually.
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(150);
|
||||
|
||||
/// One dependency with a newer compatible version available.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct PackageUpdate {
|
||||
pub name: String,
|
||||
pub current: String,
|
||||
pub available: String,
|
||||
}
|
||||
|
||||
/// A known security advisory affecting an npm dependency.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct SecurityAdvisory {
|
||||
pub package: String,
|
||||
/// npm severity label: critical / high / moderate / low / info.
|
||||
pub severity: String,
|
||||
/// Short advisory title, when npm reported one.
|
||||
pub title: Option<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,4 +1,3 @@
|
|||
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::crypto::VaultKey;
|
||||
|
|
|
|||
|
|
@ -39,11 +39,6 @@ pub struct StoredProfile {
|
|||
/// profiles stored before pictures were introduced.
|
||||
#[serde(default)]
|
||||
pub picture: Option<String>,
|
||||
/// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set.
|
||||
/// Absent for profiles stored before NIP-05 was introduced. Published as
|
||||
/// part of kind 0 metadata so clients show a human handle.
|
||||
#[serde(default)]
|
||||
pub nip05: Option<String>,
|
||||
}
|
||||
|
||||
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
||||
|
|
@ -462,7 +457,6 @@ mod tests {
|
|||
secret_key: "00ff".to_string(),
|
||||
created_at: 1_700_000_000,
|
||||
picture: None,
|
||||
nip05: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue