Add fullscreen preview, PWA support, and connect without a button
Some checks failed
CI / build-and-deploy (push) Has been cancelled

Fullscreen: a button on the preview pane fullscreens the live view, carrying
the countdown over as an overlay - fullscreen hides the entire control column,
so without it you're left staring at a picture with no idea when the next
frame lands.

Wake lock: held from app start until you switch it off, rather than only for
the duration of a sequence. You're usually mid-setup when the display would
otherwise sleep.

PWA: manifest, icons and a service worker, so it installs to a standalone
window and runs with no network. That matters more here than for most web
apps - the camera is on a USB cable, so this is fully functional in a field
with no signal.

The worker precaches the pinned unpkg dependencies at install rather than
leaving them to the runtime cache. On a first visit the worker isn't
controlling the page yet, so the app's own imports go straight to the network
and never reach the fetch handler; without the precache it looked cached but
died offline on its imports. Our own files are network-first so a redeploy
always wins, and the version-pinned CDN files are cache-first.

Connecting: no connect button. The app reaches for the camera on load, retries
every 1.5s, and listens for USB connect events, so switching the camera on
mid-wait attaches it with no click or reload. The exception is a browser that
has never been granted access to the device: Chrome will not open its WebUSB
chooser outside a user gesture, so that case still shows a one-off prompt.
After that the permission is remembered and getDevices() finds the camera with
no interaction.

tsconfig excludes sw.js, which runs in ServiceWorkerGlobalScope and reports
every worker global as undefined when checked against the DOM lib.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
This commit is contained in:
Jon
2026-08-02 09:53:24 +01:00
parent c293ee47d9
commit baaa0b5472
13 changed files with 474 additions and 69 deletions

147
examples/preact/sw.js Normal file
View File

@@ -0,0 +1,147 @@
/*
* Service worker: makes the app installable and usable with no network.
*
* Offline matters more here than for most web apps - the camera is on the end
* of a USB cable, so the app is fully functional in a field with no signal, as
* long as it can load at all.
*
* Two caches with deliberately different strategies:
*
* - Our own files: network-first. They change whenever the app is redeployed,
* and a stale module paired with fresh HTML is a miserable bug to chase.
* Cache is the fallback, which is what makes offline work.
* - The unpkg dependencies (preact, web-gphoto2, the ~2MB WASM): cache-first.
* Those URLs are pinned to exact versions and are immutable, so re-checking
* them over the network buys nothing.
*/
const VERSION = 'v1';
const SHELL_CACHE = `intervalometer-shell-${VERSION}`;
const DEPS_CACHE = `intervalometer-deps-${VERSION}`;
const CACHES = [SHELL_CACHE, DEPS_CACHE];
const SHELL = [
'./',
'./index.html',
'./manifest.webmanifest',
'./index.js',
'./index-fallback.js',
'./home.js',
'./settings.js',
'./intervalometer.js',
'./storage.js',
'./config-utils.js',
'./preview.js',
'./voice.js',
'./widget.js',
'./icon-192.png',
'./icon-512.png'
];
/** Cross-origin hosts whose responses are safe to keep indefinitely. */
const IMMUTABLE_HOSTS = ['unpkg.com'];
/*
* These have to be fetched at install time, not left to the runtime cache.
* On a first visit the worker isn't controlling the page yet, so the app's own
* imports go straight to the network and never reach the fetch handler - which
* means without this the app looks cached but dies offline on its imports.
*
* Every URL is version-pinned, so they're safe to hold forever.
*/
const DEPS = [
'https://unpkg.com/web-gphoto2@0.4.1/build/camera.js',
'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.mjs',
'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.wasm',
'https://unpkg.com/preact@10.6.4/dist/preact.module.js',
'https://unpkg.com/purecss@2.0.6/build/pure-min.css'
];
self.addEventListener('install', event => {
event.waitUntil(
Promise.all([
caches.open(SHELL_CACHE).then(cache => cache.addAll(SHELL)),
precacheDeps()
]).then(() => self.skipWaiting())
);
});
/**
* Fetched individually and tolerantly: a flaky CDN shouldn't fail the whole
* install and leave the app with no worker at all. Anything that misses here
* gets picked up by the runtime cache on a later online visit.
*/
async function precacheDeps() {
let cache = await caches.open(DEPS_CACHE);
await Promise.allSettled(
DEPS.map(async url => {
let response = await fetch(url, { mode: 'cors', credentials: 'omit' });
if (!response.ok) throw new Error(`${response.status} for ${url}`);
await cache.put(url, response);
})
);
}
self.addEventListener('activate', event => {
event.waitUntil(
caches
.keys()
.then(keys =>
Promise.all(keys.filter(k => !CACHES.includes(k)).map(k => caches.delete(k)))
)
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
let { request } = event;
if (request.method !== 'GET') return;
let url = new URL(request.url);
if (IMMUTABLE_HOSTS.includes(url.hostname)) {
event.respondWith(cacheFirst(request));
return;
}
if (url.origin === self.location.origin) {
event.respondWith(networkFirst(request));
}
});
/**
* @param {Request} request
*/
async function cacheFirst(request) {
let cache = await caches.open(DEPS_CACHE);
let hit = await cache.match(request);
if (hit) return hit;
let response = await fetch(request);
// An opaque response would break the page's cross-origin isolation on
// replay, so only keep ones that actually passed CORS.
if (response.ok && response.type !== 'opaque') {
cache.put(request, response.clone());
}
return response;
}
/**
* @param {Request} request
*/
async function networkFirst(request) {
let cache = await caches.open(SHELL_CACHE);
try {
let response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch (err) {
let hit = await cache.match(request);
if (hit) return hit;
// A navigation with nothing cached for this exact URL still wants the shell.
if (request.mode === 'navigate') {
let shell = await cache.match('./');
if (shell) return shell;
}
throw err;
}
}