- TypeScript 49%
- Rust 44.2%
- CSS 6.1%
- Python 0.5%
- JavaScript 0.1%
| frontend | ||
| src | ||
| .gitignore | ||
| __init__.py | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHECKPOINT-encryption.md | ||
| concept3.svg | ||
| LICENSE | ||
| nostr_feed_manager.py | ||
| NostrAppNamePossibilities | ||
| README.md | ||
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.
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 (
npubidentities) - Compose screen — write with live character count and instant Write / Preview tabs
- Image attachments — pick a local image, upload to nostr.build, and
publish with NIP-92
imeta(plus legacyimage) tags so clients render it - Link preview cards — up to three URLs per note get a title/image/description preview
- Image attachments — pick a local image, upload to nostr.build, and
publish with NIP-92
- 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.
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:
- Client (UI) — the React renderer requests an action (e.g.
publish_note) through thewindow.backendbridge. - Electron main — forwards the request to the Rust backend over stdio as one JSON object per line.
- Backend — the
serveIPC loop dispatches to the right module (profiles,publish,relays,settings,signer). - 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 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
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
# 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:
# .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
.envout of version control. It is not gitignored by default — add it to.gitignoreif you create one in the repo root, and never commit real passwords.
4. Package (optional)
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:
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 connectloop 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:
{"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
0700and files0600. 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
# 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)
# 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.
Reporting bugs
Open an issue on the issue tracker 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
- Fork the repository from the Fork button on Forgejo.
- Branch from
masterwith a descriptive name (fix/relay-timeout,feat/avatar-support). - Make your change. Match the surrounding style and keep the diff focused.
- Run the full verification suite (see Verify) — everything must pass.
- Write tests for new behavior (test through the IPC surface wherever possible).
- Update docs for user-visible changes.
- Commit with a concise imperative subject line, push your branch, and open a pull
request against
master. - In the PR description, summarize the change, link issues, and list what you tested.
Code style
- Rust:
cargo fmt;cargo clippy --all-targetsclean; 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 file. Copyright (c) 2026 Avi.