R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s
Build / Build (push) Successful in 56s
Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
d33697e3fc
commit
ab17959ed4
29 files changed
+2047
-67
No files matched your search
@@ -0,0 +1,101 @@
|
||||
// The service worker of one persona's Web Push subscription. wwwroot/js/push.js registers this script once per persona
|
||||
// the reader turns notifications on for, at the scope push/{slot}/ (a random name this device keeps in localStorage's
|
||||
// PushDevice, Services/PushService.cs), so each persona has a push subscription of its own: its own endpoint and keys,
|
||||
// which PrivaPub stores with that persona's token alone. The same file serves development and published builds; the
|
||||
// app's own workers (service-worker.js, service-worker.published.js) receive no pushes. It controls no page, so it has no
|
||||
// fetch handler.
|
||||
//
|
||||
// A push shows the payload's title, body and icon, tagged with its notification id so a repeat replaces it. The payload
|
||||
// is Mastodon's, so it also carries the subscription's access token: that is never read, kept, shown or passed on here.
|
||||
// A click opens decePub on the notifications of the persona this registration is for: an open decePub tab is focused and
|
||||
// told which (main.js, CascadingState.PushOpened), else a new one opens at ?notifications=1&push={slot}.
|
||||
|
||||
const appRoot = new URL("./", self.location).href
|
||||
const slot = new URL(self.registration.scope).pathname.split("/").filter(part => part.length > 0).pop()
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting())
|
||||
|
||||
self.addEventListener("push", event => event.waitUntil(show(event.data)))
|
||||
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close()
|
||||
event.waitUntil(open())
|
||||
})
|
||||
|
||||
// the push service changed the subscription: the same key subscribes again here, and the app tells PrivaPub the new
|
||||
// endpoint the next time it starts (PushService.Reconcile), since this worker holds no token to tell it with
|
||||
self.addEventListener("pushsubscriptionchange", event => event.waitUntil(renew(event)))
|
||||
|
||||
async function show(data) {
|
||||
let title = "decePub"
|
||||
const options = { icon: appRoot + "imgs/icon-192.png", timestamp: Date.now(), data: {} }
|
||||
try {
|
||||
const payload = data ? data.json() : null
|
||||
if (payload && typeof payload == "object") {
|
||||
if (typeof payload.title == "string" && payload.title.length > 0)
|
||||
title = payload.title
|
||||
if (typeof payload.body == "string")
|
||||
options.body = payload.body
|
||||
if (isWebAddress(payload.icon))
|
||||
options.icon = payload.icon
|
||||
if (payload.notification_id != null)
|
||||
options.tag = String(payload.notification_id)
|
||||
if (typeof payload.preferred_locale == "string")
|
||||
options.lang = payload.preferred_locale
|
||||
options.data = { notificationId: options.tag ?? null, type: typeof payload.notification_type == "string" ? payload.notification_type : null }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
// a push always shows something (userVisibleOnly): one that can't be read shows decePub's name alone
|
||||
await self.registration.showNotification(title, options)
|
||||
try {
|
||||
// the count is the app's to say (Index.razor); until it opens, a dot says something waits
|
||||
await self.navigator.setAppBadge?.()
|
||||
} catch (e) {
|
||||
console.debug(e)
|
||||
}
|
||||
}
|
||||
|
||||
function isWebAddress(value) {
|
||||
if (typeof value != "string")
|
||||
return false
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === "https:" || url.protocol === "http:"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
|
||||
const app = windows.filter(client => client.url.startsWith(appRoot))
|
||||
const target = app.find(client => client.focused) ?? app.find(client => client.visibilityState === "visible") ?? app[0]
|
||||
if (target) {
|
||||
try {
|
||||
await target.focus()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
// (navigate() is only for the worker that controls the tab: the app's own one does)
|
||||
target.postMessage({ type: "decepub-push-open", slot })
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(appRoot)
|
||||
url.searchParams.set("notifications", "1")
|
||||
url.searchParams.set("push", slot)
|
||||
await self.clients.openWindow(url.href)
|
||||
}
|
||||
|
||||
async function renew(event) {
|
||||
try {
|
||||
const key = event.oldSubscription?.options?.applicationServerKey
|
||||
if (!key || event.newSubscription)
|
||||
return
|
||||
await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user