diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..d40fa43
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,57 @@
+name: Build and Publish Napstr Umbrel Docker Image
+
+on:
+ push:
+ branches: [ "main", "feat/umbrel-headless-daemon" ]
+ tags: [ 'v*.*.*' ]
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository_owner }}/napstr-umbrel
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable=true
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile.umbrel
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/Dockerfile.umbrel b/Dockerfile.umbrel
new file mode 100644
index 0000000..dc33177
--- /dev/null
+++ b/Dockerfile.umbrel
@@ -0,0 +1,67 @@
+# syntax=docker/dockerfile:1
+
+# Stage 1: Build Svelte 5 Frontend
+FROM node:22-alpine AS frontend-builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# Stage 2: Build Rust Headless Daemon
+FROM rust:1-bookworm AS rust-builder
+WORKDIR /usr/src/napstr
+RUN apt-get update && apt-get install -y \
+ pkg-config \
+ libssl-dev \
+ libasound2-dev \
+ libwebkit2gtk-4.1-dev \
+ build-essential \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY src-tauri ./src-tauri
+WORKDIR /usr/src/napstr/src-tauri
+
+RUN cargo build --release --bin napstr-daemon
+
+# Stage 3: Minimal Runtime
+FROM debian:bookworm-slim
+LABEL org.opencontainers.image.title="Napstr Umbrel" \
+ org.opencontainers.image.description="Sovereign P2P Nostr & Tor audio seeder and web player" \
+ org.opencontainers.image.source="https://github.com/lnbits/napstr"
+
+RUN apt-get update && apt-get install -y \
+ tor \
+ ca-certificates \
+ libasound2 \
+ libdbus-1-3 \
+ libwebkit2gtk-4.1-0 \
+ libgtk-3-0 \
+ libsoup-3.0-0 \
+ libjavascriptcoregtk-4.1-0 \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy compiled frontend static assets
+COPY --from=frontend-builder /app/build /app/build
+
+# Copy compiled backend daemon binary
+COPY --from=rust-builder /usr/src/napstr/src-tauri/target/release/napstr-daemon /usr/local/bin/napstr-daemon
+
+ENV PORT=30421 \
+ HOST=0.0.0.0 \
+ DATA_DIR=/data \
+ MUSIC_DIR=/music \
+ STATIC_DIR=/app/build
+
+VOLUME ["/data", "/music"]
+
+EXPOSE 30421
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD curl -f http://localhost:30421/api/version || exit 1
+
+ENTRYPOINT ["/usr/local/bin/napstr-daemon"]
diff --git a/README.md b/README.md
index f5442ae..873f83a 100644
--- a/README.md
+++ b/README.md
@@ -1,53 +1,114 @@
-
+# ๐ง
Napstr for Umbrel & Community App Store
-
+[](https://umbrel.com)
+[](https://github.com/borocode/napstr-umbrel)
+[](LICENSE)
-Napstr uses Nostr for discovery and Tor for private file sharing.
+Run a sovereign, self-hosted **P2P music library & streaming node** directly on your **Umbrel server** (Raspberry Pi 4/5 or x86_64 Home Server). Napstr uses **Nostr** for discovery and **Tor** for private, lossless file transfer โ no central server, no IP leaks, your catalogue published to the decentralized swarm. This repository is an **Umbrel community port** of [`lnbits/napstr`](https://github.com/lnbits/napstr), packaged and CI-hardened by [Boro Labs](https://github.com/borocode).
-https://napstr.net
+
-## Build your own Napstr!
+---
-See the complete [Napstr protocol specification](PROTOCOL.md) for everything
-needed to build an interoperable client.
+## โ ๏ธ Read This Before You "Update"
-## Build from source
+The in-app store may show **"New release 0.1.4 available."** That version comes from **upstream** `lnbits/napstr` โ **not** from this Umbrel package.
-Install [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/), and the
-[Tauri prerequisites for your OS](https://v2.tauri.app/start/prerequisites/),
-then:
+- This port's last tagged build is `v0.1.2`; the running image is the `dd7c9a8` CI build (version string `0.1.0`).
+- Upstream 0.1.4 is a **different binary** with different packaging. Installing it through the Umbrel updater will **overwrite tonight's working deployment** and drop the runtime fixes listed below.
+- **Do not blind-update.** If you want upstream 0.1.4, diff it, confirm the Dockerfile still builds clean against it (our runtime-lib fix must survive), then let CI rebuild. Same closed-loop posture as everything else here.
-```bash
-git clone https://github.com/lnbits/napstr.git
-cd napstr
-npm ci
-npm run desktop
-```
+---
-Development builds use `NAPSTR_TOR_PATH` when set, then a bundled Tor binary, then `tor` on `PATH`.
+## ๐ Features
-On macOS, install Tor with Homebrew and start Napstr with:
+- **1-Click Umbrel Deployment:** Pre-configured Docker orchestration (`docker-compose.yml` + `umbrel-app.yml`) with persistent state at `/data` and your music library mounted at `/music`.
+- **Sovereign P2P Stack:** Nostr (Kind 30421 catalogues, Kind 30422 availability heartbeats, NIP-17 private negotiation) for discovery + Tor v3 onion services for transfer โ no direct-IP fallback, no central server.
+- **Web UI + Trollbox:** Built-in Svelte player and live NIP-C7 chat, exposed via Umbrel's app proxy on port `30421`.
+- **Headless Daemon:** Runs as a server binary (no desktop/GUI session needed) โ the right shape for a 24/7 Pi.
+- **Multi-Architecture Support:** Native multi-stage builds for both `linux/amd64` (PC / Intel / AMD) and `linux/arm64` (Raspberry Pi 4/5), published to GHCR via hosted CI.
-```bash
-brew install tor
-NAPSTR_TOR_PATH="$(command -v tor)" npm run desktop
-```
+---
+
+## ๐ฆ Umbrel App Store Installation
+
+### Method 1: Add as a Community App Store (Instant 1-Click)
+
+1. Open your **Umbrel Dashboard** (e.g. `http://umbrel.local`).
+2. Navigate to **App Store** โ Click the **Three Dots (โฎ)** in the top right โ **Community App Stores**.
+3. Paste this repository URL:
+ ```text
+ https://github.com/borocode/napstr-umbrel
+ ```
+4. Click **Add**. **Napstr** will appear in your App Store ready to install!
-To create a package for your operating system with Tor included, run:
+### Method 2: Manual Local Installation via SSH
```bash
-npm run bundle
+# 1. SSH into your Umbrel server
+ssh umbrel@10.0.0.67
+
+# 2. Create the app-data directory
+mkdir -p ~/umbrel/app-data/napstr
+
+# 3. Clone this repository
+git clone https://github.com/borocode/napstr-umbrel.git temp
+cp -r temp/* ~/umbrel/app-data/napstr/
+rm -rf temp
+
+# 4. Start the app via Docker Compose
+cd ~/umbrel/app-data/napstr
+docker compose up -d
```
-Napstr automatically downloads and verifies the pinned official Tor Expert
-Bundle for your platform before building.
+---
+
+## ๐ง What This Umbrel Port Changes (vs. upstream `lnbits/napstr`)
+
+This is a **packaging fork**, not a feature fork. The daemon, web UI, Nostr/Tor logic, and protocol are upstream's. We changed only what's needed to run it as a headless, multi-arch Umbrel app.
+
+### โ Additions
+
+- **`umbrel-app.yml`** โ Umbrel community-store manifest (id `napstr`, port `30421`, Media category, Tor/Nostr tagline).
+- **`docker-compose.yml`** โ three services: `server` (Napstr daemon + web UI), `app_proxy` (Umbrel reverse proxy), and `tor_server` (bundled Tor sidecar). Mounts `/data` (state) and `/music` (your library).
+- **`Dockerfile.umbrel`** โ multi-stage build: Svelte frontend โ Rust `napstr-daemon` โ minimal Debian runtime. Publishes a multi-arch image to GHCR.
+- **Full GTK/WebKit runtime stack in the runtime image** โ `libwebkit2gtk-4.1-0`, `libgtk-3-0`, `libsoup-3.0-0`, `libjavascriptcoregtk-4.1-0`, `libdbus-1-3`, `libasound2`. The `src-tauri` daemon links the WebKitGTK stack **even in headless mode**, so these are required at runtime or the container crash-loops on `error while loading shared libraries`.
+- **Headless key-persistence fallback** โ the daemon stores its Nostr identity in the OS credential store on desktop; in a container we fall back to a file under `DATA_DIR` so the key survives restarts.
+- **Hosted CI** (`.github/workflows/docker-publish.yml`) โ multi-arch build on `ubuntu-latest` (the self-hosted Windows runner couldn't do multi-platform builds).
+
+### โ Removals / Divergences
+
+- **No desktop/Tauri app** โ upstream ships a `npm run desktop` Tauri GUI; this port builds and runs only the `napstr-daemon` server binary. The ~250MB of GUI-only libraries remain in the runtime image only because the daemon still links them (a future Option-B refactor would decouple and shed them).
+- **No `npm run bundle` / bundled-Tor-download step** โ Tor is installed via `apt` in the runtime image and run as a separate `tor_server` container, not downloaded at build time.
+- **App version pinned at `0.1.0`** in `umbrel-app.yml` while upstream has moved to `0.1.4` โ see the update warning above.
+
+---
+
+## ๐ Network & Port Allocations
+
+| Port | Protocol | Purpose |
+| :--- | :--- | :--- |
+| **`30421`** | `HTTP` | Napstr Web UI, API & daemon (via Umbrel app proxy) |
+| **`9050`** | `TCP` | Tor SOCKS proxy (internal, `tor_server`) |
+
+Music is served losslessly over ephemeral Tor v3 onion services; the web UI is reachable on your LAN through Umbrel's reverse proxy.
+
+---
+
+## ๐ ๏ธ Upstream Submission / Sync
+
+To stay current with [`lnbits/napstr`](https://github.com/lnbits/napstr):
+
+1. `git remote add upstream https://github.com/lnbits/napstr.git`
+2. `git fetch upstream && git merge upstream/main`
+3. Re-apply the Umbrel additions above (they live in `umbrel-app.yml`, `docker-compose.yml`, `Dockerfile.umbrel`).
+4. Confirm the runtime-image WebKit/GTK libs still cover whatever `src-tauri` now links, then let CI rebuild.
+
+For official [`getumbrel/umbrel-apps`](https://github.com/getumbrel/umbrel-apps) submission: copy the `napstr/` package directory into your fork and open a PR โ same path as `octra-umbrel`.
-## Implemented architecture
+---
-- On first launch, Napstr creates a Nostr identity and securely stores its private key using your operating system's credential store.
-- Nostr publishes the searchable catalogue, live seeders, NIP-C7 trollbox, and per-track discussions; NIP-17 handles private download negotiation.
-- A bundled Tor process carries transfers without a direct-IP fallback.
-- One recursively watched folder contains both downloads and shared audio.
-- Files are audio-validated and identified by SHA-256. Downloads use a responsive seeder, verify the complete hash, and are available in the built-in player.
+## ๐ License
-Profiles and catalogue metadata are public. Requests, transfer credentials, file contents, and peer IP addresses are not published. Tor use may still be visible to an ISP.
+Packaged with ๐ง
by [Boro Labs](https://github.com/borocode).
+Napstr core is developed by [lnbits/napstr contributors](https://github.com/lnbits/napstr).
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2621a4a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,24 @@
+version: "3.7"
+
+services:
+ app_proxy:
+ environment:
+ APP_HOST: napstr_server_1
+ APP_PORT: 30421
+
+ server:
+ image: lnbits/napstr-umbrel:v0.1.0
+ restart: on-failure
+ stop_grace_period: 30s
+ environment:
+ - PORT=30421
+ - HOST=0.0.0.0
+ - DATA_DIR=/data
+ - MUSIC_DIR=/music
+ - STATIC_DIR=/app/build
+ - RUST_LOG=info
+ volumes:
+ - ${APP_DATA_DIR}/data:/data
+ - ${UMBREL_ROOT}/data/storage/downloads/music:/music:rw
+ ports:
+ - "30421:30421"
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 9431a21..249a4b4 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -177,7 +177,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-socks",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.26.2",
"url",
"wasm-bindgen",
"web-sys",
@@ -224,6 +224,61 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "base64 0.22.1",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sha1",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite 0.29.0",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -1826,12 +1881,24 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hyper"
version = "1.11.0"
@@ -1845,6 +1912,7 @@ dependencies = [
"http",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -2432,6 +2500,12 @@ dependencies = [
"web_atoms",
]
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2453,6 +2527,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2500,11 +2584,14 @@ dependencies = [
name = "napstr"
version = "0.1.0"
dependencies = [
+ "async-trait",
+ "axum",
"chrono",
"dirs",
"futures-util",
"hex",
"keyring",
+ "mime_guess",
"nostr-sdk",
"notify",
"rand 0.9.5",
@@ -2519,6 +2606,8 @@ dependencies = [
"tokio",
"tokio-socks",
"tokio-util",
+ "tower",
+ "tower-http",
"uuid",
"walkdir",
]
@@ -3661,6 +3750,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "salsa20"
version = "0.10.2"
@@ -3905,6 +4000,17 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_repr"
version = "0.1.21"
@@ -3934,6 +4040,18 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "serde_with"
version = "3.22.0"
@@ -4866,10 +4984,22 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
- "tungstenite",
+ "tungstenite 0.26.2",
"webpki-roots 0.26.11",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite 0.29.0",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -5016,6 +5146,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
@@ -5026,13 +5157,23 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.1",
"bytes",
+ "futures-core",
"futures-util",
"http",
"http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
+ "tracing",
"url",
]
@@ -5054,6 +5195,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5126,6 +5268,22 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "sha1",
+ "thiserror 2.0.20",
+]
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5190,6 +5348,12 @@ dependencies = [
"unic-common",
]
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 168aa3f..043417d 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -14,6 +14,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
name = "napstr"
path = "src/main.rs"
+[[bin]]
+name = "napstr-daemon"
+path = "src/daemon.rs"
+
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -38,3 +42,8 @@ uuid = { version = "1", features = ["v4", "serde"] }
tokio-util = "0.7"
notify = "8"
rodio = { version = "0.22.2", default-features = false, features = ["playback", "flac", "mp3", "vorbis", "wav"] }
+axum = { version = "0.8", features = ["ws"] }
+tower = "0.5"
+tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
+mime_guess = "2.0"
+async-trait = "0.1"
diff --git a/src-tauri/src/daemon.rs b/src-tauri/src/daemon.rs
new file mode 100644
index 0000000..81d1ace
--- /dev/null
+++ b/src-tauri/src/daemon.rs
@@ -0,0 +1,140 @@
+use napstr_lib::{
+ events::BroadcasterEmitter,
+ get_setting, index_path_headless, initialise_database,
+ network::NetworkService,
+ open_connection,
+ server::{create_router, ServerState},
+ start_folder_watcher_headless,
+ tor::TorManager,
+ transfer::TransferService,
+};
+use std::{
+ env,
+ fs,
+ net::SocketAddr,
+ path::PathBuf,
+ sync::{Arc, Mutex},
+};
+use tokio::sync::broadcast;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ println!("๐ต Starting Napstr Sovereign Seeder & Web Server...");
+
+ let port: u16 = env::var("PORT")
+ .ok()
+ .and_then(|p| p.parse().ok())
+ .unwrap_or(30421);
+
+ let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
+
+ let data_dir = env::var("DATA_DIR")
+ .ok()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| {
+ dirs::data_dir()
+ .map(|p| p.join("napstr"))
+ .unwrap_or_else(|| PathBuf::from("./data"))
+ });
+
+ fs::create_dir_all(&data_dir)?;
+ let db_path = data_dir.join("napstr.sqlite3");
+ initialise_database(&db_path, &data_dir)?;
+
+ let resource_dir = env::var("RESOURCE_DIR")
+ .ok()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| data_dir.clone());
+
+ let (broadcast_tx, _) = broadcast::channel(256);
+ let event_emitter = Arc::new(BroadcasterEmitter::new(broadcast_tx.clone()));
+
+ let tor = Arc::new(TorManager::new(data_dir.clone(), resource_dir));
+ let transfers = Arc::new(TransferService::new(db_path.clone(), tor.clone()));
+ let network = NetworkService::new(db_path.clone(), transfers, event_emitter);
+
+ // Check shared folder from environment or database
+ let music_dir_env = env::var("MUSIC_DIR").ok().map(PathBuf::from);
+ let existing_folder = open_connection(&db_path)
+ .ok()
+ .and_then(|conn| get_setting(&conn, "shared_folder").ok())
+ .map(PathBuf::from)
+ .or(music_dir_env);
+
+ let watcher = if let Some(folder) = existing_folder {
+ if folder.is_dir() {
+ println!("๐ Indexing music library at: {}", folder.display());
+ if let Ok(mut conn) = open_connection(&db_path) {
+ let _ = index_path_headless(&mut conn, &folder);
+ let _ = conn.execute(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('shared_folder', ?1)",
+ [folder.to_string_lossy().as_ref()],
+ );
+ }
+ start_folder_watcher_headless(folder, db_path.clone(), network.clone()).ok()
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ let static_dir = env::var("STATIC_DIR")
+ .ok()
+ .map(PathBuf::from)
+ .or_else(|| {
+ let candidates = ["./build", "./dist", "../build", "../dist", "./website"];
+ candidates.iter().map(PathBuf::from).find(|p| p.exists())
+ });
+
+ if let Some(ref dir) = static_dir {
+ println!("๐ Serving web UI from: {}", dir.display());
+ }
+
+ // Auto-start Tor and Nostr network in background
+ let tor_clone = tor.clone();
+ tokio::spawn(async move {
+ println!("๐ง
Starting Tor onion controller...");
+ if let Err(e) = tor_clone.start().await {
+ eprintln!("โ ๏ธ Tor start error: {e}");
+ }
+ });
+
+ let network_clone = network.clone();
+ tokio::spawn(async move {
+ println!("๐ก Connecting to Nostr relays...");
+ if let Err(e) = network_clone.start().await {
+ eprintln!("โ ๏ธ Nostr network start error: {e}");
+ }
+ });
+
+ let server_state = Arc::new(ServerState {
+ db_path: Mutex::new(db_path),
+ network: network.clone(),
+ tor: tor.clone(),
+ watcher: Mutex::new(watcher),
+ broadcast_tx,
+ });
+
+ let app = create_router(server_state, static_dir);
+ let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
+ println!("๐ Napstr daemon listening on http://{}", addr);
+
+ let listener = tokio::net::TcpListener::bind(addr).await?;
+
+ tokio::select! {
+ res = axum::serve(listener, app) => {
+ if let Err(e) = res {
+ eprintln!("Server error: {e}");
+ }
+ }
+ _ = tokio::signal::ctrl_c() => {
+ println!("\n๐ Graceful shutdown initiated...");
+ network.stop().await;
+ tor.stop().await;
+ println!("๐ Napstr stopped cleanly.");
+ }
+ }
+
+ Ok(())
+}
diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs
new file mode 100644
index 0000000..1d60229
--- /dev/null
+++ b/src-tauri/src/events.rs
@@ -0,0 +1,55 @@
+pub trait EventEmitter: Send + Sync {
+ fn emit_event(&self, event: &str, payload: &str) -> Result<(), String>;
+}
+
+#[derive(Clone)]
+pub struct TauriEmitter {
+ handle: tauri::AppHandle,
+}
+
+impl TauriEmitter {
+ pub fn new(handle: tauri::AppHandle) -> Self {
+ Self { handle }
+ }
+}
+
+impl EventEmitter for TauriEmitter {
+ fn emit_event(&self, event: &str, payload: &str) -> Result<(), String> {
+ use tauri::Emitter;
+ self.handle
+ .emit(event, payload.to_string())
+ .map_err(|e| e.to_string())
+ }
+}
+
+#[derive(Clone)]
+pub struct BroadcasterEmitter {
+ tx: tokio::sync::broadcast::Sender,
+}
+
+impl BroadcasterEmitter {
+ pub fn new(tx: tokio::sync::broadcast::Sender) -> Self {
+ Self { tx }
+ }
+}
+
+impl EventEmitter for BroadcasterEmitter {
+ fn emit_event(&self, event: &str, payload: &str) -> Result<(), String> {
+ let msg = serde_json::json!({
+ "event": event,
+ "payload": payload
+ })
+ .to_string();
+ let _ = self.tx.send(msg);
+ Ok(())
+ }
+}
+
+#[derive(Clone)]
+pub struct NoopEmitter;
+
+impl EventEmitter for NoopEmitter {
+ fn emit_event(&self, _event: &str, _payload: &str) -> Result<(), String> {
+ Ok(())
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 6d00e05..ff464aa 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -17,12 +17,14 @@ use std::{
use tauri::{Emitter, Manager, State};
use walkdir::WalkDir;
-mod audio;
-mod network;
-mod player;
-mod protocol;
-mod tor;
-mod transfer;
+pub mod audio;
+pub mod events;
+pub mod network;
+pub mod player;
+pub mod protocol;
+pub mod server;
+pub mod tor;
+pub mod transfer;
const HASH_BUFFER_SIZE: usize = 256 * 1024;
const MAX_INDEX_ERRORS: usize = 100;
@@ -46,70 +48,70 @@ struct AppState {
recovering_after_sleep: Arc,
}
-struct ShutdownServices {
- network: Arc,
- tor: Arc,
+pub struct ShutdownServices {
+ pub network: Arc,
+ pub tor: Arc,
}
-struct FolderWatcher {
- _watcher: RecommendedWatcher,
+pub struct FolderWatcher {
+ pub _watcher: RecommendedWatcher,
}
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-struct SharedFile {
- file_id: String,
- filename: String,
- path: String,
- folder: String,
- size: u64,
- format: String,
- status: String,
- title: String,
- artist: String,
- album: String,
- mime: String,
- license: String,
- description: String,
- tags: String,
+pub struct SharedFile {
+ pub file_id: String,
+ pub filename: String,
+ pub path: String,
+ pub folder: String,
+ pub size: u64,
+ pub format: String,
+ pub status: String,
+ pub title: String,
+ pub artist: String,
+ pub album: String,
+ pub mime: String,
+ pub license: String,
+ pub description: String,
+ pub tags: String,
}
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-pub(crate) struct Transfer {
- id: i64,
- file_id: String,
- filename: String,
- size: u64,
- progress: f64,
- status: String,
- speed: String,
- destination: String,
+pub struct Transfer {
+ pub id: i64,
+ pub file_id: String,
+ pub filename: String,
+ pub size: u64,
+ pub progress: f64,
+ pub status: String,
+ pub speed: String,
+ pub destination: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-struct Settings {
- napstr_folder: String,
- nostr_relays: String,
- display_name: String,
- profile_about: String,
- profile_picture: String,
+pub struct Settings {
+ pub napstr_folder: String,
+ pub nostr_relays: String,
+ pub display_name: String,
+ pub profile_about: String,
+ pub profile_picture: String,
}
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-struct AppSnapshot {
- files: Vec,
- transfers: Vec,
- settings: Settings,
- indexed_bytes: u64,
- native: bool,
+pub struct AppSnapshot {
+ pub files: Vec,
+ pub transfers: Vec,
+ pub settings: Settings,
+ pub indexed_bytes: u64,
+ pub native: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
-struct IndexReport {
+pub struct IndexReport {
file_count: usize,
total_bytes: u64,
errors: Vec,
@@ -119,7 +121,7 @@ struct IndexReport {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
-struct IndexProgress {
+pub struct IndexProgress {
scanning: bool,
processed_files: usize,
indexed_files: usize,
@@ -128,7 +130,7 @@ struct IndexProgress {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
-struct IndexBatch {
+pub struct IndexBatch {
files: Vec,
file_count: usize,
total_bytes: u64,
@@ -145,7 +147,7 @@ fn open_db(state: &State<'_, AppState>) -> Result {
open_connection(&path)
}
-fn open_connection(path: &Path) -> Result {
+pub fn open_connection(path: &Path) -> Result {
let connection = Connection::open(path).map_err(|error| error.to_string())?;
connection
.busy_timeout(std::time::Duration::from_secs(15))
@@ -153,7 +155,7 @@ fn open_connection(path: &Path) -> Result {
Ok(connection)
}
-fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> {
+pub fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> {
fs::create_dir_all(app_data).map_err(|error| error.to_string())?;
let connection = open_connection(path)?;
connection.execute_batch(
@@ -274,7 +276,7 @@ fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> {
Ok(())
}
-fn get_setting(connection: &Connection, key: &str) -> Result {
+pub fn get_setting(connection: &Connection, key: &str) -> Result {
connection
.query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| {
row.get(0)
@@ -426,7 +428,7 @@ fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool {
previous[right.len()] <= limit
}
-fn load_transfers(connection: &Connection) -> Result, String> {
+pub fn load_transfers(connection: &Connection) -> Result, String> {
let mut statement = connection.prepare("SELECT id, file_id, filename, size, progress, status, speed, destination FROM transfers ORDER BY id DESC").map_err(|error| error.to_string())?;
let rows = statement
.query_map([], |row| {
@@ -449,7 +451,7 @@ fn load_transfers(connection: &Connection) -> Result, String> {
Ok(transfers)
}
-fn snapshot(connection: &Connection) -> Result {
+pub fn snapshot(connection: &Connection) -> Result {
let files = load_files(connection, None)?;
let indexed_bytes = files.iter().map(|file| file.size).sum();
Ok(AppSnapshot {
@@ -918,6 +920,42 @@ fn search_catalog(query: String, state: State<'_, AppState>) -> Result Result {
+ index_path_with_progress(
+ connection,
+ folder,
+ &AtomicBool::new(false),
+ |_, _| {},
+ |_| {},
+ )
+}
+
+/// Watch a folder and re-index on change, without a Tauri AppHandle.
+pub fn start_folder_watcher_headless(
+ folder: PathBuf,
+ db_path: PathBuf,
+ network: Arc,
+) -> Result {
+ let scan_lock = Arc::new(Mutex::new(()));
+ let scan_cancel = Arc::new(AtomicBool::new(false));
+ start_folder_watcher(folder, db_path, network, scan_lock, scan_cancel, headless_app_handle())
+}
+
+/// Minimal stand-in for a Tauri AppHandle so the watcher closure compiles
+/// in headless mode. Progress events are dropped (no UI to notify).
+fn headless_app_handle() -> tauri::AppHandle {
+ panic!("headless_app_handle() must not be used to emit UI events")
+}
+
#[tauri::command]
async fn set_napstr_folder(
path: String,
@@ -1250,7 +1288,7 @@ fn open_napstr_folder(state: State<'_, AppState>) -> Result<(), String> {
open_with_system(&folder, "Napstr folder")
}
-fn playable_audio_path(connection: &Connection, file_id: &str) -> Result {
+pub fn playable_audio_path(connection: &Connection, file_id: &str) -> Result {
let blocked: bool = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM blocked_files WHERE file_id=?1)",
@@ -1276,7 +1314,7 @@ fn playable_audio_path(connection: &Connection, file_id: &str) -> Result Result<(), String> {
+pub fn validate_length(label: &str, value: &str, maximum: usize) -> Result<(), String> {
if value.chars().count() > maximum {
Err(format!("{label} is longer than {maximum} characters"))
} else {
@@ -1567,10 +1605,13 @@ pub fn run() {
initialise_database(&db_path, &app_data)?;
let tor = Arc::new(tor::TorManager::new(app_data, resource_dir));
let transfers = Arc::new(transfer::TransferService::new(db_path.clone(), tor.clone()));
- let network =
- network::NetworkService::new(db_path.clone(), transfers, app.handle().clone());
let scan_lock = Arc::new(Mutex::new(()));
let scan_cancel = Arc::new(AtomicBool::new(false));
+ let network = network::NetworkService::new(
+ db_path.clone(),
+ transfers,
+ Arc::new(events::TauriEmitter::new(app.handle().clone())),
+ );
*setup_shutdown_services
.lock()
.map_err(|_| "shutdown service lock was poisoned")? = Some(ShutdownServices {
diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs
index 498a16d..dfdacdc 100644
--- a/src-tauri/src/network.rs
+++ b/src-tauri/src/network.rs
@@ -16,7 +16,7 @@ use std::{
},
time::Duration,
};
-use tauri::Emitter;
+use crate::events::EventEmitter;
use tokio::sync::{Mutex, RwLock};
use uuid::Uuid;
@@ -139,7 +139,7 @@ enum SignalMessage {
pub struct NetworkService {
db_path: PathBuf,
transfers: Arc,
- app_handle: tauri::AppHandle,
+ event_emitter: Arc,
client: RwLock