diff --git a/docs/web.md b/docs/web.md index f5959d3..dcbb671 100644 --- a/docs/web.md +++ b/docs/web.md @@ -11,6 +11,15 @@ npx expo export -p web # static site in dist/ Web Bluetooth only works in a secure context, so serve the exported site over HTTPS. `http://localhost` also counts as secure for development. +## Installing as an app + +The web app is a progressive web app, so Chrome and Edge offer to install it to the home screen or desktop, where it opens in its own window. + +- `public/manifest.json` sets the name, start URL, standalone display and colours. Its icons are in `public/icons/`: 192 and 512 pixel versions of the app icon, plus a maskable icon made from the Android adaptive foreground. +- `src/app/+html.tsx` links the manifest and registers `public/sw.js`. Registration only happens in production exports, so `npm run web` never caches Metro's development bundles. +- The service worker fetches pages from the network first and falls back to the cache when offline, so a new deployment is never stuck behind an old copy. Content-hashed bundles and assets under `/_expo/static/` and `/assets/` are cached once and reused. Bluetooth still needs the PC nearby; offline only means the app opens. +- `vercel.json` serves `sw.js` and the manifest with `no-cache`, and the hashed bundles as immutable. To change caching behaviour, change the `CACHE` name in `sw.js` so old caches are cleared on activation. + ## Supported browsers Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and ChromeOS. Safari, Firefox and every iOS browser lack it. In those browsers the PCs screen shows that Bluetooth is unavailable and names the supported browsers. diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..cb6a217 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..8c973c3 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..4154821 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 0000000..21b23eb Binary files /dev/null and b/public/icons/maskable-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..8f1f1cf --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "/", + "name": "Switchify Remote", + "short_name": "Switchify Remote", + "description": "Control a Windows PC or Mac running Switchify PC over Bluetooth.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#050505", + "theme_color": "#050505", + "categories": ["accessibility", "utilities"], + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..9d29986 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,54 @@ +// Switchify Remote service worker. Pages come from the network first so a new +// deployment is never stuck behind the cache; content-hashed bundles and assets +// are cached once and reused, which lets the app open offline after a visit. +const CACHE = 'switchify-remote-v1'; +const SHELL = ['/', '/manifest.json', '/icons/icon-192.png', '/icons/icon-512.png']; +const IMMUTABLE = ['/_expo/static/', '/assets/']; + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +async function networkFirst(request) { + const cache = await caches.open(CACHE); + try { + const response = await fetch(request); + if (response.ok) await cache.put(request, response.clone()); + return response; + } catch (error) { + const cached = (await cache.match(request, { ignoreSearch: true })) ?? (await cache.match('/')); + if (cached) return cached; + throw error; + } +} + +async function cacheFirst(request) { + const cache = await caches.open(CACHE); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) await cache.put(request, response.clone()); + return response; +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + if (request.mode === 'navigate') { + event.respondWith(networkFirst(request)); + } else if (IMMUTABLE.some((prefix) => url.pathname.startsWith(prefix))) { + event.respondWith(cacheFirst(request)); + } else { + event.respondWith(networkFirst(request)); + } +}); diff --git a/src/app/+html.tsx b/src/app/+html.tsx new file mode 100644 index 0000000..05b0d2d --- /dev/null +++ b/src/app/+html.tsx @@ -0,0 +1,31 @@ +import { ScrollViewStyleReset } from 'expo-router/html'; +import { type PropsWithChildren } from 'react'; + +// Runs only in Node during static rendering, so it decides here whether the page +// registers the service worker. Development builds never do, which keeps Metro's +// live bundles out of the cache. +const registerServiceWorker = ` +if ('serviceWorker' in navigator && window.isSecureContext) { + window.addEventListener('load', function () { + navigator.serviceWorker.register('/sw.js').catch(function () {}); + }); +}`; + +export default function Root({ children }: PropsWithChildren) { + return ( + + + + + + + + + + + {process.env.NODE_ENV === 'production' ?