diff --git a/README.md b/README.md
index 6259229..604ca20 100644
--- a/README.md
+++ b/README.md
@@ -229,6 +229,31 @@ The WASM patcher performs several checks on each patched binary before including
- **ELF header validation** — verifies the magic bytes (`\x7fELF`), 32-bit class, little-endian encoding, and ARM machine type (`0x28`) are intact after patching.
- **Archive consistency check** — after building the output tar.gz, re-reads the entire archive and verifies the sum of entry sizes matches what was written.
+## Analytics (optional)
+
+The app supports optional, privacy-focused analytics via [Umami](https://umami.is). Analytics are disabled by default and only activate when two environment variables are set on the server:
+
+```bash
+UMAMI_WEBSITE_ID=your-website-id
+UMAMI_SCRIPT_URL=https://your-umami-instance/script.js
+```
+
+When enabled, the server injects the Umami tracking script into `index.html` at runtime. A "Privacy" link appears in the footer with a modal explaining what is tracked.
+
+**What is tracked** (no personal identifiers):
+
+- **Flow start** — whether the user connected a Kobo directly (`connect`) or chose manual download (`manual`)
+- **NickelMenu option** — which option was selected (`sample`, `nickelmenu-only`, or `remove`)
+- **Flow end** — how the process completed (`nm-write`, `nm-download`, `nm-remove`, `patches-write`, `patches-download`, `restore-write`, `restore-download`)
+
+**What is not tracked**: device model, serial number, firmware version, IP address, browsing behaviour. Umami is cookie-free and GDPR/CCPA/PECR compliant.
+
+For local installs via `./serve-locally.sh`, analytics is disabled unless the environment variables are set:
+
+```bash
+UMAMI_WEBSITE_ID=... UMAMI_SCRIPT_URL=... ./serve-locally.sh
+```
+
## Credits
Built on [kobopatch](https://github.com/pgaskin/kobopatch) and [NickelMenu](https://pgaskin.net/NickelMenu/) by pgaskin. Uses [JSZip](https://stuk.github.io/jszip/) for client-side ZIP handling and [esbuild](https://esbuild.github.io/) for bundling. Software patches and discussion on the [MobileRead forums](https://www.mobileread.com/forums/forumdisplay.php?f=247).
diff --git a/web/serve.mjs b/web/serve.mjs
index 52969ef..d9351ca 100644
--- a/web/serve.mjs
+++ b/web/serve.mjs
@@ -1,10 +1,36 @@
import { createServer } from 'node:http';
-import { createReadStream, statSync, existsSync } from 'node:fs';
+import { createReadStream, readFileSync, statSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
const DIST = join(import.meta.dirname, 'dist');
const PORT = process.env.PORT || 8888;
+const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || '';
+const UMAMI_SCRIPT_URL = process.env.UMAMI_SCRIPT_URL || '';
+const analyticsEnabled = !!(UMAMI_WEBSITE_ID && UMAMI_SCRIPT_URL);
+
+// Pre-build the analytics snippet (injected before in index.html)
+let analyticsSnippet = '';
+if (analyticsEnabled) {
+ analyticsSnippet =
+ ` \n` +
+ ` \n`;
+}
+
+// Cache the processed index.html at startup
+let cachedIndexHtml = null;
+function getIndexHtml() {
+ if (cachedIndexHtml) return cachedIndexHtml;
+ const indexPath = join(DIST, 'index.html');
+ if (!existsSync(indexPath)) return null;
+ let html = readFileSync(indexPath, 'utf-8');
+ if (analyticsSnippet) {
+ html = html.replace('', analyticsSnippet + '');
+ }
+ cachedIndexHtml = html;
+ return cachedIndexHtml;
+}
+
const MIME = {
'.html': 'text/html',
'.css': 'text/css',
@@ -26,6 +52,16 @@ createServer((req, res) => {
if (filePath.endsWith('/')) filePath = join(filePath, 'index.html');
if (!extname(filePath) && existsSync(filePath + '/index.html')) filePath += '/index.html';
+ // Serve processed index.html with analytics injection
+ if (filePath.endsWith('index.html')) {
+ const html = getIndexHtml();
+ if (html) {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(html);
+ return;
+ }
+ }
+
try {
const stat = statSync(filePath);
if (!stat.isFile()) throw new Error();
@@ -36,5 +72,5 @@ createServer((req, res) => {
res.end('Not found');
}
}).listen(PORT, () => {
- console.log(`Serving web/dist on http://localhost:${PORT}`);
+ console.log(`Serving web/dist on http://localhost:${PORT}` + (analyticsEnabled ? ' (analytics enabled)' : ''));
});
diff --git a/web/src/css/style.css b/web/src/css/style.css
index 195003e..f3b6c9c 100644
--- a/web/src/css/style.css
+++ b/web/src/css/style.css
@@ -1095,7 +1095,8 @@ button:focus-visible {
margin-bottom: 0.75rem;
}
-.modal-body ol {
+.modal-body ol,
+.modal-body ul {
margin: 0 0 0.75rem 1.25rem;
}
diff --git a/web/src/index.html b/web/src/index.html
index d89759f..454c448 100644
--- a/web/src/index.html
+++ b/web/src/index.html
@@ -385,6 +385,8 @@