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.
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([

View File

@@ -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 = '<option value="">-- Select firmware version --</option>';
manualVersion.innerHTML = '<option value="">-- Select software version --</option>';
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.'
: '';

View File

@@ -13,7 +13,7 @@
<body>
<main>
<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>
</header>
@@ -40,7 +40,7 @@
<button id="btn-connect" class="primary">Select Kobo Drive</button>
<p class="fallback-hint">
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>
</section>
@@ -56,10 +56,10 @@
and write patched files directly to it, which is even easier to do!
</p>
<p>
Select your firmware version first, and then the Kobo model.
Select your software version first, and then the Kobo model.
</p>
<select id="manual-version">
<option value="">-- Select firmware version --</option>
<option value="">-- Select software version --</option>
</select>
<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>.
@@ -90,7 +90,7 @@
<span id="device-serial" class="value">--</span>
</div>
<div class="info-row">
<span class="label">Firmware</span>
<span class="label">Software</span>
<span id="device-firmware" class="value">--</span>
</div>
</div>
@@ -115,12 +115,14 @@
<!-- Step 3: Review & Build -->
<section id="step-firmware" class="step" hidden>
<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>
<span id="firmware-description">will be downloaded automatically from Kobo's servers and will be patched after the download completes.</span>
</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">
<summary>Firmware download URL</summary>
<summary>Software download URL</summary>
<code id="firmware-download-url"></code>
<p id="firmware-verify-notice" class="fallback-hint">
You can verify if this URL matches your Kobo's model on
@@ -129,7 +131,7 @@
</details>
<div class="step-actions">
<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>
</section>
@@ -211,14 +213,14 @@
<ol>
<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.
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.
Patches in the same group are mutually exclusive (radio buttons).
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>
<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
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
to the ELF binaries, validates the results (ELF headers, file sizes, archive consistency),
and produces a new <code>KoboRoot.tgz</code>.</li>
@@ -229,9 +231,9 @@
<h3>Restoring original software</h3>
<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
from the firmware zip as-is.
from the software update as-is.
</p>
<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');
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,8 +349,12 @@ class PatchUI {
});
}
if (isGrouped) {
groupWrappers[patch.patchGroup].appendChild(item);
} else {
list.appendChild(item);
}
}
section.appendChild(list);
container.appendChild(section);
@@ -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.
*/

View File

@@ -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;