22 Commits

Author SHA1 Message Date
93e6a7939f fix: define custom-protocol feature in src-tauri/Cargo.toml
All checks were successful
CI / Format, lint, build, test (push) Successful in 6m6s
CI / Build SRPM (push) Successful in 1m54s
CI / Publish to COPR (push) Successful in 20m25s
Tauri's custom-protocol feature must be declared in the app crate
and forwarded to the tauri dependency. Without this, cargo rejects
--features custom-protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:21:05 +03:00
004fa17f42 fix: enable custom-protocol feature for Tauri RPM build
Tauri only embeds frontend assets when built with the custom-protocol
feature. Without it, generate_context!() skips embedding and the
binary falls back to devUrl (localhost:5173), causing "Connection
refused" when installed from RPM.

cargo tauri build adds this feature automatically, but our spec uses
cargo build directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:32:04 +03:00
e20481b957 fix: enable custom-protocol feature for Tauri RPM build
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m2s
CI / Build SRPM (push) Successful in 2m3s
CI / Publish to COPR (push) Failing after 2m10s
Tauri only embeds frontend assets when built with the custom-protocol
feature. Without it, generate_context!() skips embedding and the
binary falls back to devUrl (localhost:5173), causing "Connection
refused" when installed from RPM.

cargo tauri build adds this feature automatically, but our spec uses
cargo build directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:27:11 +03:00
52a9d5e43d ci: add set -ex and ls verification to source tarball step
All checks were successful
CI / Format, lint, build, test (push) Successful in 5m59s
CI / Build SRPM (push) Successful in 1m27s
CI / Publish to COPR (push) Successful in 18m55s
Debug why dist/index.html isn't in the tarball despite being built
successfully in the previous step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:29:27 +03:00
f7f97d084a fix: approve esbuild build script in package.json, remove || true
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m2s
CI / Build SRPM (push) Failing after 34s
CI / Publish to COPR (push) Has been skipped
Use pnpm.onlyBuiltDependencies in both package.json files to
explicitly allow esbuild's postinstall script. This is the proper
declarative mechanism -- no runtime hacks needed in CI.

Remove all || true patterns from the build pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:19:31 +03:00
95c1164366 ci: debug frontend pre-build in rpm job
Add set -ex, fallback pnpm install, esbuild approval, and ls to
diagnose why dist/ is missing from the source tarball.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:19:31 +03:00
ca89ba18f1 ci: debug frontend pre-build in rpm job
Some checks failed
CI / Format, lint, build, test (push) Successful in 5m57s
CI / Build SRPM (push) Failing after 27s
CI / Publish to COPR (push) Has been skipped
Add set -ex, fallback pnpm install, esbuild approval, and ls to
diagnose why dist/ is missing from the source tarball.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:08:25 +03:00
81a837de99 ci: verify frontend assets are included in source tarball
Some checks failed
CI / Format, lint, build, test (push) Successful in 5m59s
CI / Build SRPM (push) Failing after 24s
CI / Publish to COPR (push) Has been skipped
Add a check that dist/index.html exists in the tarball after
generation. Fails fast if the pre-built frontend wasn't included,
which would cause the desktop app to show "Connection refused".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:00:00 +03:00
f63a8d7647 docs: add COPR badge, install instructions, and systemd section
All checks were successful
CI / Format, lint, build, test (push) Successful in 6m2s
CI / Build SRPM (push) Successful in 1m30s
CI / Publish to COPR (push) Has been skipped
- Add COPR build status badge at the top
- Add "Install from COPR" as the first section
- Add systemd service install instructions for headless server
- Add listen_addr to server config example
- Consolidate build instructions (no tauri-cli needed for release)
- Add monsoon-server to manual install section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:21:48 +03:00
24d758da1c fix: exclude debug sources from shebang mangling in RPM build
All checks were successful
CI / Format, lint, build, test (push) Successful in 6m3s
CI / Build SRPM (push) Successful in 1m51s
CI / Publish to COPR (push) Successful in 18m24s
Fedora's brp-mangle-shebangs misinterprets Rust #![allow(...)]
attributes in vendored crate sources as shebangs. Exclude
/usr/src/debug from the check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:50:01 +03:00
5ae88de22f fix: write source tarball to /tmp to avoid tar self-reference error
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m2s
CI / Build SRPM (push) Successful in 1m51s
CI / Publish to COPR (push) Failing after 16m53s
tar exits with status 1 when the output file is in the directory
being archived ("file changed as we read it"). Write to /tmp first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:23:58 +03:00
82faef4cba fix: pre-build frontends in CI, remove pnpm from RPM build
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m1s
CI / Build SRPM (push) Failing after 27s
CI / Publish to COPR (push) Has been skipped
COPR's mock chroot has no network, so pnpm install --offline fails
without vendored node_modules. Instead, build both frontends (desktop
dist/ and monsoon-web/dist/) in CI and include the built assets in
the source tarball. The spec now only needs cargo, no Node.js/pnpm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:15:15 +03:00
2396795617 chore: consolidate version to workspace, stamp all manifests from tag
Single source of truth: version in Cargo.toml [workspace.package].
Member crates inherit via version.workspace = true.

CI stamps all 7 version locations from the git tag before building:
- Cargo.toml (workspace)
- src-tauri/tauri.conf.json
- package.json (desktop frontend)
- monsoon-web/package.json (web frontend)
- monsoon.spec (RPM)

Source tarballs, SRPM, and COPR build all use the tag version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:09:33 +03:00
f2c6be22cb fix: bake tag version into spec file before SRPM build
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m1s
CI / Build SRPM (push) Successful in 1m43s
CI / Publish to COPR (push) Failing after 5m14s
COPR rebuilds the SRPM from the spec + sources, losing any --define
overrides from CI. Now the CI seds the Version field directly into
the spec before rpmbuild, so COPR sees the correct version and finds
the matching source tarballs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:58:52 +03:00
60664a084c ci: handle missing git in rpm build job
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m3s
CI / Build SRPM (push) Successful in 1m43s
CI / Publish to COPR (push) Failing after 2m5s
Fall back to tar if git isn't available on the runner. The checkout
action downloads via REST API when git is missing, so git archive
won't work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:39:05 +03:00
5a49b3e0b1 ci: tag-driven versioning and COPR publish on release tags
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m2s
CI / Build SRPM (push) Failing after 31s
CI / Publish to COPR (push) Has been skipped
- Extract version from git tag (v*) or fall back to Cargo.toml
- Pass version to rpmbuild via --define so spec uses tag version
- COPR publish only triggers on version tags, not every main push
- Spec file accepts version override via %{?version} macro

Release flow: git tag v0.2.0 && git push --tags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:25:16 +03:00
aff6acbec1 ci: use --no-net for appstream validation
Some checks failed
CI / Format, lint, build, test (push) Successful in 6m13s
CI / Build SRPM (push) Successful in 2m0s
CI / Publish to COPR (push) Failing after 24s
The runner can't reach git.lair.cafe URLs during validation due to
SSL cert issues on the internal network. Skip URL checks in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:08:11 +03:00
63eabb82ca fix: resolve all clippy warnings
Some checks failed
CI / Format, lint, build, test (push) Failing after 6m42s
CI / Build SRPM (push) Has been skipped
CI / Publish to COPR (push) Has been skipped
- Derive Default for ServerConfig instead of manual impl
- Collapse nested if statements using let-chains
- Use &Path instead of &PathBuf in function signatures
- Replace loop/match with while-let for channel recv
- Allow too_many_arguments on run_torrent_scope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:16:40 +03:00
69a9f8e0f8 style: apply cargo fmt to all workspace crates
Some checks failed
CI / Format, lint, build, test (push) Failing after 2m16s
CI / Build SRPM (push) Has been skipped
CI / Publish to COPR (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:09:05 +03:00
2d0345eb86 ci: retrigger after installing rustfmt and clippy on runner
Some checks failed
CI / Format, lint, build, test (push) Failing after 4s
CI / Build SRPM (push) Has been skipped
CI / Publish to COPR (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:08:07 +03:00
69621c22c7 ci: add Gitea Actions workflow for lint, build, test, and COPR publish
Some checks failed
CI / Format, lint, build, test (push) Failing after 3s
CI / Build SRPM (push) Has been skipped
CI / Publish to COPR (push) Has been skipped
Three-stage pipeline:
- check: cargo fmt/clippy/build/test, frontend builds, desktop/appstream validation
- rpm: generate source + vendor tarballs, build SRPM
- copr: submit SRPM to Fedora COPR (main branch and tags only)

Runs on bare Fedora hosts via act_runner, no containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:01:28 +03:00
f3b8190e68 feat: add RPM spec, systemd service, and COPR packaging
- monsoon.spec: two subpackages (monsoon desktop, monsoon-server
  headless) with vendored deps, desktop/appstream validation, and
  systemd integration
- data/monsoon-server.service: runs as dedicated monsoon user with
  StateDirectory/ConfigurationDirectory/CacheDirectory
- dist.sh: generates source + vendored dependency tarballs for
  offline builds
- .copr/Makefile: SCM integration for automated COPR builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:04:28 +03:00
20 changed files with 603 additions and 261 deletions

5
.copr/Makefile Normal file
View File

@@ -0,0 +1,5 @@
srpm:
./dist.sh
rpmbuild -bs monsoon.spec \
--define "_sourcedir $(CURDIR)" \
--define "_srcrpmdir $(outdir)"

151
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,151 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
check:
name: Format, lint, build, test
runs-on: fedora
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: cargo fmt --check --all
- name: Clippy
run: cargo clippy --workspace -- -D warnings
- name: Build (Rust)
run: cargo build --workspace
- name: Test (Rust)
run: cargo test --workspace
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Build desktop frontend
run: pnpm build
- name: Install web frontend deps
run: cd monsoon-web && pnpm install --frozen-lockfile
- name: Build web frontend
run: cd monsoon-web && pnpm build
- name: Validate desktop file
run: desktop-file-validate data/cafe.lair.monsoon.desktop
- name: Validate AppStream metadata
run: appstreamcli validate --no-net data/cafe.lair.monsoon.metainfo.xml
rpm:
name: Build SRPM
runs-on: fedora
needs: check
steps:
- uses: actions/checkout@v4
- name: Determine version
id: version
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
else
VERSION=$(sed -n '/\[workspace\.package\]/,/\[/{ s/^version *= *"\(.*\)"/\1/p }' Cargo.toml)
fi
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Building version: ${VERSION}"
- name: Stamp version into all manifests
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
# Cargo workspace version (single source of truth for Rust crates)
sed -i '/\[workspace\.package\]/,/\[/{ s/^version = ".*"/version = "'"${VERSION}"'"/ }' Cargo.toml
# Tauri
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' src-tauri/tauri.conf.json
# Frontend package.json files
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' package.json
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' monsoon-web/package.json
# RPM spec
sed -i "s/^Version:.*/Version: ${VERSION}/" monsoon.spec
echo "Stamped version ${VERSION} into all manifests"
- name: Pre-build frontends
run: |
set -ex
pnpm install --frozen-lockfile
pnpm build
ls -la dist/
cd monsoon-web
pnpm install --frozen-lockfile
pnpm build
ls -la dist/
cd ..
- name: Generate source tarball
run: |
set -ex
VERSION="${{ steps.version.outputs.VERSION }}"
# Verify dist/ exists before tarring
ls -la dist/index.html
ls -la monsoon-web/dist/index.html
# Include pre-built dist/ and monsoon-web/dist/ so the RPM build
# doesn't need Node.js/pnpm. Write to /tmp to avoid "file changed
# as we read it" from tar writing into the directory it's reading.
tar czf /tmp/monsoon-${VERSION}.tar.gz \
--transform "s,^\.,monsoon-${VERSION}," \
--exclude='./target' \
--exclude='./node_modules' \
--exclude='./monsoon-web/node_modules' \
--exclude='./vendor' \
--exclude='./.git' \
--exclude='*.tar.gz' \
--exclude='*.src.rpm' \
.
mv /tmp/monsoon-${VERSION}.tar.gz .
# Verify frontend assets are in the tarball
tar tzf monsoon-${VERSION}.tar.gz | grep "dist/index.html"
- name: Vendor Rust dependencies
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
cargo vendor vendor/
tar czf monsoon-${VERSION}-vendor.tar.gz vendor/
rm -rf vendor/
- name: Build SRPM
run: |
rpmbuild -bs monsoon.spec \
--define "_sourcedir $(pwd)" \
--define "_srcrpmdir $(pwd)"
- name: Upload SRPM artifact
uses: actions/upload-artifact@v3
with:
name: srpm
path: '*.src.rpm'
copr:
name: Publish to COPR
runs-on: fedora
needs: rpm
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download SRPM
uses: actions/download-artifact@v3
with:
name: srpm
- name: Configure copr-cli
run: |
mkdir -p ~/.config
echo "${{ secrets.COPR_CONFIG }}" > ~/.config/copr
- name: Submit build to COPR
run: copr-cli build monsoon *.src.rpm

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
/target
/dist
/node_modules
/vendor
*.tar.gz
*.src.rpm
*.log
.env

View File

@@ -7,6 +7,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "GPL-3.0-or-later"
repository = "https://git.lair.cafe/monsoon/monsoon"

View File

@@ -1,11 +1,21 @@
# Monsoon
[![Copr build status](https://copr.fedorainfracloud.org/coprs/grenade/monsoon/package/monsoon/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/grenade/monsoon/package/monsoon/)
A fast BitTorrent client for the GNOME desktop and headless servers, built on the [Vortex](https://github.com/Nehliin/vortex) io-uring engine.
Two interfaces:
- **Desktop app** -- Tauri + Svelte GUI with GNOME integration
- **Headless server** -- REST API + WebSocket + web GUI for remote/LAN deployment
## Install from COPR (Fedora)
```bash
sudo dnf copr enable grenade/monsoon
sudo dnf install monsoon # desktop app
sudo dnf install monsoon-server # headless server
```
## Screenshots
### Torrent List
@@ -20,46 +30,39 @@ Two interfaces:
### Files
![File listing](data/screenshot/torrent-files.png)
## Dependencies
## Building from Source
### Fedora
### Dependencies (Fedora)
```bash
sudo dnf install gtk3-devel webkit2gtk4.1-devel libsoup3-devel \
sudo dnf install rust cargo gcc nodejs pnpm \
gtk3-devel webkit2gtk4.1-devel libsoup3-devel \
pango-devel gdk-pixbuf2-devel glib2-devel libappindicator-gtk3-devel
```
The GTK dependencies are only needed for the desktop app. The headless server has no system library requirements beyond a working Rust toolchain.
The GTK dependencies are only needed for the desktop app. The headless server only requires `rust`, `cargo`, and `gcc`.
### Toolchain
- Rust 1.85+ (edition 2024)
- Node.js 18+
- [pnpm](https://pnpm.io)
- [Tauri CLI](https://tauri.app): `cargo install tauri-cli` (desktop app only)
## Desktop App
### Development
### Desktop App
```bash
pnpm install
cargo tauri dev
pnpm install && pnpm build # build frontend
cargo build --release -p monsoon # build desktop binary (embeds frontend)
```
### Release
For development with hot-reload: `cargo install tauri-cli && cargo tauri dev`
### Headless Server
```bash
pnpm install
cargo tauri build
cd monsoon-web && pnpm install && pnpm build && cd ..
cargo build --release -p monsoon-server
```
The release binary is at `target/release/monsoon` with the frontend assets embedded.
### Install
### Manual Install
```bash
sudo install -Dm755 target/release/monsoon /usr/local/bin/monsoon
sudo install -Dm755 target/release/monsoon-server /usr/local/bin/monsoon-server
sudo install -Dm644 data/cafe.lair.monsoon.desktop /usr/share/applications/cafe.lair.monsoon.desktop
sudo install -Dm644 data/cafe.lair.monsoon.metainfo.xml /usr/share/metainfo/cafe.lair.monsoon.metainfo.xml
sudo install -Dm644 src-tauri/icons/icon.png /usr/share/icons/hicolor/256x256/apps/cafe.lair.monsoon.png
@@ -70,18 +73,11 @@ sudo gtk-update-icon-cache /usr/share/icons/hicolor/
## Headless Server
### Building
```bash
cd monsoon-web && pnpm install && pnpm build && cd ..
cargo build --release -p monsoon-server
```
### Running
```bash
# With web GUI (run from the repo root, or set MONSOON_WEB_DIR)
cargo run --bin monsoon-server
monsoon-server
# Or with explicit web asset path
MONSOON_WEB_DIR=/path/to/monsoon-web/dist monsoon-server
@@ -89,6 +85,13 @@ MONSOON_WEB_DIR=/path/to/monsoon-web/dist monsoon-server
The server listens on `0.0.0.0:3000` by default. Open `http://server-ip:3000` in a browser for the web GUI.
### Systemd
```bash
sudo install -Dm644 data/monsoon-server.service /usr/lib/systemd/system/monsoon-server.service
sudo systemctl enable --now monsoon-server
```
### REST API
| Method | Endpoint | Description |
@@ -138,12 +141,14 @@ Configure in `~/.config/monsoon/config.toml`:
```toml
[server]
listen_addr = "0.0.0.0:3000"
max_concurrent_downloads = 3
min_peers_before_queue = 2
seed_completed_by_default = true
webhook_url = "http://localhost:8080/hook"
```
- **listen_addr** -- HTTP API and web GUI listen address (default `0.0.0.0:3000`)
- **max_concurrent_downloads** -- new torrents are queued when all slots are full; the next queued torrent auto-starts when a slot frees up
- **min_peers_before_queue** -- if a torrent has fewer peers than this and download speed is below 1 KiB/s for 30 consecutive seconds, it rotates to the back of the queue
- **seed_completed_by_default** -- when `false`, completed torrents are paused instead of seeding

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Monsoon BitTorrent Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/monsoon-server
User=monsoon
Group=monsoon
Environment=MONSOON_WEB_DIR=/usr/share/monsoon/web
StateDirectory=monsoon
ConfigurationDirectory=monsoon
CacheDirectory=monsoon
[Install]
WantedBy=multi-user.target

23
dist.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
VERSION=$(sed -n '/\[workspace\.package\]/,/\[/{ s/^version *= *"\(.*\)"/\1/p }' Cargo.toml)
NAME=monsoon
echo "Packaging ${NAME}-${VERSION}..."
# Source tarball from git
git archive --prefix=${NAME}-${VERSION}/ HEAD | gzip > ${NAME}-${VERSION}.tar.gz
echo " Created ${NAME}-${VERSION}.tar.gz"
# Vendored Rust dependencies (includes git deps like vortex)
cargo vendor vendor/
tar czf ${NAME}-${VERSION}-vendor.tar.gz vendor/
rm -rf vendor/
echo " Created ${NAME}-${VERSION}-vendor.tar.gz"
echo ""
echo "To build SRPM:"
echo " rpmbuild -bs ${NAME}.spec \\"
echo " --define \"_sourcedir \$(pwd)\" \\"
echo " --define \"_srcrpmdir \$(pwd)\""

View File

@@ -1,6 +1,6 @@
[package]
name = "monsoon-core"
version = "0.1.0"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@@ -17,7 +17,7 @@ pub struct MonsoonConfig {
pub server: ServerConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ServerConfig {
/// HTTP API listen address (default "0.0.0.0:3000")
#[serde(skip_serializing_if = "Option::is_none")]
@@ -40,18 +40,6 @@ pub struct ServerConfig {
pub webhook_url: Option<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
listen_addr: None,
max_concurrent_downloads: None,
min_peers_before_queue: None,
seed_completed_by_default: None,
webhook_url: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PathsConfig {
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -1,9 +1,9 @@
use std::collections::HashMap;
use std::net::TcpListener;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
use std::thread::JoinHandle;
use crossbeam_channel::Sender;
@@ -94,7 +94,7 @@ impl ManagedTorrent {
}
}
pub fn to_details(&self, download_dir: &PathBuf) -> TorrentDetails {
pub fn to_details(&self, download_dir: &Path) -> TorrentDetails {
TorrentDetails {
id: self.id.clone(),
name: self.snapshot.name.clone(),
@@ -148,11 +148,11 @@ impl TorrentManager {
let (event_tx, event_rx) = crossbeam_channel::unbounded();
let mut builder = Dht::builder();
if dht_cache_path.exists() {
if let Ok(list) = std::fs::read_to_string(&dht_cache_path) {
let cached_nodes: Vec<String> = list.lines().map(|l| l.to_string()).collect();
builder.extra_bootstrap(&cached_nodes);
}
if dht_cache_path.exists()
&& let Ok(list) = std::fs::read_to_string(&dht_cache_path)
{
let cached_nodes: Vec<String> = list.lines().map(|l| l.to_string()).collect();
builder.extra_bootstrap(&cached_nodes);
}
let dht = builder.build().unwrap_or_else(|e| {
log::error!("Failed to build DHT client with cache: {e}");
@@ -305,15 +305,12 @@ impl TorrentManager {
}
pub fn remove_torrent(&mut self, id: &str) -> Result<(), anyhow::Error> {
let was_downloading = self
.torrents
.get(id)
.is_some_and(|t| {
matches!(
t.snapshot.state,
TorrentState::Downloading | TorrentState::DownloadingMetadata
)
});
let was_downloading = self.torrents.get(id).is_some_and(|t| {
matches!(
t.snapshot.state,
TorrentState::Downloading | TorrentState::DownloadingMetadata
)
});
if let Some(mut t) = self.torrents.remove(id) {
self.registry.remove(&hex::encode(t.info_hash));
@@ -395,15 +392,15 @@ impl TorrentManager {
}
let id = self.queue.remove(0);
if let Some(t) = self.torrents.get_mut(&id) {
if t.snapshot.state == TorrentState::Queued {
let _ = t.cmd_tx.send(Command::Resume);
t.dht_paused.store(false, Ordering::Relaxed);
t.snapshot.state = TorrentState::Downloading;
t.snapshot.stall_ticks = 0;
log::info!("Starting queued torrent: {}", t.snapshot.name);
return Some(id);
}
if let Some(t) = self.torrents.get_mut(&id)
&& t.snapshot.state == TorrentState::Queued
{
let _ = t.cmd_tx.send(Command::Resume);
t.dht_paused.store(false, Ordering::Relaxed);
t.snapshot.state = TorrentState::Downloading;
t.snapshot.stall_ticks = 0;
log::info!("Starting queued torrent: {}", t.snapshot.name);
return Some(id);
}
// If that one wasn't valid, try the next
self.start_next_queued()
@@ -439,10 +436,10 @@ impl TorrentManager {
t.snapshot.download_speed = *download_throughput;
t.snapshot.upload_speed = *upload_throughput;
t.snapshot.num_peers = *num_peers;
if let Some(total) = t.snapshot.total_pieces {
if total > 0 {
t.snapshot.progress = *pieces_completed as f64 / total as f64;
}
if let Some(total) = t.snapshot.total_pieces
&& total > 0
{
t.snapshot.progress = *pieces_completed as f64 / total as f64;
}
// Stall detection: track consecutive ticks with low speed + low peers
@@ -461,30 +458,34 @@ impl TorrentManager {
}
// Check if this torrent should be rotated out
let should_rotate = self
.torrents
.get(id)
.is_some_and(|t| {
t.snapshot.stall_ticks >= STALL_TICKS_THRESHOLD
&& !self.queue.is_empty()
&& matches!(
t.snapshot.state,
TorrentState::Downloading | TorrentState::DownloadingMetadata
)
});
let should_rotate = self.torrents.get(id).is_some_and(|t| {
t.snapshot.stall_ticks >= STALL_TICKS_THRESHOLD
&& !self.queue.is_empty()
&& matches!(
t.snapshot.state,
TorrentState::Downloading | TorrentState::DownloadingMetadata
)
});
if should_rotate {
let id_owned = id.clone();
log::info!(
"Torrent {} stalled for {} ticks, rotating to queue",
self.torrents.get(id).map(|t| t.snapshot.name.as_str()).unwrap_or("?"),
self.torrents
.get(id)
.map(|t| t.snapshot.name.as_str())
.unwrap_or("?"),
STALL_TICKS_THRESHOLD
);
self.queue_torrent(&id_owned);
self.start_next_queued();
}
}
AggregatedEvent::StateChanged { id, new_state, name } => {
AggregatedEvent::StateChanged {
id,
new_state,
name,
} => {
if let Some(t) = self.torrents.get_mut(id) {
t.snapshot.state = new_state.clone();
if let Some(n) = name {
@@ -533,13 +534,13 @@ impl TorrentManager {
let id_owned = id.clone();
self.queue_torrent(&id_owned);
self.start_next_queued();
} else if let Some(t) = self.torrents.get(id) {
if t.snapshot.crash_count >= 3 {
log::error!(
"Torrent {} crashed 3 times, leaving stopped",
t.snapshot.name
);
}
} else if let Some(t) = self.torrents.get(id)
&& t.snapshot.crash_count >= 3
{
log::error!(
"Torrent {} crashed 3 times, leaving stopped",
t.snapshot.name
);
}
}
}
@@ -594,15 +595,16 @@ impl TorrentManager {
// Try to load metadata from download_dir/{info_hash} (saved by MetadataComplete)
let metadata_path = download_dir.join(&entry.info_hash);
// Try to load metadata from saved file or original source
let loaded_metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&metadata_path)
.ok()
.or_else(|| {
if let TorrentSource::TorrentFile { path } = &entry.source {
lava_torrent::torrent::v1::Torrent::read_from_file(path).ok()
} else {
None
}
});
let loaded_metadata =
lava_torrent::torrent::v1::Torrent::read_from_file(&metadata_path)
.ok()
.or_else(|| {
if let TorrentSource::TorrentFile { path } = &entry.source {
lava_torrent::torrent::v1::Torrent::read_from_file(path).ok()
} else {
None
}
});
let result = if let Some(metadata) = loaded_metadata {
let name = metadata.name.clone();
@@ -714,6 +716,7 @@ fn extract_trackers(metadata: Option<&TorrentMetadata>) -> Vec<String> {
}
/// Runs a single torrent inside a thread::scope, owning the heapless Queue on the stack.
#[allow(clippy::too_many_arguments)]
fn run_torrent_scope(
torrent_id: TorrentId,
peer_id: PeerId,
@@ -790,7 +793,7 @@ fn forward_event(
torrent_id: &str,
event: TorrentEvent,
tx: &Sender<AggregatedEvent>,
download_dir: &PathBuf,
download_dir: &Path,
) {
match event {
TorrentEvent::TorrentMetrics {
@@ -831,7 +834,7 @@ fn forward_event(
}
TorrentEvent::MetadataComplete(metadata) => {
// Persist metadata so we can resume without re-fetching
let root = download_dir.clone();
let root = download_dir.to_path_buf();
let info_hash = metadata.info_hash();
let meta_clone = metadata.clone();
std::thread::spawn(move || {

View File

@@ -1,6 +1,6 @@
[package]
name = "monsoon-server"
version = "0.1.0"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@@ -15,8 +15,8 @@ use monsoon_core::types::{TorrentDetails, TorrentInfo};
use serde::Deserialize;
use vortex_bittorrent::State as BtState;
use crate::ws::ws_handler;
use crate::AppState;
use crate::ws::ws_handler;
pub fn router(state: Arc<AppState>) -> Router {
let api = Router::new()
@@ -36,8 +36,9 @@ pub fn router(state: Arc<AppState>) -> Router {
// index.html for SPA client-side routing.
if let Some(web_dir) = find_web_dir() {
log::info!("Serving web GUI from {}", web_dir.display());
let serve = tower_http::services::ServeDir::new(&web_dir)
.fallback(tower_http::services::ServeFile::new(web_dir.join("index.html")));
let serve = tower_http::services::ServeDir::new(&web_dir).fallback(
tower_http::services::ServeFile::new(web_dir.join("index.html")),
);
api.fallback_service(serve)
} else {
log::warn!("No web GUI found. API-only mode.");
@@ -55,12 +56,12 @@ fn find_web_dir() -> Option<std::path::PathBuf> {
}
}
// 2. ../monsoon-web/dist relative to the binary
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
let p = parent.join("../monsoon-web/dist");
if p.join("index.html").exists() {
return Some(p);
}
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
let p = parent.join("../monsoon-web/dist");
if p.join("index.html").exists() {
return Some(p);
}
}
// 3. monsoon-web/dist relative to cwd (development)
@@ -103,8 +104,7 @@ async fn add_torrent(
let id = match req {
AddTorrentRequest::Magnet { magnet } => {
let info = parse_magnet_link(&magnet)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let info = parse_magnet_link(&magnet).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let source = TorrentSource::MagnetLink {
magnet: magnet.clone(),
@@ -120,9 +120,8 @@ async fn add_torrent(
let total_pieces = metadata.pieces.len();
let total_size = metadata.length as u64;
let metadata_ref = metadata.clone();
let bt_state =
BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let bt_state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
mgr.add_torrent(
name,
bt_state,
@@ -138,17 +137,21 @@ async fn add_torrent(
}
}
AddTorrentRequest::TorrentFile { path } => {
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&path)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid torrent file: {e}")))?;
let metadata =
lava_torrent::torrent::v1::Torrent::read_from_file(&path).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid torrent file: {e}"),
)
})?;
let name = metadata.name.clone();
let total_pieces = metadata.pieces.len();
let total_size = metadata.length as u64;
let metadata_ref = metadata.clone();
let bt_state =
BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let bt_state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let source = TorrentSource::TorrentFile {
path: PathBuf::from(&path),
@@ -177,31 +180,45 @@ async fn upload_torrent(
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() == Some("file") {
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read upload: {e}")))?;
let data = field.bytes().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to read upload: {e}"),
)
})?;
file_data = Some(data.to_vec());
break;
}
}
let data = file_data.ok_or((StatusCode::BAD_REQUEST, "No file field in upload".to_string()))?;
let data = file_data.ok_or((
StatusCode::BAD_REQUEST,
"No file field in upload".to_string(),
))?;
// Write to a temp file so lava_torrent can read it
let data_dir = {
let mgr = state.manager.lock().unwrap();
mgr.download_dir.parent().unwrap_or(&mgr.download_dir).to_path_buf()
mgr.download_dir
.parent()
.unwrap_or(&mgr.download_dir)
.to_path_buf()
};
let temp_path = data_dir.join(format!(".upload-{}.torrent", uuid::Uuid::new_v4()));
std::fs::write(&temp_path, &data)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write temp file: {e}")))?;
std::fs::write(&temp_path, &data).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write temp file: {e}"),
)
})?;
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&temp_path)
.map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
(StatusCode::BAD_REQUEST, format!("Invalid torrent file: {e}"))
})?;
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&temp_path).map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
(
StatusCode::BAD_REQUEST,
format!("Invalid torrent file: {e}"),
)
})?;
let name = metadata.name.clone();
let total_pieces = metadata.pieces.len();
@@ -212,8 +229,8 @@ async fn upload_torrent(
let download_dir = mgr.download_dir.clone();
let bt_config = mgr.bt_config;
let bt_state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| {
let bt_state =
BtState::from_metadata_and_root(metadata, download_dir, bt_config).map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
@@ -222,7 +239,14 @@ async fn upload_torrent(
path: temp_path.clone(),
};
let id = mgr
.add_torrent(name, bt_state, Some(total_size), Some(total_pieces), source, Some(&metadata_ref))
.add_torrent(
name,
bt_state,
Some(total_size),
Some(total_pieces),
source,
Some(&metadata_ref),
)
.map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())

View File

@@ -52,33 +52,24 @@ async fn main() -> anyhow::Result<()> {
// broadcasts JSON to WebSocket clients, fires webhooks.
let aggregator_state = Arc::clone(&state);
std::thread::spawn(move || {
loop {
match event_rx.recv() {
Ok(event) => {
// Update manager snapshot
if let Ok(mut mgr) = aggregator_state.manager.lock() {
mgr.apply_event(&event);
}
let json = event_to_json(&event);
// Fire webhook on torrent completion
if let AggregatedEvent::StateChanged {
ref new_state, ..
} = event
{
if *new_state == monsoon_core::types::TorrentState::Seeding {
if let Some(ref url) = webhook_url {
fire_webhook(url, &json);
}
}
}
// Broadcast to WebSocket clients
let _ = aggregator_state.event_tx.send(json);
}
Err(_) => break,
while let Ok(event) = event_rx.recv() {
// Update manager snapshot
if let Ok(mut mgr) = aggregator_state.manager.lock() {
mgr.apply_event(&event);
}
let json = event_to_json(&event);
// Fire webhook on torrent completion
if let AggregatedEvent::StateChanged { ref new_state, .. } = event
&& *new_state == monsoon_core::types::TorrentState::Seeding
&& let Some(ref url) = webhook_url
{
fire_webhook(url, &json);
}
// Broadcast to WebSocket clients
let _ = aggregator_state.event_tx.send(json);
}
});
@@ -113,7 +104,11 @@ fn event_to_json(event: &AggregatedEvent) -> String {
"numPeers": num_peers,
})
.to_string(),
AggregatedEvent::StateChanged { id, new_state, name } => serde_json::json!({
AggregatedEvent::StateChanged {
id,
new_state,
name,
} => serde_json::json!({
"type": "stateChanged",
"id": id,
"newState": new_state,

View File

@@ -1,10 +1,10 @@
use std::sync::Arc;
use axum::extract::ws::Message;
use axum::{
extract::{State, WebSocketUpgrade},
response::IntoResponse,
};
use axum::extract::ws::Message;
use crate::AppState;

View File

@@ -8,6 +8,9 @@
"build": "vite build",
"preview": "vite preview"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",

121
monsoon.spec Normal file
View File

@@ -0,0 +1,121 @@
Name: monsoon
Version: 0.1.0
Release: 1%{?dist}
Summary: A fast BitTorrent client powered by io-uring
License: GPL-3.0-or-later
URL: https://git.lair.cafe/monsoon/monsoon
Source0: %{name}-%{version}.tar.gz
Source1: %{name}-%{version}-vendor.tar.gz
ExclusiveArch: x86_64
BuildRequires: rust >= 1.85
BuildRequires: cargo
BuildRequires: gcc
BuildRequires: webkit2gtk4.1-devel
BuildRequires: gtk3-devel
BuildRequires: libsoup3-devel
BuildRequires: pango-devel
BuildRequires: gdk-pixbuf2-devel
BuildRequires: glib2-devel
BuildRequires: libappindicator-gtk3-devel
BuildRequires: desktop-file-utils
BuildRequires: libappstream-glib
BuildRequires: systemd-rpm-macros
%description
Monsoon is a modern BitTorrent client for the GNOME desktop,
built on the high-performance Vortex bittorrent engine which uses
Linux io-uring for efficient I/O. It supports magnet links,
DHT peer discovery, and simultaneous multi-torrent management.
%package server
Summary: Monsoon headless BitTorrent server with REST API
Requires(pre): shadow-utils
%description server
Headless BitTorrent daemon with REST API, WebSocket event streaming,
and web GUI for remote torrent management. Designed for deployment
on LAN servers to offload torrent downloading with completion
notifications for post-download automation.
%prep
%autosetup
tar xf %{SOURCE1}
mkdir -p .cargo
cat > .cargo/config.toml << 'EOF'
[source.crates-io]
replace-with = "vendored-sources"
[source."git+https://github.com/grenade/vortex.git?branch=fix-double-panic"]
git = "https://github.com/grenade/vortex.git"
branch = "fix-double-panic"
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
EOF
%build
# Prevent brp-mangle-shebangs from misinterpreting Rust #![...] attributes
# in vendored sources as shebangs
%global __brp_mangle_shebangs_exclude_from /usr/src/debug
# Frontends are pre-built and included in the source tarball
# (dist/ for desktop, monsoon-web/dist/ for server web GUI)
# custom-protocol feature tells Tauri to embed frontend assets into the
# binary. Without it, the app expects a local Vite dev server at :5173.
cargo build --release -p monsoon --features custom-protocol
cargo build --release -p monsoon-server
%install
# Desktop app
install -Dm755 target/release/monsoon %{buildroot}%{_bindir}/monsoon
install -Dm644 data/cafe.lair.monsoon.desktop %{buildroot}%{_datadir}/applications/cafe.lair.monsoon.desktop
install -Dm644 data/cafe.lair.monsoon.metainfo.xml %{buildroot}%{_metainfodir}/cafe.lair.monsoon.metainfo.xml
install -Dm644 src-tauri/icons/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/cafe.lair.monsoon.png
install -Dm644 src-tauri/icons/128x128.png %{buildroot}%{_datadir}/icons/hicolor/128x128/apps/cafe.lair.monsoon.png
install -Dm644 src-tauri/icons/32x32.png %{buildroot}%{_datadir}/icons/hicolor/32x32/apps/cafe.lair.monsoon.png
# Server
install -Dm755 target/release/monsoon-server %{buildroot}%{_bindir}/monsoon-server
install -Dm644 data/monsoon-server.service %{buildroot}%{_unitdir}/monsoon-server.service
install -dm755 %{buildroot}%{_datadir}/monsoon/web
cp -r monsoon-web/dist/* %{buildroot}%{_datadir}/monsoon/web/
%check
desktop-file-validate %{buildroot}%{_datadir}/applications/cafe.lair.monsoon.desktop
appstream-util validate-relax --nonet %{buildroot}%{_metainfodir}/cafe.lair.monsoon.metainfo.xml
%pre server
getent group monsoon >/dev/null || groupadd -r monsoon
getent passwd monsoon >/dev/null || useradd -r -g monsoon -d /var/lib/monsoon -s /sbin/nologin monsoon
%post server
%systemd_post monsoon-server.service
%preun server
%systemd_preun monsoon-server.service
%postun server
%systemd_postun_with_restart monsoon-server.service
%files
%license LICENSE
%doc README.md
%{_bindir}/monsoon
%{_datadir}/applications/cafe.lair.monsoon.desktop
%{_metainfodir}/cafe.lair.monsoon.metainfo.xml
%{_datadir}/icons/hicolor/*/apps/cafe.lair.monsoon.png
%files server
%license LICENSE
%doc README.md
%{_bindir}/monsoon-server
%{_unitdir}/monsoon-server.service
%{_datadir}/monsoon/
%changelog
* Sun Apr 05 2026 Rob Thijssen <grenade@rob.tn> - 0.1.0-1
- Initial package

View File

@@ -9,6 +9,9 @@
"preview": "vite preview",
"tauri": "tauri"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/api": "^2.5.0",

View File

@@ -1,9 +1,12 @@
[package]
name = "monsoon"
version = "0.1.0"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -32,8 +32,15 @@ pub fn add_torrent_file(
let source = TorrentSource::TorrentFile {
path: PathBuf::from(&path),
};
mgr.add_torrent(name, state, Some(total_size), Some(total_pieces), source, Some(&metadata_ref))
.map_err(|e| format!("Failed to add torrent: {e}"))
mgr.add_torrent(
name,
state,
Some(total_size),
Some(total_pieces),
source,
Some(&metadata_ref),
)
.map_err(|e| format!("Failed to add torrent: {e}"))
}
#[tauri::command]
@@ -65,8 +72,15 @@ pub fn add_magnet_link(
let metadata_ref = metadata.clone();
let state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| format!("Failed to initialize torrent state: {e}"))?;
mgr.add_torrent(name, state, Some(total_size), Some(total_pieces), source, Some(&metadata_ref))
.map_err(|e| format!("Failed to add torrent: {e}"))
mgr.add_torrent(
name,
state,
Some(total_size),
Some(total_pieces),
source,
Some(&metadata_ref),
)
.map_err(|e| format!("Failed to add torrent: {e}"))
} else {
let state = BtState::unstarted(info.info_hash, download_dir, bt_config);
let name = info.name.unwrap_or_else(|| hex::encode(info.info_hash));
@@ -76,30 +90,21 @@ pub fn add_magnet_link(
}
#[tauri::command]
pub fn remove_torrent(
id: String,
manager: State<'_, Mutex<TorrentManager>>,
) -> Result<(), String> {
pub fn remove_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
log::info!("Removing torrent: {id}");
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
mgr.remove_torrent(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn pause_torrent(
id: String,
manager: State<'_, Mutex<TorrentManager>>,
) -> Result<(), String> {
pub fn pause_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
log::info!("Pausing torrent: {id}");
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
mgr.pause_torrent(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn resume_torrent(
id: String,
manager: State<'_, Mutex<TorrentManager>>,
) -> Result<(), String> {
pub fn resume_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
log::info!("Resuming torrent: {id}");
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
mgr.resume_torrent(&id).map_err(|e| e.to_string())
@@ -116,9 +121,7 @@ pub fn get_torrent_details(
}
#[tauri::command]
pub fn get_torrents(
manager: State<'_, Mutex<TorrentManager>>,
) -> Result<Vec<TorrentInfo>, String> {
pub fn get_torrents(manager: State<'_, Mutex<TorrentManager>>) -> Result<Vec<TorrentInfo>, String> {
let mgr = manager.lock().map_err(|e| e.to_string())?;
Ok(mgr.get_torrents())
}

View File

@@ -72,79 +72,73 @@ pub fn run() {
// Event aggregator thread: drains events from all torrent scopes
// and emits them as Tauri events to the frontend.
std::thread::spawn(move || {
loop {
match event_rx.recv() {
Ok(event) => {
// Single lock: update snapshot and read state for emit
let emit_data = if let Ok(mut mgr) =
app_handle.state::<Mutex<TorrentManager>>().lock()
{
mgr.apply_event(&event);
match &event {
AggregatedEvent::Metrics { id, .. } => {
mgr.torrents.get(id).map(|t| {
let info = t.to_info();
(info.state, info.progress)
})
}
_ => None,
}
} else {
None
};
// Emit to frontend (outside lock)
match &event {
AggregatedEvent::Metrics {
id,
pieces_completed: _,
download_throughput,
upload_throughput,
num_peers,
} => {
let (state, progress) = emit_data.unwrap_or((
monsoon_core::types::TorrentState::Downloading,
0.0,
));
let _ = app_handle.emit(
"torrent-metrics",
TorrentMetricsEvent {
id: id.clone(),
state,
progress,
download_speed: *download_throughput,
upload_speed: *upload_throughput,
num_peers: *num_peers,
},
);
}
AggregatedEvent::StateChanged { id, new_state, name } => {
let _ = app_handle.emit(
"torrent-state-changed",
TorrentStateChangedEvent {
id: id.clone(),
old_state: new_state.clone(),
new_state: new_state.clone(),
name: name.clone(),
},
);
}
AggregatedEvent::MetadataComplete { id, metadata } => {
let _ = app_handle.emit(
"torrent-state-changed",
TorrentStateChangedEvent {
id: id.clone(),
old_state:
monsoon_core::types::TorrentState::DownloadingMetadata,
new_state:
monsoon_core::types::TorrentState::Downloading,
name: Some(metadata.name.clone()),
},
);
}
}
while let Ok(event) = event_rx.recv() {
// Single lock: update snapshot and read state for emit
let emit_data = if let Ok(mut mgr) =
app_handle.state::<Mutex<TorrentManager>>().lock()
{
mgr.apply_event(&event);
match &event {
AggregatedEvent::Metrics { id, .. } => mgr.torrents.get(id).map(|t| {
let info = t.to_info();
(info.state, info.progress)
}),
_ => None,
}
} else {
None
};
// Emit to frontend (outside lock)
match &event {
AggregatedEvent::Metrics {
id,
pieces_completed: _,
download_throughput,
upload_throughput,
num_peers,
} => {
let (state, progress) = emit_data
.unwrap_or((monsoon_core::types::TorrentState::Downloading, 0.0));
let _ = app_handle.emit(
"torrent-metrics",
TorrentMetricsEvent {
id: id.clone(),
state,
progress,
download_speed: *download_throughput,
upload_speed: *upload_throughput,
num_peers: *num_peers,
},
);
}
AggregatedEvent::StateChanged {
id,
new_state,
name,
} => {
let _ = app_handle.emit(
"torrent-state-changed",
TorrentStateChangedEvent {
id: id.clone(),
old_state: new_state.clone(),
new_state: new_state.clone(),
name: name.clone(),
},
);
}
AggregatedEvent::MetadataComplete { id, metadata } => {
let _ = app_handle.emit(
"torrent-state-changed",
TorrentStateChangedEvent {
id: id.clone(),
old_state:
monsoon_core::types::TorrentState::DownloadingMetadata,
new_state: monsoon_core::types::TorrentState::Downloading,
name: Some(metadata.name.clone()),
},
);
}
Err(_) => break, // All senders dropped
}
}
});