From e792ef3c7411da39da7e496473e5be1b20c0293d Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Sun, 15 Mar 2026 22:57:43 +0100 Subject: [PATCH] Tweaks for first prototype --- README.md | 58 ++++++++++++ README.txt | 17 ---- src/public/app.js | 203 ++++++++++++++++++++++++----------------- src/public/index.html | 48 ++++++---- src/public/patch-ui.js | 18 ++++ src/public/style.css | 129 ++++++++++++++++++++++++-- wip/architecture.md | 5 +- wip/todo.md | 17 ++-- 8 files changed, 359 insertions(+), 136 deletions(-) create mode 100644 README.md delete mode 100644 README.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c75267 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# KoboPatch Web UI + +A fully client-side web application for applying [kobopatch](https://github.com/pgaskin/kobopatch) patches to Kobo e-readers. No backend required — runs entirely in the browser using WebAssembly. + +## Features + +- **Auto mode** (Chromium): detect your Kobo model and firmware via the File System Access API, then write the patched file directly back to the device +- **Manual mode** (all browsers): select your model and firmware version from dropdowns, download the result +- Firmware is downloaded automatically from Kobo's servers +- Step-by-step wizard with live build progress +- Patch descriptions and PatchGroup mutual exclusion + +## How it works + +1. Connect your Kobo via USB (or select your model/firmware manually) +2. Enable/disable patches in the configurator +3. Click **Build** — firmware is fetched from Kobo's CDN, patches are applied via WASM in a Web Worker +4. Write `KoboRoot.tgz` to your device or download it manually +5. Safely eject and reboot your Kobo + +## Building + +### Prerequisites + +- Go 1.21+ (for compiling kobopatch to WASM) + +### Setup & build + +```bash +cd kobopatch-wasm +./setup.sh # clones kobopatch source, copies wasm_exec.js +./build.sh # compiles WASM, copies artifacts to src/public/ +``` + +### Running locally + +Any static file server works: + +```bash +python3 -m http.server -d src/public/ 8888 +``` + +Then open `http://localhost:8888`. + +## Supported devices + +Currently supports firmware **4.45.23646** for: + +- Kobo Libra Colour +- Kobo Clara BW (N365) +- Kobo Clara BW (P365) +- Kobo Clara Colour + +Additional firmware versions can be added by placing patch zips in `src/public/patches/` and updating `index.json` and the firmware URL map in `kobo-device.js`. + +## License + +kobopatch is by [pgaskin](https://github.com/pgaskin/kobopatch). Patches are community-contributed via [MobileRead](https://www.mobileread.com/). diff --git a/README.txt b/README.txt deleted file mode 100644 index 1a3b5d5..0000000 --- a/README.txt +++ /dev/null @@ -1,17 +0,0 @@ -I would like to build a web application that uses the USB file system API with Chrome to interface with a Kobo Libra Color / Kobo Clara Color / Kobo Clara BW and provide a gui for custom kobo patches. - -The website should make it easy to configure which patches need to be executed, run the patcher, connect to the USB device, and place the KoboRoot.tgz in the .kobo directory, after which the user will be instructed to reboot. - -To verify nothing bad can happen, we should make sure that we can identify what device and operating system version a particular device is before starting. I've copied the root of my Kobo Libra Color's accessible filesystem over to the kobo_usb_root folder for further research. - -So, in short, what's needed: - -- Determining the operating system and device (via the browser / usb filesystem API) -- Downloading the firmware for that version from https://pgaskin.net/KoboStuff/kobofirmware.html (currently we can hardcode only the latest release? I've included the firmware in the ./firmware directory) -- Applying patches -- Copying the patch to the target device via the browser -- ... that's it? - -Patches are made available via the MobileRead forums and it would be necessary to manually update this patcher when new kobo os versions come out. - -As a bonus, it would be nice if we could also install NickelMenu (see https://pgaskin.net/NickelMenu/) via this method as well. (Or uninstall it, by placing the correct file in the correct location.) diff --git a/src/public/app.js b/src/public/app.js index f0ad6f7..868dc5b 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -4,12 +4,13 @@ const runner = new KobopatchRunner(); let firmwareURL = null; - // let firmwareFile = null; // fallback: manual file input let resultTgz = null; let manualMode = false; let selectedPrefix = null; + let patchesLoaded = false; // DOM elements + const stepNav = document.getElementById('step-nav'); const stepConnect = document.getElementById('step-connect'); const stepManual = document.getElementById('step-manual'); const stepDevice = document.getElementById('step-device'); @@ -25,14 +26,16 @@ const manualVersion = document.getElementById('manual-version'); const manualModel = document.getElementById('manual-model'); const manualChromeHint = document.getElementById('manual-chrome-hint'); + const btnDeviceNext = document.getElementById('btn-device-next'); + const btnPatchesBack = document.getElementById('btn-patches-back'); + const btnPatchesNext = document.getElementById('btn-patches-next'); + const btnBuildBack = document.getElementById('btn-build-back'); const btnBuild = document.getElementById('btn-build'); const btnWrite = document.getElementById('btn-write'); const btnDownload = document.getElementById('btn-download'); const btnRetry = document.getElementById('btn-retry'); - // const firmwareInput = document.getElementById('firmware-input'); // fallback const firmwareAutoInfo = document.getElementById('firmware-auto-info'); - // const firmwareManualInfo = document.getElementById('firmware-manual-info'); // fallback const errorMessage = document.getElementById('error-message'); const errorLog = document.getElementById('error-log'); const deviceStatus = document.getElementById('device-status'); @@ -40,12 +43,36 @@ const buildStatus = document.getElementById('build-status'); const writeSuccess = document.getElementById('write-success'); const firmwareVersionLabel = document.getElementById('firmware-version-label'); - // const firmwareVersionLabelManual = document.getElementById('firmware-version-label-manual'); // fallback const patchCountHint = document.getElementById('patch-count-hint'); + const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError]; + + // --- Step navigation --- + function showStep(step) { + for (const s of allSteps) { + s.hidden = (s !== step); + } + } + + function setNavStep(num) { + const items = stepNav.querySelectorAll('li'); + items.forEach((li, i) => { + const stepNum = i + 1; + li.classList.remove('active', 'done'); + if (stepNum < num) li.classList.add('done'); + else if (stepNum === num) li.classList.add('active'); + }); + stepNav.hidden = false; + } + + function hideNav() { + stepNav.hidden = true; + } + + // --- Patch count --- function updatePatchCount() { const count = patchUI.getEnabledCount(); - btnBuild.disabled = count === 0; + btnPatchesNext.disabled = count === 0; patchCountHint.textContent = count === 0 ? 'Select at least one patch to continue.' : count === 1 @@ -55,53 +82,29 @@ patchUI.onChange = updatePatchCount; - const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError]; - - // Decide initial step based on browser support - const hasFileSystemAccess = KoboDevice.isSupported(); - if (hasFileSystemAccess) { - showSteps(stepConnect); - } else { - // Skip straight to manual mode - enterManualMode(); - } - - function showSteps(...steps) { - for (const s of allSteps) { - s.hidden = !steps.includes(s); - } - } - - function showError(message, log) { - errorMessage.textContent = message; - if (log) { - errorLog.textContent = log; - errorLog.hidden = false; - } else { - errorLog.hidden = true; - } - showSteps(stepError); - } - - /** - * Configure the firmware step for auto-download. - */ + // --- Firmware step config --- function configureFirmwareStep(version, prefix) { firmwareURL = prefix ? getFirmwareURL(prefix, version) : null; firmwareVersionLabel.textContent = version; document.getElementById('firmware-download-url').textContent = firmwareURL || ''; } + // --- Initial state --- + const hasFileSystemAccess = KoboDevice.isSupported(); + if (hasFileSystemAccess) { + setNavStep(1); + showStep(stepConnect); + } else { + enterManualMode(); + } + + // --- Step 1: Device selection --- async function enterManualMode() { manualMode = true; - - // Show the Chrome hint only if the browser actually supports it - // (i.e., user chose manual mode voluntarily) if (hasFileSystemAccess) { manualChromeHint.hidden = false; } - // Populate version dropdown from available patches const available = await scanAvailablePatches(); manualVersion.innerHTML = ''; for (const p of available) { @@ -112,30 +115,18 @@ manualVersion.appendChild(opt); } - // Reset model dropdown manualModel.innerHTML = ''; manualModel.hidden = true; - showSteps(stepManual); + setNavStep(1); + showStep(stepManual); } - async function loadPatchesForVersion(version, available) { - const match = available.find(p => p.version === version); - if (!match) return false; - - await patchUI.loadFromURL('patches/' + match.filename); - patchUI.render(patchContainer); - updatePatchCount(); - return true; - } - - // Switch to manual mode from auto mode btnManualFromAuto.addEventListener('click', (e) => { e.preventDefault(); enterManualMode(); }); - // Manual mode: version selected → populate model dropdown manualVersion.addEventListener('change', () => { const version = manualVersion.value; selectedPrefix = null; @@ -146,7 +137,6 @@ return; } - // Populate device dropdown for this firmware version const devices = getDevicesForVersion(version); manualModel.innerHTML = ''; for (const d of devices) { @@ -159,13 +149,12 @@ btnManualConfirm.disabled = true; }); - // Manual mode: model selected manualModel.addEventListener('change', () => { selectedPrefix = manualModel.value || null; btnManualConfirm.disabled = !manualVersion.value || !manualModel.value; }); - // Manual mode: confirm selection + // Manual confirm → load patches → go to step 2 btnManualConfirm.addEventListener('click', async () => { const version = manualVersion.value; if (!version || !selectedPrefix) return; @@ -178,13 +167,13 @@ return; } configureFirmwareStep(version, selectedPrefix); - showSteps(stepPatches, stepFirmware); + goToPatches(); } catch (err) { showError(err.message); } }); - // Auto mode: connect device + // Auto connect → show device info btnConnect.addEventListener('click', async () => { try { const info = await device.connect(); @@ -205,15 +194,17 @@ await patchUI.loadFromURL('patches/' + match.filename); patchUI.render(patchContainer); updatePatchCount(); + patchesLoaded = true; configureFirmwareStep(info.firmware, info.serialPrefix); - showSteps(stepDevice, stepPatches, stepFirmware); + showStep(stepDevice); } else { deviceStatus.className = 'status-unsupported'; deviceStatus.textContent = 'No patches available for firmware ' + info.firmware + '. ' + 'Supported versions: ' + available.map(p => p.version).join(', '); - showSteps(stepDevice); + btnDeviceNext.hidden = true; + showStep(stepDevice); } } catch (err) { if (err.name === 'AbortError') return; @@ -221,18 +212,55 @@ } }); - // // Firmware file selected (fallback for devices without auto-download URL) - // firmwareInput.addEventListener('change', () => { - // firmwareFile = firmwareInput.files[0] || null; - // }); + // Device info → patches + btnDeviceNext.addEventListener('click', () => { + if (patchesLoaded) goToPatches(); + }); + + async function loadPatchesForVersion(version, available) { + const match = available.find(p => p.version === version); + if (!match) return false; + + await patchUI.loadFromURL('patches/' + match.filename); + patchUI.render(patchContainer); + updatePatchCount(); + patchesLoaded = true; + return true; + } + + // --- Step 2: Patches --- + function goToPatches() { + setNavStep(2); + showStep(stepPatches); + } + + btnPatchesBack.addEventListener('click', () => { + setNavStep(1); + if (manualMode) { + showStep(stepManual); + } else { + showStep(stepDevice); + } + }); + + btnPatchesNext.addEventListener('click', () => { + if (patchUI.getEnabledCount() === 0) return; + goToBuild(); + }); + + // --- Step 3: Review & Build --- + function goToBuild() { + setNavStep(3); + showStep(stepFirmware); + } + + btnBuildBack.addEventListener('click', () => { + goToPatches(); + }); const buildProgress = document.getElementById('build-progress'); const buildLog = document.getElementById('build-log'); - /** - * Download firmware zip from Kobo's servers with progress tracking. - * Returns Uint8Array of the zip file. - */ async function downloadFirmware(url) { const resp = await fetch(url); if (!resp.ok) { @@ -241,7 +269,6 @@ const contentLength = resp.headers.get('Content-Length'); if (!contentLength || !resp.body) { - // Fallback: no streaming progress buildProgress.textContent = 'Downloading firmware...'; return new Uint8Array(await resp.arrayBuffer()); } @@ -262,7 +289,6 @@ buildProgress.textContent = `Downloading firmware... ${mb} / ${totalMB} MB (${pct}%)`; } - // Concatenate chunks into single Uint8Array const result = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { @@ -277,10 +303,9 @@ buildLog.scrollTop = buildLog.scrollHeight; } - // Build btnBuild.addEventListener('click', async () => { - const stepsToShow = manualMode ? [stepBuilding] : [stepDevice, stepBuilding]; - showSteps(...stepsToShow); + hideNav(); + showStep(stepBuilding); buildLog.textContent = ''; buildProgress.textContent = 'Starting...'; @@ -299,7 +324,6 @@ const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => { appendLog(msg); - // Update headline with high-level steps const trimmed = msg.trimStart(); if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') || trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) { @@ -313,22 +337,19 @@ (resultTgz.length / 1024).toFixed(0) + ' KB.'; writeSuccess.hidden = true; - // Copy log to done step const doneLog = document.getElementById('done-log'); doneLog.textContent = buildLog.textContent; doneLog.scrollTop = doneLog.scrollHeight; - // In manual mode, hide the "Write to Kobo" button btnWrite.hidden = manualMode; - - const doneSteps = manualMode ? [stepDone] : [stepDevice, stepDone]; - showSteps(...doneSteps); + hideNav(); + showStep(stepDone); } catch (err) { showError('Build failed: ' + err.message, buildLog.textContent); } }); - // Write to device (auto mode only) + // --- Done step --- btnWrite.addEventListener('click', async () => { if (!resultTgz || !device.directoryHandle) return; @@ -344,7 +365,6 @@ } }); - // Download btnDownload.addEventListener('click', () => { if (!resultTgz) return; const blob = new Blob([resultTgz], { type: 'application/gzip' }); @@ -356,17 +376,32 @@ URL.revokeObjectURL(url); }); - // Retry + // --- Error / Retry --- + function showError(message, log) { + errorMessage.textContent = message; + if (log) { + errorLog.textContent = log; + errorLog.hidden = false; + } else { + errorLog.hidden = true; + } + hideNav(); + showStep(stepError); + } + btnRetry.addEventListener('click', () => { device.disconnect(); firmwareURL = null; resultTgz = null; manualMode = false; selectedPrefix = null; + patchesLoaded = false; btnWrite.hidden = false; + btnDeviceNext.hidden = false; if (hasFileSystemAccess) { - showSteps(stepConnect); + setNavStep(1); + showStep(stepConnect); } else { enterManualMode(); } diff --git a/src/public/index.html b/src/public/index.html index 030493a..a33fbe0 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -14,9 +14,17 @@

Custom patches for your Kobo e-reader

- + + + + - + - + - + - + - + - + diff --git a/src/public/patch-ui.js b/src/public/patch-ui.js index 6a515fd..7aa2bdf 100644 --- a/src/public/patch-ui.js +++ b/src/public/patch-ui.js @@ -272,6 +272,15 @@ class PatchUI { header.appendChild(input); header.appendChild(nameSpan); + if (patch.description) { + const toggle = document.createElement('button'); + toggle.className = 'patch-desc-toggle'; + toggle.textContent = '?'; + toggle.title = 'Toggle description'; + toggle.type = 'button'; + header.appendChild(toggle); + } + if (patch.patchGroup) { const groupBadge = document.createElement('span'); groupBadge.className = 'patch-group-badge'; @@ -285,7 +294,16 @@ class PatchUI { const desc = document.createElement('p'); desc.className = 'patch-description'; desc.textContent = patch.description; + desc.hidden = true; item.appendChild(desc); + + const toggle = header.querySelector('.patch-desc-toggle'); + toggle.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + desc.hidden = !desc.hidden; + toggle.textContent = desc.hidden ? '?' : '\u2212'; + }); } list.appendChild(item); diff --git a/src/public/style.css b/src/public/style.css index 05d85dd..6b2365e 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -45,8 +45,8 @@ main { /* Hero header */ .hero { - margin-bottom: 2.5rem; - padding-bottom: 1.5rem; + margin-bottom: 1.5rem; + padding-bottom: 1rem; border-bottom: 1px solid var(--border-light); } @@ -74,9 +74,83 @@ h2 { color: var(--text); } +/* Step navigation */ +.step-nav { + margin-bottom: 1.5rem; +} + +.step-nav ol { + display: flex; + list-style: none; + gap: 0; + counter-reset: step; +} + +.step-nav li { + flex: 1; + text-align: center; + padding: 0.5rem 0; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + position: relative; + counter-increment: step; +} + +.step-nav li::before { + content: counter(step); + display: block; + width: 1.6rem; + height: 1.6rem; + line-height: 1.6rem; + margin: 0 auto 0.3rem; + border-radius: 50%; + background: var(--border-light); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; +} + +.step-nav li.active::before { + background: var(--primary); + color: #fff; +} + +.step-nav li.active { + color: var(--primary); + font-weight: 600; +} + +.step-nav li.done::before { + background: var(--success-text); + color: #fff; + content: "\2713"; +} + +.step-nav li.done { + color: var(--success-text); +} + +/* Connector lines between steps */ +.step-nav li + li::after { + content: ''; + position: absolute; + top: 1.05rem; + right: 50%; + width: 100%; + height: 2px; + background: var(--border-light); + z-index: -1; +} + +.step-nav li.done + li::after, +.step-nav li.done + li.active::after { + background: var(--success-text); +} + /* Steps */ .step { - margin-bottom: 2rem; + margin-bottom: 1rem; } .step p { @@ -86,6 +160,13 @@ h2 { line-height: 1.6; } +/* Step action buttons (back/next) */ +.step-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.25rem; +} + /* Buttons */ button { font-size: 0.9rem; @@ -207,6 +288,15 @@ button:disabled { font-size: 0.88rem; } +/* Scrollable patch container */ +.patch-container-scroll { + max-height: 50vh; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.5rem; +} + /* Patch file sections */ .patch-file-section { background: var(--card-bg); @@ -239,7 +329,6 @@ button:disabled { .patch-count { font-weight: 400; - color: var(--text-secondary); font-size: 0.8rem; background: var(--primary-light); color: var(--primary); @@ -252,7 +341,7 @@ button:disabled { } .patch-item { - padding: 0.5rem 1rem; + padding: 0.4rem 1rem; } .patch-item + .patch-item { @@ -264,7 +353,7 @@ button:disabled { align-items: center; gap: 0.5rem; cursor: pointer; - font-size: 0.88rem; + font-size: 0.85rem; } .patch-header input { @@ -276,6 +365,23 @@ button:disabled { font-weight: 500; } +.patch-desc-toggle { + flex-shrink: 0; + background: none; + border: none; + padding: 0 0.3rem; + font-size: 0.7rem; + color: var(--text-secondary); + cursor: pointer; + box-shadow: none; + opacity: 0.6; + transition: opacity 0.1s; +} + +.patch-desc-toggle:hover { + opacity: 1; +} + .patch-group-badge { font-size: 0.7rem; font-weight: 500; @@ -290,10 +396,15 @@ button:disabled { .patch-description { margin-top: 0.3rem; margin-left: 1.6rem; - font-size: 0.78rem; + font-size: 0.75rem; color: var(--text-secondary); white-space: pre-line; - line-height: 1.45; + line-height: 1.4; + padding: 0.25rem 0; +} + +.patch-description[hidden] { + display: none; } /* Firmware input */ @@ -374,7 +485,7 @@ input[type="file"] { } .fallback-hint { - margin-top: 1rem; + margin-top: 0.75rem; font-size: 0.83rem; color: var(--text-secondary); } diff --git a/wip/architecture.md b/wip/architecture.md index 279907d..a4f228f 100644 --- a/wip/architecture.md +++ b/wip/architecture.md @@ -47,7 +47,7 @@ Browser - Enforces PatchGroup mutual exclusion (radio buttons) - Generates kobopatch.yaml config with overrides from UI state -### `patch-worker.js` — Web Worker (in progress) +### `patch-worker.js` — Web Worker - Loads `wasm_exec.js` and `kobopatch.wasm` off the main thread - Receives patch config + firmware via `postMessage` - Sends progress updates back to main thread for live UI rendering @@ -65,7 +65,8 @@ Browser ### `kobopatch.js` — Runner Interface - Abstracts WASM loading and invocation -- Will be updated to communicate with Web Worker +- Spawns Web Worker per build, handles progress/done/error messages +- Transfers firmware buffer zero-copy via transferable ### Static Assets - Patch config zips in `src/public/patches/` with `index.json` index diff --git a/wip/todo.md b/wip/todo.md index ca23db0..4c10b4c 100644 --- a/wip/todo.md +++ b/wip/todo.md @@ -26,6 +26,15 @@ - [x] Progress reporting: download % with MB, WASM log output in terminal window - [x] WASM `patchFirmware` accepts optional progress callback (4th arg) - [x] Verified patched binaries are byte-identical between native and WASM builds +- [x] Web Worker for WASM patching (non-blocking UI, live progress) +- [x] Cache-busting timestamp on WASM file (`?ts=` query string) +- [x] Matched log output to native kobopatch (no debug spam from patchfile.Log) +- [x] Step navigation: 3-step indicator (Device → Patches → Build) with back/forward +- [x] Discrete steps with proper state management +- [x] Scrollable patch list (50vh max height with border) +- [x] Toggleable patch descriptions (hidden by default, `?` button) +- [x] UI polish: renamed to "KoboPatch Web UI", styled firmware URL, patch count hint +- [x] Disambiguated identical model names in dropdown (serial prefix suffix) ## To Test @@ -33,11 +42,6 @@ - [ ] Verify File System Access API write to `.kobo/KoboRoot.tgz` - [ ] Test manual mode flow across Firefox/Safari/Chrome -## In Progress - -- [ ] Web Worker for WASM patching (avoid blocking UI, enable live progress) - - `patch-worker.js` created, not yet wired up - ## Remaining Work - [ ] Better error messages for common failures @@ -58,5 +62,6 @@ - **Firmware auto-downloaded from Kobo's CDN.** `ereaderfiles.kobo.com` serves `Access-Control-Allow-Origin: *`, so direct `fetch()` works. User no longer needs to provide firmware manually. URLs hardcoded in `kobo-device.js`. -- **Web Worker for WASM (in progress).** +- **Web Worker for WASM.** Moves patching off the main thread so progress updates render live during the build. + `patch-worker.js` loads `wasm_exec.js` + `kobopatch.wasm`, communicates via `postMessage`.