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.
112 lines
4.1 KiB
TypeScript
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()
|
|
}
|