Add installable PWA shell with safe updates and offline status
This commit is contained in:
1 parent
64915a7b94
commit
1562519f08
18 files changed
+310
-5
No files matched your search
+10
-2
@@ -2,6 +2,8 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, setUnauthorizedHandler } from './api'
|
||||
import AuthView from './AuthView.vue'
|
||||
import AppStatus from './AppStatus.vue'
|
||||
import { online, registerPwa } from './pwa'
|
||||
import Toasts from './Toasts.vue'
|
||||
import Workspace from './Workspace.vue'
|
||||
import { stopWorkspace } from './workspace'
|
||||
@@ -11,6 +13,8 @@ const phase = ref<'loading' | 'setup' | 'login' | 'ready' | 'error'>('loading')
|
||||
async function check() {
|
||||
try {
|
||||
const s = await api.authState()
|
||||
// 请求成功比浏览器的网络状态提示更可靠(局域网和网络切换时可能不同步)。
|
||||
online.value = true
|
||||
phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login'
|
||||
} catch {
|
||||
phase.value = 'error'
|
||||
@@ -21,15 +25,19 @@ setUnauthorizedHandler(() => {
|
||||
stopWorkspace()
|
||||
phase.value = 'login'
|
||||
})
|
||||
onMounted(check)
|
||||
onMounted(() => {
|
||||
void check()
|
||||
void registerPwa()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppStatus />
|
||||
<AuthView v-if="phase === 'setup' || phase === 'login'" :mode="phase" @done="phase = 'ready'" />
|
||||
<Workspace v-else-if="phase === 'ready'" @logout="phase = 'login'" />
|
||||
<div v-else class="rm-splash">
|
||||
<template v-if="phase === 'error'">
|
||||
<p>无法连接到服务器</p>
|
||||
<p>{{ online ? '无法连接到服务器' : '离线,无法加载笔记' }}</p>
|
||||
<button class="rm-btn" @click="check">重试</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { installApp, installPrompt, online, waitingWorker } from './pwa'
|
||||
import { flushAll, readyToReload, toast } from './workspace'
|
||||
|
||||
const busy = ref(false)
|
||||
const activated = ref(false)
|
||||
|
||||
async function update() {
|
||||
const worker = waitingWorker.value
|
||||
if ((!worker && !activated.value) || busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await flushAll()
|
||||
if (!readyToReload()) {
|
||||
toast('请先完成保存、图片上传或文件操作,再更新应用')
|
||||
return
|
||||
}
|
||||
if (!activated.value && worker) {
|
||||
// 等待新版激活后,仅刷新主动点击更新的页面。
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = () => {
|
||||
if (worker.state !== 'activated' && worker.state !== 'redundant') return
|
||||
clearTimeout(timer)
|
||||
worker.removeEventListener('statechange', finish)
|
||||
if (worker.state === 'activated') resolve()
|
||||
else reject(new Error('新版本激活失败'))
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
worker.removeEventListener('statechange', finish)
|
||||
reject(new Error('等待新版本超时'))
|
||||
}, 15000)
|
||||
worker.addEventListener('statechange', finish)
|
||||
worker.postMessage('ACTIVATE')
|
||||
finish()
|
||||
})
|
||||
activated.value = true
|
||||
}
|
||||
// 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。
|
||||
if (readyToReload()) location.reload()
|
||||
else toast('仍有未完成的保存或操作,请完成后再次点击刷新')
|
||||
} catch {
|
||||
toast('暂时无法更新,请稍后重试')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!online || waitingWorker || activated || installPrompt" class="rm-app-status" role="status">
|
||||
<span v-if="!online">离线,无法加载或保存笔记</span>
|
||||
<template v-else-if="waitingWorker || activated">
|
||||
<span>有新版本</span>
|
||||
<button class="rm-btn" :disabled="busy" @click="update">{{ busy ? '正在保存…' : '保存并刷新' }}</button>
|
||||
</template>
|
||||
<template v-else-if="installPrompt">
|
||||
<span>将 RawMark 添加到主屏幕</span>
|
||||
<button class="rm-btn" @click="installApp">安装</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -72,7 +72,7 @@ async function request(
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify(init.json)
|
||||
}
|
||||
const res = await fetch(url, { method, headers, body, credentials: 'same-origin', keepalive: init.keepalive })
|
||||
const res = await fetch(url, { method, headers, body, credentials: 'same-origin', cache: 'no-store', keepalive: init.keepalive })
|
||||
if (!res.ok) {
|
||||
let data: Record<string, unknown> = {}
|
||||
try {
|
||||
|
||||
@@ -27,7 +27,12 @@ body {
|
||||
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
|
||||
#app {
|
||||
height: var(--rm-app-height, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rm-app-status { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 12px; padding-top: max(6px, env(safe-area-inset-top)); background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: 13px; flex: none; }
|
||||
#app > .rm-shell, #app > .rm-splash, #app > .rm-auth { flex: 1; min-height: 0; height: auto; }
|
||||
@media (pointer: coarse) { .rm-app-status .rm-btn { min-height: 44px; } }
|
||||
body {
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
interface InstallPrompt extends Event {
|
||||
prompt(): Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
export const online = ref(navigator.onLine)
|
||||
export const waitingWorker = shallowRef<ServiceWorker | null>(null)
|
||||
export const installPrompt = shallowRef<InstallPrompt | null>(null)
|
||||
|
||||
window.addEventListener('online', () => (online.value = true))
|
||||
window.addEventListener('offline', () => (online.value = false))
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault()
|
||||
installPrompt.value = event as InstallPrompt
|
||||
})
|
||||
window.addEventListener('appinstalled', () => (installPrompt.value = null))
|
||||
|
||||
export async function installApp() {
|
||||
const prompt = installPrompt.value
|
||||
if (!prompt) return
|
||||
await prompt.prompt()
|
||||
installPrompt.value = null
|
||||
}
|
||||
|
||||
export async function registerPwa() {
|
||||
// 开发服务器不注册,避免外壳缓存干扰热更新。
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator) || !window.isSecureContext) return
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
||||
const checkWaiting = () => {
|
||||
if (registration.waiting) waitingWorker.value = registration.waiting
|
||||
else if (waitingWorker.value?.state === 'redundant') waitingWorker.value = null
|
||||
}
|
||||
const trackInstalling = () => {
|
||||
registration.installing?.addEventListener('statechange', checkWaiting)
|
||||
checkWaiting()
|
||||
}
|
||||
trackInstalling()
|
||||
registration.addEventListener('updatefound', trackInstalling)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
// 其他标签页激活新版时,本页也保留手动更新入口。
|
||||
waitingWorker.value = navigator.serviceWorker.controller
|
||||
})
|
||||
const checkUpdate = () => {
|
||||
if (document.visibilityState === 'visible' && navigator.onLine) {
|
||||
void registration.update().catch((err) => console.warn('检查应用更新失败', err))
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', checkUpdate)
|
||||
window.addEventListener('online', checkUpdate)
|
||||
// 新旧页面报告入口哈希,确认所有页面均已更新后才回收旧资源。
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if (event.data === 'SHELL_ENTRY') {
|
||||
const script = document.querySelector<HTMLScriptElement>('script[type="module"][src]')!
|
||||
event.ports[0]!.postMessage(new URL(script.src).pathname)
|
||||
}
|
||||
})
|
||||
const ready = await navigator.serviceWorker.ready
|
||||
ready.active?.postMessage('CLEANUP')
|
||||
} catch (err) {
|
||||
console.warn('注册离线外壳失败', err)
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,12 @@ export async function flushAll(keepalive = false) {
|
||||
await Promise.all([...slots.keys()].map((p) => flush(p, keepalive)))
|
||||
}
|
||||
|
||||
// 保存失败会留在标签页状态中,flushAll 完成并不意味着可以安全刷新。
|
||||
export function readyToReload(): boolean {
|
||||
return opsInFlight === 0 && !ws.tabs.some((t) => t.dirty || t.status === 'loading') &&
|
||||
[...slots.values()].every((s) => !s.uploads && !s.paused && !s.inflight)
|
||||
}
|
||||
|
||||
// ---- 冲突处理 ----
|
||||
|
||||
// 放弃本地修改,载入服务器版本
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const CACHE = 'rawmark-shell-__VERSION__'
|
||||
const FILES = __PRECACHE__
|
||||
const paths = new Set(FILES)
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
// 逐批下载,避免同时请求数百个语法包和字体。
|
||||
for (let i = 0; i < FILES.length; i += 16) {
|
||||
await cache.addAll(FILES.slice(i, i + 16).map((url) => new Request(url, { cache: 'reload' })))
|
||||
}
|
||||
})())
|
||||
})
|
||||
|
||||
function clientUsesCurrentShell(client) {
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel()
|
||||
const timer = setTimeout(() => { channel.port1.close(); resolve(false) }, 2000)
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timer)
|
||||
channel.port1.close()
|
||||
resolve(paths.has(event.data))
|
||||
}
|
||||
client.postMessage('SHELL_ENTRY', [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
async function cleanCaches() {
|
||||
// 安装中的新版本也使用独立缓存,旧 worker 不能把它当作过期资源删除。
|
||||
if (self.registration.installing || self.registration.waiting) return
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
if (!(await Promise.all(clients.map(clientUsesCurrentShell))).every(Boolean)) return
|
||||
for (const name of await caches.keys()) {
|
||||
if (self.registration.installing || self.registration.waiting) return
|
||||
if (name.startsWith('rawmark-shell-') && name !== CACHE) await caches.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(cleanCaches())
|
||||
})
|
||||
|
||||
// 激活会更换所有页面的控制器,但只由点击更新的页面主动刷新。
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data === 'ACTIVATE') event.waitUntil(self.skipWaiting())
|
||||
if (event.data === 'CLEANUP') event.waitUntil(cleanCaches())
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event
|
||||
const url = new URL(request.url)
|
||||
if (request.method !== 'GET' || url.origin !== self.location.origin) return
|
||||
// 只匹配构建产物与应用入口,不缓存 API、分享地址、笔记图片或插件。
|
||||
const entry = request.mode === 'navigate' && (url.pathname === '/' || url.pathname === '/index.html')
|
||||
if (!entry && !paths.has(url.pathname) && !url.pathname.startsWith('/assets/')) return
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
const cached = await cache.match(entry ? '/index.html' : url.pathname)
|
||||
if (cached) return cached
|
||||
// 旧标签页尚未刷新时,按原来的哈希加载旧语法包与字体。
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
for (const name of await caches.keys()) {
|
||||
if (!name.startsWith('rawmark-shell-')) continue
|
||||
const previous = await (await caches.open(name)).match(url.pathname)
|
||||
if (previous) return previous
|
||||
}
|
||||
}
|
||||
return fetch(request)
|
||||
})())
|
||||
})
|
||||
Reference in new issue
Block a user