proper productionize project
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
|
||||
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('旧版 PWA 清理失败', 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('控制台 PWA 注册失败', 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(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
|
||||
return
|
||||
}
|
||||
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
|
||||
if (!confirmed) return
|
||||
reloadForUpdate = true
|
||||
worker.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
|
||||
export function PwaControls() {
|
||||
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" />离线</span>}
|
||||
{state.waitingWorker && (
|
||||
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
||||
更新可用
|
||||
</button>
|
||||
)}
|
||||
{canInstall && (
|
||||
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
|
||||
安装到设备
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user