1
0

Add optional analytics
All checks were successful
Build and test project / build-and-test (push) Successful in 1m30s

This commit is contained in:
2026-03-21 16:51:52 +01:00
parent aaf3bf8749
commit 82f32460cb
6 changed files with 147 additions and 8 deletions

View File

@@ -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).

View File

@@ -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 </head> in index.html)
let analyticsSnippet = '';
if (analyticsEnabled) {
analyticsSnippet =
` <script>window.__ANALYTICS_ENABLED=true</script>\n` +
` <script defer src="${UMAMI_SCRIPT_URL}" data-website-id="${UMAMI_WEBSITE_ID}"></script>\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('</head>', analyticsSnippet + '</head>');
}
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)' : ''));
});

View File

@@ -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;
}

View File

@@ -385,6 +385,8 @@
<footer class="site-footer">
<p>
<a href="#" id="btn-how-it-works">How does this work?</a>
<span id="privacy-link-separator" hidden>&nbsp;&middot;&nbsp;</span>
<a href="#" id="btn-privacy" hidden>Privacy</a>
&nbsp;&middot;&nbsp;
<a id="commit-link" href="https://github.com/nicoverbruggen/kobopatch-webui" target="_blank">Version <span id="commit-hash"></span></a>
<br/>
@@ -461,5 +463,41 @@
</div>
</div>
</dialog>
<dialog id="privacy-dialog" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Privacy</h2>
<button id="btn-close-privacy" class="modal-close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>
This website uses <a href="https://umami.is" target="_blank">Umami</a>, a privacy-focused
analytics tool, to help improve the experience. Analytics are only enabled on the hosted
version of this site, not on local or self-hosted installs.
</p>
<h3>What is tracked</h3>
<p>
A small number of anonymous events are collected to understand how the tool is used:
</p>
<ul>
<li><strong>How the flow was started</strong> &mdash; whether you connected a Kobo
directly or chose the manual download option.</li>
<li><strong>Which NickelMenu option was selected</strong> &mdash; preset installation,
NickelMenu only, or removal.</li>
<li><strong>How the flow ended</strong> &mdash; whether files were written to the
device or downloaded as a ZIP.</li>
</ul>
<h3>What is not tracked</h3>
<p>
No personal identifiers are collected. No cookies are used. Your device model, serial
number, firmware version, IP address, and browsing behaviour are never recorded by the
analytics tool. Umami does not use cookies and is compliant with GDPR, CCPA, and PECR.
</p>
</div>
</div>
</dialog>
</body>
</html>

18
web/src/js/analytics.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* Privacy-focused analytics wrapper.
* Only tracks events when Umami is loaded (via server-side injection).
* No personal identifiers are ever sent.
*/
export function isEnabled() {
return !!window.__ANALYTICS_ENABLED;
}
export function track(eventName, data) {
if (!isEnabled() || typeof window.umami === 'undefined') return;
try {
window.umami.track(eventName, data);
} catch {
// Silently ignore tracking errors
}
}

View File

@@ -4,6 +4,7 @@ import { PatchUI, scanAvailablePatches } from './patch-ui.js';
import { KoboPatchRunner } from './patch-runner.js';
import { NickelMenuInstaller } from './nickelmenu.js';
import { TL } from './strings.js';
import { isEnabled as analyticsEnabled, track } from './analytics.js';
import JSZip from 'jszip';
(() => {
@@ -252,6 +253,7 @@ import JSZip from 'jszip';
// "Download files manually" — enter manual mode, go to mode selection
btnManual.addEventListener('click', () => {
manualMode = true;
track('flow-start', { method: 'manual' });
goToModeSelection();
});
@@ -313,6 +315,7 @@ import JSZip from 'jszip';
}
btnConnect.addEventListener('click', async () => {
track('flow-start', { method: 'connect' });
try {
const info = await device.connect();
@@ -535,11 +538,7 @@ import JSZip from 'jszip';
const selected = $q('input[name="nm-option"]:checked', stepNickelMenu);
if (!selected) return;
nickelMenuOption = selected.value;
if (nickelMenuOption === 'remove') {
goToNmReview();
return;
}
track('nm-option', { option: nickelMenuOption });
goToNmReview();
});
@@ -644,9 +643,11 @@ import JSZip from 'jszip';
if (mode === 'remove') {
nmDoneStatus.textContent = TL.STATUS.NM_REMOVED_ON_REBOOT;
$('nm-reboot-instructions').hidden = false;
track('flow-end', { result: 'nm-remove' });
} else if (mode === 'written') {
nmDoneStatus.textContent = TL.STATUS.NM_INSTALLED;
$('nm-write-instructions').hidden = false;
track('flow-end', { result: 'nm-write' });
} else {
nmDoneStatus.textContent = TL.STATUS.NM_DOWNLOAD_READY;
triggerDownload(resultNmZip, 'NickelMenu-install.zip', 'application/zip');
@@ -655,6 +656,7 @@ import JSZip from 'jszip';
const showConfStep = nickelMenuOption === 'sample';
$('nm-download-conf-step').hidden = !showConfStep;
$('nm-download-reboot-step').hidden = !showConfStep;
track('flow-end', { result: 'nm-download' });
}
setNavStep(5);
@@ -885,6 +887,7 @@ import JSZip from 'jszip';
btnWrite.textContent = TL.BUTTON.WRITTEN;
btnWrite.className = 'btn-success';
writeInstructions.hidden = false;
track('flow-end', { result: isRestore ? 'restore-write' : 'patches-write' });
} catch (err) {
btnWrite.disabled = false;
btnWrite.textContent = TL.BUTTON.WRITE_TO_KOBO;
@@ -898,6 +901,7 @@ import JSZip from 'jszip';
writeInstructions.hidden = true;
downloadInstructions.hidden = false;
$('download-device-name').textContent = KoboModels[selectedPrefix] || 'Kobo';
track('flow-end', { result: isRestore ? 'restore-download' : 'patches-download' });
});
// --- Error / Retry ---
@@ -971,4 +975,21 @@ import JSZip from 'jszip';
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
// --- Privacy dialog (only visible when analytics is enabled) ---
if (analyticsEnabled()) {
$('btn-privacy').hidden = false;
$('privacy-link-separator').hidden = false;
}
const privacyDialog = $('privacy-dialog');
$('btn-privacy').addEventListener('click', (e) => {
e.preventDefault();
privacyDialog.showModal();
});
$('btn-close-privacy').addEventListener('click', () => {
privacyDialog.close();
});
privacyDialog.addEventListener('click', (e) => {
if (e.target === privacyDialog) privacyDialog.close();
});
})();