Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bce9ed7d2 | ||
|
b27bca436f
|
|||
|
87cb3722ec
|
|||
|
44ec4463d2
|
|||
|
266d91aa61
|
|||
|
150e6c7b4d
|
|||
|
3fcbbd4dc2
|
|||
|
17e50ca0c8
|
|||
|
e480ea4ea6
|
|||
|
c19a32a875
|
|||
|
93e6a7939f
|
|||
|
004fa17f42
|
|||
|
e20481b957
|
|||
|
52a9d5e43d
|
|||
|
f7f97d084a
|
|||
|
95c1164366
|
|||
|
ca89ba18f1
|
|||
|
81a837de99
|
|||
|
f63a8d7647
|
@@ -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
79
CLAUDE.md
Normal 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
302
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
67
README.md
67
README.md
@@ -1,11 +1,21 @@
|
||||
# Monsoon
|
||||
|
||||
[](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
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
17
monsoon.spec
17
monsoon.spec
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
130
src-tauri/src/desktop_config.rs
Normal file
130
src-tauri/src/desktop_config.rs
Normal 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
75
src-tauri/src/handler.rs
Normal 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:?"))
|
||||
}
|
||||
@@ -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!())
|
||||
|
||||
@@ -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
314
src-tauri/src/remote.rs
Normal 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
300
src-tauri/src/source.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Monsoon",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.10",
|
||||
"identifier": "cafe.lair.monsoon",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
69
src/lib/SourceSwitcher.svelte
Normal file
69
src/lib/SourceSwitcher.svelte
Normal 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>
|
||||
276
src/lib/SourcesSettings.svelte
Normal file
276
src/lib/SourcesSettings.svelte
Normal 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>
|
||||
103
src/lib/ipc.ts
103
src/lib/ipc.ts
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user