diff --git a/server.ts b/server.ts index e963f06..b1a90fd 100644 --- a/server.ts +++ b/server.ts @@ -144,6 +144,29 @@ function serveArtifact(req: IncomingMessage, res: ServerResponse) { createReadStream(resolvedFile).pipe(res); } +function cacheControlFor(relative: string): string { + const name = relative.replace(/^\/+/, ""); + const base = name.split("/").pop() || name; + // The service-worker script, its registration shim, and the workbox runtime + // MUST never be cached: if iOS Safari serves a stale /sw.js it never detects + // a new service worker, so the old SW keeps serving the old precached bundle + // forever (refresh appears to do nothing). Force revalidation every load. + if (base === "sw.js" || base === "registerSW.js" || /^workbox-[A-Za-z0-9_-]+\.js$/.test(base)) { + return "no-cache, no-store, must-revalidate"; + } + // HTML / the app shell is served network-first and must stay fresh so it + // always references the current hashed asset names. + if (base === "index.html" || base.endsWith(".html") || base === "manifest.webmanifest") { + return "no-cache"; + } + // Content-hashed build assets are immutable — safe to cache aggressively. + if (name.startsWith("assets/") && /-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/.test(base)) { + return "public, max-age=31536000, immutable"; + } + // Everything else (icons, svg, etc.): revalidate to stay safe. + return "no-cache"; +} + function serveStatic(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const pathname = decodeURIComponent(url.pathname); @@ -155,7 +178,10 @@ function serveStatic(req: IncomingMessage, res: ServerResponse) { return; } - res.writeHead(200, { "content-type": contentTypes[extname(file)] || "application/octet-stream" }); + res.writeHead(200, { + "content-type": contentTypes[extname(file)] || "application/octet-stream", + "cache-control": cacheControlFor(relative), + }); createReadStream(file).pipe(res); } @@ -2301,6 +2327,37 @@ const server = createServer(async (req, res) => { return sendJson(res, 404, { ok: false, error: "Unknown API route" }); } + // Server-side cache-bust: when PI_WEB_SW_RESET=1, serve a self-destroying + // service worker at /sw.js. A client captured by a stale SW re-fetches the + // SW script on its next navigation (the spec bypasses the HTTP cache for + // the script, and our no-store header guarantees it), installs this one, + // which deletes every cache and unregisters itself — freeing the client to + // load fresh content straight from the network. Loop-safe: it force-reloads + // clients only the first time (guarded by a marker cache), so re-registering + // it does not bounce the page repeatedly. Flip the flag off once clients + // have recovered to restore the normal PWA service worker. + if (req.method === "GET" && url.pathname === "/sw.js" && (process.env.PI_WEB_SW_RESET === "1" || existsSync(join(appDir, ".pi-sw-reset")))) { + const body = `// pi-web self-destroying service worker (PI_WEB_SW_RESET=1) +const KILL_FLAG = 'pi-sw-killed'; +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + const firstTime = !(await caches.has(KILL_FLAG)); + for (const key of await caches.keys()) { if (key !== KILL_FLAG) await caches.delete(key); } + try { await self.registration.unregister(); } catch (e) {} + if (firstTime) { + await caches.open(KILL_FLAG); + const clients = await self.clients.matchAll({ type: 'window' }); + for (const client of clients) { try { client.navigate(client.url); } catch (e) {} } + } + })()); +}); +`; + res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache, no-store, must-revalidate" }); + res.end(body); + return; + } + if (viteDevServer) { viteDevServer.middlewares(req, res, () => { if (!res.writableEnded) sendJson(res, 404, { ok: false, error: "Not found" }); diff --git a/src/status/statusBar.ts b/src/status/statusBar.ts index 996f2bb..2e252b0 100644 --- a/src/status/statusBar.ts +++ b/src/status/statusBar.ts @@ -96,6 +96,7 @@ export function createStatusBar(options: { const originalValue = input.value.trim(); let finished = false; + let blurArmed = false; const finish = (save: boolean) => { if (finished) return; finished = true; @@ -114,12 +115,27 @@ export function createStatusBar(options: { finish(false); } }); - input.addEventListener("blur", () => finish(true)); + // iOS Safari fires a spurious `blur` immediately after we focus the input + // (while the on-screen keyboard animates in and the visual viewport + // resizes). If we acted on that blur we would tear the rename field back + // down to the static title before the user could type a single character + // — which looks exactly like "tapping the header does nothing". Only arm + // the blur-to-save handler once focus has settled; an early blur instead + // re-focuses the input so the keyboard stays up. + input.addEventListener("blur", () => { + if (finished) return; + if (!blurArmed && document.body.contains(input)) { + requestAnimationFrame(() => { if (!finished) input.focus(); }); + return; + } + finish(true); + }); elements.statusTitleEl.textContent = ""; elements.statusTitleEl.append(input); input.focus(); input.select(); + window.setTimeout(() => { blurArmed = true; }, 350); } function clearConnectionTimers() { @@ -303,7 +319,18 @@ export function createStatusBar(options: { elements.statusTitleEl.setAttribute("role", "button"); elements.statusTitleEl.tabIndex = 0; setStatusTitle(state.currentSessionTitle); - elements.statusTitleEl.addEventListener("click", beginRenameSessionTitle); + + // The title is role="button" and opens an inline rename input on + // activation. On iOS Safari two things matter: + // - text selection must be disabled (user-select:none + touch-action: + // manipulation in CSS) so a finger tap is treated as a tap, not the + // start of a text-selection gesture that swallows the click; and + // - the input must be focused inside the *click* handler. iOS only + // raises the on-screen keyboard when focus() runs in a click/touchend + // user gesture; doing it from pointerup (or after preventDefault) + // opens the field but leaves the keyboard down on real devices. + // So we trigger purely on click — the canonical, device-tested path. + elements.statusTitleEl.addEventListener("click", () => beginRenameSessionTitle()); elements.statusTitleEl.addEventListener("keydown", (event) => { if (event.target !== elements.statusTitleEl || (event.key !== "Enter" && event.key !== " ")) return; event.preventDefault(); diff --git a/src/styles/statusBar.css b/src/styles/statusBar.css index b1488c8..c237330 100644 --- a/src/styles/statusBar.css +++ b/src/styles/statusBar.css @@ -49,6 +49,16 @@ white-space: nowrap; max-width: 240px; cursor: pointer; + /* The status title acts as a button (role="button") that swaps in a + rename input on click. On iOS Safari a tap on a plain with + selectable text is interpreted as native text-selection (showing the + blue selection handles + magnifier), which suppresses the click event + and the rename input never appears. Disable text selection and the + callout so iOS reliably treats the tap as a button activation. */ + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; } .statusTitle:hover { color: var(--accent); diff --git a/tests/api.test.ts b/tests/api.test.ts index 3371ff9..f482c11 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -820,3 +820,96 @@ describe("WebSocket authentication", () => { } }, 25_000); }); + +describe("static asset cache headers (iOS service-worker freshness)", () => { + let child: ChildProcess; + let baseUrl: string; + + beforeAll(async () => { + const port = await freePort(); + baseUrl = `http://127.0.0.1:${port}`; + // Production mode (PI_WEB_DEV=0) so the built-in static file server runs. + // In dev mode Vite's middleware serves these files with its own headers, + // which is not the code path real deployments use. + child = spawn(process.execPath, ["--import", "tsx", "server.ts"], { + env: { ...process.env, PI_WEB_MOCK: "1", PI_WEB_DEV: "0", NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), PI_WEB_TOKEN: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr?.on("data", (data) => process.stderr.write(data)); + await waitForServer(baseUrl); + }, 20_000); + + afterAll(() => { + child?.kill(); + }); + + it("never caches the service worker or its registration shim", async () => { + // Regression: with no Cache-Control, iOS Safari heuristically caches + // /sw.js, never detects a new service worker, and keeps serving the old + // precached bundle forever (a deploy looks like it "did nothing" on + // iPhone). The SW script + register shim must always revalidate. + const sw = await fetch(`${baseUrl}/sw.js`); + if (sw.status === 404) { + // dist/ not built in this environment — nothing to assert. + return; + } + expect(sw.headers.get("cache-control")).toContain("no-store"); + + const reg = await fetch(`${baseUrl}/registerSW.js`); + expect(reg.status).toBe(200); + expect(reg.headers.get("cache-control")).toContain("no-store"); + }); + + it("serves the app shell HTML as no-cache so it references current asset hashes", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect((res.headers.get("cache-control") || "")).toContain("no-cache"); + }); + + it("caches content-hashed build assets immutably", async () => { + const html = await (await fetch(`${baseUrl}/`)).text(); + const match = html.match(/assets\/index-[A-Za-z0-9_-]+\.(?:js|css)/); + if (!match) return; // dist/ not built + const asset = await fetch(`${baseUrl}/${match[0]}`); + expect(asset.status).toBe(200); + const cc = asset.headers.get("cache-control") || ""; + expect(cc).toContain("immutable"); + expect(cc).toContain("max-age=31536000"); + }); +}); + +describe("service-worker kill switch (PI_WEB_SW_RESET)", () => { + let child: ChildProcess; + let baseUrl: string; + + beforeAll(async () => { + const port = await freePort(); + baseUrl = `http://127.0.0.1:${port}`; + child = spawn(process.execPath, ["--import", "tsx", "server.ts"], { + env: { ...process.env, PI_WEB_MOCK: "1", PI_WEB_DEV: "0", NODE_ENV: "production", PI_WEB_SW_RESET: "1", HOST: "127.0.0.1", PORT: String(port), PI_WEB_TOKEN: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr?.on("data", (data) => process.stderr.write(data)); + await waitForServer(baseUrl); + }, 20_000); + + afterAll(() => { + child?.kill(); + }); + + it("serves a self-destroying service worker that clears caches and unregisters", async () => { + // Server-side cache bust: a client captured by a stale SW re-fetches + // /sw.js on navigation, installs this one, which wipes caches and + // unregisters itself so fresh content loads from the network. + const res = await fetch(`${baseUrl}/sw.js`); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toContain("no-store"); + const body = await res.text(); + expect(body).toContain("self.registration.unregister()"); + expect(body).toContain("caches.delete"); + expect(body).toContain("skipWaiting"); + // It must NOT be the precaching workbox SW (that would re-cache the bundle). + expect(body).not.toContain("precacheAndRoute"); + expect(body).not.toContain("__WB_MANIFEST"); + }); +}); diff --git a/tests/e2e/pi-web.spec.ts b/tests/e2e/pi-web.spec.ts index 6228b4b..9c097a2 100644 --- a/tests/e2e/pi-web.spec.ts +++ b/tests/e2e/pi-web.spec.ts @@ -263,6 +263,58 @@ test.describe("composer layout", () => { expect(sessions.sessions.find((item: any) => item.id === "mock-current").name).toBe("Renamed from title"); }); + test("status title is not text-selectable on iOS so taps reach the rename handler", async ({ page }) => { + // Regression: on iOS Safari, tapping a with selectable text (the + // status title) shows the native blue selection handles + magnifier and + // suppresses the click event, so the rename input never appears. The fix + // is to mark the status title as non-user-selectable. Verify the rendered + // styles still enforce that contract. + await page.goto("/"); + await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); + + const styles = await page.locator("#statusTitle").evaluate((el) => { + const cs = getComputedStyle(el); + return { + userSelect: cs.userSelect || (cs as any).webkitUserSelect, + webkitUserSelect: (cs as any).webkitUserSelect, + webkitTouchCallout: (cs as any).webkitTouchCallout, + touchAction: cs.touchAction, + cursor: cs.cursor, + role: el.getAttribute("role"), + }; + }); + expect(styles.role).toBe("button"); + expect(styles.cursor).toBe("pointer"); + expect([styles.userSelect, styles.webkitUserSelect]).toContain("none"); + // touch-action: manipulation removes the iOS 300ms tap delay too. + expect(styles.touchAction).toBe("manipulation"); + }); + + test("tapping the status title on a touch viewport opens the rename input and survives an iOS keyboard blur", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "Touch-tap + iOS blur race is only meaningful on the mobile project."); + // End-to-end version of the iOS bug: simulate a real tap (not a synthetic + // mouse click) and assert the rename input is rendered and focused. + await page.goto("/"); + await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); + + const box = await page.locator("#statusTitle").boundingBox(); + expect(box).not.toBeNull(); + await page.touchscreen.tap(box!.x + Math.min(40, box!.width / 2), box!.y + box!.height / 2); + + await expect(page.locator("#statusTitle input")).toBeVisible(); + await expect(page.locator("#statusTitle input")).toBeFocused(); + + // iOS Safari fires a spurious blur right after focus while the on-screen + // keyboard animates in. The editor must NOT be torn down by it (otherwise + // the tap appears to "do nothing"). Fire that blur within the grace window. + await page.locator("#statusTitle input").evaluate((el) => el.dispatchEvent(new FocusEvent("blur"))); + await expect(page.locator("#statusTitle input")).toBeVisible(); + + await page.locator("#statusTitle input").fill("Renamed via tap"); + await page.locator("#statusTitle input").press("Enter"); + await expect(page.locator("#statusTitle")).toHaveText("Renamed via tap"); + }); + test("new session resets status title", async ({ page }) => { await page.goto("/"); await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); diff --git a/tests/sw-config.test.ts b/tests/sw-config.test.ts new file mode 100644 index 0000000..07b2c39 --- /dev/null +++ b/tests/sw-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Guards the generated service worker's navigation strategy. + * + * Regression: vite-plugin-pwa registers an SPA NavigationRoute bound to the + * *precached* index.html, and it is registered before runtimeCaching. Left + * alone it shadows our NetworkFirst navigate route, so a stale service worker + * keeps serving the old precached HTML/JS forever — a deploy looks like it + * "did nothing" on the client (especially iOS). We deny-list every navigation + * from that precache fallback so navigations fall through to NetworkFirst and + * a reload always fetches fresh HTML. + */ +describe("service worker navigation strategy", () => { + const swPath = join(process.cwd(), "dist", "sw.js"); + + it("denylists the precache navigation fallback and serves navigations NetworkFirst", async () => { + if (!existsSync(swPath)) { + // dist/ not built in this environment (e.g. unit-only CI without build). + return; + } + const sw = await readFile(swPath, "utf8"); + + // The SPA precache NavigationRoute must be present but neutralised by a + // catch-all denylist so it never serves stale precached HTML. + expect(sw).toMatch(/NavigationRoute/); + expect(sw).toMatch(/denylist:\[\/\.\/\]/); + + // Navigations must be handled by a NetworkFirst route (fresh HTML online, + // cached fallback offline). + expect(sw).toMatch(/"navigate"===\w+\.mode/); + expect(sw).toMatch(/NetworkFirst\(\{cacheName:"pages"/); + + // The auto-update primitives must stay in place so a new SW activates and + // claims open clients immediately. + expect(sw).toMatch(/skipWaiting\(\)/); + expect(sw).toMatch(/clientsClaim\(\)/); + expect(sw).toMatch(/cleanupOutdatedCaches\(\)/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 3ed7eb3..153e26c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,15 +22,27 @@ export default defineConfig({ ], }, workbox: { - // Network-first: always try server, fall back to cache - // Only precache the app shell assets - navigateFallback: "/index.html", + // Navigations must be NETWORK-FIRST so a reload always fetches fresh + // HTML (and therefore the current hashed asset names). vite-plugin-pwa + // always registers an SPA NavigationRoute bound to the *precached* + // index.html, and it is registered before runtimeCaching — so left + // alone it shadows the NetworkFirst route and a stale service worker + // keeps serving old HTML/JS forever (a deploy looks like it "did + // nothing", especially on iOS). Deny-list every navigation from that + // precache fallback so navigations fall through to the NetworkFirst + // route below, which serves fresh HTML online and the cached copy + // offline. + navigateFallback: "index.html", + navigateFallbackDenylist: [/./], globPatterns: ["index.html", "assets/index-*.{js,css}", "*.{svg,png,webmanifest}"], runtimeCaching: [ { urlPattern: ({ request }) => request.mode === "navigate", handler: "NetworkFirst", - options: { cacheName: "pages" }, + options: { + cacheName: "pages", + networkTimeoutSeconds: 3, + }, }, ], },