diff --git a/e2e/integration.spec.js b/e2e/integration.spec.js index d03b41f..8a03324 100644 --- a/e2e/integration.spec.js +++ b/e2e/integration.spec.js @@ -193,7 +193,7 @@ test('restore original firmware pipeline', async ({ page }) => { // Step 5: Verify build step shows restore text. await expect(page.locator('#step-firmware')).not.toBeHidden(); await expect(page.locator('#firmware-description')).toContainText('without modifications'); - await expect(page.locator('#btn-build')).toContainText('Restore Original Firmware'); + await expect(page.locator('#btn-build')).toContainText('Restore Original Software'); // Step 6: Build and wait for completion. await page.click('#btn-build'); @@ -208,7 +208,7 @@ test('restore original firmware pipeline', async ({ page }) => { throw new Error(`Restore failed: ${errorMsg}`); } - await expect(page.locator('#build-status')).toContainText('Firmware extracted'); + await expect(page.locator('#build-status')).toContainText('Software extracted'); // Step 7: Download KoboRoot.tgz and verify it matches the original. const [download] = await Promise.all([ diff --git a/web/public/app.js b/web/public/app.js index b7968ee..12e730f 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -78,7 +78,7 @@ const count = patchUI.getEnabledCount(); btnPatchesNext.disabled = false; if (count === 0) { - patchCountHint.textContent = 'No patches selected — continuing will restore the original unpatched firmware.'; + patchCountHint.textContent = 'No patches selected — continuing will restore the original unpatched software.'; } else { patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.'; } @@ -109,7 +109,7 @@ manualChromeHint.hidden = false; const available = await scanAvailablePatches(); - manualVersion.innerHTML = ''; + manualVersion.innerHTML = ''; for (const p of available) { const opt = document.createElement('option'); opt.value = p.version; @@ -169,7 +169,7 @@ const available = await scanAvailablePatches(); const loaded = await loadPatchesForVersion(version, available); if (!loaded) { - showError('Could not load patches for firmware ' + version); + showError('Could not load patches for software version ' + version); return; } configureFirmwareStep(version, selectedPrefix); @@ -185,7 +185,13 @@ const info = await device.connect(); document.getElementById('device-model').textContent = info.model; - document.getElementById('device-serial').textContent = info.serial; + const serialEl = document.getElementById('device-serial'); + serialEl.textContent = ''; + const prefixLen = info.serialPrefix.length; + const u = document.createElement('u'); + u.textContent = info.serial.slice(0, prefixLen); + serialEl.appendChild(u); + serialEl.appendChild(document.createTextNode(info.serial.slice(prefixLen))); document.getElementById('device-firmware').textContent = info.firmware; selectedPrefix = info.serialPrefix; @@ -196,7 +202,7 @@ if (match) { deviceStatus.className = ''; deviceStatus.textContent = - 'KoboPatch Web UI currently supports this version of the firmware. ' + + 'KoboPatch Web UI currently supports this version of the software. ' + 'You can choose to customize it or simply restore the original software.'; await patchUI.loadFromURL('patches/' + match.filename); @@ -211,7 +217,7 @@ } else { deviceStatus.className = 'status-unsupported'; deviceStatus.textContent = - 'No patches available for firmware ' + info.firmware + '. ' + + 'No patches available for software version ' + info.firmware + '. ' + 'Supported versions: ' + available.map(p => p.version).join(', '); btnDeviceNext.hidden = true; btnDeviceRestore.hidden = true; @@ -273,12 +279,27 @@ if (isRestore) { firmwareDescription.textContent = 'will be downloaded and extracted without modifications to restore the original unpatched software.'; - btnBuild.textContent = 'Restore Original Firmware'; + btnBuild.textContent = 'Restore Original Software'; } else { firmwareDescription.textContent = 'will be downloaded automatically from Kobo\u2019s servers and will be patched after the download completes.'; - btnBuild.textContent = 'Build Patched Firmware'; + btnBuild.textContent = 'Build Patched Software'; } + // Populate selected patches list. + const patchList = document.getElementById('selected-patches-list'); + patchList.innerHTML = ''; + const enabled = patchUI.getEnabledPatches(); + if (enabled.length > 0) { + for (const name of enabled) { + const li = document.createElement('li'); + li.textContent = name; + patchList.appendChild(li); + } + } + const hasPatches = enabled.length > 0; + patchList.hidden = !hasPatches; + document.getElementById('selected-patches-heading').hidden = !hasPatches; + setNavStep(3); showStep(stepFirmware); } @@ -293,12 +314,12 @@ async function downloadFirmware(url) { const resp = await fetch(url); if (!resp.ok) { - throw new Error('Firmware download failed: HTTP ' + resp.status); + throw new Error('Download failed: HTTP ' + resp.status); } const contentLength = resp.headers.get('Content-Length'); if (!contentLength || !resp.body) { - buildProgress.textContent = 'Downloading firmware...'; + buildProgress.textContent = 'Downloading software update...'; return new Uint8Array(await resp.arrayBuffer()); } @@ -315,7 +336,7 @@ const pct = ((received / total) * 100).toFixed(0); const mb = (received / 1024 / 1024).toFixed(1); const totalMB = (total / 1024 / 1024).toFixed(1); - buildProgress.textContent = `Downloading firmware... ${mb} / ${totalMB} MB (${pct}%)`; + buildProgress.textContent = `Downloading software update... ${mb} / ${totalMB} MB (${pct}%)`; } const result = new Uint8Array(received); @@ -337,24 +358,24 @@ buildLog.textContent = ''; buildProgress.textContent = 'Starting...'; document.getElementById('build-wait-hint').textContent = isRestore - ? 'Please wait while the original firmware is being downloaded and extracted...' + ? 'Please wait while the original software is being downloaded and extracted...' : 'Please wait while the patch is being applied...'; try { if (!firmwareURL) { - showError('No firmware download URL available for this device.'); + showError('No download URL available for this device.'); return; } const firmwareBytes = await downloadFirmware(firmwareURL); - appendLog('Firmware downloaded: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB'); + appendLog('Download complete: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB'); if (isRestore) { buildProgress.textContent = 'Extracting KoboRoot.tgz...'; - appendLog('Extracting original KoboRoot.tgz from firmware...'); + appendLog('Extracting original KoboRoot.tgz from software update...'); const zip = await JSZip.loadAsync(firmwareBytes); const koboRoot = zip.file('KoboRoot.tgz'); - if (!koboRoot) throw new Error('KoboRoot.tgz not found in firmware zip'); + if (!koboRoot) throw new Error('KoboRoot.tgz not found in software update'); resultTgz = new Uint8Array(await koboRoot.async('arraybuffer')); appendLog('Extracted KoboRoot.tgz: ' + (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'); } else { @@ -374,7 +395,7 @@ resultTgz = result.tgz; } const sizeTxt = (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'; - const action = isRestore ? 'Firmware extracted' : 'Patching complete'; + const action = isRestore ? 'Software extracted' : 'Patching complete'; const description = isRestore ? 'This will restore the original unpatched software.' : ''; diff --git a/web/public/index.html b/web/public/index.html index e05eaa4..7dc78b7 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -13,7 +13,7 @@
-

KoboPatch Web UI

+

KoboPatch Web UI beta

Custom patches for your Kobo e-reader

@@ -40,7 +40,7 @@

Don't want to use Chrome? - Select your firmware version manually instead. + Select your software version manually instead.

@@ -56,10 +56,10 @@ and write patched files directly to it, which is even easier to do!

- Select your firmware version first, and then the Kobo model. + Select your software version first, and then the Kobo model.

You can identify your the version number shown on your Kobo under More > Settings > Device information and by checking Software version. @@ -90,7 +90,7 @@ --

- Firmware + Software --
@@ -115,12 +115,14 @@ @@ -211,14 +213,14 @@
  1. Device selection — On Chromium-based browsers (Chrome, Edge), the app can auto-detect your Kobo via the File System Access API when connected over USB. - On other browsers, you manually select your model and firmware version.
  2. + On other browsers, you manually select your model and software version.
  3. Patch configuration — You choose which patches to enable or disable. Patches in the same group are mutually exclusive (radio buttons). The patches themselves are community-contributed via the MobileRead forums.
  4. -
  5. Build — The correct firmware is downloaded directly from Kobo's servers +
  6. Build — The correct software update is downloaded directly from Kobo's servers (ereaderfiles.kobo.com). The patcher, compiled from Go to WebAssembly, runs - inside a Web Worker so the UI stays responsive. It extracts the firmware's + inside a Web Worker so the UI stays responsive. It extracts KoboRoot.tgz, applies your selected patches as in-place byte replacements to the ELF binaries, validates the results (ELF headers, file sizes, archive consistency), and produces a new KoboRoot.tgz.
  7. @@ -229,9 +231,9 @@

    Restoring original software

    - You can also use this tool to restore the original unpatched firmware. In that case, + You can also use this tool to restore the original unpatched software. In that case, no patches are applied — the original KoboRoot.tgz is extracted - from the firmware zip as-is. + from the software update as-is.

    Safety

    diff --git a/web/public/patch-ui.js b/web/public/patch-ui.js index 3e17249..4d948c2 100644 --- a/web/public/patch-ui.js +++ b/web/public/patch-ui.js @@ -236,7 +236,58 @@ class PatchUI { } } - for (const patch of patches) { + // Sort: grouped (radio) patches first, then standalone (checkbox) patches. + const sorted = [...patches].sort((a, b) => { + const aGrouped = a.patchGroup && patchGroups[a.patchGroup].length > 1 ? 0 : 1; + const bGrouped = b.patchGroup && patchGroups[b.patchGroup].length > 1 ? 0 : 1; + return aGrouped - bGrouped; + }); + + const renderedGroupNone = {}; + // Group wrapper elements keyed by patchGroup name. + const groupWrappers = {}; + + for (const patch of sorted) { + const isGrouped = patch.patchGroup && patchGroups[patch.patchGroup].length > 1; + + // Create a group wrapper and "None" option before the first patch in each group. + if (isGrouped && !renderedGroupNone[patch.patchGroup]) { + renderedGroupNone[patch.patchGroup] = true; + + const wrapper = document.createElement('div'); + wrapper.className = 'patch-group'; + + const groupLabel = document.createElement('div'); + groupLabel.className = 'patch-group-label'; + groupLabel.textContent = patch.patchGroup; + wrapper.appendChild(groupLabel); + + const noneItem = document.createElement('div'); + noneItem.className = 'patch-item'; + const noneHeader = document.createElement('label'); + noneHeader.className = 'patch-header'; + const noneInput = document.createElement('input'); + noneInput.type = 'radio'; + noneInput.name = `pg_${filename}_${patch.patchGroup}`; + noneInput.checked = !patchGroups[patch.patchGroup].some(p => p.enabled); + noneInput.addEventListener('change', () => { + for (const other of patchGroups[patch.patchGroup]) { + other.enabled = false; + } + this._updateCounts(container); + }); + const noneName = document.createElement('span'); + noneName.className = 'patch-name patch-name-none'; + noneName.textContent = 'None (do not patch)'; + noneHeader.appendChild(noneInput); + noneHeader.appendChild(noneName); + noneItem.appendChild(noneHeader); + wrapper.appendChild(noneItem); + + groupWrappers[patch.patchGroup] = wrapper; + list.appendChild(wrapper); + } + const item = document.createElement('div'); item.className = 'patch-item'; @@ -244,7 +295,6 @@ class PatchUI { header.className = 'patch-header'; const input = document.createElement('input'); - const isGrouped = patch.patchGroup && patchGroups[patch.patchGroup].length > 1; if (isGrouped) { input.type = 'radio'; @@ -281,13 +331,6 @@ class PatchUI { header.appendChild(toggle); } - if (patch.patchGroup) { - const groupBadge = document.createElement('span'); - groupBadge.className = 'patch-group-badge'; - groupBadge.textContent = patch.patchGroup; - header.appendChild(groupBadge); - } - item.appendChild(header); if (patch.description) { @@ -306,7 +349,11 @@ class PatchUI { }); } - list.appendChild(item); + if (isGrouped) { + groupWrappers[patch.patchGroup].appendChild(item); + } else { + list.appendChild(item); + } } section.appendChild(list); @@ -338,6 +385,19 @@ class PatchUI { return count; } + /** + * Get names of all enabled patches across all files. + */ + getEnabledPatches() { + const names = []; + for (const [, { patches }] of Object.entries(this.patchFiles)) { + for (const p of patches) { + if (p.enabled) names.push(p.name); + } + } + return names; + } + /** * Build the overrides map for the WASM patcher. */ diff --git a/web/public/style.css b/web/public/style.css index fa8b217..25060cf 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -61,6 +61,20 @@ h1 { font-weight: 600; } +.beta-pill { + font-size: 0.55rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--error-text); + color: #fff; + padding: 0.15rem 0.5rem; + border-radius: 10px; + vertical-align: middle; + position: relative; + top: -0.15rem; +} + .subtitle { color: var(--text-secondary); font-size: 0.95rem; @@ -135,7 +149,7 @@ h2 { .step-nav li + li::after { content: ''; position: absolute; - top: 1.05rem; + top: 1.3rem; right: 50%; width: 100%; height: 2px; @@ -324,12 +338,37 @@ button.btn-success:hover { padding: 0.6rem 0.75rem; cursor: pointer; display: flex; - justify-content: space-between; align-items: center; font-weight: 500; font-size: 0.93rem; user-select: none; transition: background 0.1s; + list-style: none; +} + +.patch-file-section summary::-webkit-details-marker { + display: none; +} + +.patch-file-section summary::before { + content: "\203A"; + display: inline-block; + width: 1rem; + margin-right: 0.35rem; + flex-shrink: 0; + text-align: center; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-secondary); + transition: transform 0.15s ease; +} + +.patch-file-section[open] summary::before { + transform: rotate(90deg) translateX(0.1rem); +} + +.patch-file-section summary .patch-count { + margin-left: auto; } .patch-file-section summary:hover { @@ -378,6 +417,10 @@ button.btn-success:hover { font-weight: 500; } +.patch-name-none { + color: var(--text-secondary); +} + .patch-desc-toggle { flex-shrink: 0; background: none; @@ -395,15 +438,25 @@ button.btn-success:hover { opacity: 1; } -.patch-group-badge { - font-size: 0.7rem; - font-weight: 500; - background: var(--primary-light); +/* Visual grouping for mutually exclusive patches */ +.patch-group { + background: #f8fafc; + border-left: 3px solid var(--primary); + margin: 0.35rem 0.5rem; + border-radius: 0 6px 6px 0; +} + +.patch-group .patch-item { + padding: 0.4rem 0.75rem; +} + +.patch-group-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; color: var(--primary); - padding: 0.1rem 0.5rem; - border-radius: 4px; - margin-left: auto; - flex-shrink: 0; + padding: 0.45rem 0.75rem 0; } .step .patch-description { @@ -587,6 +640,18 @@ select + .fallback-hint { margin-bottom: 1rem; } +/* Selected patches summary on build step */ +.selected-patches-list { + margin: 0 0 0.75rem 1.25rem; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.selected-patches-list li { + padding: 0.05rem 0; +} + #firmware-download-url { display: inline-block; margin: 0.4rem 0;