Files
lxc-streamutils/apps/overlay/src/pwa.tsx
T
felis f79852d8e6 add account localization and switchable rooms
Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs.
2026-07-18 23:28:05 -07:00

252 lines
7.7 KiB
TypeScript

/**
* Install, offline and explicit-update lifecycle for the control-console PWA.
*
* Registration is restricted to `/control/`; `/obs/*` is deliberately outside
* the scope. The worker caches only the public application shell and static
* assets. API responses, WebSockets and secrets remain network-only. A waiting
* worker activates only after user confirmation and after every registered
* one-time-secret or dirty-form blocker has cleared.
*/
import { useEffect, useSyncExternalStore } from 'react'
import { translate, useI18n } from './i18n'
interface InstallChoice {
outcome: 'accepted' | 'dismissed'
platform: string
}
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[]
readonly userChoice: Promise<InstallChoice>
prompt(): Promise<void>
}
interface PwaSnapshot {
online: boolean
standalone: boolean
installPrompt?: BeforeInstallPromptEvent
registration?: ServiceWorkerRegistration
waitingWorker?: ServiceWorker
}
const listeners = new Set<() => void>()
const updateBlockers = new Map<string, string>()
let snapshot: PwaSnapshot = {
online: navigator.onLine,
standalone: isStandalone(),
}
let initialized = false
let reloadForUpdate = false
function isStandalone(): boolean {
const iosNavigator = navigator as Navigator & { standalone?: boolean }
return window.matchMedia('(display-mode: standalone)').matches || iosNavigator.standalone === true
}
function emit(patch: Partial<PwaSnapshot>) {
snapshot = { ...snapshot, ...patch }
listeners.forEach(listener => listener())
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
function getSnapshot() {
return snapshot
}
function addManifest() {
if (document.head.querySelector('link[rel="manifest"]')) return
const manifest = document.createElement('link')
manifest.rel = 'manifest'
manifest.href = '/control/manifest.webmanifest'
document.head.append(manifest)
}
function observeRegistration(registration: ServiceWorkerRegistration) {
emit({
registration,
waitingWorker:
registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
})
registration.addEventListener('updatefound', () => {
const worker = registration.installing
if (!worker) return
worker.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
emit({ waitingWorker: worker })
}
})
})
}
async function removeLegacyRootWorker() {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(
registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy =
scopePath === '/' &&
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}),
)
const cacheNames = await caches.keys()
await Promise.all(
cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)),
)
}
export function cleanupLegacyPwa() {
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
void removeLegacyRootWorker().catch(error => {
console.warn('Legacy PWA cleanup failed', error)
})
}
async function registerWorker() {
try {
await removeLegacyRootWorker()
const registration = await navigator.serviceWorker.register(
`/control/sw.js?v=${encodeURIComponent(__PWA_BUILD_ID__)}`,
{ scope: '/control/', updateViaCache: 'none' },
)
observeRegistration(registration)
} catch (error) {
// A failed PWA registration must never stop the online control console.
console.warn('Console PWA registration failed', error)
}
}
export function initializePwa() {
if (initialized || location.pathname === '/obs' || location.pathname.startsWith('/obs/')) return
initialized = true
addManifest()
const displayMode = window.matchMedia('(display-mode: standalone)')
const updateConnection = () => emit({ online: navigator.onLine })
const updateDisplayMode = () => emit({ standalone: isStandalone() })
window.addEventListener('online', updateConnection)
window.addEventListener('offline', updateConnection)
const modernListener = (
displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}
).addEventListener
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
else
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
displayMode,
updateDisplayMode,
)
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault()
emit({ installPrompt: event as BeforeInstallPromptEvent })
})
window.addEventListener('appinstalled', () =>
emit({ installPrompt: undefined, standalone: true }),
)
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!reloadForUpdate) return
reloadForUpdate = false
location.reload()
})
const start = () => void registerWorker()
if (document.readyState === 'complete') start()
else window.addEventListener('load', start, { once: true })
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
void snapshot.registration?.update()
}
})
window.setInterval(
() => {
if (navigator.onLine) void snapshot.registration?.update()
},
60 * 60 * 1000,
)
}
export function authRoute(name: 'login' | 'register' | 'setup'): string {
const inControlScope =
location.pathname === '/control' || location.pathname.startsWith('/control/')
return inControlScope ? `/control/${name}` : `/${name}`
}
export function usePwaUpdateBlocker(key: string, reason: string, active: boolean) {
useEffect(() => {
if (active) updateBlockers.set(key, reason)
else updateBlockers.delete(key)
return () => {
updateBlockers.delete(key)
}
}, [active, key, reason])
}
async function requestInstall() {
const prompt = snapshot.installPrompt
if (!prompt) return
await prompt.prompt()
await prompt.userChoice
emit({ installPrompt: undefined, standalone: isStandalone() })
}
function applyUpdate() {
const worker = snapshot.waitingWorker
if (!worker) return
const reasons = [...new Set(updateBlockers.values())]
if (reasons.length > 0) {
window.alert(
translate('pwa.blocked', { reasons: reasons.map(reason => `• ${reason}`).join('\n') }),
)
return
}
const confirmed = window.confirm(translate('pwa.confirm_update'))
if (!confirmed) return
reloadForUpdate = true
worker.postMessage({ type: 'SKIP_WAITING' })
}
export function PwaControls() {
const { t } = useI18n()
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const canInstall = Boolean(state.installPrompt) && !state.standalone
if (state.online && !state.waitingWorker && !canInstall) return null
return (
<div className="pwa-controls" aria-live="polite">
{!state.online && (
<span className="pwa-state offline">
<i aria-hidden="true" />
{t('pwa.offline')}
</span>
)}
{state.waitingWorker && (
<button type="button" className="pwa-action update" onClick={applyUpdate}>
{t('pwa.update')}
</button>
)}
{canInstall && (
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
{t('pwa.install')}
</button>
)}
</div>
)
}