Improve UX for grouped patches
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m40s
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m40s
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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.'
|
||||
: '';
|
||||
|
||||
@@ -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 > Settings > 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">‹ 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> — 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> — 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> — The correct firmware is downloaded directly from Kobo's servers
|
||||
<li><strong>Build</strong> — 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 — the original <code>KoboRoot.tgz</code> is extracted
|
||||
from the firmware zip as-is.
|
||||
from the software update as-is.
|
||||
</p>
|
||||
|
||||
<h3>Safety</h3>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user