diff --git a/docs/README.md b/docs/README.md index 3bb3fd1..b7a3ad6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ The project README introduces reader. Use the guides below for installation, eve | --- | --- | --- | | 安装、首次登录、添加书籍、更新 / Installation and first steps | [入门指南](getting-started.zh-CN.md) | [Getting started](getting-started.en.md) | | 书架、阅读、朗读、来源和离线 / Shelves, reading, TTS, sources, offline | [使用手册](user-guide.zh-CN.md) | Chinese only | +| 安装到桌面、离线启动和版本更新 / PWA installation, offline startup and updates | [PWA 指南](pwa.zh-CN.md) | Chinese only | | 环境变量、目录权限、HTTPS、排错 / Settings, permissions, HTTPS, troubleshooting | [部署配置](configuration.zh-CN.md) | [Configuration](configuration.en.md) | | 本地开发、验证、镜像与 Android 构建 / Development, checks, Docker and Android builds | [开发指南](development.zh-CN.md) | [Development](development.en.md) | diff --git a/docs/pwa.zh-CN.md b/docs/pwa.zh-CN.md new file mode 100644 index 0000000..003367b --- /dev/null +++ b/docs/pwa.zh-CN.md @@ -0,0 +1,15 @@ +# PWA 安装与离线使用 + +生产构建(`cd web && npm run build`)自动生成应用清单、桌面图标和 Service Worker,随现有 Web 服务一起部署,无需额外服务。 + +- 使用 HTTPS 访问;本机 `localhost` / `127.0.0.1` 可使用 HTTP。通过局域网 IP 的普通 HTTP 访问仍能在线阅读,但浏览器不会启用 PWA 离线缓存。 +- Chrome / Edge:通过地址栏安装图标或浏览器菜单中的安装应用功能安装。 +- iPhone / iPad:在 Safari 中选择“分享 → 添加到主屏幕”。 +- 首次联网打开后,等待页面资源缓存完成,再离线使用。书籍正文需要在书架中提前下载;页面缓存不会自动下载整个书库。 +- 离线时保留原有登录状态即可打开已缓存书架和已下载书籍。首次登录、未下载内容和需要服务器的功能仍需联网。 +- 新版本会在访问时后台下载,关闭该站点的所有页面及独立应用窗口,再次打开后生效。更新不会强制刷新正在阅读的页面。 +- Service Worker 仅缓存应用静态资源,不缓存 API、令牌或书籍响应;书籍和阅读数据继续使用原有按账号隔离的存储。 + +开发服务器不注册 Service Worker;Android 原生 WebView 继续使用本地资源。清理站点数据会同时删除离线页面及本地阅读缓存。 + +验证:`cd web && npm run build && node tools/pwa/smoke.mjs`。测试需要 Playwright 的 Chromium,可通过 `npx playwright install chromium` 安装。 diff --git a/server/src/http/routes/web.ts b/server/src/http/routes/web.ts index 186e37a..4f8e27c 100644 --- a/server/src/http/routes/web.ts +++ b/server/src/http/routes/web.ts @@ -81,11 +81,9 @@ export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void { return; } if (existsSync(candidate) && statSync(candidate).isFile()) { - // Vite writes content-hashed file names, so a JS/CSS asset is immutable: - // its bytes cannot change without the name changing too. `index.html` is - // the one file whose name never changes, so it must never be cached or a - // deployment would never pick up a new bundle. - const immutable = extname(candidate) !== '.html'; + // The Android-compatible build uses fixed names (client.js, sw.js, etc.). + // Only explicitly fingerprinted assets can safely be cached forever. + const immutable = raw.startsWith('assets/') && /-[a-zA-Z0-9_-]{6,}\.[^.]+$/.test(raw); return sendFile( candidate, reply, diff --git a/server/test/web.test.ts b/server/test/web.test.ts index 06d251a..7d6d20d 100644 --- a/server/test/web.test.ts +++ b/server/test/web.test.ts @@ -23,6 +23,9 @@ before(async () => { await writeFile(join(root, 'index.html'), 'reader'); await mkdir(join(root, 'assets')); await writeFile(join(root, 'assets', 'index-abc123.js'), 'console.log(1)'); + await writeFile(join(root, 'assets', 'client.js'), 'console.log(2)'); + await writeFile(join(root, 'sw.js'), 'self.addEventListener("install", () => {})'); + await writeFile(join(root, 'manifest.webmanifest'), '{"name":"reader"}'); // A file one level above the build, to prove traversal is refused. await writeFile(join(root, '..', `secret-${process.pid}.txt`), 'do not serve me'); @@ -57,6 +60,18 @@ describe('static client hosting', () => { assert.match(res.headers['cache-control'] as string, /immutable/); }); + test('fixed-name bundles and PWA metadata must revalidate after deployment', async () => { + for (const url of ['/assets/client.js', '/sw.js', '/manifest.webmanifest']) { + const res = await app.inject({ method: 'GET', url }); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['cache-control'], 'no-cache'); + } + const worker = await app.inject({ method: 'GET', url: '/sw.js' }); + assert.match(worker.headers['content-type'] as string, /javascript/); + const manifest = await app.inject({ method: 'GET', url: '/manifest.webmanifest' }); + assert.match(manifest.headers['content-type'] as string, /application\/manifest\+json/); + }); + test('an asset is served with its real bytes, not an empty second send', async () => { // This was a 200 with `content-length: 0` for every asset. The cause was a // `reply.send(stream)` followed by a bare `return` in an async handler: diff --git a/web/index.html b/web/index.html index 887dcf1..2eb2d0c 100644 --- a/web/index.html +++ b/web/index.html @@ -22,6 +22,11 @@ --> + + + + + reader