diff --git a/internal/api/api.go b/internal/api/api.go
index 3361060..fe2de22 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -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)
}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index 50544bf..f09170d 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -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\n")
diff --git a/internal/api/static.go b/internal/api/static.go
index 715d3b8..a639948 100644
--- a/internal/api/static.go
+++ b/internal/api/static.go
@@ -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")
diff --git a/web/index.html b/web/index.html
index 418c210..ad4d015 100644
--- a/web/index.html
+++ b/web/index.html
@@ -6,6 +6,9 @@
RawMark
+
+
+
diff --git a/web/public/icon.svg b/web/public/icon.svg
new file mode 100644
index 0000000..944f4ba
--- /dev/null
+++ b/web/public/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..b157102
Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ
diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png
new file mode 100644
index 0000000..063e07c
Binary files /dev/null and b/web/public/icons/icon-192.png differ
diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png
new file mode 100644
index 0000000..8df2768
Binary files /dev/null and b/web/public/icons/icon-512.png differ
diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png
new file mode 100644
index 0000000..8df2768
Binary files /dev/null and b/web/public/icons/maskable-512.png differ
diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest
new file mode 100644
index 0000000..a41b40e
--- /dev/null
+++ b/web/public/manifest.webmanifest
@@ -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" }
+ ]
+}
diff --git a/web/src/app/App.vue b/web/src/app/App.vue
index 2ad4f71..3c13d16 100644
--- a/web/src/app/App.vue
+++ b/web/src/app/App.vue
@@ -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()
+})
+
- 无法连接到服务器
+ {{ online ? '无法连接到服务器' : '离线,无法加载笔记' }}
diff --git a/web/src/app/AppStatus.vue b/web/src/app/AppStatus.vue
new file mode 100644
index 0000000..1cffb5b
--- /dev/null
+++ b/web/src/app/AppStatus.vue
@@ -0,0 +1,62 @@
+
+
+
+
+ 离线,无法加载或保存笔记
+
+ 有新版本
+
+
+
+ 将 RawMark 添加到主屏幕
+
+
+
+
diff --git a/web/src/app/api.ts b/web/src/app/api.ts
index cee2839..9411366 100644
--- a/web/src/app/api.ts
+++ b/web/src/app/api.ts
@@ -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 = {}
try {
diff --git a/web/src/app/app.css b/web/src/app/app.css
index 1ce4a5e..cf2d1ed 100644
--- a/web/src/app/app.css
+++ b/web/src/app/app.css
@@ -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;
diff --git a/web/src/app/pwa.ts b/web/src/app/pwa.ts
new file mode 100644
index 0000000..b394cc0
--- /dev/null
+++ b/web/src/app/pwa.ts
@@ -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(null)
+export const installPrompt = shallowRef(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('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)
+ }
+}
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index cd7bf46..83fea57 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -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)
+}
+
// ---- 冲突处理 ----
// 放弃本地修改,载入服务器版本
diff --git a/web/src/service-worker.js b/web/src/service-worker.js
new file mode 100644
index 0000000..b1bb9c7
--- /dev/null
+++ b/web/src/service-worker.js
@@ -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)
+ })())
+})
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 5f830ad..eed2656 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -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',