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.
359 lines
13 KiB
TypeScript
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)
|
|
}
|