feat: Add push notification support with VAPID keys
- Backend: web-push integration with VAPID keys - Push subscription endpoints (GET /api/push/public-key, POST /api/push/subscribe) - In-memory subscription store - Frontend: Auto-subscribe to push after notification permission granted - Service Worker: Push event handler - Send push notifications when torrents complete - Works even when browser is closed
This commit is contained in:
1218
Cargo.lock
generated
1218
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -26,3 +26,5 @@ thiserror = "2.0.18"
|
|||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||||
|
web-push = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
push,
|
||||||
xmlrpc::{self, RpcParam},
|
xmlrpc::{self, RpcParam},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
@@ -673,3 +674,39 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- PUSH NOTIFICATION HANDLERS ---
|
||||||
|
|
||||||
|
/// Get VAPID public key for push subscription
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/push/public-key",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "VAPID public key", body = String)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_push_public_key_handler() -> impl IntoResponse {
|
||||||
|
let public_key = push::get_vapid_public_key();
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to push notifications
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/push/subscribe",
|
||||||
|
request_body = push::PushSubscription,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Subscription saved"),
|
||||||
|
(status = 400, description = "Invalid subscription data")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn subscribe_push_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(subscription): Json<push::PushSubscription>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
tracing::info!("Received push subscription: {:?}", subscription);
|
||||||
|
|
||||||
|
state.push_store.add_subscription(subscription).await;
|
||||||
|
|
||||||
|
(StatusCode::OK, "Subscription saved").into_response()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod diff;
|
mod diff;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod push;
|
||||||
mod scgi;
|
mod scgi;
|
||||||
mod sse;
|
mod sse;
|
||||||
mod xmlrpc;
|
mod xmlrpc;
|
||||||
@@ -30,6 +31,7 @@ pub struct AppState {
|
|||||||
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
|
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
|
||||||
pub event_bus: broadcast::Sender<AppEvent>,
|
pub event_bus: broadcast::Sender<AppEvent>,
|
||||||
pub scgi_socket_path: String,
|
pub scgi_socket_path: String,
|
||||||
|
pub push_store: push::PushSubscriptionStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -61,7 +63,9 @@ struct Args {
|
|||||||
handlers::set_file_priority_handler,
|
handlers::set_file_priority_handler,
|
||||||
handlers::set_label_handler,
|
handlers::set_label_handler,
|
||||||
handlers::get_global_limit_handler,
|
handlers::get_global_limit_handler,
|
||||||
handlers::set_global_limit_handler
|
handlers::set_global_limit_handler,
|
||||||
|
handlers::get_push_public_key_handler,
|
||||||
|
handlers::subscribe_push_handler
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -74,7 +78,9 @@ struct Args {
|
|||||||
shared::TorrentTracker,
|
shared::TorrentTracker,
|
||||||
shared::SetFilePriorityRequest,
|
shared::SetFilePriorityRequest,
|
||||||
shared::SetLabelRequest,
|
shared::SetLabelRequest,
|
||||||
shared::GlobalLimitRequest
|
shared::GlobalLimitRequest,
|
||||||
|
push::PushSubscription,
|
||||||
|
push::PushKeys
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -137,12 +143,14 @@ async fn main() {
|
|||||||
tx: tx.clone(),
|
tx: tx.clone(),
|
||||||
event_bus: event_bus.clone(),
|
event_bus: event_bus.clone(),
|
||||||
scgi_socket_path: args.socket.clone(),
|
scgi_socket_path: args.socket.clone(),
|
||||||
|
push_store: push::PushSubscriptionStore::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn background task to poll rTorrent
|
// Spawn background task to poll rTorrent
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx.clone();
|
||||||
let event_bus_tx = event_bus.clone();
|
let event_bus_tx = event_bus.clone();
|
||||||
let socket_path = args.socket.clone(); // Clone for background task
|
let socket_path = args.socket.clone(); // Clone for background task
|
||||||
|
let push_store_clone = app_state.push_store.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = xmlrpc::RtorrentClient::new(&socket_path);
|
let client = xmlrpc::RtorrentClient::new(&socket_path);
|
||||||
@@ -193,6 +201,26 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
diff::DiffResult::Partial(updates) => {
|
diff::DiffResult::Partial(updates) => {
|
||||||
for update in updates {
|
for update in updates {
|
||||||
|
// Check if this is a torrent completion notification
|
||||||
|
if let AppEvent::Notification(ref notif) = update {
|
||||||
|
if notif.message.contains("tamamlandı") {
|
||||||
|
// Send push notification in background
|
||||||
|
let push_store = push_store_clone.clone();
|
||||||
|
let title = "Torrent Tamamlandı".to_string();
|
||||||
|
let body = notif.message.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = push::send_push_notification(
|
||||||
|
&push_store,
|
||||||
|
&title,
|
||||||
|
&body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Failed to send push notification: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = event_bus_tx.send(update);
|
let _ = event_bus_tx.send(update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +295,8 @@ async fn main() {
|
|||||||
"/api/settings/global-limits",
|
"/api/settings/global-limits",
|
||||||
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
|
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
|
||||||
)
|
)
|
||||||
|
.route("/api/push/public-key", get(handlers::get_push_public_key_handler))
|
||||||
|
.route("/api/push/subscribe", post(handlers::subscribe_push_handler))
|
||||||
.fallback(handlers::static_handler) // Serve static files for everything else
|
.fallback(handlers::static_handler) // Serve static files for everything else
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(
|
.layer(
|
||||||
|
|||||||
127
backend/src/push.rs
Normal file
127
backend/src/push.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use web_push::{
|
||||||
|
IsahcWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
// VAPID keys - PRODUCTION'DA ENVIRONMENT VARIABLE'DAN ALINMALI!
|
||||||
|
const VAPID_PUBLIC_KEY: &str = "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU";
|
||||||
|
const VAPID_PRIVATE_KEY: &str = "aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10";
|
||||||
|
const VAPID_EMAIL: &str = "mailto:admin@vibetorrent.app";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct PushSubscription {
|
||||||
|
pub endpoint: String,
|
||||||
|
pub keys: PushKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct PushKeys {
|
||||||
|
pub p256dh: String,
|
||||||
|
pub auth: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory store for push subscriptions
|
||||||
|
/// TODO: Replace with database in production
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct PushSubscriptionStore {
|
||||||
|
subscriptions: Arc<RwLock<Vec<PushSubscription>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PushSubscriptionStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
subscriptions: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_subscription(&self, subscription: PushSubscription) {
|
||||||
|
let mut subs = self.subscriptions.write().await;
|
||||||
|
|
||||||
|
// Remove duplicate endpoint if exists
|
||||||
|
subs.retain(|s| s.endpoint != subscription.endpoint);
|
||||||
|
|
||||||
|
subs.push(subscription);
|
||||||
|
tracing::info!("Added push subscription. Total: {}", subs.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_subscription(&self, endpoint: &str) {
|
||||||
|
let mut subs = self.subscriptions.write().await;
|
||||||
|
subs.retain(|s| s.endpoint != endpoint);
|
||||||
|
tracing::info!("Removed push subscription. Total: {}", subs.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> {
|
||||||
|
self.subscriptions.read().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send push notification to all subscribed clients
|
||||||
|
pub async fn send_push_notification(
|
||||||
|
store: &PushSubscriptionStore,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let subscriptions = store.get_all_subscriptions().await;
|
||||||
|
|
||||||
|
if subscriptions.is_empty() {
|
||||||
|
tracing::debug!("No push subscriptions to send to");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Sending push notification to {} subscribers", subscriptions.len());
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"icon": "/icon-192.png",
|
||||||
|
"badge": "/icon-192.png",
|
||||||
|
"tag": "vibetorrent"
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = IsahcWebPushClient::new()?;
|
||||||
|
|
||||||
|
for subscription in subscriptions {
|
||||||
|
let subscription_info = SubscriptionInfo {
|
||||||
|
endpoint: subscription.endpoint.clone(),
|
||||||
|
keys: web_push::SubscriptionKeys {
|
||||||
|
p256dh: subscription.keys.p256dh.clone(),
|
||||||
|
auth: subscription.keys.auth.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sig_builder = VapidSignatureBuilder::from_base64(
|
||||||
|
VAPID_PRIVATE_KEY,
|
||||||
|
web_push::URL_SAFE_NO_PAD,
|
||||||
|
&subscription_info,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
sig_builder.add_claim("sub", VAPID_EMAIL);
|
||||||
|
sig_builder.add_claim("aud", subscription.endpoint.clone());
|
||||||
|
let signature = sig_builder.build()?;
|
||||||
|
|
||||||
|
let mut builder = WebPushMessageBuilder::new(&subscription_info);
|
||||||
|
builder.set_vapid_signature(signature);
|
||||||
|
|
||||||
|
let payload_str = payload.to_string();
|
||||||
|
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
|
||||||
|
|
||||||
|
match client.send(builder.build()?).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to send push notification: {}", e);
|
||||||
|
// TODO: Remove invalid subscriptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_vapid_public_key() -> &'static str {
|
||||||
|
VAPID_PUBLIC_KEY
|
||||||
|
}
|
||||||
@@ -41,7 +41,13 @@ web-sys = { version = "0.3", features = [
|
|||||||
"Navigator",
|
"Navigator",
|
||||||
"Notification",
|
"Notification",
|
||||||
"NotificationOptions",
|
"NotificationOptions",
|
||||||
"NotificationPermission"
|
"NotificationPermission",
|
||||||
|
"ServiceWorkerContainer",
|
||||||
|
"ServiceWorkerRegistration",
|
||||||
|
"PushManager",
|
||||||
|
"PushSubscription",
|
||||||
|
"PushSubscriptionOptions",
|
||||||
|
"PushSubscriptionOptionsInit"
|
||||||
] }
|
] }
|
||||||
shared = { path = "../shared" }
|
shared = { path = "../shared" }
|
||||||
tailwind_fuse = "0.3.2"
|
tailwind_fuse = "0.3.2"
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ use leptos_router::*;
|
|||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
|
|
||||||
|
// Initialize push notifications after user grants permission
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async {
|
||||||
|
// Wait a bit for service worker to be ready
|
||||||
|
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||||
|
|
||||||
|
// Check if Notification API is available and permission is granted
|
||||||
|
let window = web_sys::window().expect("window should exist");
|
||||||
|
if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
|
||||||
|
if !notification_class.is_undefined() {
|
||||||
|
if let Ok(permission) = js_sys::Reflect::get(¬ification_class, &"permission".into()) {
|
||||||
|
if let Some(perm_str) = permission.as_string() {
|
||||||
|
if perm_str == "granted" {
|
||||||
|
tracing::info!("Notification permission granted, subscribing to push...");
|
||||||
|
crate::store::subscribe_to_push_notifications().await;
|
||||||
|
} else {
|
||||||
|
tracing::info!("Notification permission not granted yet: {}", perm_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// Main app wrapper - ensures proper stacking context
|
// Main app wrapper - ensures proper stacking context
|
||||||
<div class="relative w-full h-screen" style="height: 100dvh;">
|
<div class="relative w-full h-screen" style="height: 100dvh;">
|
||||||
|
|||||||
@@ -282,3 +282,234 @@ pub fn provide_torrent_store() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Push Notification Subscription
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
struct PushSubscriptionData {
|
||||||
|
endpoint: String,
|
||||||
|
keys: PushKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
struct PushKeys {
|
||||||
|
p256dh: String,
|
||||||
|
auth: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe user to push notifications
|
||||||
|
/// Call this after service worker is registered and notification permission is granted
|
||||||
|
pub async fn subscribe_to_push_notifications() {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
// Get VAPID public key from backend
|
||||||
|
let public_key_response = match Request::get("/api/push/public-key").send().await {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get VAPID public key: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let public_key_data: serde_json::Value = match public_key_response.json().await {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to parse VAPID public key: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let public_key = match public_key_data.get("publicKey").and_then(|v| v.as_str()) {
|
||||||
|
Some(key) => key,
|
||||||
|
None => {
|
||||||
|
tracing::error!("Missing publicKey in response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert VAPID public key to Uint8Array
|
||||||
|
let public_key_array = match url_base64_to_uint8array(public_key) {
|
||||||
|
Ok(arr) => arr,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to convert VAPID key: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get service worker registration
|
||||||
|
let window = web_sys::window().expect("window should exist");
|
||||||
|
let navigator = window.navigator();
|
||||||
|
let service_worker = navigator.service_worker();
|
||||||
|
|
||||||
|
let registration_promise = match service_worker.ready() {
|
||||||
|
Ok(promise) => promise,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get ready promise: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let registration_future = wasm_bindgen_futures::JsFuture::from(registration_promise);
|
||||||
|
|
||||||
|
let registration = match registration_future.await {
|
||||||
|
Ok(reg) => reg,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get service worker registration: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let service_worker_registration = registration
|
||||||
|
.dyn_into::<web_sys::ServiceWorkerRegistration>()
|
||||||
|
.expect("should be ServiceWorkerRegistration");
|
||||||
|
|
||||||
|
// Subscribe to push
|
||||||
|
let push_manager = match service_worker_registration.push_manager() {
|
||||||
|
Ok(pm) => pm,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get push manager: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let subscribe_options = web_sys::PushSubscriptionOptionsInit::new();
|
||||||
|
subscribe_options.set_user_visible_only(true);
|
||||||
|
subscribe_options.set_application_server_key(&public_key_array);
|
||||||
|
|
||||||
|
let subscribe_promise = match push_manager.subscribe_with_options(&subscribe_options) {
|
||||||
|
Ok(promise) => promise,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to subscribe to push: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let subscription_future = wasm_bindgen_futures::JsFuture::from(subscribe_promise);
|
||||||
|
|
||||||
|
let subscription = match subscription_future.await {
|
||||||
|
Ok(sub) => sub,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get push subscription: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let push_subscription = subscription
|
||||||
|
.dyn_into::<web_sys::PushSubscription>()
|
||||||
|
.expect("should be PushSubscription");
|
||||||
|
|
||||||
|
// Get subscription JSON using toJSON() method
|
||||||
|
let json_result = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) {
|
||||||
|
Ok(func) if func.is_function() => {
|
||||||
|
let json_func = js_sys::Function::from(func);
|
||||||
|
match json_func.call0(&push_subscription) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to call toJSON: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::error!("toJSON method not found on PushSubscription");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let json_value = match js_sys::JSON::stringify(&json_result) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to stringify subscription: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let subscription_json_str = json_value.as_string().expect("should be string");
|
||||||
|
|
||||||
|
tracing::info!("Push subscription: {}", subscription_json_str);
|
||||||
|
|
||||||
|
// Parse and send to backend
|
||||||
|
let subscription_data: serde_json::Value = match serde_json::from_str(&subscription_json_str) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to parse subscription JSON: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract endpoint and keys
|
||||||
|
let endpoint = subscription_data
|
||||||
|
.get("endpoint")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("endpoint should exist")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let keys_obj = subscription_data
|
||||||
|
.get("keys")
|
||||||
|
.expect("keys should exist");
|
||||||
|
|
||||||
|
let p256dh = keys_obj
|
||||||
|
.get("p256dh")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("p256dh should exist")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let auth = keys_obj
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("auth should exist")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let push_data = PushSubscriptionData {
|
||||||
|
endpoint,
|
||||||
|
keys: PushKeys { p256dh, auth },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to backend
|
||||||
|
let response = match Request::post("/api/push/subscribe")
|
||||||
|
.json(&push_data)
|
||||||
|
.expect("serialization should succeed")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to send subscription to backend: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if response.ok() {
|
||||||
|
tracing::info!("Successfully subscribed to push notifications");
|
||||||
|
} else {
|
||||||
|
tracing::error!("Backend rejected push subscription: {:?}", response.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to convert URL-safe base64 string to Uint8Array
|
||||||
|
fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> {
|
||||||
|
// Add padding
|
||||||
|
let padding = (4 - (base64_string.len() % 4)) % 4;
|
||||||
|
let mut padded = base64_string.to_string();
|
||||||
|
padded.push_str(&"=".repeat(padding));
|
||||||
|
|
||||||
|
// Replace URL-safe characters
|
||||||
|
let standard_base64 = padded.replace('-', "+").replace('_', "/");
|
||||||
|
|
||||||
|
// Decode base64
|
||||||
|
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
|
||||||
|
let decoded = window.atob(&standard_base64)?;
|
||||||
|
|
||||||
|
// Convert to Uint8Array
|
||||||
|
let array = js_sys::Uint8Array::new_with_length(decoded.len() as u32);
|
||||||
|
for (i, byte) in decoded.bytes().enumerate() {
|
||||||
|
array.set_index(i as u32, byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(array)
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,20 +98,23 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push notification event (for future use)
|
// Push notification event
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
console.log('[Service Worker] Push received');
|
console.log('[Service Worker] Push notification received');
|
||||||
const data = event.data ? event.data.json() : {};
|
const data = event.data ? event.data.json() : {};
|
||||||
|
|
||||||
|
const title = data.title || 'VibeTorrent';
|
||||||
const options = {
|
const options = {
|
||||||
body: data.message || 'New notification',
|
body: data.body || 'New notification',
|
||||||
icon: '/icon-192.png',
|
icon: data.icon || '/icon-192.png',
|
||||||
badge: '/icon-192.png',
|
badge: data.badge || '/icon-192.png',
|
||||||
tag: data.tag || 'vibetorrent-notification',
|
tag: data.tag || 'vibetorrent-notification',
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[Service Worker] Showing notification:', title, options);
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title || 'VibeTorrent', options)
|
self.registration.showNotification(title, options)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user