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.
252 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|