19 Commits
v0.2.1 ... main

Author SHA1 Message Date
Gitea Actions
9bce9ed7d2 chore: bump version to 0.2.10 2026-05-14 17:21:04 +00:00
b27bca436f build: pin pnpm to 10.30.3 via packageManager field
All checks were successful
CI / Format, lint, build, test (push) Successful in 9m57s
CI / Build SRPM (push) Successful in 4m17s
CI / Publish to COPR (push) Successful in 29m43s
CI / Bump version and commit back (push) Successful in 1m10s
The runner image installs pnpm via `corepack prepare pnpm@latest`,
which now resolves to pnpm 11.1.2. pnpm 11.0 removed the
`onlyBuiltDependencies` setting (replaced by `allowBuilds` in
pnpm-workspace.yaml) and now exits non-zero on ignored build
scripts, breaking esbuild's postinstall in CI even though the
package was previously whitelisted.

Pin pnpm 10.30.3 (the version used locally) via the standard
`packageManager` field so corepack downloads the matching version
in CI regardless of what's baked into the runner image. Future
pnpm 11 migration can happen deliberately when we're ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:52:48 +03:00
87cb3722ec ci: switch runner labels from fedora to rust/rpm/rust-gtk3
Some checks failed
CI / Format, lint, build, test (push) Failing after 10m45s
CI / Build SRPM (push) Has been skipped
CI / Publish to COPR (push) Has been skipped
CI / Bump version and commit back (push) Has been skipped
The `fedora` runner label was retired in the gongfoo runner fleet.
Map each job to the toolchain layer it actually needs:

- check        -> rust-gtk3  (cargo + pnpm + GTK/WebKit dev libs)
- rpm          -> rpm        (rpmbuild, copr-cli inherited)
- copr         -> rpm        (copr-publish action shells out to copr-cli)
- bump-version -> rust       (cargo check is `|| true`, no GTK needed)

pnpm, desktop-file-validate, and appstreamcli are now provided by
the base fedora image and inherited by every layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:36:08 +03:00
44ec4463d2 feat: register-magnet-handler subcommand and local/remote desktop sources
Some checks failed
CI / Format, lint, build, test (push) Has been cancelled
CI / Build SRPM (push) Has been cancelled
CI / Publish to COPR (push) Has been cancelled
CI / Bump version and commit back (push) Has been cancelled
Two related additions to the Tauri desktop app:

- New `monsoon register-magnet-handler` subcommand. Linux-only XDG
  registration that writes a user-scope cafe.lair.monsoon.desktop file
  to ~/.local/share/applications/ pointing at the canonical binary
  path with `Exec=<exe> %u` and runs `xdg-mime default ... magnet`.
  Ports the upstream vortex-cli behaviour with monsoon's AppStream id.

- Local vs remote source modes. The desktop keeps its current local
  TorrentManager (always running, default) and gains the ability to
  connect to one or more remote monsoon-server instances and act as a
  thin client. Sources, default source, and per-add target are managed
  via a new desktop.toml config and Sources settings dialog; a header
  switcher chooses the active view. All routing happens in the Rust
  backend (reqwest + tokio-tungstenite), so monsoon-server needs no
  CORS or auth changes.

When monsoon is invoked with a `magnet:` URI (either via xdg-mime at
launch or forwarded by tauri-plugin-single-instance), the AddTorrent
dialog opens pre-filled with the magnet and the target dropdown
pre-selected to the user's configured default source. The single-
instance handoff also unminimizes and focuses the main window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:30:58 +03:00
266d91aa61 docs: add CLAUDE.md with codebase guide for Claude Code
Generated via /init. Covers workspace layout, the TorrentManager +
per-torrent scope thread + event aggregator architecture, build/test
commands, the custom-protocol release-build requirement, and XDG paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:30:43 +03:00
150e6c7b4d ci: use rpm-changelog and copr-publish actions, commit version bump back
Wire in two shared actions from git.lair.cafe/actions:
- rpm-changelog@v1 generates a %changelog entry from commits since the
  previous release tag, keeping the spec in sync with git history.
- copr-publish@v1 submits the SRPM, watches the build, and dumps each
  chroot's builder-live.log so CI output is informative on failure.

Gate the rpm, copr, and bump-version jobs on v* tag refs so stamping
and publishing only fire for releases. Add a bump-version job that
re-stamps the version, re-runs rpm-changelog, and pushes the result
back to main so the repo source and the published RPM stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:49:58 +03:00
3fcbbd4dc2 fix: switch vortex dependency back to upstream after patch merged
All checks were successful
CI / Format, lint, build, test (push) Successful in 6m21s
CI / Build SRPM (push) Successful in 3m13s
CI / Publish to COPR (push) Successful in 21m27s
Our subpiece slice bounds fix has been merged into Nehliin/vortex,
so we can drop the fork and pin upstream at 3b1cb7e.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:02:34 +03:00
17e50ca0c8 fix: switch vortex dependency to fork with subpiece slice bounds fix
All checks were successful
CI / Format, lint, build, test (push) Successful in 5m30s
CI / Build SRPM (push) Successful in 2m1s
CI / Publish to COPR (push) Successful in 26m34s
Upstream vortex panics when piece_length is not a multiple of
SUBPIECE_SIZE (16384). The completion handler always slices 16384 bytes
from the offset, overflowing the buffer on the last subpiece. This
takes down the entire application via double-panic abort.

The fork (grenade/vortex@ae9dbbe) caps end_idx at piece_len and adds
a panicking() guard in Buffer::drop as defense-in-depth.

Tracking upstream merge: https://github.com/Nehliin/vortex/pull/124

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:40:51 +03:00
e480ea4ea6 fix: switch vortex dependency from fork to upstream
All checks were successful
CI / Format, lint, build, test (push) Successful in 6m1s
CI / Build SRPM (push) Successful in 1m38s
CI / Publish to COPR (push) Successful in 25m1s
Replace grenade/vortex fork (fix-double-panic branch) with upstream
Nehliin/vortex pinned to rev fbb3da44, which includes PR #129 expanding
piece request validation to prevent the double-panic at its root cause.

Also suppress a11y_click_events_have_key_events warning on the overlay
div in AddTorrent.svelte (keyboard dismiss already handled via
svelte:window keydown).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:41:23 +03:00
c19a32a875 docs: document custom-protocol feature in build instructions
All checks were successful
CI / Format, lint, build, test (push) Successful in 6m5s
CI / Build SRPM (push) Successful in 1m55s
CI / Publish to COPR (push) Has been skipped
Without --features custom-protocol, the desktop binary expects a
local Vite dev server instead of embedding the frontend assets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:20:28 +03:00
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
23 changed files with 2078 additions and 222 deletions

View File

@@ -10,7 +10,7 @@ on:
jobs:
check:
name: Format, lint, build, test
runs-on: fedora
runs-on: rust-gtk3
steps:
- uses: actions/checkout@v4
@@ -46,19 +46,18 @@ jobs:
rpm:
name: Build SRPM
runs-on: fedora
runs-on: rpm
needs: check
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- 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
VERSION="${GITHUB_REF#refs/tags/v}"
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Building version: ${VERSION}"
@@ -76,15 +75,31 @@ jobs:
sed -i "s/^Version:.*/Version: ${VERSION}/" monsoon.spec
echo "Stamped version ${VERSION} into all manifests"
- name: Generate changelog entry
uses: https://git.lair.cafe/actions/rpm-changelog@v1
with:
spec: monsoon.spec
version: ${{ steps.version.outputs.VERSION }}
- name: Pre-build frontends
run: |
set -ex
pnpm install --frozen-lockfile
pnpm build
cd monsoon-web && pnpm install --frozen-lockfile && pnpm build && cd ..
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.
@@ -99,6 +114,8 @@ jobs:
--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: |
@@ -121,7 +138,7 @@ jobs:
copr:
name: Publish to COPR
runs-on: fedora
runs-on: rpm
needs: rpm
if: startsWith(github.ref, 'refs/tags/v')
steps:
@@ -130,10 +147,58 @@ jobs:
with:
name: srpm
- name: Configure copr-cli
run: |
mkdir -p ~/.config
echo "${{ secrets.COPR_CONFIG }}" > ~/.config/copr
- name: Publish to COPR
uses: https://git.lair.cafe/actions/copr-publish@v1
with:
project: grenade/monsoon
srpm: "*.src.rpm"
copr-config: ${{ secrets.COPR_CONFIG }}
- name: Submit build to COPR
run: copr-cli build monsoon *.src.rpm
bump-version:
name: Bump version and commit back
runs-on: rust
needs: copr
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Determine version
id: version
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Stamp version into all manifests
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
sed -i '/\[workspace\.package\]/,/\[/{ s/^version = ".*"/version = "'"${VERSION}"'"/ }' Cargo.toml
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' src-tauri/tauri.conf.json
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' package.json
sed -i 's/"version": "[^"]*"/"version": "'"${VERSION}"'"/' monsoon-web/package.json
sed -i "s/^Version:.*/Version: ${VERSION}/" monsoon.spec
cargo check --workspace 2>/dev/null || true
- name: Regenerate changelog entry
uses: https://git.lair.cafe/actions/rpm-changelog@v1
with:
spec: monsoon.spec
version: ${{ steps.version.outputs.VERSION }}
- name: Commit and push
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
git config user.name "Gitea Actions"
git config user.email "actions@git.lair.cafe"
git add Cargo.toml Cargo.lock src-tauri/tauri.conf.json package.json monsoon-web/package.json monsoon.spec
if git diff --cached --quiet; then
echo "Version already at ${VERSION}"
else
git commit -m "chore: bump version to ${VERSION}"
git remote set-url origin "https://gitea-actions:${GITEA_TOKEN}@git.lair.cafe/monsoon/monsoon.git"
git push origin HEAD:main
fi

79
CLAUDE.md Normal file
View File

@@ -0,0 +1,79 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Workspace layout
Cargo workspace (Rust 2024 edition, resolver 3) with three crates, two frontends, and an RPM packaging pipeline:
- `monsoon-core/` — shared library: `config`, `manager` (TorrentManager + per-torrent scope threads), `registry` (on-disk torrent list), `magnet`, `types`. Both host crates depend on this; almost all torrent logic lives here.
- `src-tauri/` — Tauri desktop binary (crate name `monsoon`). Thin wrapper: `lib.rs` boots Tauri and the event aggregator, `commands.rs` exposes `#[tauri::command]` shims into `monsoon-core::manager::TorrentManager`.
- `monsoon-server/` — headless `monsoon-server` axum binary. REST API (`api.rs`), WebSocket event stream (`ws.rs`). Reuses the same `TorrentManager` wrapped in a `tokio::sync::Mutex` via `AppState`.
- `src/` + `package.json` (root) — Svelte 5 + Vite frontend for the desktop app. Built to `dist/`, embedded into the Tauri binary when the `custom-protocol` feature is enabled.
- `monsoon-web/` — separate Svelte 5 + Vite frontend for the headless server. Built to `monsoon-web/dist/`, served by `monsoon-server` via `tower-http::ServeDir` (found via `MONSOON_WEB_DIR`, or `../monsoon-web/dist` relative to the binary, or `monsoon-web/dist` relative to cwd).
Version is single-sourced from `[workspace.package]` in `Cargo.toml` and stamped by CI into `src-tauri/tauri.conf.json`, both `package.json` files, and `monsoon.spec`.
## Architecture: the torrent manager
`monsoon-core::manager::TorrentManager` is the single owner of all active torrents. The host (Tauri app or axum server) holds it inside a `Mutex` and is responsible for running an **event aggregator thread**:
1. Each torrent runs in its own *scope thread* spawned by `add_torrent` and managed via `run_torrent_scope` (from `vortex-bittorrent`). Communication is via `SyncSender<Command>` in, and `crossbeam_channel::Sender<AggregatedEvent>` out.
2. All scope threads share one `crossbeam_channel` receiver (`manager.event_rx`). The host clones it and spawns a thread that loops on `recv()`.
3. For every event the aggregator must call `mgr.apply_event(&event)` to update the in-memory snapshot. Then it emits to the UI: the Tauri app uses `app_handle.emit(...)`, the server broadcasts JSON over `tokio::sync::broadcast` to WebSocket clients and optionally fires `webhook_url`.
4. Persistence lives in `monsoon-core::registry::Registry` (a JSON list of `TorrentEntry { info_hash, name, source: TorrentFile|MagnetLink, paused }`). `restore_torrents()` re-adds entries on startup.
Queue management (`max_concurrent_downloads`, `min_peers_before_queue`, `STALL_TICKS_THRESHOLD`, `STALL_SPEED_THRESHOLD`) is enforced inside `TorrentManager`; stalled torrents rotate to the back of the queue.
When making changes that touch both hosts, the change usually belongs in `monsoon-core`. The Tauri commands and axum handlers should stay thin — they translate transport (Tauri IPC vs. HTTP/WS) into manager calls.
## Common commands
```bash
# Rust workspace (these are the CI gates — run them before claiming "done")
cargo fmt --check --all
cargo clippy --workspace -- -D warnings
cargo build --workspace
cargo test --workspace
# Run a single test
cargo test -p monsoon-core <test_name>
# Desktop app: build frontend (root) then the binary. custom-protocol embeds dist/.
pnpm install && pnpm build
cargo build --release -p monsoon --features custom-protocol
# Desktop dev with hot reload
cargo tauri dev
# Headless server: build its own frontend, then the binary
cd monsoon-web && pnpm install && pnpm build && cd ..
cargo build --release -p monsoon-server
MONSOON_WEB_DIR=$PWD/monsoon-web/dist ./target/release/monsoon-server
# Validate desktop/AppStream metadata (CI runs these)
desktop-file-validate data/cafe.lair.monsoon.desktop
appstreamcli validate --no-net data/cafe.lair.monsoon.metainfo.xml
```
There is no JS/TS test or lint script — frontend validation is just `pnpm build` (typecheck via svelte/vite).
## Build features and packaging
- `custom-protocol` (in `src-tauri/Cargo.toml`) — required for **release builds** of the desktop app; without it Tauri expects a running Vite dev server. Always pass `--features custom-protocol` when building `-p monsoon` outside `cargo tauri dev`.
- `release` profile is `lto = "fat"`, `codegen-units = 1`, `strip = "symbols"` — release builds are slow on purpose.
- The `vortex-bittorrent` dependency is a pinned git rev (see `[workspace.dependencies]` in `Cargo.toml`). The RPM build vendors all deps including this git source; if you bump the rev, the vendor tarball regenerates on next CI run.
- `dist.sh` produces `monsoon-<version>.tar.gz` (from `git archive`) + `monsoon-<version>-vendor.tar.gz` (from `cargo vendor`). `.copr/Makefile` calls this then `rpmbuild -bs monsoon.spec`. CI in `.gitea/workflows/ci.yml` runs the same flow on `v*` tags and publishes to the `grenade/monsoon` COPR project.
- The CI tag workflow stamps the new version into every manifest and then commits the bump back to `main` — do not also bump versions manually when cutting a release; just tag.
## XDG paths (resolved by `monsoon-core::config::resolve_paths`)
| Purpose | Default |
|---|---|
| Config | `~/.config/monsoon/config.toml` |
| Data + registry | `~/.local/share/monsoon/` (torrents.json lives here) |
| Downloads | `~/.local/share/monsoon/downloads/` |
| Logs | `~/.local/state/monsoon/monsoon.log` |
| DHT bootstrap cache | `~/.cache/monsoon/dht_bootstrap_nodes` |
Both binaries call `config::load_or_create(None)` then `config::resolve_paths(&cfg)` at startup and create the directories before initializing the manager — keep that order if adding new path-dependent state.

302
Cargo.lock generated
View File

@@ -306,7 +306,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.28.0",
"tower",
"tower-layer",
"tower-service",
@@ -594,6 +594,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
@@ -1617,8 +1623,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1628,9 +1636,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1962,6 +1972,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.7",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2500,6 +2526,12 @@ version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2651,13 +2683,14 @@ dependencies = [
[[package]]
name = "monsoon"
version = "0.1.0"
version = "0.2.10"
dependencies = [
"anyhow",
"crossbeam-channel",
"data-encoding",
"dirs",
"env_logger",
"futures-util",
"heapless",
"hex",
"lava_torrent",
@@ -2665,6 +2698,7 @@ dependencies = [
"mainline",
"mimalloc",
"monsoon-core",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
@@ -2673,13 +2707,17 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tokio",
"tokio-tungstenite 0.24.0",
"toml 1.1.2+spec-1.1.0",
"url",
"uuid",
"vortex-bittorrent",
]
[[package]]
name = "monsoon-core"
version = "0.1.0"
version = "0.2.10"
dependencies = [
"anyhow",
"crossbeam-channel",
@@ -2699,7 +2737,7 @@ dependencies = [
[[package]]
name = "monsoon-server"
version = "0.1.0"
version = "0.2.10"
dependencies = [
"anyhow",
"axum",
@@ -3479,6 +3517,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3728,6 +3821,46 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3786,6 +3919,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3814,6 +3961,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -4540,7 +4722,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -4964,6 +5146,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.51.0"
@@ -4990,6 +5187,32 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.24.0",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -4999,7 +5222,7 @@ dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
"tungstenite 0.28.0",
]
[[package]]
@@ -5251,6 +5474,26 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
@@ -5356,6 +5599,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5425,8 +5674,8 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vortex-bittorrent"
version = "0.6.0"
source = "git+https://github.com/grenade/vortex.git?branch=fix-double-panic#baebe400e801c794ee42759464b5bbcf7ecedfe5"
version = "0.6.1"
source = "git+https://github.com/Nehliin/vortex?rev=3b1cb7eb32afb388cd72a0fc6c75e1022a279274#3b1cb7eb32afb388cd72a0fc6c75e1022a279274"
dependencies = [
"ahash",
"bitvec",
@@ -5627,6 +5876,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -5683,6 +5942,24 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5913,6 +6190,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"

View File

@@ -7,7 +7,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.2.10"
edition = "2024"
license = "GPL-3.0-or-later"
repository = "https://git.lair.cafe/monsoon/monsoon"
@@ -17,7 +17,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = { version = "0.4", features = ["release_max_level_info"] }
anyhow = "1"
vortex-bittorrent = { git = "https://github.com/grenade/vortex.git", branch = "fix-double-panic" }
vortex-bittorrent = { git = "https://github.com/Nehliin/vortex", rev = "3b1cb7eb32afb388cd72a0fc6c75e1022a279274" }
mainline = { version = "6", default-features = false, features = ["node"] }
crossbeam-channel = "0.5"
dirs = "6.0"

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,41 @@ 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 --features custom-protocol # build desktop binary (embeds frontend)
```
### Release
The `custom-protocol` feature is required for release builds -- it tells Tauri to embed the frontend assets into the binary. Without it, the app expects a local Vite dev server.
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 +75,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 +87,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 +143,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

@@ -1,13 +1,17 @@
{
"name": "monsoon-web",
"private": true,
"version": "0.1.0",
"version": "0.2.10",
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",

View File

@@ -1,5 +1,5 @@
Name: monsoon
Version: 0.1.0
Version: 0.2.10
Release: 1%{?dist}
Summary: A fast BitTorrent client powered by io-uring
@@ -48,9 +48,9 @@ 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"
[source."git+https://github.com/Nehliin/vortex?rev=3b1cb7eb32afb388cd72a0fc6c75e1022a279274"]
git = "https://github.com/Nehliin/vortex"
rev = "3b1cb7eb32afb388cd72a0fc6c75e1022a279274"
replace-with = "vendored-sources"
[source.vendored-sources]
@@ -64,7 +64,10 @@ EOF
# Frontends are pre-built and included in the source tarball
# (dist/ for desktop, monsoon-web/dist/ for server web GUI)
cargo build --release -p monsoon -p monsoon-server
# 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
@@ -114,5 +117,9 @@ getent passwd monsoon >/dev/null || useradd -r -g monsoon -d /var/lib/monsoon -s
%{_datadir}/monsoon/
%changelog
* Thu May 14 2026 Gitea Actions <actions@git.lair.cafe> - 0.2.10-1
- build: pin pnpm to 10.30.3 via packageManager field
- ci: switch runner labels from fedora to rust/rpm/rust-gtk3
* Sun Apr 05 2026 Rob Thijssen <grenade@rob.tn> - 0.1.0-1
- Initial package

View File

@@ -1,14 +1,18 @@
{
"name": "monsoon",
"private": true,
"version": "0.1.0",
"version": "0.2.10",
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/api": "^2.5.0",

View File

@@ -4,6 +4,9 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -28,4 +31,10 @@ uuid = { workspace = true }
dirs = { workspace = true }
hex = { workspace = true }
data-encoding = { workspace = true }
toml = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
url = "2"
mimalloc = { version = "0.1", features = ["v3"] }

View File

@@ -1,137 +1,83 @@
use crate::manager::TorrentManager;
use monsoon_core::magnet::parse_magnet_link;
use monsoon_core::registry::TorrentSource;
use std::sync::Arc;
use monsoon_core::types::{TorrentDetails, TorrentInfo};
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::State;
use vortex_bittorrent::State as BtState;
use crate::desktop_config::{LOCAL_SOURCE_ID, RemoteServer, SourceInfo};
use crate::source::AppState;
#[tauri::command]
pub fn add_torrent_file(
pub async fn add_torrent_file(
path: String,
manager: State<'_, Mutex<TorrentManager>>,
target: Option<String>,
state: State<'_, Arc<AppState>>,
) -> Result<String, String> {
log::info!("Adding torrent from file: {path}");
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(&path)
.map_err(|e| format!("Failed to read 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 mut mgr = manager.lock().map_err(|e| e.to_string())?;
let download_dir = mgr.download_dir.clone();
let bt_config = mgr.bt_config;
let state = BtState::from_metadata_and_root(metadata, download_dir, bt_config)
.map_err(|e| format!("Failed to initialize torrent state: {e}"))?;
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}"))
log::info!("Adding torrent from file: {path} (target={target:?})");
let t = state.resolve_target(target).await?;
t.add_torrent_file(&state, &path).await
}
#[tauri::command]
pub fn add_magnet_link(
pub async fn add_magnet_link(
magnet: String,
manager: State<'_, Mutex<TorrentManager>>,
target: Option<String>,
state: State<'_, Arc<AppState>>,
) -> Result<String, String> {
log::info!("Adding magnet link: {magnet}");
let info = parse_magnet_link(&magnet)?;
let mut mgr = manager.lock().map_err(|e| e.to_string())?;
let download_dir = mgr.download_dir.clone();
let bt_config = mgr.bt_config;
let source = TorrentSource::MagnetLink {
magnet: magnet.clone(),
};
let loaded = lava_torrent::torrent::v1::Torrent::read_from_file(
download_dir.join(hex::encode(info.info_hash)),
)
.ok();
if let Some(metadata) = loaded {
let name = metadata.name.clone();
let total_pieces = metadata.pieces.len();
let total_size = metadata.length as u64;
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}"))
} else {
let state = BtState::unstarted(info.info_hash, download_dir, bt_config);
let name = info.name.unwrap_or_else(|| hex::encode(info.info_hash));
mgr.add_torrent(name, state, None, None, source, None)
.map_err(|e| format!("Failed to add torrent: {e}"))
}
log::info!("Adding magnet link: {magnet} (target={target:?})");
let t = state.resolve_target(target).await?;
t.add_magnet(&state, &magnet).await
}
#[tauri::command]
pub fn remove_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
pub async fn remove_torrent(id: String, state: State<'_, Arc<AppState>>) -> 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())
let t = state.active_target().await?;
t.remove(&state, &id).await
}
#[tauri::command]
pub fn pause_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
pub async fn pause_torrent(id: String, state: State<'_, Arc<AppState>>) -> 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())
let t = state.active_target().await?;
t.pause(&state, &id).await
}
#[tauri::command]
pub fn resume_torrent(id: String, manager: State<'_, Mutex<TorrentManager>>) -> Result<(), String> {
pub async fn resume_torrent(id: String, state: State<'_, Arc<AppState>>) -> 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())
let t = state.active_target().await?;
t.resume(&state, &id).await
}
#[tauri::command]
pub fn get_torrent_details(
pub async fn get_torrent_details(
id: String,
manager: State<'_, Mutex<TorrentManager>>,
state: State<'_, Arc<AppState>>,
) -> Result<TorrentDetails, String> {
let mgr = manager.lock().map_err(|e| e.to_string())?;
mgr.get_torrent_details(&id)
.ok_or_else(|| format!("Torrent not found: {id}"))
let t = state.active_target().await?;
t.get_details(&state, &id).await
}
#[tauri::command]
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())
pub async fn get_torrents(state: State<'_, Arc<AppState>>) -> Result<Vec<TorrentInfo>, String> {
let t = state.active_target().await?;
t.list_torrents(&state).await
}
/// For local torrents this is a filesystem path on this machine; for remote
/// torrents we don't expose a path (the file lives on the server's disk).
#[tauri::command]
pub fn get_torrent_download_path(
pub async fn get_torrent_download_path(
id: String,
manager: State<'_, Mutex<TorrentManager>>,
state: State<'_, Arc<AppState>>,
) -> Result<String, String> {
let mgr = manager.lock().map_err(|e| e.to_string())?;
let view = state.view.lock().await;
if view.source_id != LOCAL_SOURCE_ID {
return Err("Download path is only available for local torrents".to_string());
}
drop(view);
let mgr = state.local.lock().map_err(|e| e.to_string())?;
let torrent = mgr
.torrents
.get(&id)
@@ -139,7 +85,6 @@ pub fn get_torrent_download_path(
let info = torrent.to_info();
let path = mgr.download_dir.join(&info.name);
if path.exists() {
Ok(path.to_string_lossy().into_owned())
} else {
@@ -160,3 +105,64 @@ pub fn get_system_theme() -> String {
pub fn get_config() -> Result<monsoon_core::config::MonsoonConfig, String> {
monsoon_core::config::load_or_create(None).map_err(|e| e.to_string())
}
// ---- Source management ----
#[tauri::command]
pub fn list_sources(state: State<'_, Arc<AppState>>) -> Vec<SourceInfo> {
state.list_sources()
}
#[tauri::command]
pub async fn get_active_source(state: State<'_, Arc<AppState>>) -> Result<String, String> {
Ok(state.view.lock().await.source_id.clone())
}
#[tauri::command]
pub async fn set_active_source(
source_id: String,
state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
state.set_active_source(&source_id).await
}
#[tauri::command]
pub fn get_default_source(state: State<'_, Arc<AppState>>) -> String {
state.default_source_id()
}
#[tauri::command]
pub fn set_default_source(
source_id: String,
state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
cfg.set_default_source(&source_id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_remote_server(
name: String,
label: String,
url: String,
state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
cfg.add_remote(RemoteServer { name, label, url })
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_remote_server(name: String, state: State<'_, Arc<AppState>>) -> Result<(), String> {
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
cfg.remove_remote(&name).map_err(|e| e.to_string())
}
/// Return and clear any magnet URI stashed by the xdg-mime handler on
/// first launch. The frontend calls this on mount; if it returns a magnet,
/// the AddTorrent dialog is opened pre-filled.
#[tauri::command]
pub fn take_pending_magnet(state: State<'_, Arc<AppState>>) -> Option<String> {
state.pending_magnet.lock().ok().and_then(|mut p| p.take())
}

View File

@@ -0,0 +1,130 @@
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const LOCAL_SOURCE_ID: &str = "local";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DesktopConfig {
/// `"local"` or `"remote:<name>"`.
#[serde(default = "default_source_local")]
pub default_source: String,
#[serde(default, rename = "remote")]
pub remotes: Vec<RemoteServer>,
}
fn default_source_local() -> String {
LOCAL_SOURCE_ID.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteServer {
/// Stable identifier used in `default_source = "remote:<name>"` and in
/// the `target` arg of add/pause/etc. commands.
pub name: String,
/// Human-readable label for the UI.
pub label: String,
/// Base URL of the monsoon-server, e.g. `http://192.168.1.100:3000`.
pub url: String,
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("monsoon")
.join("desktop.toml")
}
impl DesktopConfig {
pub fn load() -> Self {
let path = config_path();
if !path.exists() {
return Self::default();
}
match fs::read_to_string(&path) {
Ok(contents) => toml::from_str(&contents).unwrap_or_else(|e| {
log::error!("Failed to parse desktop config: {e}");
Self::default()
}),
Err(e) => {
log::error!("Failed to read desktop config: {e}");
Self::default()
}
}
}
pub fn save(&self) -> anyhow::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
fs::write(&path, contents)?;
Ok(())
}
pub fn find_remote(&self, name: &str) -> Option<&RemoteServer> {
self.remotes.iter().find(|r| r.name == name)
}
pub fn add_remote(&mut self, remote: RemoteServer) -> anyhow::Result<()> {
if remote.name == LOCAL_SOURCE_ID {
anyhow::bail!("'{LOCAL_SOURCE_ID}' is reserved");
}
if self.find_remote(&remote.name).is_some() {
anyhow::bail!("Remote '{}' already exists", remote.name);
}
self.remotes.push(remote);
self.save()
}
pub fn remove_remote(&mut self, name: &str) -> anyhow::Result<()> {
let before = self.remotes.len();
self.remotes.retain(|r| r.name != name);
if self.remotes.len() == before {
anyhow::bail!("Remote '{name}' not found");
}
if self.default_source == format!("remote:{name}") {
self.default_source = LOCAL_SOURCE_ID.to_string();
}
self.save()
}
pub fn set_default_source(&mut self, source_id: &str) -> anyhow::Result<()> {
validate_source_id(source_id, &self.remotes)?;
self.default_source = source_id.to_string();
self.save()
}
}
fn validate_source_id(id: &str, remotes: &[RemoteServer]) -> anyhow::Result<()> {
if id == LOCAL_SOURCE_ID {
return Ok(());
}
if let Some(name) = id.strip_prefix("remote:") {
if remotes.iter().any(|r| r.name == name) {
return Ok(());
}
anyhow::bail!("Unknown remote '{name}'");
}
anyhow::bail!("Invalid source id '{id}' (expected '{LOCAL_SOURCE_ID}' or 'remote:<name>')")
}
/// Information about a configured source, returned to the frontend.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceInfo {
/// `"local"` or `"remote:<name>"`.
pub id: String,
pub label: String,
pub kind: SourceKind,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum SourceKind {
Local,
Remote,
}

75
src-tauri/src/handler.rs Normal file
View File

@@ -0,0 +1,75 @@
//! Register monsoon as the system handler for `magnet:` URIs.
//!
//! Linux-only XDG implementation (matches the upstream vortex-cli behaviour):
//! writes a user-scope `.desktop` file under `~/.local/share/applications/`
//! and points `xdg-mime` at it. Windows/macOS registration is not implemented.
use std::process::Command;
const DESKTOP_FILE_NAME: &str = "cafe.lair.monsoon.desktop";
/// Write the user-scope `.desktop` file and register it as the
/// `x-scheme-handler/magnet` handler via `xdg-mime`.
pub fn register_magnet_handler() -> anyhow::Result<()> {
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!("register-magnet-handler is only supported on Linux");
}
#[cfg(target_os = "linux")]
{
let exe_path = std::env::current_exe()?.canonicalize()?;
let applications_dir = dirs::data_dir()
.ok_or_else(|| anyhow::anyhow!("Failed to determine data directory"))?
.join("applications");
std::fs::create_dir_all(&applications_dir)?;
let desktop_file = applications_dir.join(DESKTOP_FILE_NAME);
let desktop_contents = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=Monsoon\n\
GenericName=BitTorrent Client\n\
Comment=A fast BitTorrent client powered by io-uring\n\
Exec={} %u\n\
Icon=cafe.lair.monsoon\n\
Terminal=false\n\
Categories=Network;FileTransfer;P2P;GTK;\n\
MimeType=application/x-bittorrent;x-scheme-handler/magnet;\n\
StartupNotify=true\n",
exe_path.display()
);
std::fs::write(&desktop_file, &desktop_contents)?;
let status = Command::new("xdg-mime")
.args(["default", DESKTOP_FILE_NAME, "x-scheme-handler/magnet"])
.status()?;
if !status.success() {
anyhow::bail!("xdg-mime exited with status {status}");
}
// Best-effort: refresh the desktop database so newly written
// entries are picked up without a logout.
let _ = Command::new("update-desktop-database")
.arg(&applications_dir)
.status();
println!("Registered Monsoon as the default magnet link handler.");
println!("Desktop file written to: {}", desktop_file.display());
Ok(())
}
}
/// Extract the first `magnet:` URI from the process argv (or the args
/// forwarded by `tauri-plugin-single-instance`). Returns `None` if none
/// is present.
pub fn magnet_from_args<I, S>(args: I) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter()
.map(|s| s.as_ref().to_string())
.find(|s| s.starts_with("magnet:?"))
}

View File

@@ -1,13 +1,16 @@
mod commands;
pub mod desktop_config;
pub mod handler;
mod manager;
pub mod remote;
mod source;
use manager::{AggregatedEvent, TorrentManager};
use monsoon_core::config;
use monsoon_core::types::{TorrentMetricsEvent, TorrentStateChangedEvent};
use std::sync::Mutex;
use tauri::{Emitter, Manager};
fn is_gnome_dark() -> bool {
pub fn is_gnome_dark() -> bool {
std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
@@ -15,7 +18,7 @@ fn is_gnome_dark() -> bool {
.unwrap_or(false)
}
pub fn run() {
pub fn run(initial_magnet: Option<String>) {
env_logger::init();
// Set GTK theme variant before Tauri creates the window,
@@ -48,12 +51,32 @@ pub fn run() {
manager.restore_torrents();
let event_rx = manager.event_rx.clone();
let desktop_cfg = desktop_config::DesktopConfig::load();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {}))
.manage(Mutex::new(manager))
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// Second instance forwarded its argv. If it contained a magnet,
// stash it for the frontend, emit a one-shot event so an already-
// mounted UI can react immediately, and raise the window so the
// user actually sees the dialog.
if let Some(magnet) = handler::magnet_from_args(args.iter().skip(1)) {
let state = app
.state::<std::sync::Arc<source::AppState>>()
.inner()
.clone();
if let Ok(mut pending) = state.pending_magnet.lock() {
*pending = Some(magnet.clone());
}
let _ = app.emit("magnet-received", magnet);
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}))
.invoke_handler(tauri::generate_handler![
commands::add_torrent_file,
commands::add_magnet_link,
@@ -65,18 +88,31 @@ pub fn run() {
commands::get_torrent_download_path,
commands::get_system_theme,
commands::get_config,
commands::list_sources,
commands::get_active_source,
commands::set_active_source,
commands::get_default_source,
commands::set_default_source,
commands::add_remote_server,
commands::remove_remote_server,
commands::take_pending_magnet,
])
.setup(move |app| {
let app_handle = app.handle().clone();
// Event aggregator thread: drains events from all torrent scopes
// and emits them as Tauri events to the frontend.
// Build and manage the unified AppState (local manager + config
// + active view). All commands resolve through this.
let state = source::AppState::new(manager, desktop_cfg, app_handle.clone());
app.manage(state.clone());
// Event aggregator thread: drains events from the *local*
// TorrentManager and re-emits them as Tauri events. Remote
// events are forwarded by a separate WS task spawned in
// `set_active_source` — both end up as the same event names.
let emit_handle = app_handle.clone();
std::thread::spawn(move || {
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()
{
let emit_data = if let Ok(mut mgr) = state.local.lock() {
mgr.apply_event(&event);
match &event {
AggregatedEvent::Metrics { id, .. } => mgr.torrents.get(id).map(|t| {
@@ -89,7 +125,18 @@ pub fn run() {
None
};
// Emit to frontend (outside lock)
// Only forward to the frontend if the local manager is
// the active view; otherwise the user is watching a
// remote and would be confused by stray local updates.
let active_is_local = state
.view
.try_lock()
.map(|v| v.source_id == desktop_config::LOCAL_SOURCE_ID)
.unwrap_or(true);
if !active_is_local {
continue;
}
match &event {
AggregatedEvent::Metrics {
id,
@@ -98,13 +145,13 @@ pub fn run() {
upload_throughput,
num_peers,
} => {
let (state, progress) = emit_data
let (st, progress) = emit_data
.unwrap_or((monsoon_core::types::TorrentState::Downloading, 0.0));
let _ = app_handle.emit(
let _ = emit_handle.emit(
"torrent-metrics",
TorrentMetricsEvent {
id: id.clone(),
state,
state: st,
progress,
download_speed: *download_throughput,
upload_speed: *upload_throughput,
@@ -117,7 +164,7 @@ pub fn run() {
new_state,
name,
} => {
let _ = app_handle.emit(
let _ = emit_handle.emit(
"torrent-state-changed",
TorrentStateChangedEvent {
id: id.clone(),
@@ -128,7 +175,7 @@ pub fn run() {
);
}
AggregatedEvent::MetadataComplete { id, metadata } => {
let _ = app_handle.emit(
let _ = emit_handle.emit(
"torrent-state-changed",
TorrentStateChangedEvent {
id: id.clone(),
@@ -143,6 +190,20 @@ pub fn run() {
}
});
// If the binary was launched with a magnet URI from xdg-mime,
// stash it for the frontend to pick up after mount via
// `take_pending_magnet`. We don't emit here because the frontend
// hasn't subscribed yet; the polling-on-mount path handles it.
if let Some(magnet) = initial_magnet.clone() {
let state = app
.state::<std::sync::Arc<source::AppState>>()
.inner()
.clone();
if let Ok(mut pending) = state.pending_magnet.lock() {
*pending = Some(magnet);
}
}
Ok(())
})
.run(tauri::generate_context!())

View File

@@ -6,5 +6,19 @@ use mimalloc::MiMalloc;
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
monsoon::run();
let args: Vec<String> = std::env::args().collect();
// Subcommand: register-magnet-handler. Exits without launching the GUI.
if args.iter().skip(1).any(|a| a == "register-magnet-handler") {
if let Err(e) = monsoon::handler::register_magnet_handler() {
eprintln!("Error: {e}");
std::process::exit(1);
}
return;
}
// If the binary was invoked with a `magnet:` URI (e.g. by xdg-mime),
// hand it to `run()` so it gets added once the GUI is up.
let magnet = monsoon::handler::magnet_from_args(args.iter().skip(1));
monsoon::run(magnet);
}

314
src-tauri/src/remote.rs Normal file
View File

@@ -0,0 +1,314 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt as _;
use monsoon_core::types::{
TorrentDetails, TorrentInfo, TorrentMetricsEvent, TorrentState, TorrentStateChangedEvent,
};
use serde::Deserialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter};
use tokio::sync::Notify;
use tokio_tungstenite::tungstenite::Message;
/// HTTP + WS client for a remote monsoon-server.
///
/// All HTTP calls use a shared `reqwest::Client`. The WebSocket subscription
/// runs as a tokio task spawned in `start_ws_forwarder`; it forwards each
/// JSON event to the same Tauri event names the local manager already emits,
/// so the frontend store wiring is unchanged regardless of the active source.
#[derive(Clone)]
pub struct RemoteClient {
base_url: String,
http: reqwest::Client,
}
impl RemoteClient {
pub fn new(base_url: &str) -> anyhow::Result<Self> {
let trimmed = base_url.trim_end_matches('/').to_string();
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()?;
Ok(Self {
base_url: trimmed,
http,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn list_torrents(&self) -> anyhow::Result<Vec<TorrentInfo>> {
let resp = self
.http
.get(format!("{}/api/torrents", self.base_url))
.send()
.await?
.error_for_status()?;
Ok(resp.json().await?)
}
pub async fn get_details(&self, id: &str) -> anyhow::Result<TorrentDetails> {
let resp = self
.http
.get(format!("{}/api/torrents/{id}", self.base_url))
.send()
.await?
.error_for_status()?;
Ok(resp.json().await?)
}
pub async fn add_magnet(&self, magnet: &str) -> anyhow::Result<String> {
let body = serde_json::json!({ "type": "magnet", "magnet": magnet });
let resp = self
.http
.post(format!("{}/api/torrents", self.base_url))
.json(&body)
.send()
.await?
.error_for_status()?;
let AddResp { id } = resp.json().await?;
Ok(id)
}
/// Upload a local .torrent file to a remote server. The file is read from
/// the desktop's local filesystem and POSTed as multipart so the user can
/// pick a torrent on their laptop and start the download on a remote host.
pub async fn upload_torrent_file(&self, path: &str) -> anyhow::Result<String> {
let path_buf = PathBuf::from(path);
let file_name = path_buf
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "upload.torrent".to_string());
let bytes = tokio::fs::read(&path_buf).await?;
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(file_name)
.mime_str("application/x-bittorrent")?;
let form = reqwest::multipart::Form::new().part("file", part);
let resp = self
.http
.post(format!("{}/api/torrents/upload", self.base_url))
.multipart(form)
.send()
.await?
.error_for_status()?;
let AddResp { id } = resp.json().await?;
Ok(id)
}
pub async fn remove(&self, id: &str) -> anyhow::Result<()> {
self.http
.delete(format!("{}/api/torrents/{id}", self.base_url))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn pause(&self, id: &str) -> anyhow::Result<()> {
self.http
.post(format!("{}/api/torrents/{id}/pause", self.base_url))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn resume(&self, id: &str) -> anyhow::Result<()> {
self.http
.post(format!("{}/api/torrents/{id}/resume", self.base_url))
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[derive(Deserialize)]
struct AddResp {
id: String,
}
/// Derive the WebSocket URL from an HTTP base URL.
fn ws_url(base_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(base_url)?;
let scheme = match parsed.scheme() {
"http" => "ws",
"https" => "wss",
other => anyhow::bail!("Unsupported scheme '{other}' for remote URL"),
};
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("Remote URL has no host"))?;
let port = parsed
.port_or_known_default()
.map(|p| format!(":{p}"))
.unwrap_or_default();
Ok(format!("{scheme}://{host}{port}/ws"))
}
/// Spawn a tokio task that connects to the remote `/ws`, forwards every
/// JSON event to the Tauri frontend as `torrent-metrics` /
/// `torrent-state-changed` events, and reconnects on disconnect.
///
/// The returned `Notify` is signalled by the caller to ask the forwarder
/// to stop (used when the user switches the active view to a different
/// source).
pub fn start_ws_forwarder(app: AppHandle, base_url: String) -> Arc<Notify> {
let stop = Arc::new(Notify::new());
let stop_for_task = Arc::clone(&stop);
tokio::spawn(async move {
let url = match ws_url(&base_url) {
Ok(u) => u,
Err(e) => {
log::error!("Invalid remote URL for WS: {e}");
return;
}
};
loop {
log::info!("Connecting WS forwarder to {url}");
let conn = tokio::select! {
_ = stop_for_task.notified() => {
log::info!("WS forwarder stopped before connect");
return;
}
c = tokio_tungstenite::connect_async(&url) => c,
};
match conn {
Ok((mut ws, _)) => {
log::info!("WS forwarder connected");
loop {
tokio::select! {
_ = stop_for_task.notified() => {
let _ = ws.close(None).await;
log::info!("WS forwarder stopped");
return;
}
msg = ws.next() => match msg {
Some(Ok(Message::Text(text))) => {
forward_event(&app, &text);
}
Some(Ok(Message::Binary(_))) | Some(Ok(Message::Ping(_)))
| Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => {
log::warn!("WS forwarder lost connection, will retry");
break;
}
}
}
}
}
Err(e) => {
log::warn!("WS forwarder connect failed: {e}");
}
}
// Backoff before reconnect, but exit immediately if asked.
tokio::select! {
_ = stop_for_task.notified() => return,
_ = tokio::time::sleep(Duration::from_secs(3)) => {}
}
}
});
stop
}
fn forward_event(app: &AppHandle, json: &str) {
let v: Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to parse WS event: {e}");
return;
}
};
let kind = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match kind {
"metrics" => {
let id = v
.get("id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let payload = TorrentMetricsEvent {
id,
// The server's `metrics` event doesn't carry state or
// progress, so use defaults; the frontend store will
// merge these fields onto the existing entry.
state: TorrentState::Downloading,
progress: 0.0,
download_speed: v.get("downloadSpeed").and_then(|x| x.as_u64()).unwrap_or(0),
upload_speed: v.get("uploadSpeed").and_then(|x| x.as_u64()).unwrap_or(0),
num_peers: v.get("numPeers").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
};
let _ = app.emit("torrent-metrics", payload);
}
"stateChanged" => {
let new_state = v
.get("newState")
.and_then(parse_state)
.unwrap_or(TorrentState::Downloading);
let payload = TorrentStateChangedEvent {
id: v
.get("id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
old_state: new_state.clone(),
new_state,
name: v
.get("name")
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
};
let _ = app.emit("torrent-state-changed", payload);
}
"metadataComplete" => {
let payload = TorrentStateChangedEvent {
id: v
.get("id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
old_state: TorrentState::DownloadingMetadata,
new_state: TorrentState::Downloading,
name: v
.get("name")
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
};
let _ = app.emit("torrent-state-changed", payload);
}
other => log::debug!("Ignoring WS event type '{other}'"),
}
}
fn parse_state(v: &Value) -> Option<TorrentState> {
serde_json::from_value(v.clone()).ok()
}
/// Stop a previously-started forwarder. Awaiters wake immediately; if the
/// forwarder was mid-`connect_async`, the request is dropped.
pub fn stop_forwarder(stop: &Arc<Notify>) {
stop.notify_waiters();
}
/// Marker used by sufficient-state to ensure the WS forwarder is only ever
/// active for *one* source at a time.
pub struct ForwarderHandle {
pub source_id: String,
pub stop: Arc<Notify>,
}
impl Drop for ForwarderHandle {
fn drop(&mut self) {
stop_forwarder(&self.stop);
}
}

300
src-tauri/src/source.rs Normal file
View File

@@ -0,0 +1,300 @@
use std::sync::{Arc, Mutex};
use monsoon_core::manager::TorrentManager;
use monsoon_core::types::{TorrentDetails, TorrentInfo};
use tauri::AppHandle;
use tokio::sync::Mutex as AsyncMutex;
use crate::desktop_config::{DesktopConfig, LOCAL_SOURCE_ID, SourceInfo, SourceKind};
use crate::remote::{ForwarderHandle, RemoteClient, start_ws_forwarder};
/// The desktop's full live state, owned by the Tauri runtime as a managed
/// resource. Wraps the always-running local `TorrentManager`, the loaded
/// desktop config (remotes + default source), and the currently active view.
pub struct AppState {
pub local: Mutex<TorrentManager>,
pub config: Mutex<DesktopConfig>,
pub view: AsyncMutex<View>,
pub app: AppHandle,
/// A magnet URI received via xdg-mime that the frontend hasn't picked
/// up yet. Set on first-launch (before the GUI mounts) and as a fallback
/// for second-instance handoff. Cleared by `take_pending_magnet`.
pub pending_magnet: Mutex<Option<String>>,
}
/// The "currently viewed source" — drives what `get_torrents` returns and
/// where the WS forwarder is pointing. The local manager runs regardless
/// of this value.
pub struct View {
pub source_id: String,
/// Set when `source_id` is a remote.
pub remote: Option<RemoteClient>,
/// Held only so its `Drop` impl stops the WS forwarder when the view
/// is replaced; never read after construction.
#[allow(dead_code)]
pub forwarder: Option<ForwarderHandle>,
}
impl View {
pub fn local() -> Self {
Self {
source_id: LOCAL_SOURCE_ID.to_string(),
remote: None,
forwarder: None,
}
}
}
impl AppState {
pub fn new(local: TorrentManager, config: DesktopConfig, app: AppHandle) -> Arc<Self> {
Arc::new(Self {
local: Mutex::new(local),
config: Mutex::new(config),
view: AsyncMutex::new(View::local()),
app,
pending_magnet: Mutex::new(None),
})
}
pub fn list_sources(&self) -> Vec<SourceInfo> {
let cfg = self.config.lock().unwrap();
let mut out = vec![SourceInfo {
id: LOCAL_SOURCE_ID.to_string(),
label: "Local".to_string(),
kind: SourceKind::Local,
}];
for r in &cfg.remotes {
out.push(SourceInfo {
id: format!("remote:{}", r.name),
label: r.label.clone(),
kind: SourceKind::Remote,
});
}
out
}
pub fn default_source_id(&self) -> String {
self.config.lock().unwrap().default_source.clone()
}
/// Resolve a `target` from a Tauri command. `None` means "use the current
/// active view's source"; `Some("local")` or `Some("remote:<name>")` is
/// an explicit override (e.g. from the AddTorrent dialog selector).
pub async fn resolve_target(&self, target: Option<String>) -> Result<TargetRef, String> {
let id = match target {
Some(t) if !t.is_empty() => t,
_ => self.view.lock().await.source_id.clone(),
};
self.resolve_id(&id)
}
fn resolve_id(&self, id: &str) -> Result<TargetRef, String> {
if id == LOCAL_SOURCE_ID {
return Ok(TargetRef::Local);
}
let name = id
.strip_prefix("remote:")
.ok_or_else(|| format!("Invalid source id '{id}'"))?;
let cfg = self.config.lock().unwrap();
let r = cfg
.find_remote(name)
.ok_or_else(|| format!("Unknown remote '{name}'"))?;
let client = RemoteClient::new(&r.url).map_err(|e| e.to_string())?;
Ok(TargetRef::Remote(client))
}
/// Read the active view (the source that powers `get_torrents` and `/ws`
/// forwarding). Used by read-only commands.
pub async fn active_target(&self) -> Result<TargetRef, String> {
let view = self.view.lock().await;
if view.source_id == LOCAL_SOURCE_ID {
return Ok(TargetRef::Local);
}
let client = view
.remote
.clone()
.ok_or_else(|| "Active remote view has no client".to_string())?;
Ok(TargetRef::Remote(client))
}
/// Switch the active view. Stops any in-flight WS forwarder and (for
/// remote targets) starts a new one against the new base URL.
pub async fn set_active_source(&self, source_id: &str) -> Result<(), String> {
if source_id == LOCAL_SOURCE_ID {
let mut view = self.view.lock().await;
*view = View::local();
return Ok(());
}
let name = source_id
.strip_prefix("remote:")
.ok_or_else(|| format!("Invalid source id '{source_id}'"))?;
let remote = {
let cfg = self.config.lock().unwrap();
cfg.find_remote(name)
.cloned()
.ok_or_else(|| format!("Unknown remote '{name}'"))?
};
let client = RemoteClient::new(&remote.url).map_err(|e| e.to_string())?;
let stop = start_ws_forwarder(self.app.clone(), remote.url.clone());
let mut view = self.view.lock().await;
*view = View {
source_id: source_id.to_string(),
remote: Some(client),
forwarder: Some(ForwarderHandle {
source_id: source_id.to_string(),
stop,
}),
};
Ok(())
}
}
/// A resolved dispatch target. The dispatch helpers below take this and
/// call either the local `TorrentManager` or the remote `RemoteClient`.
pub enum TargetRef {
Local,
Remote(RemoteClient),
}
impl TargetRef {
pub async fn list_torrents(&self, state: &AppState) -> Result<Vec<TorrentInfo>, String> {
match self {
TargetRef::Local => {
let mgr = state.local.lock().map_err(|e| e.to_string())?;
Ok(mgr.get_torrents())
}
TargetRef::Remote(c) => c.list_torrents().await.map_err(|e| e.to_string()),
}
}
pub async fn get_details(&self, state: &AppState, id: &str) -> Result<TorrentDetails, String> {
match self {
TargetRef::Local => {
let mgr = state.local.lock().map_err(|e| e.to_string())?;
mgr.get_torrent_details(id)
.ok_or_else(|| format!("Torrent not found: {id}"))
}
TargetRef::Remote(c) => c.get_details(id).await.map_err(|e| e.to_string()),
}
}
pub async fn add_magnet(&self, state: &AppState, magnet: &str) -> Result<String, String> {
match self {
TargetRef::Local => {
use monsoon_core::magnet::parse_magnet_link;
use monsoon_core::registry::TorrentSource;
use vortex_bittorrent::State as BtState;
let info = parse_magnet_link(magnet)?;
let mut mgr = state.local.lock().map_err(|e| e.to_string())?;
let download_dir = mgr.download_dir.clone();
let bt_config = mgr.bt_config;
let source = TorrentSource::MagnetLink {
magnet: magnet.to_string(),
};
let loaded = lava_torrent::torrent::v1::Torrent::read_from_file(
download_dir.join(hex::encode(info.info_hash)),
)
.ok();
if let Some(metadata) = loaded {
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| e.to_string())?;
mgr.add_torrent(
name,
bt_state,
Some(total_size),
Some(total_pieces),
source,
Some(&metadata_ref),
)
.map_err(|e| e.to_string())
} else {
let bt_state = BtState::unstarted(info.info_hash, download_dir, bt_config);
let name = info.name.unwrap_or_else(|| hex::encode(info.info_hash));
mgr.add_torrent(name, bt_state, None, None, source, None)
.map_err(|e| e.to_string())
}
}
TargetRef::Remote(c) => c.add_magnet(magnet).await.map_err(|e| e.to_string()),
}
}
/// Add a torrent from a local file path. For local targets this reads the
/// file directly; for remote targets this uploads the file contents over
/// multipart since the remote server can't see the desktop's filesystem.
pub async fn add_torrent_file(&self, state: &AppState, path: &str) -> Result<String, String> {
match self {
TargetRef::Local => {
use monsoon_core::registry::TorrentSource;
use std::path::PathBuf;
use vortex_bittorrent::State as BtState;
let metadata = lava_torrent::torrent::v1::Torrent::read_from_file(path)
.map_err(|e| format!("Failed to read 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 mut mgr = state.local.lock().map_err(|e| e.to_string())?;
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| format!("Failed to initialize torrent state: {e}"))?;
let source = TorrentSource::TorrentFile {
path: PathBuf::from(path),
};
mgr.add_torrent(
name,
bt_state,
Some(total_size),
Some(total_pieces),
source,
Some(&metadata_ref),
)
.map_err(|e| format!("Failed to add torrent: {e}"))
}
TargetRef::Remote(c) => c.upload_torrent_file(path).await.map_err(|e| e.to_string()),
}
}
pub async fn remove(&self, state: &AppState, id: &str) -> Result<(), String> {
match self {
TargetRef::Local => {
let mut mgr = state.local.lock().map_err(|e| e.to_string())?;
mgr.remove_torrent(id).map_err(|e| e.to_string())
}
TargetRef::Remote(c) => c.remove(id).await.map_err(|e| e.to_string()),
}
}
pub async fn pause(&self, state: &AppState, id: &str) -> Result<(), String> {
match self {
TargetRef::Local => {
let mut mgr = state.local.lock().map_err(|e| e.to_string())?;
mgr.pause_torrent(id).map_err(|e| e.to_string())
}
TargetRef::Remote(c) => c.pause(id).await.map_err(|e| e.to_string()),
}
}
pub async fn resume(&self, state: &AppState, id: &str) -> Result<(), String> {
match self {
TargetRef::Local => {
let mut mgr = state.local.lock().map_err(|e| e.to_string())?;
mgr.resume_torrent(id).map_err(|e| e.to_string())
}
TargetRef::Remote(c) => c.resume(id).await.map_err(|e| e.to_string()),
}
}
}

View File

@@ -1,6 +1,6 @@
{
"productName": "Monsoon",
"version": "0.1.0",
"version": "0.2.10",
"identifier": "cafe.lair.monsoon",
"build": {
"frontendDist": "../dist",

View File

@@ -1,16 +1,54 @@
<script lang="ts">
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import TorrentList from "./lib/TorrentList.svelte";
import TorrentDetail from "./lib/TorrentDetail.svelte";
import AddTorrent from "./lib/AddTorrent.svelte";
import SourceSwitcher from "./lib/SourceSwitcher.svelte";
import SourcesSettings from "./lib/SourcesSettings.svelte";
import { takePendingMagnet } from "./lib/ipc";
let showAddDialog = $state(false);
let showSourcesDialog = $state(false);
let pendingMagnet = $state<string | null>(null);
let selectedTorrentId = $state<string | null>(null);
function openAddDialog(magnet: string | null) {
pendingMagnet = magnet;
showAddDialog = true;
}
function closeAddDialog() {
showAddDialog = false;
pendingMagnet = null;
}
// One-shot mount: drain any magnet stashed at first-launch, then
// subscribe to subsequent magnets handed off via single-instance.
$effect(() => {
let unlisten: UnlistenFn | null = null;
let cancelled = false;
(async () => {
const initial = await takePendingMagnet();
if (!cancelled && initial) openAddDialog(initial);
unlisten = await listen<string>("magnet-received", (e) => {
openAddDialog(e.payload);
});
})();
return () => {
cancelled = true;
unlisten?.();
};
});
</script>
<header class="headerbar">
<h1 class="title">Monsoon</h1>
<div class="actions">
<button class="primary" onclick={() => (showAddDialog = true)}>
<SourceSwitcher onmanage={() => (showSourcesDialog = true)} />
<button class="primary" onclick={() => openAddDialog(null)}>
Add Torrent
</button>
</div>
@@ -28,7 +66,11 @@
</main>
{#if showAddDialog}
<AddTorrent onclose={() => (showAddDialog = false)} />
<AddTorrent onclose={closeAddDialog} initialMagnet={pendingMagnet} />
{/if}
{#if showSourcesDialog}
<SourcesSettings onclose={() => (showSourcesDialog = false)} />
{/if}
<style>
@@ -46,6 +88,9 @@
.headerbar .actions {
-webkit-app-region: no-drag;
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.title {

View File

@@ -1,10 +1,26 @@
<script lang="ts">
import { untrack } from "svelte";
import { open } from "@tauri-apps/plugin-dialog";
import { addTorrentFile, addMagnetLink } from "./ipc";
import { refreshTorrents } from "./stores";
import {
activeSource,
defaultSource,
refreshTorrents,
sources,
} from "./stores";
let { onclose }: { onclose: () => void } = $props();
let magnetInput = $state("");
let {
onclose,
initialMagnet = null,
}: { onclose: () => void; initialMagnet?: string | null } = $props();
// The dialog is unmounted on close, so capturing the prop value once at
// mount is the right semantics.
let magnetInput = $state(untrack(() => initialMagnet) ?? "");
// Default the dropdown to the user's configured default source if it
// differs from the currently viewed source — otherwise default to the
// current view (least surprise).
let target = $state($defaultSource || $activeSource);
async function handleFile() {
const selected = await open({
@@ -12,7 +28,7 @@
filters: [{ name: "Torrent", extensions: ["torrent"] }],
});
if (selected) {
await addTorrentFile(selected);
await addTorrentFile(selected, target);
refreshTorrents();
onclose();
}
@@ -20,7 +36,7 @@
async function handleMagnet() {
if (magnetInput.trim()) {
await addMagnetLink(magnetInput.trim());
await addMagnetLink(magnetInput.trim(), target);
refreshTorrents();
onclose();
}
@@ -33,6 +49,7 @@
<svelte:window on:keydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="overlay" role="presentation" onclick={onclose}>
<!-- svelte-ignore a11y_interactive_supports_focus a11y_click_events_have_key_events -->
<div
@@ -43,6 +60,17 @@
>
<h2>Add Torrent</h2>
{#if $sources.length > 1}
<div class="section">
<label for="target-select">Download on</label>
<select id="target-select" bind:value={target}>
{#each $sources as src (src.id)}
<option value={src.id}>{src.label}</option>
{/each}
</select>
</div>
{/if}
<div class="section">
<button class="primary" onclick={handleFile}>Open .torrent file</button>
</div>
@@ -99,6 +127,10 @@
gap: 8px;
}
.section + .section {
margin-top: 12px;
}
.divider {
text-align: center;
color: var(--fg-secondary);
@@ -111,7 +143,8 @@
font-weight: 500;
}
input {
input,
select {
font-family: inherit;
font-size: 14px;
padding: 8px 12px;
@@ -122,7 +155,8 @@
outline: none;
}
input:focus {
input:focus,
select:focus {
border-color: var(--accent);
}

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { setActiveSource } from "./ipc";
import {
activeSource,
refreshSources,
refreshTorrents,
sources,
} from "./stores";
let { onmanage }: { onmanage: () => void } = $props();
async function pick(id: string) {
if (id === $activeSource) return;
await setActiveSource(id);
activeSource.set(id);
await refreshTorrents();
}
// Refresh once on mount so the dropdown shows whatever the desktop
// config persists across restarts.
refreshSources();
</script>
<div class="switcher">
<select
aria-label="Source"
value={$activeSource}
onchange={(e) => pick((e.target as HTMLSelectElement).value)}
>
{#each $sources as src (src.id)}
<option value={src.id}>{src.label}</option>
{/each}
</select>
<button class="manage" onclick={onmanage} title="Manage sources"></button>
</div>
<style>
.switcher {
display: flex;
align-items: center;
gap: 4px;
}
select {
font-family: inherit;
font-size: 13px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--fg-primary);
-webkit-app-region: no-drag;
}
.manage {
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--fg-secondary);
cursor: pointer;
-webkit-app-region: no-drag;
}
.manage:hover {
background: var(--bg-secondary);
color: var(--fg-primary);
}
</style>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import {
addRemoteServer,
removeRemoteServer,
setDefaultSource,
} from "./ipc";
import { defaultSource, refreshSources, sources } from "./stores";
let { onclose }: { onclose: () => void } = $props();
let nameInput = $state("");
let labelInput = $state("");
let urlInput = $state("");
let error = $state<string | null>(null);
async function setDefault(id: string) {
error = null;
try {
await setDefaultSource(id);
defaultSource.set(id);
} catch (e) {
error = String(e);
}
}
async function addRemote() {
error = null;
const name = nameInput.trim();
const label = labelInput.trim() || name;
const url = urlInput.trim();
if (!name || !url) {
error = "Name and URL are required";
return;
}
try {
await addRemoteServer(name, label, url);
nameInput = "";
labelInput = "";
urlInput = "";
await refreshSources();
} catch (e) {
error = String(e);
}
}
async function removeRemote(name: string) {
error = null;
try {
await removeRemoteServer(name);
await refreshSources();
} catch (e) {
error = String(e);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
</script>
<svelte:window on:keydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="overlay" role="presentation" onclick={onclose}>
<!-- svelte-ignore a11y_interactive_supports_focus a11y_click_events_have_key_events -->
<div
class="dialog"
role="dialog"
aria-label="Sources"
onclick={(e) => e.stopPropagation()}
>
<h2>Sources</h2>
<p class="hint">
Local downloads run on this machine. Remote sources delegate downloads to
a <code>monsoon-server</code> running elsewhere. The default source is
used when adding torrents via the system magnet handler.
</p>
<ul class="source-list">
{#each $sources as src (src.id)}
<li>
<div class="row">
<div class="info">
<span class="label">{src.label}</span>
<span class="kind">{src.kind}</span>
</div>
<div class="actions">
<label class="default-toggle">
<input
type="radio"
name="default-source"
checked={$defaultSource === src.id}
onchange={() => setDefault(src.id)}
/>
Default
</label>
{#if src.kind === "remote"}
<button
class="danger"
onclick={() => removeRemote(src.id.replace(/^remote:/, ""))}
>
Remove
</button>
{/if}
</div>
</div>
</li>
{/each}
</ul>
<h3>Add remote server</h3>
<div class="form">
<label for="src-name">Name (id)</label>
<input id="src-name" type="text" bind:value={nameInput} placeholder="home-server" />
<label for="src-label">Label</label>
<input id="src-label" type="text" bind:value={labelInput} placeholder="Home Server" />
<label for="src-url">URL</label>
<input id="src-url" type="text" bind:value={urlInput} placeholder="http://192.168.1.100:3000" />
<button class="primary" onclick={addRemote}>Add</button>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="footer">
<button onclick={onclose}>Close</button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: var(--bg-primary);
border-radius: 12px;
padding: 24px;
min-width: 480px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
h2 {
font-size: 18px;
margin-bottom: 8px;
}
h3 {
font-size: 14px;
margin: 16px 0 8px;
}
.hint {
color: var(--fg-secondary);
font-size: 12px;
margin-bottom: 16px;
}
.source-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: var(--radius);
}
.info {
display: flex;
flex-direction: column;
}
.label {
font-weight: 500;
}
.kind {
font-size: 11px;
color: var(--fg-secondary);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.default-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--fg-secondary);
}
.form {
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
gap: 8px 12px;
}
.form button {
grid-column: 2;
justify-self: start;
}
label {
font-size: 13px;
font-weight: 500;
}
input {
font-family: inherit;
font-size: 14px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--fg-primary);
outline: none;
}
input:focus {
border-color: var(--accent);
}
.danger {
background: transparent;
border: 1px solid var(--border);
color: #c74343;
padding: 4px 10px;
border-radius: var(--radius);
cursor: pointer;
}
.danger:hover {
background: rgba(199, 67, 67, 0.1);
}
.error {
color: #c74343;
font-size: 13px;
margin-top: 8px;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -15,6 +15,7 @@ export interface TorrentInfo {
}
export type TorrentState =
| "queued"
| "downloadingMetadata"
| "downloading"
| "seeding"
@@ -26,12 +27,38 @@ export interface MonsoonConfig {
port: number | null;
}
export async function addTorrentFile(path: string): Promise<string> {
return invoke("add_torrent_file", { path });
export interface TorrentDetails extends TorrentInfo {
pieceLength: number | null;
downloadPath: string;
files: { path: string; size: number }[];
trackers: string[];
comment: string | null;
createdBy: string | null;
}
export async function addMagnetLink(magnet: string): Promise<string> {
return invoke("add_magnet_link", { magnet });
export type SourceKind = "local" | "remote";
export interface SourceInfo {
/** "local" or "remote:<name>" */
id: string;
label: string;
kind: SourceKind;
}
export const LOCAL_SOURCE_ID = "local";
export async function addTorrentFile(
path: string,
target?: string,
): Promise<string> {
return invoke("add_torrent_file", { path, target });
}
export async function addMagnetLink(
magnet: string,
target?: string,
): Promise<string> {
return invoke("add_magnet_link", { magnet, target });
}
export async function removeTorrent(id: string): Promise<void> {
@@ -50,31 +77,6 @@ export async function getTorrents(): Promise<TorrentInfo[]> {
return invoke("get_torrents");
}
export interface TorrentDetails {
id: string;
name: string;
infoHash: string;
state: TorrentState;
progress: number;
downloadSpeed: number;
uploadSpeed: number;
numPeers: number;
totalSize: number | null;
pieceLength: number | null;
totalPieces: number | null;
completedPieces: number | null;
downloadPath: string;
files: FileEntry[];
trackers: string[];
comment: string | null;
createdBy: string | null;
}
export interface FileEntry {
path: string;
size: number;
}
export async function getTorrentDetails(id: string): Promise<TorrentDetails> {
return invoke("get_torrent_details", { id });
}
@@ -83,6 +85,49 @@ export async function getTorrentDownloadPath(id: string): Promise<string> {
return invoke("get_torrent_download_path", { id });
}
export async function getSystemTheme(): Promise<"dark" | "light"> {
return invoke("get_system_theme");
}
export async function getConfig(): Promise<MonsoonConfig> {
return invoke("get_config");
}
// ---- Source management ----
export async function listSources(): Promise<SourceInfo[]> {
return invoke("list_sources");
}
export async function getActiveSource(): Promise<string> {
return invoke("get_active_source");
}
export async function setActiveSource(sourceId: string): Promise<void> {
return invoke("set_active_source", { sourceId });
}
export async function getDefaultSource(): Promise<string> {
return invoke("get_default_source");
}
export async function setDefaultSource(sourceId: string): Promise<void> {
return invoke("set_default_source", { sourceId });
}
export async function addRemoteServer(
name: string,
label: string,
url: string,
): Promise<void> {
return invoke("add_remote_server", { name, label, url });
}
export async function removeRemoteServer(name: string): Promise<void> {
return invoke("remove_remote_server", { name });
}
/** Returns and clears any magnet URI stashed by the xdg-mime handler. */
export async function takePendingMagnet(): Promise<string | null> {
return invoke("take_pending_magnet");
}

View File

@@ -1,15 +1,37 @@
import { writable } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import type { TorrentInfo } from "./ipc";
import { getTorrents } from "./ipc";
import type { SourceInfo, TorrentInfo } from "./ipc";
import {
LOCAL_SOURCE_ID,
getActiveSource,
getDefaultSource,
getTorrents,
listSources,
} from "./ipc";
export const torrents = writable<TorrentInfo[]>([]);
export const sources = writable<SourceInfo[]>([
{ id: LOCAL_SOURCE_ID, label: "Local", kind: "local" },
]);
export const activeSource = writable<string>(LOCAL_SOURCE_ID);
export const defaultSource = writable<string>(LOCAL_SOURCE_ID);
export async function refreshTorrents() {
const list = await getTorrents();
torrents.set(list);
}
export async function refreshSources() {
const [list, active, def] = await Promise.all([
listSources(),
getActiveSource(),
getDefaultSource(),
]);
sources.set(list);
activeSource.set(active);
defaultSource.set(def);
}
interface MetricsPayload {
id: string;
state: TorrentInfo["state"];
@@ -27,13 +49,17 @@ listen<MetricsPayload>("torrent-metrics", (event) => {
);
});
listen<{ id: string; newState: TorrentInfo["state"] }>(
listen<{ id: string; newState: TorrentInfo["state"]; name?: string | null }>(
"torrent-state-changed",
(event) => {
torrents.update((list) =>
list.map((t) =>
t.id === event.payload.id
? { ...t, state: event.payload.newState }
? {
...t,
state: event.payload.newState,
...(event.payload.name ? { name: event.payload.name } : {}),
}
: t,
),
);