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

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