From 03bea9d6f92fb81607df880a19c0f592c833c488 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 5 Jul 2026 12:00:01 +0800 Subject: [PATCH] feat: auto-reload on SW update + privacy-friendly Cloudflare analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent changes to the standalone app entry: 1. SW freshness — after a deploy the new service worker skipWaiting()s and claims clients but never reloads the already-rendered page, so users keep seeing the previously-cached build until a manual refresh (the "refresh once more and it's fixed" symptom, which is what surfaced the stale PPTX font bug). index.ts now reloads once on `controllerchange`, guarded so it fires only on a real update (not first install), only once, and never while a document is open (a reload would discard unsaved edits). 2. Analytics — add cookieless Cloudflare Web Analytics (lib/analytics.ts) instead of Google Analytics, to stay consistent with the local-only privacy pitch (no cookies, no consent banner). The beacon loads only when a token (VITE_CF_BEACON_TOKEN) is set AND the app is top-level (never in embed iframe). With no token the whole beacon path is tree-shaken out, so the default build ships tracking-free. pages-build-site.yml injects the token from a repo variable. Verified end-to-end with chrome-devtools: first-install no reload, update reload once, doc-open no reload; beacon injects top-level, absent in embed, absent (tree-shaken) with no token. lint:ts + sw-routing tests + build pass. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pages-build-site.yml | 5 ++ ...2026-07-05-privacy-analytics-cloudflare.md | 48 ++++++++++++ .../2026-07-05-sw-stale-build-auto-reload.md | 78 +++++++++++++++++++ index.ts | 25 ++++++ lib/analytics.ts | 49 ++++++++++++ vite-env.d.ts | 14 ++++ 6 files changed, 219 insertions(+) create mode 100644 docs/explorations/2026-07-05-privacy-analytics-cloudflare.md create mode 100644 docs/explorations/2026-07-05-sw-stale-build-auto-reload.md create mode 100644 lib/analytics.ts create mode 100644 vite-env.d.ts diff --git a/.github/workflows/pages-build-site.yml b/.github/workflows/pages-build-site.yml index 2e873694..cf0b5539 100644 --- a/.github/workflows/pages-build-site.yml +++ b/.github/workflows/pages-build-site.yml @@ -40,6 +40,11 @@ jobs: - name: Build run: pnpm build + env: + # Cloudflare Web Analytics beacon token (public client-side value). + # Set it under Settings → Secrets and variables → Actions → Variables. + # Unset → analytics is tree-shaken out and the site ships tracking-free. + VITE_CF_BEACON_TOKEN: ${{ vars.VITE_CF_BEACON_TOKEN }} - name: Upload artifact uses: actions/upload-pages-artifact@v5 diff --git a/docs/explorations/2026-07-05-privacy-analytics-cloudflare.md b/docs/explorations/2026-07-05-privacy-analytics-cloudflare.md new file mode 100644 index 00000000..6e24537c --- /dev/null +++ b/docs/explorations/2026-07-05-privacy-analytics-cloudflare.md @@ -0,0 +1,48 @@ +# 埋点分析:选 Cloudflare Web Analytics 而非 Google Analytics + +日期:2026-07-05 +分支:`fix/sw-freshness` + +## 决策 + +项目核心卖点是"本地处理、无需服务器、保护隐私"。Google Analytics 与此**直接冲突**: +注入 Google 追踪脚本、设 cookie、把用户行为发给 Google,且在欧盟需 cookie 同意横幅 +(界面支持德/法/西/葡等语言 = 有欧洲用户)。故**不用 GA**。 + +改用 **Cloudflare Web Analytics**:无 cookie、无需同意横幅(GDPR 友好)、免费、一段 +beacon script 即可,最不破坏隐私定位。 + +## 实现 + +新增 [lib/analytics.ts](../../lib/analytics.ts),在 [index.ts](../../index.ts) 的 +`initEmbedApi()` 之后调用 `initAnalytics()`。两道加载条件: + +1. **配了 token 才加载**:token 从 `import.meta.env.VITE_CF_BEACON_TOKEN` 读取。未设置 → + 直接 return,且 Vite/Rolldown 会把整段 beacon 代码 **tree-shake 移除**(默认部署 = 零 + Cloudflare 引用、零外部请求)。token 是公开的客户端值,构建期内联安全。 +2. **仅顶层页面加载**:`window.parent !== window` 或 `?embed=/?embedded=` 时不加载—— + 别人把编辑器嵌进 iframe 时,不该把宿主页面的访客算进我们的统计。 + +类型:新增 [vite-env.d.ts](../../vite-env.d.ts) 声明 `VITE_CF_BEACON_TOKEN`。 + +## 如何启用 + +1. Cloudflare Dashboard → Web Analytics → Add a site(`ranuts.github.io/document/`)→ 拿到 + snippet 里的 `token`。 +2. 本地:项目根 `.env.local` 写 `VITE_CF_BEACON_TOKEN=xxxx`。 +3. CI(`pages-build-site.yml`):Build step 已注入 + `env: VITE_CF_BEACON_TOKEN: ${{ vars.VITE_CF_BEACON_TOKEN }}`(本次改动已完成)。**只需**在 + 仓库 Settings → Secrets and variables → Actions → **Variables** 加 `VITE_CF_BEACON_TOKEN` + (公开值,用 Variable 即可,无需 Secret)。 + +未做以上任何一步 → 站点行为不变(无埋点,且 beacon 代码被 tree-shake 移除)。 + +## 验证(chrome-devtools + 构建产物) + +| 构建 | 预期 | 实测 | +| --- | --- | --- | +| 无 token(默认) | 不注入 | ✅ bundle 里零 `cloudflareinsights` 引用(死代码消除) | +| 有 token · 顶层 | 注入 beacon | ✅ `data-cf-beacon={"token":...}` 正确 | +| 有 token · `?embed=1` | 不注入 | ✅ beacon 缺席,`embed-mode` class 存在 | + +`lint:ts` ✅ / prettier ✅ / `build` ✅。 diff --git a/docs/explorations/2026-07-05-sw-stale-build-auto-reload.md b/docs/explorations/2026-07-05-sw-stale-build-auto-reload.md new file mode 100644 index 00000000..729d0164 --- /dev/null +++ b/docs/explorations/2026-07-05-sw-stale-build-auto-reload.md @@ -0,0 +1,78 @@ +# PPT "字体乱码" 真因 = Service Worker 缓存旧构建 + SW 自动更新修复 + +日期:2026-07-05 +分支:`fix/sw-freshness`(基于 `release/v0.0.4`) + +## 现象 + +用户在生产站 `https://ranuts.github.io/document/` 新建 PPT,标题占位符显示为乱码 +`Ajgai rm_bb rgjc`,**再刷新一次就正常**。用户问:"不是已经修过了吗?" + +## 排查过程与被推翻的假设 + +一开始怀疑是 CLAUDE.md 里记录的 CJK split-brain(HarfBuzz 塑形字体 ≠ FreeType 渲染字体) +在生产环境复发,理由是那套真正的修复 `fontRemapMiddleware` 是 **Vite dev-server 专属**, +生产静态部署不跑。并据此打算给 slide 编辑器补 Windows 路径字体 remap。 + +**用 chrome-devtools 实测后这个方向被推翻:** + +1. 乱码串 `Ajgai rm_bb rgjc` 解码 = `Click to add title` 每个字母 glyph-ID **整体偏移 -2** + (C→A、l→j、i→g…),是 split-brain 特征,但这正是**旧构建**里"PPT 也做字体改写、 + 错映射到 glyph 不兼容字体"的行为。当前构建(`924ffb8` 改成仅 Excel 改写)不再这样。 + +2. 在线上反复冷加载(含 **20x CPU 降速 + Slow 3G** 极限放大竞态窗口)+ 暖加载共三轮, + **一次都没能复现乱码**,每次都是正常的 serif `Click to add title` / `Click to add subtitle`。 + +3. 三轮的字体网络请求**完全一致**:`c:\Windows\Fonts\arial.ttf`、`Deng.ttf`、`simsun.ttc` + 等系统字体请求**每次都 `net::ERR_FAILED`**,且**没有任何 `/fonts/*.ttf` 被请求**—— + 说明 PPT 默认模板压根不用 `/fonts/` 下的 ttf,而是用 OnlyOffice 内置引擎(`fonts.wasm`) + 渲染。那些失败请求在正常/乱码两种情况下都失败,**不是乱码原因**,remap 它们无意义。 + +## 真因 + +用户截图时 DevTools 正开在 **Application → Storage / Unregister service workers**, +结合"再刷一次就好"——真因是 **Service Worker 缓存住了旧构建**: + +- `public/sw.js` 对非 HTML 静态资源用 stale-while-revalidate(先给缓存、后台再更新)。 +- `bin/build.sh` 每次构建给 SW 注入时间戳 → 新 `CACHE_VERSION` → 新 `CACHE_NAME`, + `activate` 会清旧缓存。**版本化和清理机制都在**。 +- 唯一缺陷是**"一次加载滞后"**:页面已用旧 SW / 旧缓存渲染完,新 SW 才 `skipWaiting` + + `clients.claim` 接管,但**不会重载已经加载好的页面**。于是用户必须再手刷一次才拿到新构建。 + 旧构建恰好含已修复前的 PPT 字体改写 bug → 首屏乱码,刷新后(新构建)正常。 + +## 修复 + +`index.ts` 的 SW 注册块新增 `controllerchange` 监听,新 SW 接管时**自动 reload 一次**, +带三道护栏: + +```ts +const hadController = !!navigator.serviceWorker.controller; +let reloadingForUpdate = false; +navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!hadController || reloadingForUpdate) return; // 1. 仅更新(非首装);2. 只 reload 一次 + if (getDocmentObj().fileName) return; // 3. 有文档打开时不 reload,避免丢失编辑 + reloadingForUpdate = true; + window.location.reload(); +}); +``` + +未改 `public/sw.js` 逻辑:新 SW 换 `CACHE_NAME` → SWR 在新(空)缓存里 miss → 从网络取新资源, +所以自动 reload 后即是最新构建,与旧缓存清理时序无关。 + +## 验证(chrome-devtools 端到端) + +在 `vite preview`(dist)上: + +| 场景 | 预期 | 实测 | +| --- | --- | --- | +| 首次安装(无旧控制器) | 不 reload | ✅ 载入计数停在 1 | +| 有旧控制器 + 未开文档(模拟新部署,合成 `controllerchange`) | 自动 reload 一次 | ✅ window marker 丢失、sessionStorage 保留 = 同页刷新 | +| 文档已打开时 `controllerchange` | 不 reload | ✅ marker 仍在、编辑器 iframe 完好 | + +`pnpm run lint:ts` 通过;`sw-routing.test.ts` 31/31 通过;`pnpm run build` 成功。 + +## 结论 + +- **字体本身不用改**:当前构建 PPT 渲染正常,`924ffb8`(仅 Excel 改写)的修复是生效的。 +- 用户看到的乱码是 **SW 缓存旧构建**,本次改动让新部署后**首访即自动切到新构建**, + 消除"要再刷一次"的步骤,且不会打断正在编辑的文档。 diff --git a/index.ts b/index.ts index 81b1b1e7..d9430f7f 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,8 @@ import { initEmbedApi } from './lib/embed-api'; import { initEvents, setEventUICallbacks } from './lib/events'; import { onCreateNew, openDocumentFromUrl, setUICallbacks } from './lib/document'; import { parseReadonly } from '@ranuts/shared/document-utils'; +import { getDocmentObj } from '@ranuts/shared/store'; +import { initAnalytics } from './lib/analytics'; import { createControlPanel, createFixedActionButton, @@ -29,6 +31,9 @@ declare global { initEvents(); initEmbedApi(); +// Privacy-friendly analytics (no-op unless VITE_CF_BEACON_TOKEN is set; never in embed mode) +initAnalytics(); + // Set up UI callbacks to avoid circular dependency setUICallbacks({ hideControlPanel, @@ -99,6 +104,26 @@ if (documentUrl) { // Register Service Worker for PWA if ('serviceWorker' in navigator) { + // Whether a SW was already controlling this page when it started. Each deploy + // rebuilds sw.js with a fresh CACHE_VERSION; the new SW skipWaiting()s and + // claims clients, firing `controllerchange`. If a SW was ALREADY in control at + // startup, that event means a *new build* took over — not the first install — + // so we can reload once to swap the stale assets for the fresh ones. Without + // this, the current page keeps rendering the previously-cached build until the + // user manually refreshes (the "refresh once more and it's fixed" symptom). + const hadController = !!navigator.serviceWorker.controller; + let reloadingForUpdate = false; + + navigator.serviceWorker.addEventListener('controllerchange', () => { + // Guard 1: only on a real update (not first install). Guard 2: reload once. + // Guard 3: never while a document is open — a reload would discard unsaved + // edits. On the landing page fileName is empty, so the reload is invisible. + if (!hadController || reloadingForUpdate) return; + if (getDocmentObj().fileName) return; + reloadingForUpdate = true; + window.location.reload(); + }); + window.addEventListener('load', () => { navigator.serviceWorker .register('./sw.js') diff --git a/lib/analytics.ts b/lib/analytics.ts new file mode 100644 index 00000000..5aad3874 --- /dev/null +++ b/lib/analytics.ts @@ -0,0 +1,49 @@ +/** + * Privacy-friendly page analytics via Cloudflare Web Analytics. + * + * Deliberately NOT Google Analytics: this project's whole pitch is local-only, + * no-server privacy, so we avoid Google's tracking scripts and cookies. The + * Cloudflare beacon is cookieless and needs no consent banner (GDPR-friendly). + * + * The beacon loads only when BOTH hold: + * 1. A token is configured via VITE_CF_BEACON_TOKEN. Unset -> disabled, zero + * external requests (so forks / local dev stay tracking-free by default). + * 2. The app is the top-level standalone page — never when embedded in a host + * site's iframe, which would attribute the host's visitors to us and leak + * analytics into pages that only wanted the editor. + * + * The token is a public client-side value (it ships in the page HTML by design), + * so injecting it at build time from an env var is safe. + */ + +const BEACON_SRC = 'https://static.cloudflareinsights.com/beacon.min.js'; + +const EMBED_QUERY_KEYS = ['embed', 'embedded']; + +/** Mirror of embed-api's detection: iframe-embedded or an ?embed=/?embedded= flag. */ +function isEmbedded(): boolean { + if (window.parent !== window) { + return true; + } + const params = new URLSearchParams(window.location.search); + return EMBED_QUERY_KEYS.some((key) => { + const value = params.get(key); + return value === '' || value === '1' || value === 'true'; + }); +} + +export function initAnalytics(): void { + const token = import.meta.env.VITE_CF_BEACON_TOKEN; + if (!token) { + return; // analytics disabled until a token is configured + } + if (isEmbedded()) { + return; // never track inside a host page's iframe + } + + const script = document.createElement('script'); + script.defer = true; + script.src = BEACON_SRC; + script.setAttribute('data-cf-beacon', JSON.stringify({ token })); + document.head.appendChild(script); +} diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 00000000..40202675 --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + /** + * Cloudflare Web Analytics beacon token. When unset, analytics is disabled + * and no external request is made — keeps forks and local dev tracking-free. + * The token is a public client-side value, so exposing it in the build is safe. + */ + readonly VITE_CF_BEACON_TOKEN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}