Modular front-end approach
This commit is contained in:
1589
web/src/js/app.js
1589
web/src/js/app.js
File diff suppressed because it is too large
Load Diff
59
web/src/js/dom.js
Normal file
59
web/src/js/dom.js
Normal 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
114
web/src/js/nav.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
428
web/src/js/nickelmenu-flow.js
Normal file
428
web/src/js/nickelmenu-flow.js
Normal 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
349
web/src/js/patches-flow.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user