Files
web-terminal/public/push.ts
Yaojia Wang e554c053ee polish(v0.7): replace timeline 📜 + push 🔔 emoji with themed line icons
Swap the two v0.7 emoji buttons for lucide line icons (stroke=currentColor) so
they take the Amber theme colour like the toolbar icons:
- timeline toggle (tabs.ts) → scroll-text icon
- push toggle + disabled bell (push.ts) → bell icon, keeping the On/Off label
- icons.ts: ICON_TIMELINE, ICON_BELL; style.css sizes the inline svgs (16px)

Frontend-only. web tsc + build:web clean, 102 push tests green, bundle emoji-free.
2026-06-30 18:54:11 +02:00

359 lines
13 KiB
TypeScript

/**
* public/push.ts — Push subscribe/permission UI (N-push-ui, A1)
*
* Exports:
* PushSupportStatus — union of all push readiness states
* fetchVapidKey() — GET /push/vapid-key (null on 503/error)
* checkPushSupport(vapidKey) — async, checks all preconditions (SEC-H8)
* subscribePush(vapidKey) — request permission + SW subscribe + POST server
* unsubscribePush(sub) — DELETE server + browser unsubscribe (best-effort)
* mountPushToggle(container, opts?) — render 🔔 toggle widget
* isPushMuted() / setPushMuted(muted) — in-app mute (A1-FR9, localStorage only)
*
* SEC-H8: isSecureContext is the first check in checkPushSupport; nothing push-
* related executes in an insecure context.
* Review #12: The localStorage mute is in-app-only. Global DND is server-side
* (NOTIFY_DND env). They are independent.
*/
import { ICON_BELL } from './icons.js'
/* ── Types ─────────────────────────────────────────────────────────────────── */
/** All possible push-readiness states. Drives UI rendering in mountPushToggle. */
export type PushSupportStatus =
| 'unsupported' // SW / PushManager / Notification API unavailable in this browser
| 'insecure-context' // page is not a secure context; needs HTTPS or Tailscale (SEC-H8)
| 'vapid-missing' // server has no VAPID keys; GET /push/vapid-key returned 503
| 'permission-denied' // user blocked notifications in browser settings
| 'available' // all checks pass, ready to subscribe (no active sub yet)
| 'subscribed' // active push subscription already exists
export interface MountPushToggleOpts {
/** Called once after the status is resolved and the widget is rendered. */
onChange?: (status: PushSupportStatus) => void
}
/* ── In-app mute (A1-FR9) ──────────────────────────────────────────────────── */
/** localStorage key for the in-app mute preference. */
const PUSH_MUTE_KEY = 'web-terminal:push-muted'
/** Read the in-app mute preference. Returns false on any storage error. */
export function isPushMuted(): boolean {
try {
return localStorage.getItem(PUSH_MUTE_KEY) === '1'
} catch {
return false
}
}
/** Write the in-app mute preference. Silently ignores storage errors. */
export function setPushMuted(muted: boolean): void {
try {
if (muted) {
localStorage.setItem(PUSH_MUTE_KEY, '1')
} else {
localStorage.removeItem(PUSH_MUTE_KEY)
}
} catch {
// Ignore — private browsing or storage quota; best-effort
}
}
/* ── fetchVapidKey ─────────────────────────────────────────────────────────── */
/**
* Fetch the VAPID public key from the server.
* Returns null when push is disabled (503) or on any error.
*/
export async function fetchVapidKey(): Promise<string | null> {
try {
const res = await fetch('/push/vapid-key', { credentials: 'same-origin' })
if (!res.ok) return null
const data: unknown = await res.json()
if (
typeof data === 'object' &&
data !== null &&
'publicKey' in data &&
typeof (data as Record<string, unknown>)['publicKey'] === 'string'
) {
return (data as { publicKey: string }).publicKey
}
return null
} catch {
return null
}
}
/* ── checkPushSupport ──────────────────────────────────────────────────────── */
/**
* Check the comprehensive push support status. Checks in order:
* insecure-context → unsupported → vapid-missing → permission-denied
* → subscribed → available
*
* SEC-H8: isSecureContext is the first check.
*/
export async function checkPushSupport(vapidKey: string | null): Promise<PushSupportStatus> {
// SEC-H8: secure context is required for SW and Push API
if (!window.isSecureContext) return 'insecure-context'
// Require all three browser APIs — check values (not just property existence),
// because jsdom sets properties to undefined rather than deleting them.
const hasSW = Boolean(navigator.serviceWorker)
// Cast via `unknown` first to avoid TS2352 (Window lacks an index signature).
const winMap = window as unknown as Record<string, unknown>
const hasPushMgr = Boolean(winMap['PushManager'])
// Capture Notification locally so we can access .permission safely below.
const Notif = winMap['Notification'] as typeof Notification | undefined
if (!hasSW || !hasPushMgr || !Notif) return 'unsupported'
// Server must have VAPID keys configured
if (vapidKey === null) return 'vapid-missing'
// Check browser permission
if (Notif.permission === 'denied') return 'permission-denied'
// Check for an existing active subscription
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
const subscription = await registration.pushManager.getSubscription()
if (subscription) return 'subscribed'
}
} catch {
// Cannot determine subscription state; fall through to 'available'
}
return 'available'
}
/* ── subscribePush ─────────────────────────────────────────────────────────── */
/** Convert a URL-safe base64 string to Uint8Array for applicationServerKey. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const output = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
output[i] = rawData.charCodeAt(i) ?? 0
}
return output
}
/**
* Subscribe to push notifications.
* 1. Requests Notification permission (if not already granted).
* 2. Gets the SW registration and calls pushManager.subscribe().
* 3. POSTs the PushSubscription to /push/subscribe.
* Returns the PushSubscription on success, null on any failure.
*/
export async function subscribePush(vapidKey: string): Promise<PushSubscription | null> {
try {
const perm = await Notification.requestPermission()
if (perm !== 'granted') return null
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
const applicationServerKey = urlBase64ToUint8Array(vapidKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
// Cast to `ArrayBuffer` — Uint8Array.buffer is ArrayBufferLike which includes
// SharedArrayBuffer, but DOM types require ArrayBuffer here.
applicationServerKey: applicationServerKey.buffer as ArrayBuffer,
})
const res = await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(subscription.toJSON()),
})
if (!res.ok) {
// Roll back: unsubscribe from browser so we stay in sync
await subscription.unsubscribe()
return null
}
return subscription
} catch {
return null
}
}
/* ── unsubscribePush ───────────────────────────────────────────────────────── */
/**
* Unsubscribe from push notifications.
* Both operations are best-effort; individual errors are swallowed so the
* other operation still runs.
*/
export async function unsubscribePush(subscription: PushSubscription): Promise<void> {
try {
await fetch('/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
} catch {
// Ignore server errors — still attempt browser unsubscribe
}
try {
await subscription.unsubscribe()
} catch {
// Ignore browser errors
}
}
/* ── DOM helpers ───────────────────────────────────────────────────────────── */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── mountPushToggle ───────────────────────────────────────────────────────── */
/**
* Mount a 🔔 push subscribe/unsubscribe toggle into container.
*
* Behavior per status:
* 'vapid-missing' → hides container (server has no VAPID keys)
* 'insecure-context' → grayed 🔔 + "needs HTTPS/Tailscale" hint
* 'unsupported' → grayed 🔔 + "not supported in this browser" hint
* 'permission-denied' → grayed 🔔 + "blocked in browser settings" hint
* 'available' → active button (Off state) to initiate subscribe
* 'subscribed' → active button (On state) to initiate unsubscribe
*/
export function mountPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): void {
void initPushToggle(container, opts)
}
async function initPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): Promise<void> {
const vapidKey = await fetchVapidKey()
const status = await checkPushSupport(vapidKey)
renderPushToggle(container, status, vapidKey, opts)
}
function renderPushToggle(
container: HTMLElement,
status: PushSupportStatus,
vapidKey: string | null,
opts?: MountPushToggleOpts,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
opts?.onChange?.(status)
if (status === 'vapid-missing') {
// Server push is disabled — hide the widget entirely
container.style.display = 'none'
return
}
container.style.display = ''
if (status === 'insecure-context') {
renderDisabled(
container,
'Push notifications unavailable',
'Enable push: access via HTTPS or Tailscale',
)
return
}
if (status === 'unsupported') {
renderDisabled(
container,
'Push notifications not supported',
'Push notifications not supported in this browser',
)
return
}
if (status === 'permission-denied') {
renderDisabled(
container,
'Push notifications blocked',
'Notifications blocked — allow in browser settings',
)
return
}
// 'available' or 'subscribed' — render a functional toggle button
renderToggleButton(container, status === 'subscribed', vapidKey, opts)
}
function renderDisabled(container: HTMLElement, ariaLabel: string, hint: string): void {
const wrap = el('span', 'push-toggle-disabled')
const bell = el('span', 'push-bell push-bell-off')
bell.innerHTML = ICON_BELL
bell.setAttribute('aria-label', ariaLabel)
const hintEl = el('span', 'push-hint', hint)
wrap.append(bell, hintEl)
container.append(wrap)
}
function renderToggleButton(
container: HTMLElement,
isSubscribed: boolean,
vapidKey: string | null,
opts?: MountPushToggleOpts,
): void {
const btn = el(
'button',
isSubscribed ? 'push-toggle-btn push-toggle-on' : 'push-toggle-btn push-toggle-off',
)
btn.innerHTML = ICON_BELL
btn.append(' ' + (isSubscribed ? 'On' : 'Off'))
btn.title = isSubscribed
? 'Click to disable push notifications'
: 'Click to enable push notifications'
btn.setAttribute('aria-pressed', isSubscribed ? 'true' : 'false')
btn.addEventListener('click', () => {
btn.disabled = true
void handleToggleClick(container, vapidKey, isSubscribed, opts)
})
container.append(btn)
}
async function handleToggleClick(
container: HTMLElement,
vapidKey: string | null,
wasSubscribed: boolean,
opts?: MountPushToggleOpts,
): Promise<void> {
if (wasSubscribed) {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
const sub = await registration.pushManager.getSubscription()
if (sub) await unsubscribePush(sub)
}
} catch {
// ignore
}
} else if (vapidKey !== null) {
await subscribePush(vapidKey)
}
// Re-check status and re-render
const newStatus = await checkPushSupport(vapidKey)
renderPushToggle(container, newStatus, vapidKey, opts)
}