feat(pwa): add PWA support and browser notifications for critical events
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -706,6 +706,7 @@ dependencies = [
|
|||||||
"tailwind_fuse",
|
"tailwind_fuse",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ serde_json = "1"
|
|||||||
gloo-net = "0.5"
|
gloo-net = "0.5"
|
||||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
uuid = { version = "1", features = ["v4", "js"] }
|
uuid = { version = "1", features = ["v4", "js"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
@@ -37,7 +38,10 @@ web-sys = { version = "0.3", features = [
|
|||||||
"TouchEvent",
|
"TouchEvent",
|
||||||
"TouchList",
|
"TouchList",
|
||||||
"Touch",
|
"Touch",
|
||||||
"Navigator"
|
"Navigator",
|
||||||
|
"Notification",
|
||||||
|
"NotificationOptions",
|
||||||
|
"NotificationPermission"
|
||||||
] }
|
] }
|
||||||
shared = { path = "../shared" }
|
shared = { path = "../shared" }
|
||||||
tailwind_fuse = "0.3.2"
|
tailwind_fuse = "0.3.2"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
<link data-trunk rel="css" href="public/tailwind.css" />
|
<link data-trunk rel="css" href="public/tailwind.css" />
|
||||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
|
<link data-trunk rel="copy-file" href="icon-512.png" />
|
||||||
|
<link data-trunk rel="copy-file" href="sw.js" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var localTheme = localStorage.getItem("vibetorrent_theme");
|
var localTheme = localStorage.getItem("vibetorrent_theme");
|
||||||
@@ -107,24 +109,35 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Service Worker disabled - file names don't match Trunk's hashed output
|
|
||||||
|
<!-- Service Worker Registration & PWA Setup -->
|
||||||
<script>
|
<script>
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/sw.js")
|
.register("/sw.js")
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
console.log("SW registered: ", registration);
|
console.log("✅ Service Worker registered:", registration);
|
||||||
|
|
||||||
|
// Request notification permission after a delay (better UX)
|
||||||
|
setTimeout(() => {
|
||||||
|
if ("Notification" in window && Notification.permission === "default") {
|
||||||
|
// Only request if user hasn't decided yet
|
||||||
|
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
|
||||||
|
if (!shouldRequest) {
|
||||||
|
Notification.requestPermission().then((permission) => {
|
||||||
|
console.log("Notification permission:", permission);
|
||||||
|
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000); // Wait 3 seconds before asking
|
||||||
})
|
})
|
||||||
.catch((registrationError) => {
|
.catch((error) => {
|
||||||
console.log(
|
console.warn("⚠️ Service Worker registration failed:", error);
|
||||||
"SW registration failed: ",
|
|
||||||
registrationError,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
-->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "VibeTorrent",
|
"name": "VibeTorrent",
|
||||||
"short_name": "VibeTorrent",
|
"short_name": "VibeTorrent",
|
||||||
|
"description": "Modern web-based torrent client with real-time updates",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#1d232a",
|
"background_color": "#1d232a",
|
||||||
"theme_color": "#1d232a",
|
"theme_color": "#1d232a",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icon-512.png",
|
"src": "icon-512.png",
|
||||||
|
|||||||
@@ -213,7 +213,30 @@ pub fn provide_torrent_store() {
|
|||||||
global_stats.set(stats);
|
global_stats.set(stats);
|
||||||
}
|
}
|
||||||
AppEvent::Notification(n) => {
|
AppEvent::Notification(n) => {
|
||||||
show_toast_with_signal(notifications, n.level, n.message);
|
// Show toast notification
|
||||||
|
show_toast_with_signal(notifications, n.level.clone(), n.message.clone());
|
||||||
|
|
||||||
|
// Show browser notification for critical events
|
||||||
|
// (torrent completed, connection lost/restored, errors)
|
||||||
|
let is_critical = n.message.contains("tamamlandı")
|
||||||
|
|| n.message.contains("Reconnected")
|
||||||
|
|| n.message.contains("yeniden kuruldu")
|
||||||
|
|| n.message.contains("Lost connection")
|
||||||
|
|| n.level == shared::NotificationLevel::Error;
|
||||||
|
|
||||||
|
if is_critical {
|
||||||
|
let title = match n.level {
|
||||||
|
shared::NotificationLevel::Success => "✅ VibeTorrent",
|
||||||
|
shared::NotificationLevel::Error => "❌ VibeTorrent",
|
||||||
|
shared::NotificationLevel::Warning => "⚠️ VibeTorrent",
|
||||||
|
shared::NotificationLevel::Info => "ℹ️ VibeTorrent",
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::utils::notification::show_notification_if_enabled(
|
||||||
|
title,
|
||||||
|
&n.message
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use tailwind_fuse::merge::tw_merge;
|
use tailwind_fuse::merge::tw_merge;
|
||||||
|
|
||||||
|
pub mod notification;
|
||||||
|
|
||||||
pub fn cn(classes: impl AsRef<str>) -> String {
|
pub fn cn(classes: impl AsRef<str>) -> String {
|
||||||
tw_merge(classes.as_ref())
|
tw_merge(classes.as_ref())
|
||||||
}
|
}
|
||||||
|
|||||||
113
frontend/src/utils/notification.rs
Normal file
113
frontend/src/utils/notification.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use web_sys::{Notification, NotificationOptions};
|
||||||
|
|
||||||
|
/// Request browser notification permission from user
|
||||||
|
pub async fn request_notification_permission() -> bool {
|
||||||
|
let window = web_sys::window().expect("no global window");
|
||||||
|
|
||||||
|
// Check if Notification API is available
|
||||||
|
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(¬ification, &JsValue::from_str("requestPermission"))
|
||||||
|
.expect("requestPermission should exist");
|
||||||
|
|
||||||
|
if let Ok(function) = promise.dyn_into::<js_sys::Function>() {
|
||||||
|
if let Ok(promise) = function.call0(¬ification) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if browser notifications are supported and permitted
|
||||||
|
pub fn is_notification_supported() -> bool {
|
||||||
|
let window = web_sys::window().expect("no global window");
|
||||||
|
js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current notification permission status
|
||||||
|
pub fn get_notification_permission() -> String {
|
||||||
|
if !is_notification_supported() {
|
||||||
|
return "unsupported".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(¬ification, &JsValue::from_str("permission"))
|
||||||
|
.unwrap_or(JsValue::from_str("default"));
|
||||||
|
|
||||||
|
permission.as_string().unwrap_or("default".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a browser notification
|
||||||
|
/// Returns true if notification was shown successfully
|
||||||
|
pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool {
|
||||||
|
// Check permission first
|
||||||
|
let permission = get_notification_permission();
|
||||||
|
if permission != "granted" {
|
||||||
|
log::warn!("Notification permission not granted: {}", permission);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification options
|
||||||
|
let opts = NotificationOptions::new();
|
||||||
|
opts.set_body(body);
|
||||||
|
opts.set_icon(icon.unwrap_or("/icon-192.png"));
|
||||||
|
opts.set_badge("/icon-192.png");
|
||||||
|
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) {
|
||||||
|
Ok(notification) => {
|
||||||
|
log::info!("Browser notification shown: {}", title);
|
||||||
|
|
||||||
|
// Auto-close after 5 seconds
|
||||||
|
let _ = gloo_timers::callback::Timeout::new(5000, move || {
|
||||||
|
notification.close();
|
||||||
|
}).forget();
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to create notification: {:?}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show notification only if enabled in settings and permission granted
|
||||||
|
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 storage = window.local_storage().ok().flatten();
|
||||||
|
|
||||||
|
if let Some(storage) = storage {
|
||||||
|
let enabled = storage
|
||||||
|
.get_item("vibetorrent_browser_notifications")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or("true".to_string());
|
||||||
|
|
||||||
|
if enabled == "true" {
|
||||||
|
return show_browser_notification(title, body, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -2,40 +2,116 @@ const CACHE_NAME = 'vibetorrent-v1';
|
|||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/vibetorrent_frontend.js',
|
|
||||||
'/vibetorrent_frontend_bg.wasm',
|
|
||||||
'/tailwind.css',
|
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/icon-192.png',
|
'/icon-192.png',
|
||||||
'/icon-512.png'
|
'/icon-512.png'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Install event - cache assets
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[Service Worker] Installing...');
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
console.log('[Service Worker] Caching static assets');
|
||||||
return cache.addAll(ASSETS_TO_CACHE);
|
return cache.addAll(ASSETS_TO_CACHE);
|
||||||
|
}).then(() => {
|
||||||
|
console.log('[Service Worker] Skip waiting');
|
||||||
|
return self.skipWaiting();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
// Activate event - clean old caches
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((response) => {
|
|
||||||
return response || fetch(event.request);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[Service Worker] Activating...');
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames.map((key) => {
|
cacheNames.map((key) => {
|
||||||
if (key !== CACHE_NAME) {
|
if (key !== CACHE_NAME) {
|
||||||
|
console.log('[Service Worker] Deleting old cache:', key);
|
||||||
return caches.delete(key);
|
return caches.delete(key);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}).then(() => {
|
||||||
|
console.log('[Service Worker] Claiming clients');
|
||||||
|
return self.clients.claim();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch event - network first, cache fallback for API calls
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Network-first strategy for API calls
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.catch(() => {
|
||||||
|
// Could return cached API response or offline page
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Offline' }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first strategy for static assets
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((response) => {
|
||||||
|
return response || fetch(event.request).then((fetchResponse) => {
|
||||||
|
// Optionally cache new requests
|
||||||
|
if (fetchResponse && fetchResponse.status === 200) {
|
||||||
|
const responseToCache = fetchResponse.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseToCache);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetchResponse;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click event - focus or open app
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('[Service Worker] Notification clicked:', event.notification.tag);
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// If app is already open, focus it
|
||||||
|
for (let client of clientList) {
|
||||||
|
if (client.url === '/' && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push notification event (for future use)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('[Service Worker] Push received');
|
||||||
|
const data = event.data ? event.data.json() : {};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.message || 'New notification',
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
tag: data.tag || 'vibetorrent-notification',
|
||||||
|
requireInteraction: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'VibeTorrent', options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user