Skip to content

Commit fd0caa8

Browse files
committed
WebUI: Support push notification
1 parent 6cddf2f commit fd0caa8

File tree

6 files changed

+368
-2
lines changed

6 files changed

+368
-2
lines changed

src/webui/www/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"url": "https://github.com/qbittorrent/qBittorrent.git"
77
},
88
"scripts": {
9-
"format": "js-beautify -r *.mjs private/*.html private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && prettier --write **.css",
10-
"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",
9+
"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",
10+
"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",
1111
"test": "vitest run --dom"
1212
},
1313
"devDependencies": {

src/webui/www/private/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
<script defer src="scripts/contextmenu.js?locale=${LANG}&v=${CACHEID}"></script>
4545
<script defer src="scripts/pathAutofill.js?v=${CACHEID}"></script>
4646
<script defer src="scripts/statistics.js?v=${CACHEID}"></script>
47+
<script defer src="scripts/webpush.js?v=${CACHEID}"></script>
4748
</head>
4849

4950
<body>
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* MIT License
3+
* Copyright (C) 2025 tehcneko
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
24+
"use strict";
25+
26+
window.qBittorrent ??= {};
27+
window.qBittorrent.WebPush ??= (() => {
28+
const exports = () => {
29+
return {
30+
isSupported: isSupported,
31+
isSubscribed: isSubscribed,
32+
registerServiceWorker: registerServiceWorker,
33+
sendTestNotification: sendTestNotification,
34+
subscribe: subscribe,
35+
unsubscribe: unsubscribe,
36+
};
37+
};
38+
39+
const isSupported = () => {
40+
return (
41+
window.isSecureContext
42+
&& ("serviceWorker" in navigator)
43+
&& ("PushManager" in window)
44+
&& ("Notification" in window)
45+
);
46+
};
47+
48+
const registerServiceWorker = async () => {
49+
const officialWebUIServiceWorkerScript = "/sw-webui.js";
50+
const registrations = await navigator.serviceWorker.getRegistrations();
51+
let registered = false;
52+
for (const registration of registrations) {
53+
const isOfficialWebUI = registration.active && registration.active.scriptURL.endsWith(officialWebUIServiceWorkerScript);
54+
if (isOfficialWebUI) {
55+
registered = true;
56+
continue;
57+
}
58+
else {
59+
await registration.unregister();
60+
}
61+
}
62+
if (!registered)
63+
await navigator.serviceWorker.register(officialWebUIServiceWorkerScript);
64+
};
65+
66+
const urlBase64ToUint8Array = (base64String) => {
67+
const padding = "=".repeat((4 - base64String.length % 4) % 4);
68+
const base64 = (base64String + padding)
69+
.replace(/-/g, "+")
70+
.replace(/_/g, "/");
71+
72+
const rawData = window.atob(base64);
73+
const outputArray = new Uint8Array(rawData.length);
74+
75+
for (let i = 0; i < rawData.length; ++i)
76+
outputArray[i] = rawData.charCodeAt(i);
77+
78+
return outputArray;
79+
};
80+
81+
const requestNotificationPermission = async () => {
82+
if (Notification.permission === "granted")
83+
return true;
84+
const permission = await Notification.requestPermission();
85+
return permission === "granted";
86+
};
87+
88+
const fetchVapidPublicKey = async () => {
89+
const url = new URL("api/v2/push/vapidPublicKey", window.location);
90+
const response = await fetch(url, {
91+
method: "GET",
92+
cache: "no-store"
93+
});
94+
if (!response.ok)
95+
throw new Error("QBT_TR(Failed to fetch VAPID public key)QBT_TR[CONTEXT=PushNotification]");
96+
const responseJSON = await response.json();
97+
return responseJSON["vapidPublicKey"];
98+
};
99+
100+
const getPushManager = async () => {
101+
const registration = await navigator.serviceWorker.ready;
102+
return registration.pushManager;
103+
};
104+
105+
const subscribeToPushManager = async (vapidPublicKey) => {
106+
const pushManager = await getPushManager();
107+
const subscription = await pushManager.getSubscription();
108+
if (subscription !== null)
109+
return subscription;
110+
const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);
111+
112+
return pushManager.subscribe({
113+
userVisibleOnly: true,
114+
applicationServerKey: convertedVapidKey
115+
});
116+
};
117+
118+
const subscribeToServer = async (subscription) => {
119+
const formData = new FormData();
120+
formData.append("subscription", JSON.stringify(subscription));
121+
const url = new URL("api/v2/push/subscribe", window.location);
122+
const response = await fetch(url, {
123+
method: "post",
124+
body: formData,
125+
});
126+
if (!response.ok)
127+
throw new Error(await response.text());
128+
};
129+
130+
const unsubscribeFromServer = async (subscription) => {
131+
const formData = new FormData();
132+
formData.append("endpoint", subscription.endpoint);
133+
const url = new URL("api/v2/push/unsubscribe", window.location);
134+
const response = await fetch(url, {
135+
method: "post",
136+
body: formData,
137+
});
138+
if (!response.ok)
139+
throw new Error(await response.text());
140+
};
141+
142+
const sendTestNotification = async () => {
143+
const url = new URL("api/v2/push/test", window.location);
144+
const response = await fetch(url, {
145+
method: "GET",
146+
cache: "no-store"
147+
});
148+
if (!response.ok)
149+
throw new Error(await response.text());
150+
};
151+
152+
const subscribe = async () => {
153+
const permissionGranted = await requestNotificationPermission();
154+
if (!permissionGranted)
155+
throw new Error("QBT_TR(Notification permission denied.)QBT_TR[CONTEXT=PushNotification]");
156+
const vapidPublicKey = await fetchVapidPublicKey();
157+
const subscription = await subscribeToPushManager(vapidPublicKey);
158+
await subscribeToServer(subscription);
159+
};
160+
161+
const isSubscribed = async () => {
162+
const pushManager = await getPushManager();
163+
const subscription = await pushManager.getSubscription();
164+
return subscription !== null;
165+
};
166+
167+
const unsubscribe = async () => {
168+
const pushManager = await getPushManager();
169+
const subscription = await pushManager.getSubscription();
170+
if (subscription !== null) {
171+
await subscription.unsubscribe();
172+
await unsubscribeFromServer(subscription);
173+
}
174+
};
175+
176+
return exports();
177+
})();
178+
Object.freeze(window.qBittorrent.WebPush);
179+
180+
document.addEventListener("DOMContentLoaded", () => {
181+
if (window.qBittorrent.WebPush.isSupported()) {
182+
window.qBittorrent.WebPush.registerServiceWorker().catch((error) => {
183+
console.error("Failed to register service worker:", error);
184+
});
185+
}
186+
});

src/webui/www/private/sw-webui.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* MIT License
3+
* Copyright (C) 2025 tehcneko
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
24+
"use strict";
25+
26+
self.addEventListener("install", (event) => {
27+
event.waitUntil(self.skipWaiting());
28+
});
29+
30+
self.addEventListener("activate", (event) => {
31+
event.waitUntil(self.clients.claim());
32+
});
33+
34+
self.addEventListener("push", (e) => {
35+
if (e.data === null)
36+
return;
37+
38+
const data = e.data.json();
39+
if (data.event === undefined)
40+
return;
41+
42+
const event = data.event;
43+
const payload = data.payload || {};
44+
45+
let notificationTitle;
46+
let notificationBody;
47+
switch (event) {
48+
case "test":
49+
notificationTitle = "QBT_TR(Test Notification)QBT_TR[CONTEXT=PushNotification]";
50+
notificationBody = "QBT_TR(This is a test notification. Thank you for using qBittorrent.)QBT_TR[CONTEXT=PushNotification]";
51+
break;
52+
case "torrent_added":
53+
// ignore for now.
54+
return;
55+
case "torrent_finished":
56+
notificationTitle = "QBT_TR(Download completed)QBT_TR[CONTEXT=PushNotification]";
57+
notificationBody = "QBT_TR(%1 has finished downloading.)QBT_TR[CONTEXT=PushNotification]"
58+
.replace("%1", `"${payload.torrent_name}"`);
59+
break;
60+
case "full_disk_error":
61+
notificationTitle = "QBT_TR(I/O Error)QBT_TR[CONTEXT=PushNotification]";
62+
notificationBody = "QBT_TR(An I/O error occurred for torrent %1.\n Reason: %2)QBT_TR[CONTEXT=PushNotification]"
63+
.replace("%1", `"${payload.torrent_name}"`)
64+
.replace("%2", payload.reason);
65+
break;
66+
case "add_torrent_failed":
67+
notificationTitle = "QBT_TR(Add torrent failed)QBT_TR[CONTEXT=PushNotification]";
68+
notificationBody = "QBT_TR(Couldn't add torrent '%1', reason: %2.)QBT_TR[CONTEXT=PushNotification]"
69+
.replace("%1", payload.source)
70+
.replace("%2", payload.reason);
71+
break;
72+
default:
73+
notificationTitle = "QBT_TR(Unsupported notification)QBT_TR[CONTEXT=PushNotification]";
74+
notificationBody = "QBT_TR(An unsupported notification was received.)QBT_TR[CONTEXT=PushNotification]";
75+
break;
76+
}
77+
78+
// Keep the service worker alive until the notification is created.
79+
e.waitUntil(
80+
self.registration.showNotification(notificationTitle, {
81+
body: notificationBody,
82+
icon: "images/qbittorrent-tray.svg"
83+
})
84+
);
85+
});
86+
87+
self.addEventListener("notificationclick", (e) => {
88+
e.waitUntil(
89+
self.clients.matchAll({
90+
type: "window"
91+
}).then((clientList) => {
92+
for (const client of clientList) {
93+
if ("focus" in client)
94+
return client.focus();
95+
}
96+
if (clients.openWindow)
97+
return clients.openWindow("/");
98+
})
99+
);
100+
});

0 commit comments

Comments
 (0)