1
0

Modular front-end approach

This commit is contained in:
2026-03-22 12:29:57 +01:00
parent 546d4060b2
commit d4d74d499e
6 changed files with 1443 additions and 1112 deletions

View File

@@ -49,7 +49,11 @@ web/
css/
style.css
js/
app.js # ES module entry point: step navigation, flow orchestration
app.js # Orchestrator: shared state, device connection, mode selection, error/retry, dialogs
dom.js # Shared DOM helpers ($, $q, $qa, formatMB, populateSelect, triggerDownload)
nav.js # Step navigation, progress bar, step history, card radio interactivity
nickelmenu-flow.js # NickelMenu flow: config, features, review, install, done
patches-flow.js # Custom patches flow: configure, build, install/download
kobo-device.js # KoboModels, KoboDevice class
kobo-software-urls.js # Fetches download URLs from JSON, getSoftwareUrl, getDevicesForVersion
nickelmenu/ # NickelMenu feature modules + installer orchestrator
@@ -160,7 +164,15 @@ This downloads the latest release directly into `web/dist/koreader/`, skipping t
## Building the frontend
The JS source lives in `web/src/js/` as ES modules. esbuild bundles them into a single `web/dist/bundle.js`.
The JS source lives in `web/src/js/` as ES modules, organized around the two main user flows:
- **`app.js`** — the orchestrator: creates shared state, handles device connection, mode selection, error recovery, and dialogs. Delegates to the two flow modules below.
- **`nickelmenu-flow.js`** — the entire NickelMenu path (config, features, review, install, done).
- **`patches-flow.js`** — the entire custom patches path (configure, build, install/download).
- **`nav.js`** — step navigation, progress bar, and step history (shared by both flows).
- **`dom.js`** — tiny DOM utility helpers (`$`, `$q`, `$qa`, etc.) used everywhere.
Flow modules receive a shared `state` object by reference and call back into the orchestrator via `state.showError()` and `state.goToModeSelection()` when they need to cross module boundaries. esbuild bundles everything into a single `web/dist/bundle.js`.
```bash
cd web

File diff suppressed because it is too large Load Diff

59
web/src/js/dom.js Normal file
View File

@@ -0,0 +1,59 @@
/**
* dom.js — Shared DOM utility helpers.
*
* Thin wrappers around native DOM APIs used across all modules.
* Keeps selector syntax consistent and reduces boilerplate.
*/
/** Look up an element by its `id` attribute. */
export const $ = (id) => document.getElementById(id);
/** querySelector shorthand; defaults to searching the whole document. */
export const $q = (sel, ctx = document) => ctx.querySelector(sel);
/** querySelectorAll shorthand; defaults to searching the whole document. */
export const $qa = (sel, ctx = document) => ctx.querySelectorAll(sel);
/** Format a byte count as a human-readable "X.X MB" string. */
export function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
/**
* Replace all options in a <select> element.
* Always inserts a non-value placeholder as the first option.
* Each item in `items` can carry a `data` object whose keys become
* `data-*` attributes on the <option> element.
*/
export function populateSelect(selectEl, placeholder, items) {
selectEl.innerHTML = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = placeholder;
selectEl.appendChild(defaultOpt);
for (const { value, text, data } of items) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = text;
if (data) {
for (const [k, v] of Object.entries(data)) {
opt.dataset[k] = v;
}
}
selectEl.appendChild(opt);
}
}
/**
* Trigger a browser download of in-memory data.
* Creates a temporary object URL, clicks a hidden <a>, then revokes it.
*/
export function triggerDownload(data, filename, mimeType) {
const blob = new Blob([data], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

114
web/src/js/nav.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* nav.js — Step navigation and progress bar.
*
* The app is a single-page wizard with many "step" <div>s, only one visible
* at a time. This module manages:
* - Showing/hiding steps (with history tracking for back-navigation)
* - Rendering and updating the top progress bar (<nav> breadcrumb)
* - Card-style radio button interactivity (visual selection state)
*/
import { $, $q, $qa } from './dom.js';
import { TL } from './strings.js';
const stepNav = $('step-nav');
// Every step <div> in the app, in DOM order.
// Used by showStep() to hide all steps except the active one.
const allSteps = [
$('step-connect'), $('step-manual-version'), $('step-device'),
$('step-mode'), $('step-nickelmenu'), $('step-nm-features'),
$('step-nm-review'), $('step-nm-installing'), $('step-nm-done'),
$('step-patches'), $('step-firmware'), $('step-building'), $('step-done'),
$('step-error'),
];
let currentNavLabels = TL.NAV_DEFAULT; // eslint-disable-line no-unused-vars -- kept for debuggability; tracks which label set is active
// Tracks the order of visited steps so "Back" buttons can unwind correctly.
// Starts with stepConnect since that's always the first screen shown.
export const stepHistory = [allSteps[0]];
/**
* Show a single step and hide all others.
*
* When `push` is true (default), the step is added to `stepHistory`.
* If the step was already visited, history is rewound to that point
* (so going "back" to a previous step trims forward history).
* Pass `push = false` for transient screens like "Building..." that
* shouldn't appear in back-navigation.
*/
export function showStep(step, push = true) {
for (const s of allSteps) {
s.hidden = (s !== step);
}
if (!push) return;
const idx = stepHistory.indexOf(step);
if (idx >= 0) {
stepHistory.length = idx + 1;
} else {
stepHistory.push(step);
}
}
/**
* Replace the progress bar labels.
* Different flows have different label sets (e.g. NAV_PATCHES vs NAV_NICKELMENU).
*/
export function setNavLabels(labels) {
currentNavLabels = labels;
const ol = $q('ol', stepNav);
ol.innerHTML = '';
for (const label of labels) {
const li = document.createElement('li');
li.textContent = label;
ol.appendChild(li);
}
}
/**
* Highlight the current step in the progress bar.
* Steps before `num` get "done", step `num` gets "active" with aria-current.
*/
export function setNavStep(num) {
const items = $qa('li', stepNav);
items.forEach((li, i) => {
const stepNum = i + 1;
li.classList.remove('active', 'done');
li.removeAttribute('aria-current');
if (stepNum < num) li.classList.add('done');
else if (stepNum === num) {
li.classList.add('active');
li.setAttribute('aria-current', 'step');
}
});
stepNav.hidden = false;
}
export function hideNav() {
stepNav.hidden = true;
}
export function showNav() {
stepNav.hidden = false;
}
/**
* Wire up card-style radio buttons so the selected card gets a CSS class.
* Used for the mode selection cards and NickelMenu option cards.
* When a radio inside a <label> is checked, the label gets `selectedClass`;
* all sibling labels lose it.
*/
export function setupCardRadios(container, selectedClass) {
const labels = $qa('label', container);
for (const label of labels) {
const radio = $q('input[type="radio"]', label);
if (!radio) continue;
radio.addEventListener('change', () => {
for (const l of labels) {
if ($q('input[type="radio"]', l)) l.classList.remove(selectedClass);
}
if (radio.checked) label.classList.add(selectedClass);
});
}
}

View File

@@ -0,0 +1,428 @@
/**
* nickelmenu-flow.js — NickelMenu installation/removal flow.
*
* Handles the entire NickelMenu path through the wizard:
* 1. Config step — choose preset install, NickelMenu-only, or removal
* 2. Features step — pick which features to include (only for "preset")
* 3. Review step — confirm selections before proceeding
* 4. Installing step — progress indicator while writing files
* 5. Done step — success message with next-steps instructions
*
* Exported `initNickelMenu(state)` receives the shared app state and returns
* functions the orchestrator (app.js) needs: `goToNickelMenuConfig` and
* `resetNickelMenuState`.
*/
import { $, $q, $qa, triggerDownload } from './dom.js';
import { showStep, setNavStep } from './nav.js';
import { ALL_FEATURES } from '../nickelmenu/installer.js';
import { TL } from './strings.js';
import { track } from './analytics.js';
export function initNickelMenu(state) {
// --- DOM references (scoped to this flow) ---
const stepNickelMenu = $('step-nickelmenu');
const stepNmFeatures = $('step-nm-features');
const stepNmReview = $('step-nm-review');
const stepNmInstalling = $('step-nm-installing');
const stepNmDone = $('step-nm-done');
const nmConfigOptions = $('nm-config-options');
const nmUninstallOptions = $('nm-uninstall-options');
const btnNmBack = $('btn-nm-back');
const btnNmNext = $('btn-nm-next');
const btnNmFeaturesBack = $('btn-nm-features-back');
const btnNmFeaturesNext = $('btn-nm-features-next');
const btnNmReviewBack = $('btn-nm-review-back');
const btnNmWrite = $('btn-nm-write');
const btnNmDownload = $('btn-nm-download');
// Features detected on the device that can be cleaned up during removal
// (e.g. KOReader). Populated by checkNickelMenuInstalled().
let detectedUninstallFeatures = [];
// --- Feature checkboxes ---
// Renders one checkbox per available feature from ALL_FEATURES.
// Required features are checked and disabled; others use their default.
function renderFeatureCheckboxes() {
nmConfigOptions.innerHTML = '';
for (const feature of ALL_FEATURES) {
if (feature.available === false) continue;
const label = document.createElement('label');
label.className = 'nm-config-item';
const input = document.createElement('input');
input.type = 'checkbox';
input.name = 'nm-cfg-' + feature.id;
input.checked = feature.default;
if (feature.required) {
input.checked = true;
input.disabled = true;
}
const textDiv = document.createElement('div');
textDiv.className = 'nm-config-text';
const titleSpan = document.createElement('span');
titleSpan.className = 'nm-config-title';
let titleText = feature.title;
if (feature.required) titleText += ' (required)';
if (feature.version) titleText += ' ' + feature.version;
titleSpan.textContent = titleText;
const descSpan = document.createElement('span');
descSpan.className = 'nm-config-desc';
descSpan.textContent = feature.description;
textDiv.appendChild(titleSpan);
textDiv.appendChild(descSpan);
label.appendChild(input);
label.appendChild(textDiv);
nmConfigOptions.appendChild(label);
}
}
// --- Uninstall checkboxes ---
// When removing NickelMenu, shows checkboxes for any detected extras
// (like KOReader) so the user can opt into cleaning those up too.
function renderUninstallCheckboxes() {
nmUninstallOptions.innerHTML = '';
if (detectedUninstallFeatures.length === 0) return;
for (const feature of detectedUninstallFeatures) {
const label = document.createElement('label');
label.className = 'nm-config-item';
const input = document.createElement('input');
input.type = 'checkbox';
input.name = 'nm-uninstall-' + feature.id;
input.checked = true;
const textDiv = document.createElement('div');
textDiv.className = 'nm-config-text';
const titleSpan = document.createElement('span');
titleSpan.className = 'nm-config-title';
titleSpan.textContent = 'Also remove ' + feature.uninstall.title;
const descSpan = document.createElement('span');
descSpan.className = 'nm-config-desc';
descSpan.textContent = feature.uninstall.description;
textDiv.appendChild(titleSpan);
textDiv.appendChild(descSpan);
label.appendChild(input);
label.appendChild(textDiv);
nmUninstallOptions.appendChild(label);
}
}
/** Clear removal state when returning to mode selection. */
function resetNickelMenuState() {
detectedUninstallFeatures = [];
nmUninstallOptions.hidden = true;
nmUninstallOptions.innerHTML = '';
}
/** Return only the uninstall features whose checkboxes are checked. */
function getSelectedUninstallFeatures() {
return detectedUninstallFeatures.filter(f => {
const cb = $q(`input[name="nm-uninstall-${f.id}"]`);
return cb && cb.checked;
});
}
/** Return all features the user has selected for installation. */
function getSelectedFeatures() {
return ALL_FEATURES.filter(f => {
if (f.available === false) return false;
if (f.required) return true;
const checkbox = $q(`input[name="nm-cfg-${f.id}"]`);
return checkbox && checkbox.checked;
});
}
// --- NM installed detection ---
// Probes the connected device for .adds/nm/items to determine if
// NickelMenu is currently installed. Enables or disables the "Remove"
// radio option accordingly. Also scans for removable extras (e.g. KOReader).
async function checkNickelMenuInstalled() {
const removeOption = $('nm-option-remove');
const removeRadio = $q('input[value="remove"]', removeOption);
const removeDesc = $('nm-remove-desc');
// Only probe the device in auto mode (manual mode has no device handle).
if (!state.manualMode && state.device.directoryHandle) {
try {
const addsDir = await state.device.directoryHandle.getDirectoryHandle('.adds');
const nmDir = await addsDir.getDirectoryHandle('nm');
await nmDir.getFileHandle('items');
// NickelMenu is installed — enable removal option.
removeRadio.disabled = false;
removeOption.classList.remove('nm-option-disabled');
removeDesc.textContent = TL.STATUS.NM_REMOVAL_HINT;
// Scan for removable extras (only once per session).
if (detectedUninstallFeatures.length === 0) {
for (const feature of ALL_FEATURES) {
if (!feature.uninstall) continue;
for (const detectPath of feature.uninstall.detect) {
if (await state.device.pathExists(detectPath)) {
detectedUninstallFeatures.push(feature);
break;
}
}
}
renderUninstallCheckboxes();
}
return;
} catch {
// .adds/nm not found — NickelMenu is not installed.
}
}
// No device or NickelMenu not found — disable removal.
removeRadio.disabled = true;
removeOption.classList.add('nm-option-disabled');
removeDesc.textContent = TL.STATUS.NM_REMOVAL_DISABLED;
if (removeRadio.checked) {
const presetRadio = $q('input[value="preset"]', stepNickelMenu);
presetRadio.checked = true;
presetRadio.dispatchEvent(new Event('change'));
}
}
// --- Step: NM config ---
// Radio buttons for the three NM options: preset, nickelmenu-only, remove.
// Toggling "remove" shows/hides the uninstall checkboxes.
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
radio.addEventListener('change', () => {
nmUninstallOptions.hidden = radio.value !== 'remove' || !radio.checked || detectedUninstallFeatures.length === 0;
btnNmNext.disabled = false;
});
}
/** Entry point into the NickelMenu flow. Probes the device, then shows the config step. */
async function goToNickelMenuConfig() {
await checkNickelMenuInstalled();
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
nmUninstallOptions.hidden = !currentOption || currentOption.value !== 'remove' || detectedUninstallFeatures.length === 0;
btnNmNext.disabled = !currentOption;
setNavStep(3);
showStep(stepNickelMenu);
}
btnNmBack.addEventListener('click', () => {
state.goToModeSelection();
});
btnNmNext.addEventListener('click', () => {
const selected = $q('input[name="nm-option"]:checked', stepNickelMenu);
if (!selected) return;
state.nickelMenuOption = selected.value;
track('nm-option', { option: state.nickelMenuOption });
// "preset" goes to feature selection; other options skip to review.
if (state.nickelMenuOption === 'preset') {
goToNmFeatures();
} else {
goToNmReview();
}
});
// --- Step: Features ---
// Checkboxes are rendered lazily on first visit, then preserved
// so selections survive back-navigation.
function goToNmFeatures() {
if (!nmConfigOptions.children.length) {
renderFeatureCheckboxes();
}
setNavStep(3);
showStep(stepNmFeatures);
}
btnNmFeaturesBack.addEventListener('click', async () => {
await goToNickelMenuConfig();
});
btnNmFeaturesNext.addEventListener('click', () => {
goToNmReview();
});
// --- Step: Review ---
// Builds a summary of what will be installed/removed and shows
// the appropriate action buttons (write to device / download).
function goToNmReview() {
const summary = $('nm-review-summary');
const list = $('nm-review-list');
list.innerHTML = '';
if (state.nickelMenuOption === 'remove') {
summary.textContent = TL.STATUS.NM_WILL_BE_REMOVED;
const featuresToRemove = getSelectedUninstallFeatures();
for (const feature of featuresToRemove) {
const li = document.createElement('li');
li.textContent = feature.uninstall.title + ' will also be removed';
list.appendChild(li);
}
btnNmWrite.hidden = state.manualMode;
btnNmWrite.textContent = TL.BUTTON.REMOVE_FROM_KOBO;
btnNmDownload.hidden = true;
} else if (state.nickelMenuOption === 'nickelmenu-only') {
summary.textContent = TL.STATUS.NM_WILL_BE_INSTALLED;
const li = document.createElement('li');
li.textContent = TL.STATUS.NM_NICKEL_ROOT_TGZ;
list.appendChild(li);
btnNmWrite.hidden = false;
btnNmWrite.textContent = TL.BUTTON.WRITE_TO_KOBO;
btnNmDownload.hidden = false;
} else {
// "preset" — list NickelMenu plus all selected features.
summary.textContent = TL.STATUS.NM_WILL_BE_INSTALLED;
const items = [TL.STATUS.NM_NICKEL_ROOT_TGZ];
for (const feature of getSelectedFeatures()) {
items.push(feature.title);
}
for (const text of items) {
const li = document.createElement('li');
li.textContent = text;
list.appendChild(li);
}
btnNmWrite.hidden = false;
btnNmWrite.textContent = TL.BUTTON.WRITE_TO_KOBO;
btnNmDownload.hidden = false;
}
// "Write to Kobo" is only available when a device is connected.
if (state.manualMode || !state.device.directoryHandle) {
btnNmWrite.hidden = true;
}
btnNmWrite.disabled = false;
btnNmWrite.className = 'primary';
btnNmDownload.disabled = false;
setNavStep(4);
showStep(stepNmReview);
}
btnNmReviewBack.addEventListener('click', async () => {
if (state.nickelMenuOption === 'preset') {
goToNmFeatures();
} else {
await goToNickelMenuConfig();
}
});
// --- Install / Download ---
// Performs the actual installation or builds a downloadable ZIP.
// The removal path writes a KoboRoot.tgz (for NickelMenu's own uninstaller),
// deletes NM assets, creates an uninstall marker, then optionally removes
// detected extras like KOReader.
async function executeNmInstall(writeToDevice) {
const nmProgress = $('nm-progress');
const progressFn = (msg) => { nmProgress.textContent = msg; };
showStep(stepNmInstalling);
try {
if (state.nickelMenuOption === 'remove') {
// Removal flow: write uninstall tgz, clean up assets, remove extras.
await state.nmInstaller.loadNickelMenu(progressFn);
nmProgress.textContent = 'Writing KoboRoot.tgz...';
const tgz = await state.nmInstaller.getKoboRootTgz();
await state.device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
nmProgress.textContent = 'Removing NickelMenu assets...';
try {
await state.device.removeEntry(['.adds', 'nm'], { recursive: true });
} catch (err) {
console.warn('Could not remove .adds/nm:', err);
}
try {
await state.device.removeEntry(['.adds', 'scripts'], { recursive: true });
} catch (err) {
console.warn('Could not remove .adds/scripts:', err);
}
// Marker tells NickelMenu to finish uninstalling on next reboot.
nmProgress.textContent = 'Creating uninstall marker...';
await state.device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0));
// Remove any extras the user opted to clean up.
const featuresToRemove = getSelectedUninstallFeatures();
for (const feature of featuresToRemove) {
nmProgress.textContent = 'Removing ' + feature.uninstall.title + '...';
for (const entry of feature.uninstall.paths) {
try {
await state.device.removeEntry(entry.path, { recursive: !!entry.recursive });
} catch (err) {
console.warn(`Could not remove ${entry.path.join('/')}:`, err);
}
}
}
showNmDone('remove');
return;
}
// Install flow: either write directly to device or build a ZIP for download.
const features = state.nickelMenuOption === 'preset' ? getSelectedFeatures() : [];
if (writeToDevice && state.device.directoryHandle) {
await state.nmInstaller.installToDevice(state.device, features, progressFn);
showNmDone('written');
} else {
state.resultNmZip = await state.nmInstaller.buildDownloadZip(features, progressFn);
showNmDone('download');
}
} catch (err) {
state.showError(TL.STATUS.NM_INSTALL_FAILED + err.message);
}
}
btnNmWrite.addEventListener('click', () => executeNmInstall(true));
btnNmDownload.addEventListener('click', () => executeNmInstall(false));
// --- Done ---
// Shows the appropriate success message and post-install instructions
// depending on whether the user wrote to device, downloaded, or removed.
function showNmDone(mode) {
const nmDoneStatus = $('nm-done-status');
$('nm-write-instructions').hidden = true;
$('nm-download-instructions').hidden = true;
$('nm-reboot-instructions').hidden = true;
if (mode === 'remove') {
nmDoneStatus.textContent = TL.STATUS.NM_REMOVED_ON_REBOOT;
$('nm-reboot-instructions').hidden = false;
track('flow-end', { result: 'nm-remove' });
} else if (mode === 'written') {
nmDoneStatus.textContent = TL.STATUS.NM_INSTALLED;
$('nm-write-instructions').hidden = false;
track('flow-end', { result: 'nm-write' });
} else {
nmDoneStatus.textContent = TL.STATUS.NM_DOWNLOAD_READY;
triggerDownload(state.resultNmZip, 'NickelMenu-install.zip', 'application/zip');
$('nm-download-instructions').hidden = false;
// Only show config/reboot steps for preset installs (NM-only has nothing to configure).
const showConfStep = state.nickelMenuOption === 'preset';
$('nm-download-conf-step').hidden = !showConfStep;
$('nm-download-reboot-step').hidden = !showConfStep;
track('flow-end', { result: 'nm-download' });
}
setNavStep(5);
showStep(stepNmDone);
}
// Expose only what the orchestrator needs.
return { goToNickelMenuConfig, resetNickelMenuState };
}

349
web/src/js/patches-flow.js Normal file
View File

@@ -0,0 +1,349 @@
/**
* patches-flow.js — Custom firmware patching flow.
*
* Handles the entire custom-patches path through the wizard:
* 1. Configure patches — toggle individual patches on/off
* 2. Review & build — confirm selections, download firmware, apply patches
* 3. Install/download — write KoboRoot.tgz to device or trigger browser download
*
* Also supports "restore" mode where no patches are applied — the original
* KoboRoot.tgz is extracted from the firmware ZIP and offered as-is.
*
* Exported `initPatchesFlow(state)` receives the shared app state and returns
* functions the orchestrator needs: `goToPatches`, `goToBuild`,
* `updatePatchCount`, and `configureFirmwareStep`.
*/
import { $, formatMB, triggerDownload } from './dom.js';
import { showStep, setNavLabels, setNavStep } from './nav.js';
import { KoboModels } from './kobo-device.js';
import { TL } from './strings.js';
import { track } from './analytics.js';
import JSZip from 'jszip';
export function initPatchesFlow(state) {
// --- DOM references (scoped to this flow) ---
const stepPatches = $('step-patches');
const stepBuilding = $('step-building');
const stepDone = $('step-done');
const btnPatchesBack = $('btn-patches-back');
const btnPatchesNext = $('btn-patches-next');
const btnBuildBack = $('btn-build-back');
const btnBuild = $('btn-build');
const btnWrite = $('btn-write');
const btnDownload = $('btn-download');
const buildProgress = $('build-progress');
const buildLog = $('build-log');
const buildStatus = $('build-status');
const existingTgzWarning = $('existing-tgz-warning');
const writeInstructions = $('write-instructions');
const downloadInstructions = $('download-instructions');
const firmwareVersionLabel = $('firmware-version-label');
const firmwareDeviceLabel = $('firmware-device-label');
const firmwareDescription = $('firmware-description');
const patchCountHint = $('patch-count-hint');
// --- Patch count ---
// Updates the hint text below the patch list ("3 patches selected", etc.).
// Also wired as the onChange callback on PatchUI so it updates live.
function updatePatchCount() {
const count = state.patchUI.getEnabledCount();
btnPatchesNext.disabled = false;
if (count === 0) {
patchCountHint.textContent = TL.STATUS.PATCH_COUNT_ZERO;
} else {
patchCountHint.textContent = count === 1 ? TL.STATUS.PATCH_COUNT_ONE : TL.STATUS.PATCH_COUNT_MULTI(count);
}
}
state.patchUI.onChange = updatePatchCount;
// --- Firmware step config ---
// Sets the firmware download URL and labels shown on the review step.
// Called once when the device is detected or the user picks a manual version.
function configureFirmwareStep(version, prefix) {
state.firmwareURL = prefix ? state.getSoftwareUrl(prefix, version) : null;
firmwareVersionLabel.textContent = version;
firmwareDeviceLabel.textContent = KoboModels[prefix] || prefix;
$('firmware-download-url').textContent = state.firmwareURL || '';
}
// --- Step: Configure patches ---
function goToPatches() {
setNavStep(3);
showStep(stepPatches);
}
btnPatchesBack.addEventListener('click', () => {
if (state.manualMode) {
setNavStep(2);
showStep($('step-manual-version'));
} else {
state.goToModeSelection();
}
});
btnPatchesNext.addEventListener('click', () => {
// If zero patches are enabled, treat this as a firmware restore.
state.isRestore = state.patchUI.getEnabledCount() === 0;
goToBuild();
});
// --- Step: Review & Build ---
// Shows the list of selected patches and a "Build" button.
function populateSelectedPatchesList() {
const patchList = $('selected-patches-list');
patchList.innerHTML = '';
const enabled = state.patchUI.getEnabledPatches();
for (const name of enabled) {
const li = document.createElement('li');
li.textContent = name;
patchList.appendChild(li);
}
const hasPatches = enabled.length > 0;
patchList.hidden = !hasPatches;
$('selected-patches-heading').hidden = !hasPatches;
}
function goToBuild() {
// Adjust labels for restore vs patch mode.
if (state.isRestore) {
firmwareDescription.textContent = TL.STATUS.RESTORE_ORIGINAL;
btnBuild.textContent = TL.BUTTON.RESTORE_ORIGINAL;
} else {
firmwareDescription.textContent = TL.STATUS.FIRMWARE_WILL_BE_DOWNLOADED;
btnBuild.textContent = TL.BUTTON.BUILD_PATCHED;
}
populateSelectedPatchesList();
setNavStep(4);
// `false` = don't push to step history (building is a transient state).
showStep($('step-firmware'), false);
}
btnBuildBack.addEventListener('click', () => {
if (state.isRestore) {
// Restore was entered from the device step — go back there.
state.isRestore = false;
setNavLabels(TL.NAV_DEFAULT);
setNavStep(1);
showStep($('step-device'));
} else {
goToPatches();
}
});
// --- Download & patch ---
// These functions handle the heavy lifting: downloading firmware,
// extracting the original tgz, and running the WASM patcher.
function appendLog(msg) {
buildLog.textContent += msg + '\n';
buildLog.scrollTop = buildLog.scrollHeight;
}
/**
* Download firmware from the given URL with progress reporting.
* Uses a ReadableStream reader when Content-Length is available
* so we can show "Downloading X / Y MB (Z%)".
*/
async function downloadFirmware(url) {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error('Download failed: HTTP ' + resp.status);
}
const contentLength = resp.headers.get('Content-Length');
if (!contentLength || !resp.body) {
// No progress info available — download in one shot.
buildProgress.textContent = TL.STATUS.DOWNLOADING;
return new Uint8Array(await resp.arrayBuffer());
}
// Stream download with progress updates.
const total = parseInt(contentLength, 10);
const reader = resp.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const pct = ((received / total) * 100).toFixed(0);
buildProgress.textContent = TL.STATUS.DOWNLOADING_PROGRESS(formatMB(received), formatMB(total), pct);
}
// Reassemble chunks into a single Uint8Array.
const result = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
/** Extract the original KoboRoot.tgz from a Kobo firmware ZIP. */
async function extractOriginalTgz(firmwareBytes) {
buildProgress.textContent = TL.STATUS.EXTRACTING;
appendLog('Extracting original KoboRoot.tgz from firmware...');
const zip = await JSZip.loadAsync(firmwareBytes);
const koboRoot = zip.file('KoboRoot.tgz');
if (!koboRoot) throw new Error(TL.STATUS.EXTRACT_FAILED);
const tgz = new Uint8Array(await koboRoot.async('arraybuffer'));
appendLog('Extracted KoboRoot.tgz: ' + formatMB(tgz.length));
return tgz;
}
/**
* Run the WASM patcher on downloaded firmware bytes.
* Generates a kobopatch YAML config from the UI selections,
* then delegates to the Web Worker via KoboPatchRunner.
*/
async function runPatcher(firmwareBytes) {
buildProgress.textContent = TL.STATUS.APPLYING_PATCHES;
const configYAML = state.patchUI.generateConfig();
const patchFiles = state.patchUI.getPatchFileBytes();
const result = await state.runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => {
appendLog(msg);
// Surface key progress lines in the status bar.
const trimmed = msg.trimStart();
if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') ||
trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) {
buildProgress.textContent = trimmed;
}
});
return result.tgz;
}
// --- Build result ---
// Shown after a successful build/extract. Offers "Write to Kobo" and
// "Download" buttons. Also warns if a KoboRoot.tgz already exists on
// the device (which would be overwritten).
function showBuildResult() {
const action = state.isRestore ? 'Software extracted' : 'Patching complete';
const description = state.isRestore ? 'This will restore the original unpatched software.' : '';
const deviceName = KoboModels[state.selectedPrefix] || 'Kobo';
const installHint = state.manualMode
? 'Download the file and copy it to your ' + deviceName + '.'
: 'Write it directly to your connected Kobo, or download for manual installation.';
buildStatus.innerHTML =
action + '. <strong>KoboRoot.tgz</strong> (' + formatMB(state.resultTgz.length) + ') is ready. ' +
(description ? description + ' ' : '') + installHint;
const doneLog = $('done-log');
doneLog.textContent = buildLog.textContent;
btnWrite.hidden = state.manualMode;
btnWrite.disabled = false;
btnWrite.className = 'primary';
btnWrite.textContent = TL.BUTTON.WRITE_TO_KOBO;
btnDownload.disabled = false;
writeInstructions.hidden = true;
downloadInstructions.hidden = true;
existingTgzWarning.hidden = true;
setNavStep(5);
showStep(stepDone);
requestAnimationFrame(() => {
doneLog.scrollTop = doneLog.scrollHeight;
});
}
/** Check if the device already has a KoboRoot.tgz and show a warning if so. */
async function checkExistingTgz() {
if (state.manualMode || !state.device.directoryHandle) return;
try {
const koboDir = await state.device.directoryHandle.getDirectoryHandle('.kobo');
await koboDir.getFileHandle('KoboRoot.tgz');
existingTgzWarning.hidden = false;
} catch {
// No existing file — that's fine.
}
}
// --- Build button ---
// Orchestrates the full pipeline: download firmware -> extract/patch -> show result.
btnBuild.addEventListener('click', async () => {
showStep(stepBuilding, false);
buildLog.textContent = '';
buildProgress.textContent = TL.STATUS.BUILDING_STARTING;
$('build-wait-hint').textContent = state.isRestore
? 'Please wait while the original software is being downloaded and extracted...'
: 'Please wait while the patch is being applied...';
try {
if (!state.firmwareURL) {
state.showError(TL.STATUS.NO_FIRMWARE_URL);
return;
}
const firmwareBytes = await downloadFirmware(state.firmwareURL);
appendLog('Download complete: ' + formatMB(firmwareBytes.length));
// Either extract the original tgz (restore) or run the patcher.
state.resultTgz = state.isRestore
? await extractOriginalTgz(firmwareBytes)
: await runPatcher(firmwareBytes);
showBuildResult();
await checkExistingTgz();
} catch (err) {
state.showError('Build failed: ' + err.message, buildLog.textContent);
}
});
// --- Install step ---
// Writes the built KoboRoot.tgz to the device via File System Access API,
// or triggers a browser download.
btnWrite.addEventListener('click', async () => {
if (!state.resultTgz || !state.device.directoryHandle) return;
btnWrite.disabled = true;
btnWrite.textContent = TL.BUTTON.WRITING;
downloadInstructions.hidden = true;
try {
const koboDir = await state.device.directoryHandle.getDirectoryHandle('.kobo');
const fileHandle = await koboDir.getFileHandle('KoboRoot.tgz', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(state.resultTgz);
await writable.close();
btnWrite.textContent = TL.BUTTON.WRITTEN;
btnWrite.className = 'btn-success';
writeInstructions.hidden = false;
track('flow-end', { result: state.isRestore ? 'restore-write' : 'patches-write' });
} catch (err) {
btnWrite.disabled = false;
btnWrite.textContent = TL.BUTTON.WRITE_TO_KOBO;
state.showError(TL.STATUS.WRITE_FAILED + err.message);
}
});
btnDownload.addEventListener('click', () => {
if (!state.resultTgz) return;
triggerDownload(state.resultTgz, 'KoboRoot.tgz', 'application/gzip');
writeInstructions.hidden = true;
downloadInstructions.hidden = false;
$('download-device-name').textContent = KoboModels[state.selectedPrefix] || 'Kobo';
track('flow-end', { result: state.isRestore ? 'restore-download' : 'patches-download' });
});
// Expose only what the orchestrator needs.
return { goToPatches, goToBuild, updatePatchCount, configureFirmwareStep };
}