From 5c28ddf9b8ee6e30d21e3873f5f12ef72318dfd2 Mon Sep 17 00:00:00 2001 From: commilitia Date: Mon, 15 Jun 2026 22:14:18 +0800 Subject: [PATCH] =?UTF-8?q?web=EF=BC=9APWA=20=E5=AE=89=E8=A3=85=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=88manifest=20+=20iOS=20meta=20+=20service=20wor?= =?UTF-8?q?ker=EF=BC=89+=20PKCE=20=E6=94=B9=20localStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 web 前端可「添加到主屏幕」以独立窗口启动——iOS 上免证书的类原生体验(也使 Android/桌面出现安装提示)。 - site.webmanifest 补全可安装字段:id / start_url / scope / description / orientation;icons 拆出独立 any 与 maskable 条目。 - index.html 加 iOS 独立窗口 meta:apple-mobile-web-app-capable / status-bar-style / title + application-name(旧版 iOS 仍读这些才无 Safari chrome 启动)。 - public/sw.js(新):仅缓存同源静态壳,**绝不拦截 /api/***(SSE /api/hub/events、剪贴板、信令、中继需要实时不缓冲的网络连接,直接放行);跨源(字体/CDN)也放行。导航走 network-first(新部署即取新 HTML),静态资源 stale-while-revalidate。cdrop 离线无意义,故此 SW 只为可安装 + 秒开壳。 - main.tsx:SW 注册仅在浏览器 + prod;dev 下干扰 Vite HMR、桌面壳(Wails WebView,wails:// / loopback origin)SW 行为不可靠,故 isDesktop() 跳过。 - auth.ts:PKCE verifier / state 从 sessionStorage 改 localStorage。iOS 独立 PWA 可能在独立上下文跑完 OIDC 往返、回跳时重启 PWA 而清空 sessionStorage,导致 verifier 丢失登录失败;localStorage 跨上下文存活。verifier 在 callback 即消费并删除、且无配对 code 无用,落盘窗口的安全代价可忽略。 --- web/index.html | 8 +++ web/public/site.webmanifest | 18 +++++-- web/public/sw.js | 85 +++++++++++++++++++++++++++++++ web/src/features/auth/auth.ts | 26 ++++++---- web/src/main.tsx | 13 ++++- web/src/routes/oauth.callback.tsx | 2 +- 6 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 web/public/sw.js diff --git a/web/index.html b/web/index.html index 9ea2b01..e497c68 100644 --- a/web/index.html +++ b/web/index.html @@ -13,6 +13,14 @@ + + + + + + diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest index 2e6ccfe..445138e 100644 --- a/web/public/site.webmanifest +++ b/web/public/site.webmanifest @@ -1,11 +1,19 @@ { + "id": "/", "name": "Commilitia Drop", "short_name": "CDrop", - "icons": [ - { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, - { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } - ], + "description": "跨设备剪贴板同步与点对点文件传输", + "lang": "zh-CN", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", "theme_color": "#E8B923", "background_color": "#E8B923", - "display": "standalone" + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] } diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..7b79b41 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,85 @@ +// cdrop service worker — makes the web app installable and caches the static +// shell for instant launch. +// +// HARD RULE: it MUST NOT intercept /api/* . The SSE stream (/api/hub/events), +// clipboard, WebRTC signaling and relay all need a live, unbuffered network +// connection — those requests are passed straight through to the network. Same +// goes for cross-origin requests (fonts / CDN): the browser handles them. +// +// cdrop is useless offline (everything needs the backend), so this is a shell +// cache for fast loads + installability, not a real offline app. + +const CACHE = "cdrop-shell-v1"; +const SHELL = [ + "/", + "/site.webmanifest", + "/favicon-96.png", + "/icon-192.png", + "/apple-touch-icon-180.png", + "/logo-mark.png", +]; + +self.addEventListener("install", (event) => +{ + event.waitUntil( + caches.open(CACHE) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()) + .catch(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => +{ + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => +{ + const req = event.request; + if (req.method !== "GET") { return; } + + const url = new URL(req.url); + if (url.origin !== self.location.origin) { return; } // cross-origin (fonts/CDN) → browser default + if (url.pathname.startsWith("/api/")) { return; } // live backend → never intercept + + // SPA navigations: network-first so a new deploy serves fresh HTML (which + // points at the new hashed assets); fall back to the cached shell offline. + if (req.mode === "navigate") + { + event.respondWith( + fetch(req) + .then((resp) => + { + const copy = resp.clone(); + caches.open(CACHE).then((c) => c.put("/", copy)).catch(() => {}); + return resp; + }) + .catch(() => caches.match("/").then((r) => r || Response.error())), + ); + return; + } + + // Static assets (hashed JS/CSS/images are immutable): stale-while-revalidate. + event.respondWith( + caches.match(req).then((cached) => + { + const network = fetch(req) + .then((resp) => + { + if (resp && resp.status === 200 && resp.type === "basic") + { + const copy = resp.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {}); + } + return resp; + }) + .catch(() => cached); + return cached || network; + }), + ); +}); diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts index bc40d55..275054f 100644 --- a/web/src/features/auth/auth.ts +++ b/web/src/features/auth/auth.ts @@ -64,9 +64,15 @@ export async function fetchAuthConfig(): Promise const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier"; const OAUTH_STATE_KEY = "cdrop.oauth_state"; -// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash the -// secrets in sessionStorage, navigate the browser to the provider's -// /authorize endpoint. The callback page completes the flow. +// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them +// in localStorage, navigate the browser to the provider's /authorize endpoint. +// The callback page completes the flow. +// +// localStorage (not sessionStorage): a standalone PWA on iOS may run the +// provider round-trip in a separate context and relaunch the PWA on the redirect +// back, which wipes sessionStorage and breaks login. localStorage survives that. +// The verifier is consumed + removed immediately on callback and is useless +// without the matching auth code, so persisting it for the round-trip is fine. export async function loginProd(): Promise { const cfg = await fetchAuthConfig(); @@ -83,8 +89,8 @@ export async function loginProd(): Promise const challenge = await sha256Base64url(verifier); const state = generateRandomBase64url(16); - sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier); - sessionStorage.setItem(OAUTH_STATE_KEY, state); + localStorage.setItem(PKCE_VERIFIER_KEY, verifier); + localStorage.setItem(OAUTH_STATE_KEY, state); const params = new URLSearchParams({ client_id: cfg.client_id, @@ -111,7 +117,7 @@ interface ExchangeResp // proxy, and stamps the store with the user identity. // // Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React -// 渲染管线中重入,首次已成功 setAuth + 消耗 sessionStorage,二次入场不能再被 +// 渲染管线中重入,首次已成功 setAuth + 消耗 localStorage,二次入场不能再被 // "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接 // resolve,由上层 navigate 接管。 export async function completeOAuthLogin(code: string, state: string): Promise @@ -122,12 +128,12 @@ export async function completeOAuthLogin(code: string, state: string): Promise { startCjkAutospace(); }); + +// PWA service worker:仅在浏览器 + prod 注册。dev 下会干扰 Vite HMR;桌面壳 +// (Wails WebView,wails:// / loopback origin)已是原生应用且 SW 行为不可靠, +// 故跳过。SW 只缓存静态壳、绝不拦截 /api(SSE / 剪贴板 / 中继),见 public/sw.js。 +if (import.meta.env.PROD && !isDesktop() && "serviceWorker" in navigator) +{ + window.addEventListener("load", () => + { + void navigator.serviceWorker.register("/sw.js").catch(() => {}); + }); +} diff --git a/web/src/routes/oauth.callback.tsx b/web/src/routes/oauth.callback.tsx index e39dfdb..218b596 100644 --- a/web/src/routes/oauth.callback.tsx +++ b/web/src/routes/oauth.callback.tsx @@ -26,7 +26,7 @@ function CallbackPage() const navigate = useNavigate(); const [ err, setErr ] = useState(null); - // ref guard:completeOAuthLogin 内部会消费 sessionStorage 中的 PKCE_VERIFIER / + // ref guard:completeOAuthLogin 内部会消费 localStorage 中的 PKCE_VERIFIER / // OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在 // navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。 //