1
0

Improve UX for grouped patches
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m40s

This commit is contained in:
2026-03-16 16:12:35 +01:00
parent e7a6f8ccf4
commit 8b834d28cc
5 changed files with 200 additions and 52 deletions

View File

@@ -193,7 +193,7 @@ test('restore original firmware pipeline', async ({ page }) => {
// Step 5: Verify build step shows restore text. // Step 5: Verify build step shows restore text.
await expect(page.locator('#step-firmware')).not.toBeHidden(); await expect(page.locator('#step-firmware')).not.toBeHidden();
await expect(page.locator('#firmware-description')).toContainText('without modifications'); 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. // Step 6: Build and wait for completion.
await page.click('#btn-build'); await page.click('#btn-build');
@@ -208,7 +208,7 @@ test('restore original firmware pipeline', async ({ page }) => {
throw new Error(`Restore failed: ${errorMsg}`); 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. // Step 7: Download KoboRoot.tgz and verify it matches the original.
const [download] = await Promise.all([ const [download] = await Promise.all([

View File

@@ -78,7 +78,7 @@
const count = patchUI.getEnabledCount(); const count = patchUI.getEnabledCount();
btnPatchesNext.disabled = false; btnPatchesNext.disabled = false;
if (count === 0) { 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 { } else {
patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.'; patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.';
} }
@@ -109,7 +109,7 @@
manualChromeHint.hidden = false; manualChromeHint.hidden = false;
const available = await scanAvailablePatches(); const available = await scanAvailablePatches();
manualVersion.innerHTML = '<option value="">-- Select firmware version --</option>'; manualVersion.innerHTML = '<option value="">-- Select software version --</option>';
for (const p of available) { for (const p of available) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = p.version; opt.value = p.version;
@@ -169,7 +169,7 @@
const available = await scanAvailablePatches(); const available = await scanAvailablePatches();
const loaded = await loadPatchesForVersion(version, available); const loaded = await loadPatchesForVersion(version, available);
if (!loaded) { if (!loaded) {
showError('Could not load patches for firmware ' + version); showError('Could not load patches for software version ' + version);
return; return;
} }
configureFirmwareStep(version, selectedPrefix); configureFirmwareStep(version, selectedPrefix);
@@ -185,7 +185,13 @@
const info = await device.connect(); const info = await device.connect();
document.getElementById('device-model').textContent = info.model; 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; document.getElementById('device-firmware').textContent = info.firmware;
selectedPrefix = info.serialPrefix; selectedPrefix = info.serialPrefix;
@@ -196,7 +202,7 @@
if (match) { if (match) {
deviceStatus.className = ''; deviceStatus.className = '';
deviceStatus.textContent = 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.'; 'You can choose to customize it or simply restore the original software.';
await patchUI.loadFromURL('patches/' + match.filename); await patchUI.loadFromURL('patches/' + match.filename);
@@ -211,7 +217,7 @@
} else { } else {
deviceStatus.className = 'status-unsupported'; deviceStatus.className = 'status-unsupported';
deviceStatus.textContent = 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(', '); 'Supported versions: ' + available.map(p => p.version).join(', ');
btnDeviceNext.hidden = true; btnDeviceNext.hidden = true;
btnDeviceRestore.hidden = true; btnDeviceRestore.hidden = true;
@@ -273,12 +279,27 @@
if (isRestore) { if (isRestore) {
firmwareDescription.textContent = firmwareDescription.textContent =
'will be downloaded and extracted without modifications to restore the original unpatched software.'; 'will be downloaded and extracted without modifications to restore the original unpatched software.';
btnBuild.textContent = 'Restore Original Firmware'; btnBuild.textContent = 'Restore Original Software';
} else { } else {
firmwareDescription.textContent = firmwareDescription.textContent =
'will be downloaded automatically from Kobo\u2019s servers and will be patched after the download completes.'; '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); setNavStep(3);
showStep(stepFirmware); showStep(stepFirmware);
} }
@@ -293,12 +314,12 @@
async function downloadFirmware(url) { async function downloadFirmware(url) {
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) { 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'); const contentLength = resp.headers.get('Content-Length');
if (!contentLength || !resp.body) { if (!contentLength || !resp.body) {
buildProgress.textContent = 'Downloading firmware...'; buildProgress.textContent = 'Downloading software update...';
return new Uint8Array(await resp.arrayBuffer()); return new Uint8Array(await resp.arrayBuffer());
} }
@@ -315,7 +336,7 @@
const pct = ((received / total) * 100).toFixed(0); const pct = ((received / total) * 100).toFixed(0);
const mb = (received / 1024 / 1024).toFixed(1); const mb = (received / 1024 / 1024).toFixed(1);
const totalMB = (total / 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); const result = new Uint8Array(received);
@@ -337,24 +358,24 @@
buildLog.textContent = ''; buildLog.textContent = '';
buildProgress.textContent = 'Starting...'; buildProgress.textContent = 'Starting...';
document.getElementById('build-wait-hint').textContent = isRestore 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...'; : 'Please wait while the patch is being applied...';
try { try {
if (!firmwareURL) { if (!firmwareURL) {
showError('No firmware download URL available for this device.'); showError('No download URL available for this device.');
return; return;
} }
const firmwareBytes = await downloadFirmware(firmwareURL); 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) { if (isRestore) {
buildProgress.textContent = 'Extracting KoboRoot.tgz...'; 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 zip = await JSZip.loadAsync(firmwareBytes);
const koboRoot = zip.file('KoboRoot.tgz'); 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')); resultTgz = new Uint8Array(await koboRoot.async('arraybuffer'));
appendLog('Extracted KoboRoot.tgz: ' + (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'); appendLog('Extracted KoboRoot.tgz: ' + (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB');
} else { } else {
@@ -374,7 +395,7 @@
resultTgz = result.tgz; resultTgz = result.tgz;
} }
const sizeTxt = (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'; 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 const description = isRestore
? 'This will restore the original unpatched software.' ? 'This will restore the original unpatched software.'
: ''; : '';

View File

@@ -13,7 +13,7 @@
<body> <body>
<main> <main>
<header class="hero"> <header class="hero">
<h1>KoboPatch <span class="hero-accent">Web UI</span></h1> <h1>KoboPatch <span class="hero-accent">Web UI</span> <span class="beta-pill">beta</span></h1>
<p class="subtitle">Custom patches for your Kobo e-reader</p> <p class="subtitle">Custom patches for your Kobo e-reader</p>
</header> </header>
@@ -40,7 +40,7 @@
<button id="btn-connect" class="primary">Select Kobo Drive</button> <button id="btn-connect" class="primary">Select Kobo Drive</button>
<p class="fallback-hint"> <p class="fallback-hint">
Don't want to use Chrome? Don't want to use Chrome?
<a href="#" id="btn-manual-from-auto">Select your firmware version manually</a> instead. <a href="#" id="btn-manual-from-auto">Select your software version manually</a> instead.
</p> </p>
</section> </section>
@@ -56,10 +56,10 @@
and write patched files directly to it, which is even easier to do! and write patched files directly to it, which is even easier to do!
</p> </p>
<p> <p>
Select your firmware version first, and then the Kobo model. Select your software version first, and then the Kobo model.
</p> </p>
<select id="manual-version"> <select id="manual-version">
<option value="">-- Select firmware version --</option> <option value="">-- Select software version --</option>
</select> </select>
<p id="manual-version-hint" class="fallback-hint"> <p id="manual-version-hint" class="fallback-hint">
You can identify your the version number shown on your Kobo under <strong>More &gt; Settings &gt; Device information</strong> and by checking <strong>Software version</strong>. You can identify your the version number shown on your Kobo under <strong>More &gt; Settings &gt; Device information</strong> and by checking <strong>Software version</strong>.
@@ -90,7 +90,7 @@
<span id="device-serial" class="value">--</span> <span id="device-serial" class="value">--</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">Firmware</span> <span class="label">Software</span>
<span id="device-firmware" class="value">--</span> <span id="device-firmware" class="value">--</span>
</div> </div>
</div> </div>
@@ -115,12 +115,14 @@
<!-- Step 3: Review & Build --> <!-- Step 3: Review & Build -->
<section id="step-firmware" class="step" hidden> <section id="step-firmware" class="step" hidden>
<p id="firmware-auto-info"> <p id="firmware-auto-info">
Firmware <strong id="firmware-version-label"></strong> for Software update <strong id="firmware-version-label"></strong> for
<strong id="firmware-device-label"></strong> <strong id="firmware-device-label"></strong>
<span id="firmware-description">will be downloaded automatically from Kobo's servers and will be patched after the download completes.</span> <span id="firmware-description">will be downloaded automatically from Kobo's servers and will be patched after the download completes.</span>
</p> </p>
<p id="selected-patches-heading" hidden>The following patches will be applied:</p>
<ul id="selected-patches-list" class="selected-patches-list"></ul>
<details class="log-details"> <details class="log-details">
<summary>Firmware download URL</summary> <summary>Software download URL</summary>
<code id="firmware-download-url"></code> <code id="firmware-download-url"></code>
<p id="firmware-verify-notice" class="fallback-hint"> <p id="firmware-verify-notice" class="fallback-hint">
You can verify if this URL matches your Kobo's model on You can verify if this URL matches your Kobo's model on
@@ -129,7 +131,7 @@
</details> </details>
<div class="step-actions"> <div class="step-actions">
<button id="btn-build-back" class="secondary">&#x2039; Back</button> <button id="btn-build-back" class="secondary">&#x2039; Back</button>
<button id="btn-build" class="primary">Build Patched Firmware</button> <button id="btn-build" class="primary">Build Patched Software</button>
</div> </div>
</section> </section>
@@ -211,14 +213,14 @@
<ol> <ol>
<li><strong>Device selection</strong> &mdash; On Chromium-based browsers (Chrome, Edge), the app can <li><strong>Device selection</strong> &mdash; On Chromium-based browsers (Chrome, Edge), the app can
auto-detect your Kobo via the File System Access API when connected over USB. 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.</li> On other browsers, you manually select your model and software version.</li>
<li><strong>Patch configuration</strong> &mdash; You choose which patches to enable or disable. <li><strong>Patch configuration</strong> &mdash; You choose which patches to enable or disable.
Patches in the same group are mutually exclusive (radio buttons). Patches in the same group are mutually exclusive (radio buttons).
The patches themselves are community-contributed via the The patches themselves are community-contributed via the
<a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.</li> <a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.</li>
<li><strong>Build</strong> &mdash; The correct firmware is downloaded directly from Kobo's servers <li><strong>Build</strong> &mdash; The correct software update is downloaded directly from Kobo's servers
(<code>ereaderfiles.kobo.com</code>). The patcher, compiled from Go to WebAssembly, runs (<code>ereaderfiles.kobo.com</code>). 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
<code>KoboRoot.tgz</code>, applies your selected patches as in-place byte replacements <code>KoboRoot.tgz</code>, applies your selected patches as in-place byte replacements
to the ELF binaries, validates the results (ELF headers, file sizes, archive consistency), to the ELF binaries, validates the results (ELF headers, file sizes, archive consistency),
and produces a new <code>KoboRoot.tgz</code>.</li> and produces a new <code>KoboRoot.tgz</code>.</li>
@@ -229,9 +231,9 @@
<h3>Restoring original software</h3> <h3>Restoring original software</h3>
<p> <p>
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 &mdash; the original <code>KoboRoot.tgz</code> is extracted no patches are applied &mdash; the original <code>KoboRoot.tgz</code> is extracted
from the firmware zip as-is. from the software update as-is.
</p> </p>
<h3>Safety</h3> <h3>Safety</h3>

View File

@@ -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'); const item = document.createElement('div');
item.className = 'patch-item'; item.className = 'patch-item';
@@ -244,7 +295,6 @@ class PatchUI {
header.className = 'patch-header'; header.className = 'patch-header';
const input = document.createElement('input'); const input = document.createElement('input');
const isGrouped = patch.patchGroup && patchGroups[patch.patchGroup].length > 1;
if (isGrouped) { if (isGrouped) {
input.type = 'radio'; input.type = 'radio';
@@ -281,13 +331,6 @@ class PatchUI {
header.appendChild(toggle); 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); item.appendChild(header);
if (patch.description) { 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); section.appendChild(list);
@@ -338,6 +385,19 @@ class PatchUI {
return count; 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. * Build the overrides map for the WASM patcher.
*/ */

View File

@@ -61,6 +61,20 @@ h1 {
font-weight: 600; 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 { .subtitle {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.95rem; font-size: 0.95rem;
@@ -135,7 +149,7 @@ h2 {
.step-nav li + li::after { .step-nav li + li::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 1.05rem; top: 1.3rem;
right: 50%; right: 50%;
width: 100%; width: 100%;
height: 2px; height: 2px;
@@ -324,12 +338,37 @@ button.btn-success:hover {
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
font-weight: 500; font-weight: 500;
font-size: 0.93rem; font-size: 0.93rem;
user-select: none; user-select: none;
transition: background 0.1s; 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 { .patch-file-section summary:hover {
@@ -378,6 +417,10 @@ button.btn-success:hover {
font-weight: 500; font-weight: 500;
} }
.patch-name-none {
color: var(--text-secondary);
}
.patch-desc-toggle { .patch-desc-toggle {
flex-shrink: 0; flex-shrink: 0;
background: none; background: none;
@@ -395,15 +438,25 @@ button.btn-success:hover {
opacity: 1; opacity: 1;
} }
.patch-group-badge { /* Visual grouping for mutually exclusive patches */
font-size: 0.7rem; .patch-group {
font-weight: 500; background: #f8fafc;
background: var(--primary-light); 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); color: var(--primary);
padding: 0.1rem 0.5rem; padding: 0.45rem 0.75rem 0;
border-radius: 4px;
margin-left: auto;
flex-shrink: 0;
} }
.step .patch-description { .step .patch-description {
@@ -587,6 +640,18 @@ select + .fallback-hint {
margin-bottom: 1rem; 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 { #firmware-download-url {
display: inline-block; display: inline-block;
margin: 0.4rem 0; margin: 0.4rem 0;