diff --git a/src/webui/www/package.json b/src/webui/www/package.json index 2e0a23b4b..afc4ffd1e 100644 --- a/src/webui/www/package.json +++ b/src/webui/www/package.json @@ -6,8 +6,8 @@ "url": "https://github.com/qbittorrent/qBittorrent.git" }, "scripts": { - "format": "js-beautify -r *.mjs private/*.html private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && prettier --write **.css", - "lint": "eslint --cache *.mjs private/*.html private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && stylelint --cache **/*.css && html-validate private public", + "format": "js-beautify -r *.mjs private/*.html private/*.js private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && prettier --write **.css", + "lint": "eslint --cache *.mjs private/*.html private/*.js private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && stylelint --cache **/*.css && html-validate private public", "test": "vitest run --dom" }, "devDependencies": { diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index b5877abc4..6eac05568 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -44,6 +44,7 @@ +
diff --git a/src/webui/www/private/scripts/webpush.js b/src/webui/www/private/scripts/webpush.js new file mode 100644 index 000000000..865f0f77b --- /dev/null +++ b/src/webui/www/private/scripts/webpush.js @@ -0,0 +1,186 @@ +/* + * MIT License + * Copyright (C) 2025 tehcneko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +"use strict"; + +window.qBittorrent ??= {}; +window.qBittorrent.WebPush ??= (() => { + const exports = () => { + return { + isSupported: isSupported, + isSubscribed: isSubscribed, + registerServiceWorker: registerServiceWorker, + sendTestNotification: sendTestNotification, + subscribe: subscribe, + unsubscribe: unsubscribe, + }; + }; + + const isSupported = () => { + return ( + window.isSecureContext + && ("serviceWorker" in navigator) + && ("PushManager" in window) + && ("Notification" in window) + ); + }; + + const registerServiceWorker = async () => { + const officialWebUIServiceWorkerScript = "/sw-webui.js"; + const registrations = await navigator.serviceWorker.getRegistrations(); + let registered = false; + for (const registration of registrations) { + const isOfficialWebUI = registration.active && registration.active.scriptURL.endsWith(officialWebUIServiceWorkerScript); + if (isOfficialWebUI) { + registered = true; + continue; + } + else { + await registration.unregister(); + } + } + if (!registered) + await navigator.serviceWorker.register(officialWebUIServiceWorkerScript); + }; + + const urlBase64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, "+") + .replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) + outputArray[i] = rawData.charCodeAt(i); + + return outputArray; + }; + + const requestNotificationPermission = async () => { + if (Notification.permission === "granted") + return true; + const permission = await Notification.requestPermission(); + return permission === "granted"; + }; + + const fetchVapidPublicKey = async () => { + const url = new URL("api/v2/push/vapidPublicKey", window.location); + const response = await fetch(url, { + method: "GET", + cache: "no-store" + }); + if (!response.ok) + throw new Error("QBT_TR(Failed to fetch VAPID public key)QBT_TR[CONTEXT=PushNotification]"); + const responseJSON = await response.json(); + return responseJSON["vapidPublicKey"]; + }; + + const getPushManager = async () => { + const registration = await navigator.serviceWorker.ready; + return registration.pushManager; + }; + + const subscribeToPushManager = async (vapidPublicKey) => { + const pushManager = await getPushManager(); + const subscription = await pushManager.getSubscription(); + if (subscription !== null) + return subscription; + const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey); + + return pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }); + }; + + const subscribeToServer = async (subscription) => { + const formData = new FormData(); + formData.append("subscription", JSON.stringify(subscription)); + const url = new URL("api/v2/push/subscribe", window.location); + const response = await fetch(url, { + method: "post", + body: formData, + }); + if (!response.ok) + throw new Error(await response.text()); + }; + + const unsubscribeFromServer = async (subscription) => { + const formData = new FormData(); + formData.append("endpoint", subscription.endpoint); + const url = new URL("api/v2/push/unsubscribe", window.location); + const response = await fetch(url, { + method: "post", + body: formData, + }); + if (!response.ok) + throw new Error(await response.text()); + }; + + const sendTestNotification = async () => { + const url = new URL("api/v2/push/test", window.location); + const response = await fetch(url, { + method: "GET", + cache: "no-store" + }); + if (!response.ok) + throw new Error(await response.text()); + }; + + const subscribe = async () => { + const permissionGranted = await requestNotificationPermission(); + if (!permissionGranted) + throw new Error("QBT_TR(Notification permission denied.)QBT_TR[CONTEXT=PushNotification]"); + const vapidPublicKey = await fetchVapidPublicKey(); + const subscription = await subscribeToPushManager(vapidPublicKey); + await subscribeToServer(subscription); + }; + + const isSubscribed = async () => { + const pushManager = await getPushManager(); + const subscription = await pushManager.getSubscription(); + return subscription !== null; + }; + + const unsubscribe = async () => { + const pushManager = await getPushManager(); + const subscription = await pushManager.getSubscription(); + if (subscription !== null) { + await subscription.unsubscribe(); + await unsubscribeFromServer(subscription); + } + }; + + return exports(); +})(); +Object.freeze(window.qBittorrent.WebPush); + +document.addEventListener("DOMContentLoaded", () => { + if (window.qBittorrent.WebPush.isSupported()) { + window.qBittorrent.WebPush.registerServiceWorker().catch((error) => { + console.error("Failed to register service worker:", error); + }); + } +}); diff --git a/src/webui/www/private/sw-webui.js b/src/webui/www/private/sw-webui.js new file mode 100644 index 000000000..e1d870a8b --- /dev/null +++ b/src/webui/www/private/sw-webui.js @@ -0,0 +1,100 @@ +/* + * MIT License + * Copyright (C) 2025 tehcneko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +"use strict"; + +self.addEventListener("install", (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", (e) => { + if (e.data === null) + return; + + const data = e.data.json(); + if (data.event === undefined) + return; + + const event = data.event; + const payload = data.payload || {}; + + let notificationTitle; + let notificationBody; + switch (event) { + case "test": + notificationTitle = "QBT_TR(Test Notification)QBT_TR[CONTEXT=PushNotification]"; + notificationBody = "QBT_TR(This is a test notification. Thank you for using qBittorrent.)QBT_TR[CONTEXT=PushNotification]"; + break; + case "torrent_added": + // ignore for now. + return; + case "torrent_finished": + notificationTitle = "QBT_TR(Download completed)QBT_TR[CONTEXT=PushNotification]"; + notificationBody = "QBT_TR(%1 has finished downloading.)QBT_TR[CONTEXT=PushNotification]" + .replace("%1", `"${payload.torrent_name}"`); + break; + case "full_disk_error": + notificationTitle = "QBT_TR(I/O Error)QBT_TR[CONTEXT=PushNotification]"; + notificationBody = "QBT_TR(An I/O error occurred for torrent %1.\n Reason: %2)QBT_TR[CONTEXT=PushNotification]" + .replace("%1", `"${payload.torrent_name}"`) + .replace("%2", payload.reason); + break; + case "add_torrent_failed": + notificationTitle = "QBT_TR(Add torrent failed)QBT_TR[CONTEXT=PushNotification]"; + notificationBody = "QBT_TR(Couldn't add torrent '%1', reason: %2.)QBT_TR[CONTEXT=PushNotification]" + .replace("%1", payload.source) + .replace("%2", payload.reason); + break; + default: + notificationTitle = "QBT_TR(Unsupported notification)QBT_TR[CONTEXT=PushNotification]"; + notificationBody = "QBT_TR(An unsupported notification was received.)QBT_TR[CONTEXT=PushNotification]"; + break; + } + + // Keep the service worker alive until the notification is created. + e.waitUntil( + self.registration.showNotification(notificationTitle, { + body: notificationBody, + icon: "images/qbittorrent-tray.svg" + }) + ); +}); + +self.addEventListener("notificationclick", (e) => { + e.waitUntil( + self.clients.matchAll({ + type: "window" + }).then((clientList) => { + for (const client of clientList) { + if ("focus" in client) + return client.focus(); + } + if (clients.openWindow) + return clients.openWindow("/"); + }) + ); +}); diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index 198cb5409..f8161dcb5 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -119,6 +119,19 @@ + @@ -1793,6 +1806,8 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD updateDynDnsSettings: updateDynDnsSettings, updateWebuiLocaleSelect: updateWebuiLocaleSelect, registerDynDns: registerDynDns, + sendTestNotification: sendTestNotification, + subscribePushNotification: subscribePushNotification, applyPreferences: applyPreferences }; }; @@ -2227,6 +2242,67 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD combobox.options[0].selected = true; }; + const sendTestNotification = () => { + document.getElementById("sendTestNotificationButton").disabled = true; + document.getElementById("subscribePushNotificationButton").disabled = true; + document.getElementById("pushNotificationSpinner").style.display = "inline-block"; + window.qBittorrent.WebPush.sendTestNotification().catch((e) => { + alert(e.message); + }).finally(() => { + document.getElementById("sendTestNotificationButton").disabled = false; + document.getElementById("subscribePushNotificationButton").disabled = false; + document.getElementById("pushNotificationSpinner").style.display = "none"; + }); + }; + + const subscribePushNotification = () => { + document.getElementById("pushNotificationSpinner").style.display = "inline-block"; + document.getElementById("subscribePushNotificationButton").disabled = true; + window.qBittorrent.WebPush.isSubscribed().then((subscribed) => { + const promise = !subscribed + ? window.qBittorrent.WebPush.subscribe() + : window.qBittorrent.WebPush.unsubscribe(); + promise.catch((e) => { + alert(e.message); + }).finally(() => { + updatePushNotification(); + }); + }).catch((e) => { + alert(e.message); + }); + }; + + const updatePushNotification = () => { + const subscriptionStatus = document.getElementById("subscriptionStatus"); + const subscribePushNotificationButton = document.getElementById("subscribePushNotificationButton"); + const sendTestNotificationButton = document.getElementById("sendTestNotificationButton"); + if (!window.qBittorrent.WebPush.isSupported()) { + subscriptionStatus.textContent = "QBT_TR(Unsupported)QBT_TR[CONTEXT=OptionsDialog]"; + subscribePushNotificationButton.style.display = "none"; + sendTestNotificationButton.style.display = "none"; + return; + } + const pushNotificationSpinner = document.getElementById("pushNotificationSpinner"); + pushNotificationSpinner.style.display = "inline-block"; + window.qBittorrent.WebPush.isSubscribed().then((subscribed) => { + subscriptionStatus.textContent = subscribed + ? "QBT_TR(Subscribed)QBT_TR[CONTEXT=OptionsDialog]" + : "QBT_TR(Not Subscribed)QBT_TR[CONTEXT=OptionsDialog]"; + subscribePushNotificationButton.textContent = subscribed + ? "QBT_TR(Unsubscribe)QBT_TR[CONTEXT=OptionsDialog]" + : "QBT_TR(Subscribe)QBT_TR[CONTEXT=OptionsDialog]"; + subscribePushNotificationButton.disabled = false; + sendTestNotificationButton.style.display = subscribed ? "inline-block" : "none"; + pushNotificationSpinner.style.display = "none"; + }).catch((e) => { + subscriptionStatus.textContent = "QBT_TR(Errored)QBT_TR[CONTEXT=OptionsDialog]"; + subscribePushNotificationButton.disabled = true; + sendTestNotificationButton.style.display = "none"; + pushNotificationSpinner.style.display = "none"; + alert(e.message); + }); + }; + const loadPreferences = () => { window.parent.qBittorrent.Cache.preferences.init({ onSuccess: (pref) => { @@ -3222,6 +3298,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD }); loadPreferences(); + updatePushNotification(); window.qBittorrent.pathAutofill.attachPathAutofill(); }; diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index f17da4d50..fa3b7bdf2 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -416,9 +416,11 @@