Files
app/frontend/src/lib/webpush.ts
T
stroblme 6c8a101d24 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.
2026-08-31 10:12:01 +02:00

112 lines
4.1 KiB
TypeScript

/**
* Subscribing this browser to the instance's notifications.
*
* A push arrives through the service worker, so it reaches a phone with no tab
* open — which is the whole point, and the reason this is not the in-app
* notification stack. The engine decides *what* is worth sending in its alert
* rules; this only says which browsers hear it.
*
* Everything here is per browser and per instance: the subscription is
* stored by the instance it was made against, so a phone that opens two
* houses through the portal is two subscriptions and hears each separately.
*/
import { AlertsService } from "@/client"
import { appPath } from "@/lib/portal"
/** Whether this browser can do push at all.
*
* Plain HTTP over a LAN cannot: service workers need a secure context, and
* `http://…local` addresses are not one. Safari delivers push only to an
* installed PWA, but says so itself by refusing the permission, so there is
* nothing to detect here. */
export function supported(): boolean {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
)
}
/** Register the worker, or reuse the registration this browser already has. */
async function worker(): Promise<ServiceWorkerRegistration> {
// The scope comes from where the file is served, which is the app's root in
// both contexts — `/sw.js` alone, `/i/{id}/sw.js` through the portal.
return navigator.serviceWorker.register(appPath("/sw.js"))
}
/** Start the worker in the background; a push cannot arrive without it. */
export function registerSW(): void {
if (!supported()) return
worker().catch((error) => console.warn("No service worker:", error))
}
/** The subscription this browser already holds here, if any. */
export async function current(): Promise<PushSubscription | null> {
if (!supported()) return null
const registration = await navigator.serviceWorker.getRegistration(
appPath("/sw.js"),
)
return (await registration?.pushManager.getSubscription()) ?? null
}
/** A base64url key as the `applicationServerKey` bytes `subscribe` wants. */
function keyBytes(key: string): Uint8Array<ArrayBuffer> {
const padded = key.replace(/-/g, "+").replace(/_/g, "/")
const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4))
const bytes = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
return bytes
}
/**
* Ask permission, subscribe, and tell the instance where to reach us.
*
* Throws with something worth reading if the person says no — the caller puts
* it on screen, because a silently ignored button is worse than a refusal.
*/
export async function subscribe(): Promise<void> {
if (!supported()) throw new Error("This browser cannot receive notifications")
const permission = await Notification.requestPermission()
if (permission !== "granted") {
throw new Error(
permission === "denied"
? "Notifications are blocked for this site in the browser's settings"
: "Notifications were not allowed",
)
}
const registration = await worker()
await navigator.serviceWorker.ready
const { key } = await AlertsService.readWebpushKey()
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
// Every push carries a payload, and a browser will not deliver one it
// cannot show; both Chrome and Firefox require this to be true anyway.
userVisibleOnly: true,
applicationServerKey: keyBytes(key),
}))
const { endpoint, keys } = subscription.toJSON() as {
endpoint: string
keys: Record<string, string>
}
await AlertsService.addWebpushSubscription({
requestBody: { endpoint, keys },
})
}
/** Stop this browser hearing about it, here and at the push service. */
export async function unsubscribe(): Promise<void> {
const subscription = await current()
if (!subscription) return
await AlertsService.removeWebpushSubscription({
requestBody: { endpoint: subscription.endpoint },
})
await subscription.unsubscribe()
}