- src/feed.rs: contact_feed + contact_pubkeys fetch the active profile's kind 3 contact list and aggregate notes only from those authors; aggregate_for + FeedBuilder accept an optional author whitelist - IPC: FeedGet accepts contacts_only, resolves the active profile npub - CLI: feed --contacts [limit] filters to the active profile's contacts - Frontend: Feed screen Everyone/My contacts toggle, scope-aware empty states, feedGet(limit, contactsOnly) threading - Tests for author filtering and the contacts scope in feed.rs and FeedScreen.test.tsx
365 lines
No EOL
15 KiB
Markdown
365 lines
No EOL
15 KiB
Markdown
# Nostr Feed Manager
|
|
|
|
> A friendly Linux desktop app for managing Nostr profiles, publishing notes, and acting as a
|
|
> **NIP-46 remote signer** — all while your private keys never leave your machine.
|
|
|
|
[]()
|
|
[](LICENSE)
|
|
[]()
|
|
[]()
|
|
[]()
|
|
[]()
|
|
|
|
<!--
|
|
Forgejo CI badge — enable once a workflow exists in `.forgejo/workflows/ci.yml`:
|
|
[]([YOUR_FORGEJO_INSTANCE_URL]/<OWNER>/<REPO>/actions/workflows/ci.yml)
|
|
-->
|
|
|
|
> **Hosting note:** this project is hosted on **Forgejo**. Throughout this document,
|
|
> replace `[YOUR_FORGEJO_INSTANCE_URL]` with your instance's base URL (e.g. `https://codeberg.org`)
|
|
> and `<OWNER>/<REPO>` with the actual repository path.
|
|
|
|
---
|
|
|
|
## 📖 Introduction
|
|
|
|
Nostr Feed Manager pairs a hardened **Rust core** (the same engine behind the original CLI) with a
|
|
polished **Electron + React** desktop interface. All Nostr work — key generation, event signing,
|
|
relay communication, encryption — happens inside the Rust backend. The GUI talks to it over a
|
|
secure JSON-lines IPC channel and **never sees your secret keys**.
|
|
|
|
It also turns your machine into a **NIP-46 remote signer ("bunker")**: instead of pasting your
|
|
`nsec` into other Nostr apps, they send signing requests here, and you approve each one with a
|
|
single click. This is central to keeping your keys out of third-party apps while staying
|
|
interoperable across the Nostr ecosystem.
|
|
|
|
**Status: early beta (v0.1.0).** Actively developed and usable daily, but not yet packaged for
|
|
distribution repositories. Expect some API churn until 1.0. No Docker or database required.
|
|
|
|
## ✨ Features
|
|
|
|
- **Profile management** — create and quickly switch between Nostr profiles (`npub` identities)
|
|
- **Compose screen** — write with live character count and instant **Write / Preview** tabs
|
|
- **Image attachments** — pick a local image, upload to [nostr.build](https://nostr.build), and
|
|
publish with NIP-92 `imeta` (plus legacy `image`) tags so clients render it
|
|
- **Link preview cards** — up to three URLs per note get a title/image/description preview
|
|
- **Publishing receipts** — per-relay publish reports, so you always know where a note landed
|
|
- **Relay management** — add, remove, enable/disable, and latency-test relays from GUI or CLI
|
|
- **Feed aggregation** — a read-only, newest-first feed of recent text notes aggregated from your
|
|
enabled relays (24h window), de-duplicated across relays, from a dedicated GUI screen or the CLI.
|
|
Switch the feed to **My contacts** to show only notes from the active profile's kind 3 contact list
|
|
- **Encrypted vault** — secret keys encrypted at rest with **AES-256-GCM** under a key derived via
|
|
**Argon2id** from your password
|
|
- **Secret key recovery** — reveal a key (hex + `nsec1...`) only after unlocking, from GUI or CLI
|
|
- **NIP-46 remote signer** — sign for other Nostr apps; every sign/decrypt request needs your
|
|
explicit **Approve**/**Reject**
|
|
- **Settings** — light / dark / system theme, publish confirmation, key shortening, vault backup
|
|
- **Two interfaces, one core** — the same Rust crate powers a full CLI *and* the GUI's IPC server
|
|
|
|
## 🏗️ Architecture
|
|
|
|
The design keeps a clean, security-critical boundary: **the renderer never sees secret keys.**
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph UI[Electron + React]
|
|
R[Renderer<br/>React 18 / TS]
|
|
M[Main process<br/>spawns backend, dialogs, HTTP]
|
|
end
|
|
subgraph Core[Rust backend]
|
|
IPC[JSON-lines IPC]
|
|
S[Signer · NIP-46]
|
|
V[Vault · AES-256-GCM]
|
|
REL[Relay client · nostr-sdk]
|
|
end
|
|
subgraph Net[Network]
|
|
RELAYS[(Relays)]
|
|
HU[nostr.build]
|
|
end
|
|
|
|
R <-->|window.backend bridge| M
|
|
M <-->|"stdin/stdout 1 JSON per line"| IPC
|
|
IPC --> S
|
|
IPC --> V
|
|
IPC --> REL
|
|
REL --> V
|
|
S <--> RELAYS
|
|
REL --> HU
|
|
```
|
|
|
|
Or, as a plain step-by-step flow:
|
|
|
|
1. **Client (UI)** — the React renderer requests an action (e.g. `publish_note`) through the
|
|
`window.backend` bridge.
|
|
2. **Electron main** — forwards the request to the Rust backend over stdio as one JSON object per
|
|
line.
|
|
3. **Backend** — the `serve` IPC loop dispatches to the right module (`profiles`, `publish`,
|
|
`relays`, `settings`, `signer`).
|
|
4. **Relays** — the backend publishes signed events to the configured relays and returns
|
|
per-relay receipts.
|
|
|
|
### Tech stack
|
|
|
|
**Backend (Rust)** — Cargo · rustfmt · Clippy
|
|
|
|
| Tool | Purpose |
|
|
| --- | --- |
|
|
| [nostr-sdk](https://crates.io/crates/nostr-sdk) 0.40 (NIP-44, NIP-46, NIP-98) | Nostr protocol, relay client, signing, remote signer |
|
|
| tokio | async runtime |
|
|
| argon2 + aes-gcm | vault key derivation and at-rest encryption |
|
|
| serde / serde_json | IPC and storage encoding |
|
|
| rpassword | interactive password prompts |
|
|
|
|
**Frontend (TypeScript)** — npm
|
|
|
|
| Tool | Purpose |
|
|
| --- | --- |
|
|
| Electron 33 | desktop shell, native dialogs, IPC host |
|
|
| React 18 + TypeScript | renderer |
|
|
| Vite 5 | build tool + dev server |
|
|
| Vitest + Testing Library | renderer tests against a fake backend |
|
|
| ESLint + Prettier | linting and formatting |
|
|
| electron-builder | Linux packaging |
|
|
|
|
## 🚀 Installation & setup
|
|
|
|
### Prerequisites
|
|
|
|
- **Linux** with a display server (X11 or Wayland)
|
|
- **Rust** (stable) and Cargo
|
|
- **Node.js 20+** and npm 10+
|
|
|
|
No Docker, database, or account setup is required.
|
|
|
|
### 1. Clone and build
|
|
|
|
```sh
|
|
git clone [YOUR_FORGEJO_INSTANCE_URL]/<OWNER>/<REPO>.git
|
|
cd nost-feed-manager
|
|
|
|
# Build the Rust backend (release)
|
|
cargo build --release
|
|
|
|
# Install frontend dependencies
|
|
cd frontend
|
|
npm install
|
|
```
|
|
|
|
### 2. Run
|
|
|
|
```sh
|
|
# Renders from the built bundle via app://
|
|
cd frontend
|
|
npm start
|
|
```
|
|
|
|
On first launch the app detects a vault left behind by the original Python CLI (if you have one),
|
|
backs it up to a timestamped `*.backup-<ts>` file, and imports your profiles — leaving the original
|
|
untouched.
|
|
|
|
### 3. Configure environment
|
|
|
|
The app reads its configuration from environment variables. It does not load a `.env` file
|
|
directly, but you can export these from your shell or source an `.env` before launching:
|
|
|
|
| Variable | Purpose | Default |
|
|
| --- | --- | --- |
|
|
| `NFM_PASSWORD` | Vault password for non-interactive CLI use (create / publish / show-secret) | _prompt_ |
|
|
| `NOSTR_GUI_DEV_URL` | Renderer dev-server URL for hot reload (development only) | — |
|
|
| `XDG_DATA_HOME` | Override the data directory | `~/.local/share/nost-feed-manager` |
|
|
|
|
**`.env.example`** — copy to `.env` and adjust:
|
|
|
|
```bash
|
|
# .env — sourced by your shell, not read directly by the app
|
|
|
|
# Vault password for non-interactive CLI use (create / publish / show-secret)
|
|
NFM_PASSWORD=
|
|
|
|
# Renderer dev-server URL for hot reload (development only)
|
|
NOSTR_GUI_DEV_URL=http://localhost:5173
|
|
|
|
# Override the data directory (defaults to ~/.local/share/nost-feed-manager)
|
|
XDG_DATA_HOME=
|
|
```
|
|
|
|
> ⚠️ Keep `.env` out of version control. It is **not** gitignored by default — add it to
|
|
> `.gitignore` if you create one in the repo root, and never commit real passwords.
|
|
|
|
### 4. Package (optional)
|
|
|
|
```sh
|
|
cd frontend
|
|
npm run dist # builds renderer + backend and runs electron-builder
|
|
```
|
|
|
|
The unpacked app lands in `frontend/release/linux-unpacked/`; run it with `./nost-feed-manager`.
|
|
|
|
## 🧑💻 Usage guide
|
|
|
|
### Desktop app
|
|
|
|
Tabs cover **Compose**, **Home**, **Profiles**, **Relays**, **Settings**, and **Signer**. Connect
|
|
a relay by entering its `wss://` URL, then compose a note, attach an image, and publish to see a
|
|
per-relay report.
|
|
|
|
### Command-line interface
|
|
|
|
The Rust crate builds a single binary that acts as both the GUI's backend and a full CLI:
|
|
|
|
```sh
|
|
cargo run --release -- create "Alice" # create a profile
|
|
cargo run --release -- list # list profiles (no secret keys)
|
|
cargo run --release -- switch <npub> # select the active profile
|
|
cargo run --release -- publish <npub> "Hello" # publish a text note
|
|
cargo run --release -- feed [--contacts] [limit] # fetch recent notes; --contacts = your contacts
|
|
cargo run --release -- relays list|add|remove|enable|disable|test
|
|
cargo run --release -- settings get|set <key> <value>
|
|
cargo run --release -- set-password # encrypt the vault (or change its password)
|
|
cargo run --release -- remove-password # remove vault encryption
|
|
cargo run --release -- unlock # verify the vault password for this process
|
|
cargo run --release -- show-secret <npub> # reveal a profile's secret key
|
|
cargo run --release -- signer status # show the signer profile + relays
|
|
cargo run --release -- signer connect <nostrconnect://...>
|
|
cargo run --release -- info # show storage locations and version
|
|
cargo run --release -- serve # JSON-lines IPC server (used by the GUI)
|
|
```
|
|
|
|
Passwords are read from `NFM_PASSWORD` when set, otherwise you are prompted. They are **never**
|
|
accepted as command-line arguments — `create`, `publish`, `unlock`, and `show-secret` all unlock
|
|
the vault automatically when it is encrypted.
|
|
|
|
> **CLI + signer caveat:** the CLI's blocking `signer connect` loop cannot answer approval
|
|
> prompts — run the **GUI** to approve NIP-46 requests.
|
|
|
|
### Remote signer (GUI)
|
|
|
|
The **Signer** screen sets up the **NIP-46 remote signer** with the active profile. Paste a
|
|
`nostrconnect://` link from another Nostr app. When that app asks to sign an event or
|
|
decrypt/encrypt a message, a **Requests waiting for approval** panel appears with an
|
|
**Approve**/**Reject** button. Nothing is signed until you confirm it.
|
|
|
|
### IPC protocol (for tooling)
|
|
|
|
The `serve` command speaks one JSON object per line — the same protocol Electron adopts:
|
|
|
|
```json
|
|
{"id": 1, "method": "publish_note", "params": {"npub": "...", "content": "Hello world"}}
|
|
{"id": 1, "status": "ok", "data": {"npub": "...", "report": {"event_id": "...", "succeeded": ["wss://relay.damus.io"]}}}
|
|
```
|
|
|
|
## 🔐 Security notes
|
|
|
|
Your keys are the crown jewels in any Nostr app, and nothing here compromises them:
|
|
|
|
- **Secret keys stay in Rust.** The renderer never receives secret key material; it only sees
|
|
profile summaries and publish reports.
|
|
- **Encrypted at rest.** The vault (`profiles_vault.json`) is plaintext until you set a password
|
|
(`set-password`, or Settings → Storage). Once set, every secret key is encrypted with
|
|
**AES-256-GCM** under a key derived from your password with **Argon2id**. Labels and public keys
|
|
remain readable so you can browse profiles while the vault is locked.
|
|
- **In-memory key only.** You unlock once per session; the derived key lives only in memory and is
|
|
never written to disk.
|
|
- **Approve-before-any-signing.** The NIP-46 remote signer will not sign, encrypt, or decrypt
|
|
until you explicitly approve each request.
|
|
- **Least-privileged storage.** Files are written with directories `0700` and files `0600`.
|
|
NIP-98 auth events authorize image uploads to nostr.build without exposing sk signs.
|
|
|
|
> **Defence in depth.** Anyone with access to your user account can still read vault files. The
|
|
> password is an additional layer, not a replacement for securing your account, and the underlying
|
|
> OS should be considered your last line of defense.
|
|
|
|
## 🛠️ Development
|
|
|
|
### Run with hot reload
|
|
|
|
```sh
|
|
# terminal 1 — Vite dev server
|
|
cd frontend
|
|
npm run dev
|
|
|
|
# terminal 2 — Electron pointed at the dev server
|
|
NOSTR_GUI_DEV_URL=http://localhost:5173 npm start
|
|
```
|
|
|
|
### Verify (all green — `cargo 75`, `npm 71`, clippy/fmt/typecheck/lint/format clean)
|
|
|
|
```sh
|
|
# Rust
|
|
cargo test # 75 unit tests
|
|
cargo fmt --check # rustfmt
|
|
cargo clippy --all-targets
|
|
|
|
# Frontend
|
|
cd frontend
|
|
npm run typecheck # TypeScript (renderer + electron)
|
|
npm run lint # ESLint
|
|
npm run format:check # Prettier
|
|
npm test # Vitest (jsdom) — 71 tests across 13 files
|
|
```
|
|
|
|
The test suites exercise the real IPC protocol against a fake backend, so features are tested at
|
|
the protocol boundary rather than in isolation.
|
|
|
|
### Project layout
|
|
|
|
```
|
|
src/ Rust library + CLI + IPC server
|
|
app.rs app state, vault password/unlock lifecycle
|
|
crypto.rs Argon2id derivation + AES-256-GCM encryption
|
|
errors.rs structured AppError
|
|
ipc.rs JSON-lines serve() loop and request/reply envelope
|
|
main.rs CLI entry point
|
|
profiles.rs profile create/list/select, secret recovery
|
|
publish.rs note publishing with receipts, images and imeta tags
|
|
relays.rs default relays, validation, connection tests
|
|
settings.rs theme and preferences
|
|
signer.rs NIP-46 remote signer + approval gate
|
|
uploads.rs NIP-98 auth for image-host uploads
|
|
vault.rs vault storage (plaintext or password-encrypted)
|
|
frontend/
|
|
electron/ Electron main + preload (backend spawn, dialogs, clipboard)
|
|
src/
|
|
screens/ Compose, Home, Profiles, Relays, Settings, Signer
|
|
lib/ types, api bridge, media helpers
|
|
state/ React context (AppProvider)
|
|
test/ Vitest suite over a fake backend
|
|
package.json scripts + electron-builder config
|
|
```
|
|
|
|
## 🤝 Contributing
|
|
|
|
Contributions are welcome — bug reports, documentation, and pull requests all help. Please be
|
|
respectful and constructive; everyone is expected to follow the
|
|
[Code of Conduct](#code-of-conduct).
|
|
|
|
### Reporting bugs
|
|
|
|
Open an issue on the [issue tracker]([YOUR_FORGEJO_INSTANCE_URL]/<OWNER>/<REPO>/issues) and include:
|
|
|
|
- A clear title and description of expected vs. actual behavior
|
|
- Steps to reproduce
|
|
- Platform details (distro, Wayland/X11, version from `info`)
|
|
- Relevant log output
|
|
|
|
### Opening a pull request
|
|
|
|
1. **Fork** the repository from the **Fork** button on Forgejo.
|
|
2. **Branch** from `master` with a descriptive name (`fix/relay-timeout`, `feat/avatar-support`).
|
|
3. **Make your change.** Match the surrounding style and keep the diff focused.
|
|
4. **Run the full verification suite** (see [Verify](#🛠️-development)) — everything must pass.
|
|
5. **Write tests** for new behavior (test through the IPC surface wherever possible).
|
|
6. **Update docs** for user-visible changes.
|
|
7. **Commit** with a concise imperative subject line, **push** your branch, and open a **pull
|
|
request** against `master`.
|
|
8. In the PR description, summarize the change, link issues, and list what you tested.
|
|
|
|
### Code style
|
|
|
|
- **Rust:** `cargo fmt`; `cargo clippy --all-targets` clean; public items get doc comments
|
|
- **TypeScript/React:** Prettier + ESLint clean; strict `tsconfig`
|
|
- **General:** no secrets in code, commits, or logs; no generated files checked in
|
|
|
|
## 📄 License
|
|
|
|
**MIT** — see the [LICENSE](LICENSE) file. Copyright (c) 2026 Avi. |