Skip to content
Merged
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
172 changes: 114 additions & 58 deletions build/pwa-vite-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";

/**
Expand All @@ -11,50 +9,72 @@ const STATIC_PRECACHE = [
"/manifest.webmanifest",
"/favicon.ico",
"/favicon-32.png",
"/favicon-dark.svg",
"/apple-touch-icon.png",
"/icon-512.png",
"/mouse-preview.svg",
"/favicon-dark.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.
* Pages each Cloudflare Pages target builds, mirroring the rollup inputs in
* vite.config.ts. The app target is the gated control panel on its own
* subdomain; the public support pages live on the marketing domain, and the
* landing target reaches its root through the _redirects file that
* build/sites-vite-plugin.ts writes.
*/
const PRECACHE_PAGES: { file: string; url: string }[] = [
{ file: "index.html", url: "/" },
{ file: "demo.html", url: "/demo.html" },
];
const TARGET_PAGES: Record<string, string[]> = {
app: ["index.html"],
landing: ["landing.html", "check.html", "supported.html", "donate.html"],
};

export const ROOT_PAGE: Record<string, string> = {
app: "index.html",
landing: "landing.html",
};

function rootPage(target: string): string {
return ROOT_PAGE[target] ?? ROOT_PAGE.app;
}

/** Pages whose emitted markup is scanned for the hashed assets to precache. */
export function precachePages(target: string): string[] {
return TARGET_PAGES[target] ?? TARGET_PAGES.app;
}

/** The root page is served from "/", every other page from its own filename. */
export function pageUrl(file: string, target: string): string {
return file === rootPage(target) ? "/" : `/${file}`;
}

/** Same-origin paths the worker must never serve from its own cache. */
const BYPASS_SOURCE = [
"/^\\/api\\//",
"/^\\/control-app/",
"/^\\/protected-assets\\//",
"/^\\/control(?:\\.html)?$/",
].join(", ");
/**
* Same-origin paths the worker must never serve from its own cache. The
* control app and its bundle are validated per request by the Cloudflare
* middleware on main, so caching either would let a revoked license keep
* working. They do not exist on dev; the entries cost nothing there.
*/
export const BYPASS = [
/^\/api\//,
/^\/control-app/,
/^\/protected-assets\//,
/^\/control(?:\.html)?$/,
];

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}];
/** Vote and request endpoints are rate limited per request and must stay live. */
const BYPASS = [${BYPASS.map(String).join(", ")}];

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))
// Per entry, so one missing file cannot reject the whole install and
// leave the app with no offline copy at all.
.then((cache) => Promise.allSettled(PRECACHE.map((url) => cache.add(url))))
.then(() => self.skipWaiting()),
);
});
Expand All @@ -71,16 +91,36 @@ self.addEventListener("activate", (event) => {
);
});

/**
* Precached entries are stored by cache.add(), which sends no Origin header,
* while module scripts and stylesheets do send one. Responses carrying
* "Vary: Origin" would miss on every lookup without this.
*/
const MATCH = { ignoreVary: true };

/**
* The licensed routes answer with "Cache-Control: private, no-store". Opaque
* font responses carry no readable headers, so the directive check passes them
* through and they stay cacheable despite reporting ok === false.
*/
function storable(response) {
// Retired pages 301 to the docs site, and the Cache API rejects a redirected
// response stored against a navigation request.
if (response.redirected) return false;
if ((response.headers.get("Cache-Control") ?? "").includes("no-store")) return false;
return response.ok || response.type === "opaque";
}

/** 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 cached = await cache.match(request, MATCH);
const network = fetch(request)
.then((response) => {
if (response.ok || response.type === "opaque") cache.put(request, response.clone());
if (storable(response)) cache.put(request, response.clone());
return response;
})
.catch(() => cached);
.catch(() => cached ?? Response.error());
return cached ?? network;
}

Expand All @@ -89,15 +129,32 @@ async function networkFirst(request) {
const cache = await caches.open(CACHE);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
if (storable(response)) cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await cache.match(request) ?? await cache.match("/");
const cached = await cache.match(request, MATCH) ?? await cache.match("/", MATCH);
if (cached) return cached;
throw error;
}
}

/**
* Fills the cache as same-origin assets are requested. The precache list only
* covers what the pages link, so lazily imported chunks land here instead.
* Device art is served from R2 and never reaches this handler.
*/
async function cacheFirst(request) {
const cached = await caches.match(request, MATCH);
if (cached) return cached;

const response = await fetch(request);
if (storable(response)) {
const cache = await caches.open(CACHE);
await cache.put(request, response.clone());
}
return response;
}

self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
Expand All @@ -117,44 +174,43 @@ self.addEventListener("fetch", (event) => {
return;
}

event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
);
event.respondWith(cacheFirst(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";

export function pwa(appVersion: string, buildTarget: string): Plugin {
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);
// "post" so Vite's HTML plugin has already injected the hashed asset tags
// into each page's bundle entry. Reading the emitted source here rather
// than the output directory keeps this independent of write ordering.
generateBundle: {
order: "post",
handler(_options, bundle) {
const urls = new Set<string>(STATIC_PRECACHE);

for (const file of precachePages(buildTarget)) {
const emitted = bundle[file];
if (emitted?.type !== "asset") {
this.error(`${file} is missing from the bundle; the precache list would be wrong.`);
}

urls.add(pageUrl(file, buildTarget));
for (const [, asset] of String(emitted.source).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)}`;
const precache = [...urls].sort();
// The target is in the cache name because both Pages projects deploy
// from this repo and serve a different page from "/".
const version = `${buildTarget}-${appVersion}-${createHash("sha256").update(precache.join("\n")).digest("hex").slice(0, 8)}`;

await writeFile(resolve(outputRoot, "sw.js"), renderServiceWorker(version, precache));
this.emitFile({ type: "asset", fileName: "sw.js", source: renderServiceWorker(version, precache) });
},
},
};
}
30 changes: 27 additions & 3 deletions check.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,39 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09090b" />
<link rel="icon" href="/favicon.ico?v=2" sizes="32x32" />
<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="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<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" />
<meta name="robots" content="index, follow" />
<title>Mouse Check — OpenMouse HID Diagnostics</title>
<meta name="description" content="Check if your gaming mouse works with WebHID in the browser, or if it requires a native driver." />
<link rel="canonical" href="https://openmouse.app/check.html" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenMouse" />
<meta property="og:url" content="https://openmouse.app/check.html" />
<meta property="og:title" content="Mouse Check — OpenMouse HID Diagnostics" />
<meta property="og:description" content="Check if your gaming mouse works with WebHID in the browser, or if it requires a native driver." />
<meta property="og:image" content="https://openmouse.app/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Mouse Check",
"url": "https://openmouse.app/check.html",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Any",
"browserRequirements": "Requires WebHID. Chromium-based browser over HTTPS.",
"isAccessibleForFree": true,
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"isPartOf": { "@type": "WebSite", "name": "OpenMouse", "url": "https://openmouse.app/" }
}
</script>
</head>
<body>
<div id="check-app"></div>
Expand Down
12 changes: 12 additions & 0 deletions donate.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1618" />
<meta name="robots" content="index, follow" />
<title>Support OpenMouse — Donate</title>
<meta name="description" content="Support the OpenMouse project — free, open source, community-built. Your donation helps us test mice, write drivers, and keep the project free of vendor bloat." />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="canonical" href="https://openmouse.app/donate.html" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenMouse" />
<meta property="og:url" content="https://openmouse.app/donate.html" />
<meta property="og:title" content="Support OpenMouse — Donate" />
<meta property="og:description" content="Support the OpenMouse project — free, open source, community-built. Your donation helps us test mice, write drivers, and keep the project free of vendor bloat." />
<meta property="og:image" content="https://openmouse.app/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<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;9..40,700;9..40,800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
Expand Down
12 changes: 12 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,20 @@
<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" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="robots" content="noindex, nofollow" />
<title>OpenMouse Control</title>
<meta name="description" content="Browser-based control panel for supported gaming mice. Change DPI, polling rate, and sensor settings without installing a driver for every brand." />
<link rel="canonical" href="https://openmouse.app/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenMouse" />
<meta property="og:url" content="https://control.openmouse.app/" />
<meta property="og:title" content="OpenMouse Control" />
<meta property="og:description" content="Browser-based control panel for supported gaming mice. Change DPI, polling rate, and sensor settings without installing a driver for every brand." />
<meta property="og:image" content="https://openmouse.app/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<div id="control-app"></div>
Expand Down
11 changes: 11 additions & 0 deletions landing.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09090b" />
<meta name="robots" content="index, follow" />
<title>OpenMouse — Free, open source mouse configurator</title>
<meta
name="description"
content="OpenMouse is a free, open source mouse configurator that runs entirely in your browser — no vendor software, no accounts, no telemetry. Configure DPI, polling rate, buttons, and RGB across dozens of gaming mice."
/>
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="canonical" href="https://openmouse.app/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenMouse" />
<meta property="og:url" content="https://openmouse.app/" />
<meta property="og:title" content="OpenMouse — Free, open source mouse configurator" />
<meta property="og:description" content="OpenMouse is a free, open source mouse configurator that runs entirely in your browser. Configure DPI, polling rate, buttons, and RGB across dozens of gaming mice." />
<meta property="og:image" content="https://openmouse.app/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<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;9..40,700;9..40,800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions public/favicon-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://openmouse.app/sitemap.xml
Loading