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
148 lines
4.4 KiB
JavaScript
148 lines
4.4 KiB
JavaScript
/*
|
|
* 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;
|
|
}
|
|
}
|