Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions build/pwa-vite-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";

/**
* Public files that are not referenced by the page markup but are still needed
* for an installed app to start without a network connection.
*/
const STATIC_PRECACHE = [
"/manifest.webmanifest",
"/favicon.ico",
"/favicon-32.png",
"/favicon-dark.svg",
"/apple-touch-icon.png",
"/icon-512.png",
"/mouse-preview.svg",
];

/**
* Pages whose emitted markup is scanned for the hashed assets to precache.
* The license gate and the control app are deliberately absent: both are
* verified per request by functions/_middleware.js, so caching either one
* would let a revoked license keep working offline.
*/
const PRECACHE_PAGES: { file: string; url: string }[] = [
{ file: "index.html", url: "/" },
{ file: "demo.html", url: "/demo.html" },
];

/** Same-origin paths the worker must never serve from its own cache. */
const BYPASS_SOURCE = [
"/^\\/api\\//",
"/^\\/control-app/",
"/^\\/protected-assets\\//",
"/^\\/control(?:\\.html)?$/",
].join(", ");

function renderServiceWorker(version: string, precache: string[]): string {
return `// Generated by build/pwa-vite-plugin.ts. Do not edit by hand.
const CACHE = "openmouse-${version}";
const FONT_CACHE = "openmouse-fonts";
const PRECACHE = ${JSON.stringify(precache, null, 2)};

/**
* Licensed routes. These are validated per request by the Cloudflare
* middleware and must always reach the network, so an expired or revoked
* session cannot be served from a local cache.
*/
const BYPASS = [${BYPASS_SOURCE}];

const FONT_ORIGINS = ["https://fonts.googleapis.com", "https://fonts.gstatic.com"];

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => cache.addAll(PRECACHE))
.then(() => self.skipWaiting()),
);
});

self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key !== CACHE && key !== FONT_CACHE)
.map((key) => caches.delete(key)),
))
.then(() => self.clients.claim()),
);
});

/** Serves the cached copy immediately and refreshes it in the background. */
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const network = fetch(request)
.then((response) => {
if (response.ok || response.type === "opaque") cache.put(request, response.clone());
return response;
})
.catch(() => cached);
return cached ?? network;
}

/** Prefers fresh markup, falling back to the cache when the network is gone. */
async function networkFirst(request) {
const cache = await caches.open(CACHE);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await cache.match(request) ?? await cache.match("/");
if (cached) return cached;
throw error;
}
}

self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;

const url = new URL(request.url);

if (FONT_ORIGINS.includes(url.origin)) {
event.respondWith(staleWhileRevalidate(request, FONT_CACHE));
return;
}

if (url.origin !== self.location.origin) return;
if (BYPASS.some((pattern) => pattern.test(url.pathname))) return;

if (request.mode === "navigate") {
event.respondWith(networkFirst(request));
return;
}

event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
);
});
`;
}

/** Emits a service worker that precaches the public pages and their assets. */
export function pwa(appVersion: string): Plugin {
let root = process.cwd();
let outputDirectory = "dist";

return {
name: "openmouse-pwa",
apply: "build",
configResolved(config) {
root = config.root;
outputDirectory = config.build.outDir;
},
// Runs against the written output: Vite injects the hashed asset tags into
// the markup after generateBundle, so the emitted HTML is only complete on disk.
async closeBundle() {
const outputRoot = resolve(root, outputDirectory);
const urls = new Set<string>(STATIC_PRECACHE);

for (const page of PRECACHE_PAGES) {
const markup = await readFile(resolve(outputRoot, page.file), "utf8");

urls.add(page.url);
for (const [, asset] of markup.matchAll(/(?:href|src)="(\/assets\/[^"]+)"/g)) {
urls.add(asset);
}
}

const precache = [...urls].sort();
const version = `${appVersion}-${createHash("sha256").update(precache.join("\n")).digest("hex").slice(0, 8)}`;

await writeFile(resolve(outputRoot, "sw.js"), renderServiceWorker(version, precache));
},
};
}
1 change: 1 addition & 0 deletions demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<link rel="icon" type="image/png" href="/favicon-32.png?v=2" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg?v=2" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" sizes="180x180" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<link rel="icon" type="image/png" href="/favicon-32.png?v=2" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg?v=2" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" sizes="180x180" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="description" content="Tune supported gaming mice in your browser, adjust DPI, polling rate, and sensor settings in one place." />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://openmouse.app/" />
Expand Down
36 changes: 36 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"id": "/",
"name": "OpenMouse",
"short_name": "OpenMouse",
"description": "Manage supported gaming mice in one place — DPI, polling rate, battery, and sensor settings.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#09090b",
"categories": ["utilities", "productivity"],
"icons": [
{
"src": "/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
3 changes: 3 additions & 0 deletions src/demo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import "./demo.css";
import { registerServiceWorker } from "./register-sw";

registerServiceWorker();

const demoApp = document.querySelector<HTMLDivElement>("#demo-app");

Expand Down
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import "./styles.css";
import { registerServiceWorker } from "./register-sw";

registerServiceWorker();

const app = document.querySelector<HTMLDivElement>("#app");

Expand Down
15 changes: 15 additions & 0 deletions src/register-sw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Registers the generated service worker so the public pages stay available
* without a connection. The worker only exists in a production build, and it
* deliberately never caches the licensed control app — see
* build/pwa-vite-plugin.ts.
*/
export function registerServiceWorker(): void {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;

window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch(() => {
// Offline support is an enhancement; the pages work without it.
});
});
}
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import { resolve } from "node:path";
import { readFileSync } from "node:fs";

import { pwa } from "./build/pwa-vite-plugin";
import { sites } from "./build/sites-vite-plugin";

const packageVersion = JSON.parse(
Expand All @@ -10,7 +11,7 @@ const packageVersion = JSON.parse(
const buildChannel = process.env.OPENMOUSE_BUILD_CHANNEL ?? "insiders";

export default defineConfig({
plugins: [sites()],
plugins: [sites(), pwa(packageVersion.version)],
define: {
__APP_VERSION__: JSON.stringify(packageVersion.version),
__BUILD_CHANNEL__: JSON.stringify(buildChannel),
Expand Down