Add installable PWA shell with safe updates and offline status

This commit is contained in:
eezd committed 2026-10-19 13:22:56 +08:00
1 parent 64915a7b94
commit 1562519f08
18 files changed
+310 -5

No files matched your search

+4 -1
View File
@@ -124,6 +124,9 @@ func (s *Server) Handler(static fs.FS) http.Handler {
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("Cache-Control", "no-store")
}
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
@@ -394,7 +397,7 @@ func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) {
if ct := mime.TypeByExtension(path.Ext(info.Name())); ct != "" {
h.Set("Content-Type", ct)
}
h.Set("Cache-Control", "private, no-cache")
h.Set("Cache-Control", "no-store")
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
+36
View File
@@ -1,6 +1,7 @@
package api
import (
"compress/gzip"
"encoding/json"
"io"
"net/http"
@@ -139,6 +140,41 @@ func TestOpenAPICoversRoutes(t *testing.T) {
}
}
func TestAPIResponsesAreNotCached(t *testing.T) {
f := newFixture(t)
f.write("笔记.md", "# 正文")
// 登录状态与目录曾在 Firefox 离线时命中 HTTP 缓存,误进入无法加载的工作区。
for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} {
res := f.do(f.admin, "GET", path, "", nil)
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" {
t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control"))
}
}
}
func TestCompressedManifestContentType(t *testing.T) {
body := strings.Repeat(" ", 300) + `{"name":"RawMark","display":"standalone"}`
handler := newStaticHandler(fstest.MapFS{"manifest.webmanifest": {Data: []byte(body)}})
req := httptest.NewRequest("GET", "/manifest.webmanifest", nil)
req.Header.Set("Accept-Encoding", "gzip")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
if res.Header.Get("Content-Type") != "application/manifest+json" || res.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("清单响应头不正确:%v", res.Header)
}
zr, err := gzip.NewReader(res.Body)
if err != nil {
t.Fatal(err)
}
defer zr.Close()
var manifest map[string]string
if err := json.NewDecoder(zr).Decode(&manifest); err != nil || manifest["name"] != "RawMark" {
t.Fatalf("压缩清单无法解析:%v", err)
}
}
func TestShareAccess(t *testing.T) {
f := newFixture(t)
f.write("目录/笔记.md", "# 标题\n\n![](笔记.assets/图.png)\n")
+4 -1
View File
@@ -68,7 +68,10 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if share {
hd.Set("X-Robots-Tag", "noindex")
}
if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
if path.Ext(name) == ".webmanifest" {
// Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。
hd.Set("Content-Type", "application/manifest+json")
} else if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
hd.Set("Content-Type", ct)
}
hd.Add("Vary", "Accept-Encoding")
+3
View File
@@ -6,6 +6,9 @@
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<title>RawMark</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
</head>
<body>
<div id="app"></div>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#213547"/>
<path d="M142 362V150h112c98 0 113 130 17 145l83 67h-85l-67-65v65zm60-119h49c38 0 38-40 0-40h-49z" fill="#42d392"/>
</svg>

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"id": "/",
"name": "RawMark",
"short_name": "RawMark",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+10 -2
View File
@@ -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>
+62
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -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;
+65
View File
@@ -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)
}
}
+6
View File
@@ -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)
}
// ---- 冲突处理 ----
// 放弃本地修改,载入服务器版本
+70
View File
@@ -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)
})())
})
+24
View File
@@ -1,9 +1,33 @@
import vue from '@vitejs/plugin-vue'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [
vue(),
{
name: 'rawmark-pwa',
apply: 'build',
enforce: 'post',
generateBundle(_options, bundle) {
// 缓存列表由产物生成,覆盖懒加载资源,旧页面更新后仍能加载其依赖。
const files = Object.keys(bundle).filter((name) => name === 'index.html' || name.startsWith('assets/')).sort()
const version = createHash('sha256')
for (const name of files) {
const item = bundle[name]!
version.update(name).update(item.type === 'chunk' ? item.code : item.source)
}
for (const name of ['manifest.webmanifest', 'icon.svg', 'icons/icon-192.png', 'icons/icon-512.png', 'icons/maskable-512.png', 'icons/apple-touch-icon.png']) {
version.update(readFileSync(new URL('./public/' + name, import.meta.url)))
files.push(name)
}
const source = readFileSync(new URL('./src/service-worker.js', import.meta.url), 'utf8')
.replace('__VERSION__', version.digest('hex').slice(0, 16))
.replace('__PRECACHE__', JSON.stringify(files.map((name) => '/' + name)))
this.emitFile({ type: 'asset', fileName: 'sw.js', source })
},
},
// 开发服务器中把分享地址 /s/{token} 交给分享页入口(生产环境由 Go 服务处理)
{
name: 'rawmark-share-route',