Compare commits

..

3 Commits

Author SHA1 Message Date
spinline
ce10c5dfb2 refactor: replace magic indices with RtorrentField enum for type-safe parsing
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-08 22:50:26 +03:00
spinline
129a4c7586 refactor: move AddTorrentRequest to shared library for type safety
All checks were successful
Build MIPS Binary / build (push) Successful in 4m28s
2026-02-08 22:43:35 +03:00
spinline
f2dfa7963e refactor: modernize browser notifications with leptos-use and refactor utils/notification.rs
All checks were successful
Build MIPS Binary / build (push) Successful in 4m31s
2026-02-08 22:30:46 +03:00
10 changed files with 279 additions and 205 deletions

143
Cargo.lock generated
View File

@@ -310,6 +310,7 @@ dependencies = [
"serde_json", "serde_json",
"shared", "shared",
"sqlx", "sqlx",
"strum",
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"tokio", "tokio",
@@ -544,7 +545,7 @@ version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [ dependencies = [
"heck", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.114", "syn 2.0.114",
@@ -566,6 +567,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "codee"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445"
dependencies = [
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "collection_literals" name = "collection_literals"
version = "1.0.3" version = "1.0.3"
@@ -790,6 +800,41 @@ version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "5.5.3" version = "5.5.3"
@@ -823,6 +868,18 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "default-struct-builder"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.4.5" version = "0.4.5"
@@ -1144,7 +1201,9 @@ dependencies = [
name = "frontend" name = "frontend"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"chrono", "chrono",
"codee",
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"futures", "futures",
@@ -1152,9 +1211,11 @@ dependencies = [
"gloo-timers", "gloo-timers",
"js-sys", "js-sys",
"leptos", "leptos",
"leptos-use",
"leptos_router", "leptos_router",
"log", "log",
"serde", "serde",
"serde-wasm-bindgen",
"serde_json", "serde_json",
"shared", "shared",
"tailwind_fuse", "tailwind_fuse",
@@ -1476,6 +1537,12 @@ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.15.5",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -1819,6 +1886,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -1986,6 +2059,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "leptos-use"
version = "0.13.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "789bf9f4337e6ebd8f1b407e3f762fdc538d48dc145f9d1dce2338014b38f4dd"
dependencies = [
"cfg-if",
"chrono",
"codee",
"cookie",
"default-struct-builder",
"futures-util",
"gloo-timers",
"js-sys",
"lazy_static",
"leptos",
"paste",
"thiserror 1.0.69",
"unic-langid",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "leptos_config" name = "leptos_config"
version = "0.6.15" version = "0.6.15"
@@ -3613,7 +3710,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
"heck", "heck 0.5.0",
"hex", "hex",
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
@@ -3756,6 +3853,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.114",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -3934,6 +4053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"serde_core",
"zerovec", "zerovec",
] ]
@@ -4298,6 +4418,24 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.9.0" version = "2.9.0"
@@ -5055,6 +5193,7 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [ dependencies = [
"serde",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec-derive", "zerovec-derive",

View File

@@ -42,3 +42,4 @@ anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] } time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
tower_governor = "0.8.0" tower_governor = "0.8.0"
governor = "0.10.4" governor = "0.10.4"
strum = { version = "0.25", features = ["derive", "strum_macros"] }

View File

@@ -11,12 +11,10 @@ use axum::{
BoxError, BoxError,
}; };
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde::Deserialize;
use shared::{ use shared::{
GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
TorrentPeer, TorrentTracker, TorrentFile, TorrentPeer, TorrentTracker,
}; };
use utoipa::ToSchema;
pub mod auth; pub mod auth;
pub mod setup; pub mod setup;
@@ -25,13 +23,6 @@ pub mod setup;
#[folder = "../frontend/dist"] #[folder = "../frontend/dist"]
pub struct Asset; pub struct Asset;
#[derive(Deserialize, ToSchema)]
pub struct AddTorrentRequest {
/// Magnet link or Torrent file URL
#[schema(example = "magnet:?xt=urn:btih:...")]
uri: String,
}
pub async fn static_handler(uri: Uri) -> impl IntoResponse { pub async fn static_handler(uri: Uri) -> impl IntoResponse {
let mut path = uri.path().trim_start_matches('/').to_string(); let mut path = uri.path().trim_start_matches('/').to_string();

View File

@@ -125,7 +125,7 @@ struct Args {
), ),
components( components(
schemas( schemas(
handlers::AddTorrentRequest, shared::AddTorrentRequest,
shared::TorrentActionRequest, shared::TorrentActionRequest,
shared::Torrent, shared::Torrent,
shared::TorrentStatus, shared::TorrentStatus,
@@ -172,7 +172,7 @@ struct ApiDoc;
), ),
components( components(
schemas( schemas(
handlers::AddTorrentRequest, shared::AddTorrentRequest,
shared::TorrentActionRequest, shared::TorrentActionRequest,
shared::Torrent, shared::Torrent,
shared::TorrentStatus, shared::TorrentStatus,

View File

@@ -7,95 +7,80 @@ use axum::response::sse::{Event, Sse};
use futures::stream::{self, Stream}; use futures::stream::{self, Stream};
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus}; use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible; use std::convert::Infallible;
use strum::{Display, EnumString};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
// Field definitions to keep query and parser in sync #[derive(Debug, Clone, Copy, EnumString, Display, PartialEq)]
mod fields { enum RtorrentField {
pub const IDX_HASH: usize = 0; #[strum(serialize = "d.hash=")]
pub const CMD_HASH: &str = "d.hash="; Hash,
#[strum(serialize = "d.name=")]
pub const IDX_NAME: usize = 1; Name,
pub const CMD_NAME: &str = "d.name="; #[strum(serialize = "d.size_bytes=")]
Size,
pub const IDX_SIZE: usize = 2; #[strum(serialize = "d.bytes_done=")]
pub const CMD_SIZE: &str = "d.size_bytes="; Completed,
#[strum(serialize = "d.down.rate=")]
pub const IDX_COMPLETED: usize = 3; DownRate,
pub const CMD_COMPLETED: &str = "d.bytes_done="; #[strum(serialize = "d.up.rate=")]
UpRate,
pub const IDX_DOWN_RATE: usize = 4; #[strum(serialize = "d.state=")]
pub const CMD_DOWN_RATE: &str = "d.down.rate="; State,
#[strum(serialize = "d.complete=")]
pub const IDX_UP_RATE: usize = 5; Complete,
pub const CMD_UP_RATE: &str = "d.up.rate="; #[strum(serialize = "d.message=")]
Message,
pub const IDX_STATE: usize = 6; #[strum(serialize = "d.left_bytes=")]
pub const CMD_STATE: &str = "d.state="; LeftBytes,
#[strum(serialize = "d.creation_date=")]
pub const IDX_COMPLETE: usize = 7; CreationDate,
pub const CMD_COMPLETE: &str = "d.complete="; #[strum(serialize = "d.hashing=")]
Hashing,
pub const IDX_MESSAGE: usize = 8; #[strum(serialize = "d.custom1=")]
pub const CMD_MESSAGE: &str = "d.message="; Label,
pub const IDX_LEFT_BYTES: usize = 9;
pub const CMD_LEFT_BYTES: &str = "d.left_bytes=";
pub const IDX_CREATION_DATE: usize = 10;
pub const CMD_CREATION_DATE: &str = "d.creation_date=";
pub const IDX_HASHING: usize = 11;
pub const CMD_HASHING: &str = "d.hashing=";
pub const IDX_LABEL: usize = 12;
pub const CMD_LABEL: &str = "d.custom1=";
} }
use fields::*; const RTORRENT_FIELDS: &[RtorrentField] = &[
RtorrentField::Hash,
// Constants for rTorrent fields to ensure query and parser stay in sync RtorrentField::Name,
const RTORRENT_FIELDS: &[&str] = &[ RtorrentField::Size,
"", // Ignored by multicall pattern RtorrentField::Completed,
"main", // View RtorrentField::DownRate,
CMD_HASH, RtorrentField::UpRate,
CMD_NAME, RtorrentField::State,
CMD_SIZE, RtorrentField::Complete,
CMD_COMPLETED, RtorrentField::Message,
CMD_DOWN_RATE, RtorrentField::LeftBytes,
CMD_UP_RATE, RtorrentField::CreationDate,
CMD_STATE, RtorrentField::Hashing,
CMD_COMPLETE, RtorrentField::Label,
CMD_MESSAGE,
CMD_LEFT_BYTES,
CMD_CREATION_DATE,
CMD_HASHING,
CMD_LABEL,
]; ];
fn parse_long(s: Option<&String>) -> i64 { fn get_field_value(row: &Vec<String>, field: RtorrentField) -> String {
s.map(|v| v.parse().unwrap_or(0)).unwrap_or(0) let idx = RTORRENT_FIELDS.iter().position(|&f| f == field).unwrap_or(0);
row.get(idx).cloned().unwrap_or_default()
} }
fn parse_string(s: Option<&String>) -> String { fn parse_long(s: &str) -> i64 {
s.cloned().unwrap_or_default() s.parse().unwrap_or(0)
} }
/// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct /// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct
fn from_rtorrent_row(row: Vec<String>) -> Torrent { fn from_rtorrent_row(row: &Vec<String>) -> Torrent {
let hash = parse_string(row.get(IDX_HASH)); let hash = get_field_value(row, RtorrentField::Hash);
let name = parse_string(row.get(IDX_NAME)); let name = get_field_value(row, RtorrentField::Name);
let size = parse_long(row.get(IDX_SIZE)); let size = parse_long(&get_field_value(row, RtorrentField::Size));
let completed = parse_long(row.get(IDX_COMPLETED)); let completed = parse_long(&get_field_value(row, RtorrentField::Completed));
let down_rate = parse_long(row.get(IDX_DOWN_RATE)); let down_rate = parse_long(&get_field_value(row, RtorrentField::DownRate));
let up_rate = parse_long(row.get(IDX_UP_RATE)); let up_rate = parse_long(&get_field_value(row, RtorrentField::UpRate));
let state = parse_long(row.get(IDX_STATE)); let state = parse_long(&get_field_value(row, RtorrentField::State));
let is_complete = parse_long(row.get(IDX_COMPLETE)); let is_complete = parse_long(&get_field_value(row, RtorrentField::Complete));
let message = parse_string(row.get(IDX_MESSAGE)); let message = get_field_value(row, RtorrentField::Message);
let left_bytes = parse_long(row.get(IDX_LEFT_BYTES)); let left_bytes = parse_long(&get_field_value(row, RtorrentField::LeftBytes));
let added_date = parse_long(row.get(IDX_CREATION_DATE)); let added_date = parse_long(&get_field_value(row, RtorrentField::CreationDate));
let is_hashing = parse_long(row.get(IDX_HASHING)); let is_hashing = parse_long(&get_field_value(row, RtorrentField::Hashing));
let label_raw = parse_string(row.get(IDX_LABEL)); let label_raw = get_field_value(row, RtorrentField::Label);
let label = if label_raw.is_empty() { let label = if label_raw.is_empty() {
None None
@@ -146,7 +131,10 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
} }
pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, XmlRpcError> { pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, XmlRpcError> {
let params: Vec<RpcParam> = RTORRENT_FIELDS.iter().map(|s| RpcParam::from(*s)).collect(); let params: Vec<RpcParam> = RTORRENT_FIELDS
.iter()
.map(|&f| RpcParam::from(f.to_string()))
.collect();
let xml = client.call("d.multicall2", &params).await?; let xml = client.call("d.multicall2", &params).await?;
if xml.trim().is_empty() { if xml.trim().is_empty() {
@@ -155,7 +143,7 @@ pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, Xml
let rows = parse_multicall_response(&xml)?; let rows = parse_multicall_response(&xml)?;
let torrents = rows.into_iter().map(from_rtorrent_row).collect(); let torrents = rows.iter().map(from_rtorrent_row).collect();
Ok(torrents) Ok(torrents)
} }

View File

@@ -1,5 +1,4 @@
use leptos::*; use leptos::*;
use leptos_use::on_click_outside;
use leptos_use::storage::use_local_storage; use leptos_use::storage::use_local_storage;
use codee::string::FromToStringCodec; use codee::string::FromToStringCodec;
use shared::GlobalLimitRequest; use shared::GlobalLimitRequest;

View File

@@ -1,7 +1,7 @@
use leptos::*; use leptos::*;
use leptos::html::Dialog; use leptos::html::Dialog;
use crate::store::{show_toast_with_signal, TorrentStore}; use crate::store::{show_toast_with_signal, TorrentStore};
use shared::NotificationLevel; use shared::{AddTorrentRequest, NotificationLevel};
#[component] #[component]
@@ -36,9 +36,7 @@ pub fn AddTorrentModal(
set_error_msg.set(None); set_error_msg.set(None);
spawn_local(async move { spawn_local(async move {
let req_body = serde_json::json!({ let req_body = AddTorrentRequest { uri: uri_val };
"uri": uri_val
});
match gloo_net::http::Request::post("/api/torrents/add") match gloo_net::http::Request::post("/api/torrents/add")
.json(&req_body) .json(&req_body)

View File

@@ -131,6 +131,9 @@ pub fn provide_torrent_store() {
let notifications = create_rw_signal(Vec::<NotificationItem>::new()); let notifications = create_rw_signal(Vec::<NotificationItem>::new());
let user = create_rw_signal(Option::<String>::None); let user = create_rw_signal(Option::<String>::None);
// Browser notification hook
let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { let store = TorrentStore {
torrents, torrents,
filter, filter,
@@ -149,6 +152,7 @@ pub fn provide_torrent_store() {
return; return;
} }
let show_browser_notification = show_browser_notification.clone();
spawn_local(async move { spawn_local(async move {
let mut backoff_ms: u32 = 1000; // Start with 1 second let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds let max_backoff_ms: u32 = 30000; // Max 30 seconds
@@ -247,7 +251,7 @@ pub fn provide_torrent_store() {
shared::NotificationLevel::Info => " VibeTorrent", shared::NotificationLevel::Info => " VibeTorrent",
}; };
crate::utils::notification::show_notification_if_enabled( show_browser_notification(
title, title,
&n.message &n.message
); );
@@ -329,49 +333,21 @@ pub async fn subscribe_to_push_notifications() {
// First, request notification permission if not already granted // First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist"); let window = web_sys::window().expect("window should exist");
// Notification.permission is a static property, but web_sys exposes it via the Notification class instance or we check it manually. let permission = crate::utils::notification::get_notification_permission();
// Actually, Notification::permission() is a static method in web_sys. if permission == "default" {
match web_sys::Notification::permission() { log::info!("Requesting notification permission...");
web_sys::NotificationPermission::Granted => { if !crate::utils::notification::request_notification_permission().await {
log::info!("Notification permission already granted"); log::warn!("Notification permission denied by user");
}
web_sys::NotificationPermission::Denied => {
log::warn!("Notification permission was denied");
return; return;
} }
web_sys::NotificationPermission::Default => { } else if permission == "denied" {
log::info!("Requesting notification permission..."); log::warn!("Notification permission was denied");
let permission_promise = match web_sys::Notification::request_permission() { return;
Ok(p) => p,
Err(e) => {
log::error!("Failed to request notification permission: {:?}", e);
return;
}
};
match wasm_bindgen_futures::JsFuture::from(permission_promise).await {
Ok(val) => {
let permission = val.as_string().unwrap_or_default();
if permission != "granted" {
log::warn!("Notification permission denied by user");
return;
}
log::info!("Notification permission granted by user");
}
Err(e) => {
log::error!("Failed to await notification permission: {:?}", e);
return;
}
}
}
_ => {
log::warn!("Unknown notification permission status");
return;
}
} }
log::info!("Notification permission granted! Proceeding with push subscription..."); log::info!("Notification permission granted! Proceeding with push subscription...");
// Get VAPID public key from backend // Get VAPID public key from backend
let public_key_response = match Request::get("/api/push/public-key").send().await { let public_key_response = match Request::get("/api/push/public-key").send().await {
Ok(resp) => resp, Ok(resp) => resp,

View File

@@ -1,37 +1,19 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions}; use web_sys::{Notification, NotificationOptions};
use leptos::*;
use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission};
/// Request browser notification permission from user /// Request browser notification permission from user
pub async fn request_notification_permission() -> bool { pub async fn request_notification_permission() -> bool {
let window = web_sys::window().expect("no global window"); if let Ok(promise) = Notification::request_permission() {
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
// Check if Notification API is available return result.as_string().unwrap_or_default() == "granted";
if js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) {
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
.expect("Notification should exist");
// Request permission
let promise = js_sys::Reflect::get(&notification, &JsValue::from_str("requestPermission"))
.expect("requestPermission should exist");
if let Ok(function) = promise.dyn_into::<js_sys::Function>() {
if let Ok(promise) = function.call0(&notification) {
if let Ok(promise) = promise.dyn_into::<js_sys::Promise>() {
let result = wasm_bindgen_futures::JsFuture::from(promise).await;
if let Ok(permission) = result {
let permission_str = permission.as_string().unwrap_or_default();
return permission_str == "granted";
}
}
}
} }
} }
false false
} }
/// Check if browser notifications are supported and permitted /// Check if browser notifications are supported
pub fn is_notification_supported() -> bool { pub fn is_notification_supported() -> bool {
let window = web_sys::window().expect("no global window"); let window = web_sys::window().expect("no global window");
js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false)
@@ -39,68 +21,62 @@ pub fn is_notification_supported() -> bool {
/// Get current notification permission status /// Get current notification permission status
pub fn get_notification_permission() -> String { pub fn get_notification_permission() -> String {
if !is_notification_supported() { match Notification::permission() {
return "unsupported".to_string(); web_sys::NotificationPermission::Granted => "granted".to_string(),
web_sys::NotificationPermission::Denied => "denied".to_string(),
web_sys::NotificationPermission::Default => "default".to_string(),
_ => "default".to_string(),
} }
let window = web_sys::window().expect("no global window");
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
.expect("Notification should exist");
let permission = js_sys::Reflect::get(&notification, &JsValue::from_str("permission"))
.unwrap_or(JsValue::from_str("default"));
permission.as_string().unwrap_or("default".to_string())
} }
/// Show a browser notification /// Hook for using browser notifications within Leptos components or effects.
/// Returns true if notification was shown successfully /// This uses leptos-use for reactive permission tracking.
pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool { pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
// Check permission first let UseWebNotificationReturn { permission, .. } = use_web_notification();
let permission = get_notification_permission();
if permission != "granted" { move |title: &str, body: &str| {
log::warn!("Notification permission not granted: {}", permission); // Check user preference from localStorage
let window = web_sys::window().expect("no global window");
let storage = window.local_storage().ok().flatten();
let enabled = storage
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
.unwrap_or_else(|| "true".to_string());
// Use the reactive permission signal from leptos-use
if enabled == "true" && permission.get() == NotificationPermission::Granted {
show_browser_notification(title, body);
}
}
}
/// Show a browser notification (non-reactive version)
pub fn show_browser_notification(title: &str, body: &str) -> bool {
if get_notification_permission() != "granted" {
return false; return false;
} }
// Create notification options
let opts = NotificationOptions::new(); let opts = NotificationOptions::new();
opts.set_body(body); opts.set_body(body);
opts.set_icon(icon.unwrap_or("/icon-192.png")); opts.set_icon("/icon-192.png");
opts.set_badge("/icon-192.png");
opts.set_tag("vibetorrent"); opts.set_tag("vibetorrent");
opts.set_require_interaction(false);
opts.set_silent(Some(false));
// Create and show notification
match Notification::new_with_options(title, &opts) { match Notification::new_with_options(title, &opts) {
Ok(_notification) => { Ok(_) => true,
true Err(_) => false,
}
Err(e) => {
log::error!("Failed to create notification: {:?}", e);
false
}
} }
} }
/// Show notification only if enabled in settings and permission granted /// Legacy helper for showing notification if enabled in settings
pub fn show_notification_if_enabled(title: &str, body: &str) -> bool { pub fn show_notification_if_enabled(title: &str, body: &str) -> bool {
// Check localStorage for user preference
let window = web_sys::window().expect("no global window"); let window = web_sys::window().expect("no global window");
let storage = window.local_storage().ok().flatten(); let storage = window.local_storage().ok().flatten();
let enabled = storage
if let Some(storage) = storage { .and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
let enabled = storage .unwrap_or_else(|| "true".to_string());
.get_item("vibetorrent_browser_notifications")
.ok()
.flatten()
.unwrap_or("true".to_string());
if enabled == "true" { if enabled == "true" {
return show_browser_notification(title, body, None); return show_browser_notification(title, body);
}
} }
false false
} }

View File

@@ -130,3 +130,9 @@ pub struct SetLabelRequest {
pub hash: String, pub hash: String,
pub label: String, pub label: String,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct AddTorrentRequest {
#[schema(example = "magnet:?xt=urn:btih:...")]
pub uri: String,
}