Add optional analytics
All checks were successful
Build and test project / build-and-test (push) Successful in 1m30s
All checks were successful
Build and test project / build-and-test (push) Successful in 1m30s
This commit is contained in:
25
README.md
25
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).
|
||||
|
||||
@@ -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)' : ''));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> · </span>
|
||||
<a href="#" id="btn-privacy" hidden>Privacy</a>
|
||||
·
|
||||
<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">×</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> — whether you connected a Kobo
|
||||
directly or chose the manual download option.</li>
|
||||
<li><strong>Which NickelMenu option was selected</strong> — preset installation,
|
||||
NickelMenu only, or removal.</li>
|
||||
<li><strong>How the flow ended</strong> — 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
18
web/src/js/analytics.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user