Notify a phone that has this installation installed
A `webpush` alert channel, and the PWA it needs to arrive. The payload is encrypted to the subscription (RFC 8291) and the request signed with this installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush` does the same in one call but brings `requests` and `aiohttp` with it, two HTTP stacks beside httpx on a machine that may be a Raspberry Pi. The manifest and the worker are hand-written rather than `vite-plugin-pwa`: there is nothing worth precaching when the page carrying the credential is `no-store`, so the worker handles `push` and `notificationclick` and nothing else. `registration.scope` is the app's root in both places it runs, which is why the payload carries no URL. A run finishing in error is the first event worth waking someone for; `ok` and `cancelled` describe to nothing, so a nightly batch that works stays quiet. The events were already on the bus — only the filter changed. `WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume with the alerts beside it. Off it, a rebuild would silently stop every phone being notified: the key they subscribed against would be gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EbeFPm6WNC3YD9vrqqT3a
This commit is contained in:
@@ -103,6 +103,17 @@ export const ArtifactRowSchema = {
|
||||
media_type: {
|
||||
type: 'string',
|
||||
title: 'Media Type'
|
||||
},
|
||||
filename: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Filename'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -351,7 +362,7 @@ export const ChannelSchema = {
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['ntfy', 'smtp', 'webhook', 'dashboard'],
|
||||
enum: ['ntfy', 'smtp', 'webhook', 'dashboard', 'webpush'],
|
||||
title: 'Kind'
|
||||
},
|
||||
enabled: {
|
||||
@@ -3130,6 +3141,26 @@ export const ShareRequestSchema = {
|
||||
title: 'ShareRequest'
|
||||
} as const;
|
||||
|
||||
export const SubscriptionSchema = {
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: 'string',
|
||||
title: 'Endpoint'
|
||||
},
|
||||
keys: {
|
||||
additionalProperties: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'object',
|
||||
title: 'Keys'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['endpoint'],
|
||||
title: 'Subscription',
|
||||
description: "What a browser handed us. Opaque apart from the endpoint's host."
|
||||
} as const;
|
||||
|
||||
export const SweepCreateSchema = {
|
||||
properties: {
|
||||
runs: {
|
||||
@@ -3293,6 +3324,18 @@ export const TriggerRequestSchema = {
|
||||
title: 'TriggerRequest'
|
||||
} as const;
|
||||
|
||||
export const UnsubscribeSchema = {
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: 'string',
|
||||
title: 'Endpoint'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['endpoint'],
|
||||
title: 'Unsubscribe'
|
||||
} as const;
|
||||
|
||||
export const UpdatePasswordSchema = {
|
||||
properties: {
|
||||
current_password: {
|
||||
@@ -3699,6 +3742,18 @@ export const WaitingNodeSchema = {
|
||||
title: 'WaitingNode'
|
||||
} as const;
|
||||
|
||||
export const WebPushKeySchema = {
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
title: 'Key'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
title: 'WebPushKey'
|
||||
} as const;
|
||||
|
||||
export const WidgetDefSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,7 @@ export type ArtifactRow = {
|
||||
digest: string;
|
||||
size: number;
|
||||
media_type: string;
|
||||
filename?: (string | null);
|
||||
};
|
||||
|
||||
export type Body_login_login_access_token = {
|
||||
@@ -97,14 +98,14 @@ export type BrainNode = {
|
||||
*/
|
||||
export type Channel = {
|
||||
name: string;
|
||||
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
|
||||
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
|
||||
enabled?: boolean;
|
||||
config?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
|
||||
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
|
||||
|
||||
/**
|
||||
* A dashboard as stored, and as the API hands it over.
|
||||
@@ -1116,6 +1117,16 @@ export type ShareRequest = {
|
||||
lib_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a browser handed us. Opaque apart from the endpoint's host.
|
||||
*/
|
||||
export type Subscription = {
|
||||
endpoint: string;
|
||||
keys?: {
|
||||
[key: string]: (string);
|
||||
};
|
||||
};
|
||||
|
||||
export type SweepCreate = {
|
||||
runs?: Array<SweepEntry>;
|
||||
draft?: boolean;
|
||||
@@ -1163,6 +1174,10 @@ export type TriggerRequest = {
|
||||
};
|
||||
};
|
||||
|
||||
export type Unsubscribe = {
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
@@ -1249,6 +1264,10 @@ export type WaitingNode = {
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
export type WebPushKey = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One tile: what it shows or does, and where it sits.
|
||||
*
|
||||
@@ -1312,6 +1331,20 @@ export type AlertsSaveAlertsConfigData = {
|
||||
|
||||
export type AlertsSaveAlertsConfigResponse = (AlertsConfig);
|
||||
|
||||
export type AlertsReadWebpushKeyResponse = (WebPushKey);
|
||||
|
||||
export type AlertsAddWebpushSubscriptionData = {
|
||||
requestBody: Subscription;
|
||||
};
|
||||
|
||||
export type AlertsAddWebpushSubscriptionResponse = (Message);
|
||||
|
||||
export type AlertsRemoveWebpushSubscriptionData = {
|
||||
requestBody: Unsubscribe;
|
||||
};
|
||||
|
||||
export type AlertsRemoveWebpushSubscriptionResponse = (Message);
|
||||
|
||||
export type AlertsTestChannelData = {
|
||||
channelName: string;
|
||||
};
|
||||
@@ -1677,6 +1710,8 @@ export type ObservabilityReadTimeseriesData = {
|
||||
flow?: (string | null);
|
||||
hours?: number;
|
||||
node?: (string | null);
|
||||
since?: (string | null);
|
||||
until?: (string | null);
|
||||
};
|
||||
|
||||
export type ObservabilityReadTimeseriesResponse = (Array<SeriesPoint>);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Subscribing this browser to the installation'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 installation: the subscription is
|
||||
* stored by the installation 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 installation 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()
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { connectionStore, offlineDetail } from "./lib/connectionStore"
|
||||
import { notify } from "./lib/notificationStore"
|
||||
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
|
||||
import { safeStorage } from "./lib/safeStorage"
|
||||
import { registerSW } from "./lib/webpush"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
const portal = portalConfig()
|
||||
@@ -124,6 +125,10 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
// The worker only exists to receive pushes, so registering it costs a request
|
||||
// and nothing else. A browser that cannot have one is left alone.
|
||||
registerSW()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="system" storageKey="fluksio-ui-theme">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Plus, Send, Trash2 } from "lucide-react"
|
||||
import { Bell, BellOff, Plus, Send, Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { type AlertsConfig, AlertsService, type Channel } from "@/client"
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import * as webpush from "@/lib/webpush"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
export const Route = createFileRoute("/_layout/alerts")({
|
||||
@@ -47,6 +48,7 @@ const EVENTS: [string, string][] = [
|
||||
["engine_degraded", "The engine is struggling"],
|
||||
["cascade_dropped", "Work was given up on"],
|
||||
["queue_unavailable", "The queue is unreachable"],
|
||||
["run_finished", "A run failed"],
|
||||
]
|
||||
|
||||
/** The settings each kind of channel needs, in the order they read best. */
|
||||
@@ -61,9 +63,18 @@ const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
|
||||
// The message has to be one a flow declares, like anything a dashboard
|
||||
// writes to. A notification widget bound to it is what shows the alert.
|
||||
dashboard: [["message", "Message", "house.notice"]],
|
||||
// Nothing to configure: it goes to whichever browsers subscribed here, which
|
||||
// is a button rather than a setting.
|
||||
webpush: [],
|
||||
}
|
||||
|
||||
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook", "dashboard"]
|
||||
const KINDS: Channel["kind"][] = [
|
||||
"ntfy",
|
||||
"smtp",
|
||||
"webhook",
|
||||
"dashboard",
|
||||
"webpush",
|
||||
]
|
||||
|
||||
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
|
||||
* so text that parses as JSON is stored as JSON and survives a round trip. */
|
||||
@@ -83,6 +94,62 @@ function showSetting(value: unknown): string {
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one channel whose setting is on the device rather than in the config:
|
||||
* a browser has to ask its own permission, and what it hands back is stored
|
||||
* against this installation.
|
||||
*/
|
||||
function ThisBrowser() {
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const { data: subscribed, refetch } = useQuery({
|
||||
queryKey: ["alerts", "webpush", "this-browser"],
|
||||
queryFn: async () => (await webpush.current()) !== null,
|
||||
enabled: webpush.supported(),
|
||||
})
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: async (wanted: boolean) =>
|
||||
wanted ? webpush.subscribe() : webpush.unsubscribe(),
|
||||
onSuccess: (_result, wanted) => {
|
||||
showSuccessToast(
|
||||
wanted
|
||||
? "This browser will be notified"
|
||||
: "This browser will no longer be notified",
|
||||
)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: Error) => showErrorToast(error.message),
|
||||
})
|
||||
|
||||
if (!webpush.supported()) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This browser cannot receive notifications. They need HTTPS — over plain
|
||||
http on a local address, no browser will allow them.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{subscribed
|
||||
? "This browser is subscribed."
|
||||
: "This browser is not subscribed yet. Each device subscribes itself."}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={change.isPending}
|
||||
onClick={() => change.mutate(!subscribed)}
|
||||
>
|
||||
{subscribed ? <BellOff /> : <Bell />}
|
||||
{subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Alerts() {
|
||||
const { data } = useQuery({
|
||||
queryKey: alertsKey,
|
||||
@@ -266,6 +333,8 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.kind === "webpush" ? <ThisBrowser /> : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{FIELDS[channel.kind].map(([key, label, placeholder]) => (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
|
||||
Reference in New Issue
Block a user