WebUI: Support push notification

This commit is contained in:
tehcneko 2025-08-12 14:12:24 +01:00
commit af7a6d470d
6 changed files with 368 additions and 2 deletions

View file

@ -6,8 +6,8 @@
"url": "https://github.com/qbittorrent/qBittorrent.git" "url": "https://github.com/qbittorrent/qBittorrent.git"
}, },
"scripts": { "scripts": {
"format": "js-beautify -r *.mjs private/*.html private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && prettier --write **.css", "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/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && stylelint --cache **/*.css && html-validate private public", "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" "test": "vitest run --dom"
}, },
"devDependencies": { "devDependencies": {

View file

@ -44,6 +44,7 @@
<script defer src="scripts/contextmenu.js?locale=${LANG}&v=${CACHEID}"></script> <script defer src="scripts/contextmenu.js?locale=${LANG}&v=${CACHEID}"></script>
<script defer src="scripts/pathAutofill.js?v=${CACHEID}"></script> <script defer src="scripts/pathAutofill.js?v=${CACHEID}"></script>
<script defer src="scripts/statistics.js?v=${CACHEID}"></script> <script defer src="scripts/statistics.js?v=${CACHEID}"></script>
<script defer src="scripts/webpush.js?v=${CACHEID}"></script>
</head> </head>
<body> <body>

View file

@ -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);
});
}
});

View file

@ -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("/");
})
);
});

View file

@ -119,6 +119,19 @@
<input type="checkbox" id="useVirtualList"> <input type="checkbox" id="useVirtualList">
<label for="useVirtualList">QBT_TR(Enable optimized table rendering (experimental))QBT_TR[CONTEXT=OptionsDialog]</label> <label for="useVirtualList">QBT_TR(Enable optimized table rendering (experimental))QBT_TR[CONTEXT=OptionsDialog]</label>
</div> </div>
<fieldset class="settings">
<legend>QBT_TR(Push Notification)QBT_TR[CONTEXT=OptionsDialog]</legend>
<div class="formRow" style="margin-bottom: 3px;">
<span>QBT_TR(Status:)QBT_TR[CONTEXT=OptionsDialog]</span>
<b id="subscriptionStatus">QBT_TR(Unknown)QBT_TR[CONTEXT=OptionsDialog]</b>
<div style="display: flex;flex-direction: row; align-items: center;gap: 4px;">
<button disabled id="subscribePushNotificationButton" type="button" onclick="qBittorrent.Preferences.subscribePushNotification();">QBT_TR(Subscribe)QBT_TR[CONTEXT=OptionsDialog]</button>
<button id="sendTestNotificationButton" type="button" onclick="qBittorrent.Preferences.sendTestNotification();">QBT_TR(Send test
notification)QBT_TR[CONTEXT=OptionsDialog]</button>
<div id="pushNotificationSpinner" class="mochaSpinner" style="position: static;"></div>
</div>
</div>
</fieldset>
</fieldset> </fieldset>
</div> </div>
@ -1793,6 +1806,8 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
updateDynDnsSettings: updateDynDnsSettings, updateDynDnsSettings: updateDynDnsSettings,
updateWebuiLocaleSelect: updateWebuiLocaleSelect, updateWebuiLocaleSelect: updateWebuiLocaleSelect,
registerDynDns: registerDynDns, registerDynDns: registerDynDns,
sendTestNotification: sendTestNotification,
subscribePushNotification: subscribePushNotification,
applyPreferences: applyPreferences applyPreferences: applyPreferences
}; };
}; };
@ -2227,6 +2242,67 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
combobox.options[0].selected = true; 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 = () => { const loadPreferences = () => {
window.parent.qBittorrent.Cache.preferences.init({ window.parent.qBittorrent.Cache.preferences.init({
onSuccess: (pref) => { onSuccess: (pref) => {
@ -3222,6 +3298,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
}); });
loadPreferences(); loadPreferences();
updatePushNotification();
window.qBittorrent.pathAutofill.attachPathAutofill(); window.qBittorrent.pathAutofill.attachPathAutofill();
}; };

View file

@ -416,9 +416,11 @@
<file>private/scripts/search.js</file> <file>private/scripts/search.js</file>
<file>private/scripts/statistics.js</file> <file>private/scripts/statistics.js</file>
<file>private/scripts/torrent-content.js</file> <file>private/scripts/torrent-content.js</file>
<file>private/scripts/webpush.js</file>
<file>private/setlocation.html</file> <file>private/setlocation.html</file>
<file>private/shareratio.html</file> <file>private/shareratio.html</file>
<file>private/speedlimit.html</file> <file>private/speedlimit.html</file>
<file>private/sw-webui.js</file>
<file>private/views/about.html</file> <file>private/views/about.html</file>
<file>private/views/aboutToolbar.html</file> <file>private/views/aboutToolbar.html</file>
<file>private/views/confirmAutoTMM.html</file> <file>private/views/confirmAutoTMM.html</file>