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 = '-- Select firmware version -- ';
+ manualVersion.innerHTML = '-- Select software version -- ';
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 @@
@@ -40,7 +40,7 @@
Select Kobo Drive
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.
- -- Select firmware version --
+ -- Select software version --
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 @@
- Firmware for
+ Software update for
will be downloaded automatically from Kobo's servers and will be patched after the download completes.
+ The following patches will be applied:
+
- Firmware download URL
+ Software download URL
You can verify if this URL matches your Kobo's model on
@@ -129,7 +131,7 @@
‹ Back
- Build Patched Firmware
+ Build Patched Software
@@ -211,14 +213,14 @@
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.
+ On other browsers, you manually select your model and software version.
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 .
- Build — The correct firmware is downloaded directly from Kobo's servers
+ 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.
@@ -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;