From 6b7f329e6cdd9fdb15588576d7559056a70b3a2c Mon Sep 17 00:00:00 2001 From: Akanksha Trehun Date: Thu, 5 Nov 2026 23:38:13 +0530 Subject: [PATCH] feat(pwa): open and export offline; installable app A service worker (public/sw.js) precaches the static export, using a list and version hash that scripts/build-sw-manifest.mjs writes into the built sw.js, and keeps the FFmpeg cores from jsDelivr in their own cache the first time they download (still SRI-checked). Pages come from the network first and from the cache offline, with the headers they were served with, so isolation and the multi-threaded engine behave as online. The footer says when the editor works offline, which needs every file of the engine this page loads; a new version shows a Reload notice and never reloads by itself. A web app manifest and icons make it installable. sw.js is served with no-cache on every host. --- docs/DATADOG.md | 2 +- docs/DEPLOYMENT.md | 20 +++++ docs/USAGE.md | 12 +++ netlify.toml | 8 ++ package.json | 2 +- public/_headers | 4 + public/icons/apple-touch-icon.png | Bin 0 -> 2436 bytes public/icons/icon-192.png | Bin 0 -> 4859 bytes public/icons/icon-512.png | Bin 0 -> 14521 bytes public/icons/maskable-512.png | Bin 0 -> 6832 bytes public/manifest.webmanifest | 18 +++++ public/sw.js | 111 +++++++++++++++++++++++++++ scripts/build-sw-manifest.mjs | 76 +++++++++++++++++++ src/app/layout.tsx | 2 + src/components/Footer.tsx | 2 + src/components/OfflineStatus.tsx | 43 +++++++++++ src/hooks/useOfflineSupport.ts | 114 ++++++++++++++++++++++++++++ src/lib/engineUrls.ts | 25 ++++++ src/lib/featureTelemetry.ts | 1 + src/lib/ffmpeg.worker.ts | 10 +-- src/lib/tests/deployConfig.test.ts | 8 +- src/lib/tests/serviceWorker.test.ts | 91 ++++++++++++++++++++++ test/e2e/offline.spec.ts | 68 +++++++++++++++++ vercel.json | 4 + 24 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/maskable-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 scripts/build-sw-manifest.mjs create mode 100644 src/components/OfflineStatus.tsx create mode 100644 src/hooks/useOfflineSupport.ts create mode 100644 src/lib/engineUrls.ts create mode 100644 src/lib/tests/serviceWorker.test.ts create mode 100644 test/e2e/offline.spec.ts diff --git a/docs/DATADOG.md b/docs/DATADOG.md index 13b4e4acd..03eae21ff 100644 --- a/docs/DATADOG.md +++ b/docs/DATADOG.md @@ -157,7 +157,7 @@ mute, volume, normalize_audio, text_overlay, image_overlay, background_music, color_adjustments, sharpen, denoise, stabilization, fill_framing, custom_size, crop. Interactive features: `history` (undo, redo), `project_file` (save, open), `preview_tool` (compare, grid, framing_guide, save_frame), -`safe_area`, `export_profile`, `trim_timeline` (seek, drag_handle), `crop_tool` (aspect, drag, reset), `batch_export` (start, done, done_with_failures, cancel, save_folder). Only these names are ever sent; never values, +`safe_area`, `export_profile`, `trim_timeline` (seek, drag_handle), `crop_tool` (aspect, drag, reset), `batch_export` (start, done, done_with_failures, cancel, save_folder), `pwa` (installed, offline_ready, update). Only these names are ever sent; never values, names or text. **Synthetic check.** A Datadog Synthetic browser test opens production every diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0e62b3d90..aea530ab8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -64,6 +64,26 @@ npx netlify-cli deploy --dir out # draft URL npx netlify-cli deploy --dir out --prod # production (maintainers only) ``` +## Offline support (service worker) + +`public/sw.js` lets the editor open and export without a connection once it +has been used online. `bun run build` ends with +`scripts/build-sw-manifest.mjs`, which writes the list of files to +precache, and a version hash of their contents, into `out/sw.js`. Every +deploy that changes the site therefore changes `sw.js`, which is how +browsers notice the update. Visitors get a "Reload" notice; the page +never reloads by itself. + +- Serve `/sw.js` with `Cache-Control: no-cache`, so browsers always check + it for updates. `netlify.toml`, `public/_headers` and `vercel.json` all + do this. +- Cached pages keep the headers they were served with, COOP/COEP included, + so offline behaves like online. Fix isolation on the host, not in the + worker. +- The FFmpeg cores (jsDelivr) are cached the first time they download and + still pass their SRI checks. User videos and exports are never cached. +- Development builds (`bun run dev`) don't register the worker. + ## Other static hosts | Host | Headers | Notes | diff --git a/docs/USAGE.md b/docs/USAGE.md index b00b3b0a5..cd7ff2b32 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -110,6 +110,18 @@ field. Reframe works it out when the file loads; this can take a moment for long recordings. +## Offline and installing + +After you've opened a video once with a connection, Reframe also works +offline: the editor and its video engine are saved in your browser, and +the footer says **Works offline** once both are. With the network off you +can open the site, load videos and export as usual. Browsers can install it +as an app (the install button in the address bar, or **Add to Home +Screen** on phones). When a new version is out, a small notice offers +**Reload**; nothing reloads by itself, so an export is never interrupted. +The saved copy takes about 35 MB, and the browser may clear it when space +runs low, in which case it's saved again next time you're online. + ## Privacy Your videos never leave your device. Reframe collects anonymous performance diff --git a/netlify.toml b/netlify.toml index 9018a7dc4..582dc43cb 100644 --- a/netlify.toml +++ b/netlify.toml @@ -54,3 +54,11 @@ Strict-Transport-Security = "max-age=31536000; includeSubDomains" Cross-Origin-Opener-Policy = "same-origin" Cross-Origin-Embedder-Policy = "require-corp" + +# The service worker (public/sw.js) must always be re-checked, or browsers +# keep running an old one after a deploy. Its content changes with every +# build (scripts/build-sw-manifest.mjs), which is how updates are noticed. +[[headers]] + for = "/sw.js" + [headers.values] + Cache-Control = "no-cache" diff --git a/package.json b/package.json index 723be2926..9af43be3f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev --webpack", - "build": "next build --webpack", + "build": "next build --webpack && node scripts/build-sw-manifest.mjs out", "start": "next start", "lint": "eslint .", "test": "vitest", diff --git a/public/_headers b/public/_headers index 0462c40a6..5a95c6bf0 100644 --- a/public/_headers +++ b/public/_headers @@ -21,3 +21,7 @@ Strict-Transport-Security: max-age=31536000; includeSubDomains Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp + +# The service worker must always be re-checked (see netlify.toml). +/sw.js + Cache-Control: no-cache diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dd446e20541b859d403acb08e2cf213e9d854a60 GIT binary patch literal 2436 zcmd5;`#Tei7gv&7w6xp}Uw4U^7!BppDA(N0Epiuz@ETSkB$gz}t!T-0-=?sHH!nuB z7>SL@(9AWrVUxY`{A7DJm-1NIiGWK9PMEuLefG!JUk*c)|S_~_T3*5ILg&H zY(Ws$91pk#v*h9Yp%)!Bg*-fhMmCn0U6RUHzr!PC-Q@@y;AX@1-sKL6FZMKnDP49{ z@%Y`V)K|mvnExFG0bIWdJQngzss)(WS_7Yq7>GZ1u6>iq4{|Iux5$uI71^y+E~iL=yw9jy5lG~(dUw&TB?vH*B$*jOdARb{$en;Im|A{Q=8gg3x)1Shz8LTw{)R_n6l-sWE>c4#yuWRw`2?aSEd$yXm#d6{V$0NToa4BbN7jy<|roDWgk8B$CA@`oBTIJ!VD!2Gf zW=+m%=XR`QkKWdj<_rurQqg(ci=n+WbtneAVLV)P?jR*RCK(z%fz1hsvN8cJC#%bz zO^X;izb4OOYnJxj+-{EbQ@H8br!!g$u6@f=w-6{W%mdewCFst*(fcktzNj}$i>mVv z<_<5l?e%V?tgUR5s3%`jkJt36ZASEB1aVw{lPYQ>mrk$IA8q*UUuOr-+j5gR zNi0$ju7+r-SHxT7by!<+VI<5=|qo`QTv?$ z0l&rRRDH2xlSg801jUS)`Xxrb<@d5ErwoYPI-=gdW6MCY3Q+Z(k>8$FZZeH)BaZel zl6EX6l=9QVzAQgqadNrhH7;aO@~s&ri}HBh_~ZRkZ7ak;yQV^jG**lBnZ>l51;IY) z>zw;?;#HsKUWsDXviXqOZW8aAgF!o^LTUDInigl3e-|Pu+L0O?o7AiB!VF|XJN@EO zw0LBchWFyas6c3Wsty?wBkk1RiXifca{(FQHK^U62E=;ZcCde43#-eYa`Kl zU(P7AxjSgal;ys9(J4<_M4eb3jwAz#}C9vQG!h`*;bsy7Hu^u%!Kp!ckMog*b z3}Dn}!dKgdo8h$h67THlWBAda*b7rfqwj7r^gIECD;IvmOVK9E_|pq!3%Ofaepf~+Bx;6yXqcSqcs8-Mo$oUy>A{CU zTL_RBqNEoWi%f)s;T764*}H4=#YkP59>t8sT?1G)JHfh@!oX#Z1{LR=M)o*4*fJ&V zr2Y+p1#$YdYw*7Q2lfg3+w=X_rtP0lW`4uiVDRsRiKGdCqHfDm6C)!nBv4%M$}G$| zkkEJdenanDdD%xqA*Ewh3o#R$5<)Y>Vzrt!+65;fN;1{1E7{ySYj~usWGMCaJT(nP zL|^oj|9=d6PN+-oXZ&g{W8+#bcp;_#d6@SN)bgn{M9dZevt3wQu%E7tRCs>}VzBhI zg_#sSFEq}6;?7G`Cjow5+XCMk4LsmHdw~PM1lJoIO`pon{x!!6w~$pP=?{@qD$6@C zx|2#xQav|rH9Ny^3nHd80)Z9fB}uv_K?0M5aqfb&HajC{_zpj}zl{+g#0qL1INKuo zcqx_Ib_HF1q^89nA1Q0d!&N9RkqODnl?4byfF^3Omycb;4gW78jzU!z!O2zH z4tb(<_sWurMzz|05?A|-vrbdCIBUAEl2y5)S`WrY|y5}-h)|86a zlXFA45JD?01l|S!?Ukb!nfP5h4A6Fqf8d52_bSLvtNIQ_l=owUL<0R@G=VPrj8Oi} z=L$$_KwDI?XDl*!V0e9__01;emnZl`n5aFv>+elvSpp;jFpeu(#t-_UggNywMBQq7 zm!Z+NUe~QYW8aIf5`SqfkwXt=Lt$~>RdA8OG*kAB#E8Zhm~X?J2i5)R<#3!mp&Hh+p+>ew1 literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..c21219b98dad3996a66d737b71dde8aeec8bdc23 GIT binary patch literal 4859 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 A(*OVf literal 0 HcmV?d00001 diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 000000000..8e8ee8856 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Reframe — video editor", + "short_name": "Reframe", + "description": "Resize, trim, crop and export videos on your device. Works offline once opened.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f5f5f3", + "theme_color": "#12100e", + "categories": ["photo", "productivity", "utilities"], + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..41e3127a3 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,111 @@ +/* Reframe service worker: opens the editor offline. + * + * - The app's own files are precached on install from PRECACHE, which + * scripts/build-sw-manifest.mjs writes into the built copy of this file. + * Pages are fetched from the network first (so a deploy shows at once) and + * from the cache when offline; other files come from the cache first. + * - The video engine (FFmpeg core files on jsDelivr) is kept in its own + * cache the first time it downloads, and served from there after. They are + * CORS responses, so the browser still checks their SRI hashes. + * - A cached page keeps the headers it was served with, including the + * cross-origin isolation ones (COOP/COEP), so the multi-threaded engine + * works offline wherever it works online. + * - User videos and exports are never cached: they are blob: URLs, which + * never reach a service worker. + */ +const PRECACHE = null; // replaced at build time + +const APP_CACHE_PREFIX = "reframe-app-"; +const ENGINE_CACHE = "reframe-engine-v1"; +const ENGINE_URLS = ["https://cdn.jsdelivr.net/npm/@ffmpeg/core@", "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"]; +/** How long a page request waits for the network before using the cache. */ +const PAGE_TIMEOUT_MS = 4000; + +const appCache = PRECACHE ? `${APP_CACHE_PREFIX}${PRECACHE.version}` : null; + +/** What to do with a request: "engine", "page", "asset" or "network" (leave it alone). */ +function route(url, request) { + if (request.method !== "GET") return "network"; + if (ENGINE_URLS.some((prefix) => url.href.startsWith(prefix))) return "engine"; + if (url.origin !== self.location.origin || !appCache) return "network"; + if (request.mode === "navigate") return "page"; + if (url.pathname === "/sw.js") return "network"; + return "asset"; +} + +/** The precached page for a path: "/contact/" and "/contact.html" are "/contact"; unknown paths get "/". */ +function pageKey(pathname) { + const path = pathname.replace(/\.html$/, "").replace(/\/+$/, "") || "/"; + const name = path === "/index" ? "/" : path; + return PRECACHE.pages.includes(name) ? name : "/"; +} + +async function page(request, url) { + const cached = () => caches.open(appCache).then((cache) => cache.match(pageKey(url.pathname))); + try { + const network = await Promise.race([ + fetch(request), + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PAGE_TIMEOUT_MS)), + ]); + return network; + } catch (error) { + const hit = await cached(); + if (hit) return hit; + throw error; + } +} + +async function asset(request) { + const cache = await caches.open(appCache); + // Next's page data requests carry a ?_rsc= query; the file is the same. + const hit = await cache.match(request, { ignoreSearch: true }); + return hit ?? fetch(request); +} + +async function engine(request) { + const cache = await caches.open(ENGINE_CACHE); + const hit = await cache.match(request.url); + if (hit) return hit; + const response = await fetch(request); + if (response.ok && response.type === "cors") { + await cache.put(request.url, response.clone()); + // The page checks whether every file it needs is in now. + const clients = await self.clients.matchAll({ includeUncontrolled: true }); + clients.forEach((client) => client.postMessage({ type: "engine-cached" })); + } + return response; +} + +self.addEventListener("install", (event) => { + if (!appCache) return; + event.waitUntil( + caches + .open(appCache) + .then((cache) => cache.addAll([...PRECACHE.pages, ...PRECACHE.assets].map((url) => new Request(url, { cache: "reload" })))) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key.startsWith(APP_CACHE_PREFIX) && key !== appCache).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +// The page asks for this when the user chooses to load an update. +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "skip-waiting") self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + const kind = route(url, event.request); + if (kind === "engine") event.respondWith(engine(event.request)); + else if (kind === "page") event.respondWith(page(event.request, url)); + else if (kind === "asset") event.respondWith(asset(event.request)); +}); + +// For tests (test code reads these; the browser ignores them). +self.__reframeSw = { route, pageKey, ENGINE_CACHE, APP_CACHE_PREFIX }; diff --git a/scripts/build-sw-manifest.mjs b/scripts/build-sw-manifest.mjs new file mode 100644 index 000000000..025de3cf9 --- /dev/null +++ b/scripts/build-sw-manifest.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Lists the static export's files for the service worker (public/sw.js) to +// precache, so the editor opens offline. Runs after `next build`: +// +// node scripts/build-sw-manifest.mjs [out] +// +// Writes { version, pages, assets } into out/sw.js in place of its +// placeholder. `version` is a hash of every listed file's contents, so a +// deploy that changes anything changes sw.js too: browsers then install the +// new worker, which precaches into a new cache and removes the old one. +// Source maps, the headers file and sw.js itself are left out. +import { createHash } from "node:crypto"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SKIP = [/\.map$/, /^_headers$/, /^sw\.js$/, /(^|\/)\.DS_Store$/]; +/** The line in public/sw.js that the build replaces. */ +export const PLACEHOLDER = "const PRECACHE = null; // replaced at build time"; +/** Error pages aren't opened directly, so they aren't precached as pages. */ +const SKIP_PAGES = new Set(["404", "_not-found"]); + +/** + * Splits the export's files (paths relative to out/, with "/") into page + * URLs ("index.html" → "/", "contact.html" → "/contact") and asset URLs. + */ +export function precacheList(files) { + const pages = []; + const assets = []; + for (const file of [...files].sort()) { + if (SKIP.some((re) => re.test(file))) continue; + if (file.endsWith(".html") && !file.includes("/")) { + const name = file.slice(0, -".html".length); + if (SKIP_PAGES.has(name)) continue; + pages.push(name === "index" ? "/" : `/${name}`); + continue; + } + if (file.endsWith(".html")) continue; + assets.push(`/${file}`); + } + return { pages, assets }; +} + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => (entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)])) + ); + return files.flat(); +} + +export async function buildManifest(root) { + const files = (await walk(root)).map((path) => relative(root, path).split(sep).join("/")); + const list = precacheList(files); + const hash = createHash("sha256"); + const included = new Set([...list.assets.map((a) => a.slice(1)), ...files.filter((f) => f.endsWith(".html"))]); + for (const file of [...included].sort()) { + hash.update(file); + hash.update(await readFile(join(root, file))); + } + return { version: hash.digest("hex").slice(0, 16), ...list }; +} + +/** sw.js with the precache list in place of its placeholder. */ +export function injectManifest(source, manifest) { + if (!source.includes(PLACEHOLDER)) throw new Error("sw.js has no precache placeholder"); + return source.replace(PLACEHOLDER, `const PRECACHE = ${JSON.stringify(manifest)};`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const root = process.argv[2] ?? "out"; + const manifest = await buildManifest(root); + const swPath = join(root, "sw.js"); + await writeFile(swPath, injectManifest(await readFile(swPath, "utf8"), manifest)); + console.log(`sw.js: ${manifest.pages.length} pages, ${manifest.assets.length} assets, version ${manifest.version}`); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d15a5478b..a87bf5df1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -64,7 +64,9 @@ export const metadata: Metadata = { icons: { icon: [{ url: "/favicon.svg", type: "image/svg+xml" }], shortcut: "/favicon.svg", + apple: "/icons/apple-touch-icon.png", }, + manifest: "/manifest.webmanifest", }; export default function RootLayout({ diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a57a9c5a5..936b865c7 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import OfflineStatus from "./OfflineStatus"; import { Github, Twitter, @@ -51,6 +52,7 @@ export default function Footer() { ))} + {/* Navigation */} diff --git a/src/components/OfflineStatus.tsx b/src/components/OfflineStatus.tsx new file mode 100644 index 000000000..506360098 --- /dev/null +++ b/src/components/OfflineStatus.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { CloudOff, RefreshCw, X } from "lucide-react"; +import { useOfflineSupport, type OfflineState } from "@/hooks/useOfflineSupport"; + +const TEXT: Record, string> = { + preparing: "Saving the editor for offline use…", + "needs-engine": "Open a video once while online to use the editor offline.", + ready: "Works offline", +}; + +/** + * Whether the editor opens offline (in the footer), and a small notice when + * a new version is ready to load. + */ +export default function OfflineStatus() { + const { state, updateReady, applyUpdate, dismissUpdate } = useOfflineSupport(); + return ( + <> + {state !== "unavailable" && ( +

+

+ )} + {updateReady && ( +
+ A new version of Reframe is ready. + + +
+ )} + + ); +} diff --git a/src/hooks/useOfflineSupport.ts b/src/hooks/useOfflineSupport.ts new file mode 100644 index 000000000..896629c35 --- /dev/null +++ b/src/hooks/useOfflineSupport.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { trackFeature } from "@/lib/featureTelemetry"; +import { engineFileUrls } from "@/lib/engineUrls"; + +/** Must match ENGINE_CACHE in public/sw.js. */ +const ENGINE_CACHE = "reframe-engine-v1"; +const REPORTED_KEY = "reframe:offline-ready-reported"; + +export type OfflineState = + /** No service worker (unsupported, or a development build). */ + | "unavailable" + /** The app is being saved for offline use. */ + | "preparing" + /** The app is saved, but the video engine hasn't been downloaded yet. */ + | "needs-engine" + /** The editor and the engine both open offline. */ + | "ready"; + +async function engineCached(): Promise { + if (typeof caches === "undefined") return false; + try { + const cache = await caches.open(ENGINE_CACHE); + const cached = new Set((await cache.keys()).map((request) => request.url)); + return engineFileUrls(self.crossOriginIsolated === true).every((url) => cached.has(url)); + } catch { + return false; + } +} + +/** + * Registers the service worker (production builds only) and reports whether + * the editor works offline yet. When a new version has been installed, + * `updateReady` is true and `applyUpdate` switches to it and reloads; it + * never reloads on its own, so it can't interrupt an export. + */ +export function useOfflineSupport() { + const [state, setState] = useState("unavailable"); + const [waiting, setWaiting] = useState(null); + + useEffect(() => { + if (process.env.NODE_ENV !== "production" || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return; + let cancelled = false; + const sw = navigator.serviceWorker; + + const refresh = async () => { + if (cancelled) return; + if (!sw.controller) { + setState("preparing"); + return; + } + const ready = await engineCached(); + if (cancelled) return; + setState(ready ? "ready" : "needs-engine"); + if (ready) { + try { + if (!localStorage.getItem(REPORTED_KEY)) { + localStorage.setItem(REPORTED_KEY, "1"); + trackFeature("pwa", "offline_ready"); + } + } catch { + // storage blocked: report nothing + } + } + }; + + const watch = (registration: ServiceWorkerRegistration) => { + // A worker waiting while another controls the page is an update. + if (registration.waiting && sw.controller) setWaiting(registration.waiting); + registration.addEventListener("updatefound", () => { + const installing = registration.installing; + installing?.addEventListener("statechange", () => { + if (installing.state === "installed" && sw.controller) setWaiting(installing); + if (installing.state === "activated") void refresh(); + }); + }); + }; + + const onMessage = (event: MessageEvent) => { + if (event.data?.type === "engine-cached") void refresh(); + }; + const onControllerChange = () => void refresh(); + const onInstalled = () => trackFeature("pwa", "installed"); + + sw.addEventListener("message", onMessage); + sw.addEventListener("controllerchange", onControllerChange); + window.addEventListener("appinstalled", onInstalled); + sw.register("/sw.js", { scope: "/" }) + .then((registration) => { + if (!cancelled) watch(registration); + return refresh(); + }) + .catch(() => { + if (!cancelled) setState("unavailable"); + }); + + return () => { + cancelled = true; + sw.removeEventListener("message", onMessage); + sw.removeEventListener("controllerchange", onControllerChange); + window.removeEventListener("appinstalled", onInstalled); + }; + }, []); + + const applyUpdate = useCallback(() => { + if (!waiting) return; + trackFeature("pwa", "update"); + navigator.serviceWorker.addEventListener("controllerchange", () => window.location.reload(), { once: true }); + waiting.postMessage({ type: "skip-waiting" }); + }, [waiting]); + + return { state, updateReady: waiting !== null, applyUpdate, dismissUpdate: () => setWaiting(null) }; +} diff --git a/src/lib/engineUrls.ts b/src/lib/engineUrls.ts new file mode 100644 index 000000000..67e4f1f69 --- /dev/null +++ b/src/lib/engineUrls.ts @@ -0,0 +1,25 @@ +/** + * Where the FFmpeg cores are downloaded from. Shared by the engine worker + * (src/lib/ffmpeg.worker.ts, which checks each file's SRI hash) and the + * offline check (src/hooks/useOfflineSupport.ts, which looks for these files + * in the service worker's cache). + */ +export const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; +// MT core aligned to the same version and distribution format as CORE_BASE_URL. +// Previously used @0.12.6/dist/esm which caused two problems: +// 1. The version mismatch meant SRI hashes computed for @0.12.10 were applied +// to different file contents, causing all MT fetches to fail with an integrity +// error instead of falling back gracefully. +// 2. The ESM distribution is not consumed the same way as UMD by ffmpeg.js and +// produced subtle runtime failures in crossOriginIsolated contexts. +export const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd"; + +/** + * The engine files a page loads: the multi-threaded core when cross-origin + * isolated, which also needs its worker script, otherwise the + * single-threaded one. + */ +export function engineFileUrls(isolated: boolean): string[] { + const base = isolated ? MT_CORE_BASE_URL : CORE_BASE_URL; + return ["ffmpeg-core.js", "ffmpeg-core.wasm", ...(isolated ? ["ffmpeg-core.worker.js"] : [])].map((name) => `${base}/${name}`); +} diff --git a/src/lib/featureTelemetry.ts b/src/lib/featureTelemetry.ts index fda905bb1..b5c265350 100644 --- a/src/lib/featureTelemetry.ts +++ b/src/lib/featureTelemetry.ts @@ -19,6 +19,7 @@ export const FEATURE_ACTIONS = { crop_tool: ["aspect", "drag", "reset"], batch_export: ["start", "done", "done_with_failures", "cancel", "save_folder"], trim_timeline: ["seek", "drag_handle"], + pwa: ["installed", "offline_ready", "update"], // One event per editing tool an export used (see toolsUsed). trim: ["exported"], rotate: ["exported"], diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts index 76369411d..b99a1e89a 100644 --- a/src/lib/ffmpeg.worker.ts +++ b/src/lib/ffmpeg.worker.ts @@ -12,15 +12,7 @@ import { type EngineMode, } from "./ffmpegArgs"; -const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; -// MT core aligned to the same version and distribution format as CORE_BASE_URL. -// Previously used @0.12.6/dist/esm which caused two problems: -// 1. The version mismatch meant SRI hashes computed for @0.12.10 were applied -// to different file contents, causing all MT fetches to fail with an integrity -// error instead of falling back gracefully. -// 2. The ESM distribution is not consumed the same way as UMD by ffmpeg.js and -// produced subtle runtime failures in crossOriginIsolated contexts. -const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd"; +import { CORE_BASE_URL, MT_CORE_BASE_URL } from "./engineUrls"; // Keys are full URLs so hashes for the non-MT and MT packages never collide. // Every engine file is integrity-checked, so a tampered CDN response fails to diff --git a/src/lib/tests/deployConfig.test.ts b/src/lib/tests/deployConfig.test.ts index cfed8dedf..c964d13c6 100644 --- a/src/lib/tests/deployConfig.test.ts +++ b/src/lib/tests/deployConfig.test.ts @@ -67,9 +67,11 @@ describe("cross-origin isolation compatibility", () => { // Under COEP require-corp, cross-origin loads need CORS (or CORP). // fetch() defaults to mode "cors"; jsdelivr answers with // Access-Control-Allow-Origin: *. The cores are then used as blob: URLs, - // which are same-origin. - expect(worker).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core@"); - expect(worker).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"); + // which are same-origin. The URLs live in engineUrls.ts. + const urls = read("src/lib/engineUrls.ts"); + expect(worker).toContain('from "./engineUrls"'); + expect(urls).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core@"); + expect(urls).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"); expect(worker).not.toMatch(/mode:\s*["']no-cors["']/); }); diff --git a/src/lib/tests/serviceWorker.test.ts b/src/lib/tests/serviceWorker.test.ts new file mode 100644 index 000000000..11ecf2d31 --- /dev/null +++ b/src/lib/tests/serviceWorker.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { precacheList, injectManifest, PLACEHOLDER } from "../../../scripts/build-sw-manifest.mjs"; +import { engineFileUrls } from "../engineUrls"; + +const SOURCE = readFileSync(join(__dirname, "../../../public/sw.js"), "utf8"); + +type Route = (url: URL, request: { method: string; mode: string }) => string; + +/** Runs sw.js against a stand-in `self`, as the browser would, and returns its test hooks. */ +function loadWorker(precache: unknown) { + const listeners: string[] = []; + const self = { + location: { origin: "https://reframe.test" }, + addEventListener: (type: string) => listeners.push(type), + } as Record; + const source = precache ? injectManifest(SOURCE, precache) : SOURCE; + new Function("self", "caches", source)(self, {}); + return { ...(self.__reframeSw as { route: Route; pageKey: (path: string) => string }), listeners }; +} + +const PRECACHE = { version: "abc", pages: ["/", "/contact", "/privacy"], assets: ["/_next/static/chunks/app.js"] }; +const get = (mode = "cors") => ({ method: "GET", mode }); + +describe("precache list (scripts/build-sw-manifest.mjs)", () => { + it("turns HTML files into page URLs and keeps everything else as assets", () => { + const list = precacheList([ + "index.html", + "contact.html", + "privacy.html", + "404.html", + "_not-found.html", + "contact/__next._tree.txt", + "_next/static/chunks/app.js", + "_next/static/chunks/app.js.map", + "_headers", + "sw.js", + "fonts/Geist-Regular.ttf", + ]); + expect(list.pages).toEqual(["/contact", "/", "/privacy"]); + expect(list.assets).toEqual(["/_next/static/chunks/app.js", "/contact/__next._tree.txt", "/fonts/Geist-Regular.ttf"]); + }); + + it("writes the list into sw.js in place of its placeholder", () => { + expect(SOURCE).toContain(PLACEHOLDER); + const built = injectManifest(SOURCE, PRECACHE); + expect(built).not.toContain(PLACEHOLDER); + expect(built).toContain('const PRECACHE = {"version":"abc"'); + expect(() => injectManifest("// no placeholder", PRECACHE)).toThrow(/placeholder/); + }); +}); + +describe("service worker routing (public/sw.js)", () => { + const sw = loadWorker(PRECACHE); + + it("handles install, activate, fetch and update messages", () => { + expect(sw.listeners).toEqual(expect.arrayContaining(["install", "activate", "fetch", "message"])); + }); + + it("keeps every engine file the page loads, in either mode", () => { + for (const url of [...engineFileUrls(true), ...engineFileUrls(false)]) { + expect(sw.route(new URL(url), get())).toBe("engine"); + } + }); + + it("serves the app's pages and files, and leaves everything else alone", () => { + expect(sw.route(new URL("https://reframe.test/?preset=square"), get("navigate"))).toBe("page"); + expect(sw.route(new URL("https://reframe.test/_next/static/chunks/app.js"), get())).toBe("asset"); + expect(sw.route(new URL("https://reframe.test/sw.js"), get())).toBe("network"); + expect(sw.route(new URL("https://reframe.test/"), { method: "POST", mode: "cors" })).toBe("network"); + // Other sites (analytics, other CDN files) are never cached. + expect(sw.route(new URL("https://browser-intake-datadoghq.com/api"), get())).toBe("network"); + expect(sw.route(new URL("https://cdn.jsdelivr.net/npm/other@1/x.js"), get())).toBe("network"); + }); + + it("finds the cached page for a path, and the editor for anything unknown", () => { + expect(sw.pageKey("/")).toBe("/"); + expect(sw.pageKey("/contact")).toBe("/contact"); + expect(sw.pageKey("/contact/")).toBe("/contact"); + expect(sw.pageKey("/privacy.html")).toBe("/privacy"); + expect(sw.pageKey("/index.html")).toBe("/"); + expect(sw.pageKey("/nope")).toBe("/"); + }); + + it("does nothing for the app itself before a build has written the list", () => { + const dev = loadWorker(null); + expect(dev.route(new URL("https://reframe.test/"), get("navigate"))).toBe("network"); + expect(dev.route(new URL(engineFileUrls(false)[1]!), get())).toBe("engine"); + }); +}); diff --git a/test/e2e/offline.spec.ts b/test/e2e/offline.spec.ts new file mode 100644 index 000000000..36aa2c224 --- /dev/null +++ b/test/e2e/offline.spec.ts @@ -0,0 +1,68 @@ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +// Offline support through the real browser: the service worker saves the +// app and the video engine, then with the network off the editor still +// opens, is cross-origin isolated exactly as online, and exports. +const FIXTURES = join(__dirname, "..", "fixtures"); + +async function openVideo(page: Page) { + await page.locator('input[type=file][accept="video/*"]').first().setInputFiles(join(FIXTURES, "normal.mp4")); + await expect(page.getByRole("button", { name: "Export video" }).first()).toBeVisible(); +} + +test("the editor opens and exports with the network off once it has been used online", async ({ page, context }, testInfo) => { + test.slow(); + await page.addInitScript(() => localStorage.setItem("reframe_onboarding_complete", "1")); + await page.goto("/"); + const onlineIsolated = await page.evaluate(() => crossOriginIsolated); + expect(onlineIsolated).toBe(testInfo.project.name === "multi-threaded"); + + // The worker installs, precaches the app and takes control of the page. + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); + } + }); + const status = page.getByTestId("offline-status"); + await expect(status).toHaveAttribute("data-state", "needs-engine"); + + // Opening a video loads the engine (for the filmstrip), which the worker keeps. + await openVideo(page); + await expect(status).toHaveAttribute("data-state", "ready", { timeout: 120_000 }); + await expect(status).toHaveText("Works offline"); + + await context.setOffline(true); + await page.reload(); + await expect(page.locator('input[type=file][accept="video/*"]').first()).toBeAttached(); + expect(await page.evaluate(() => navigator.onLine)).toBe(false); + // Same isolation as online, so the same engine loads. + expect(await page.evaluate(() => crossOriginIsolated)).toBe(onlineIsolated); + + await openVideo(page); + await page.getByRole("button", { name: /^Trim\b/ }).first().click(); + await page.getByLabel("Trim end time in seconds").fill("2"); + await page.getByRole("button", { name: "Export video" }).first().click(); + const download = page.getByRole("link", { name: /^Download MP4$/ }); + await expect(download).toBeVisible({ timeout: 180_000 }); + const [file] = await Promise.all([page.waitForEvent("download"), download.click()]); + const out = testInfo.outputPath("offline.mp4"); + await file.saveAs(out); + const seconds = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out], { encoding: "utf8" })); + expect(seconds).toBeGreaterThan(1.8); + expect(seconds).toBeLessThan(2.3); +}); + +test("the app can be installed: the manifest is linked and complete", async ({ page, request }) => { + await page.goto("/"); + const href = await page.locator('link[rel="manifest"]').getAttribute("href"); + expect(href).toBe("/manifest.webmanifest"); + const manifest = await (await request.get(href!)).json(); + expect(manifest).toMatchObject({ name: expect.any(String), short_name: "Reframe", start_url: "/", display: "standalone" }); + const sizes = manifest.icons.map((icon: { sizes: string }) => icon.sizes); + expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"])); + expect(manifest.icons.some((icon: { purpose?: string }) => icon.purpose === "maskable")).toBe(true); + for (const icon of manifest.icons) expect((await request.get(icon.src)).ok()).toBe(true); +}); diff --git a/vercel.json b/vercel.json index 709d0f724..320affb8e 100644 --- a/vercel.json +++ b/vercel.json @@ -22,6 +22,10 @@ "value": "require-corp" } ] + }, + { + "source": "/sw.js", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] } ] }