/** * app.js — Main orchestrator. * * This is the entry point for the application. It: * - Creates the shared state object used by all flow modules * - Kicks off eager data fetches (software URLs, available patches, KOReader) * - Initializes the two flow modules (NickelMenu and custom patches) * - Handles the steps that are shared between flows: * • Step 1: Connection method (connect device or manual mode) * • Device info display and unknown-model warning * • Mode selection (NickelMenu vs custom patches) * • Manual version/model selection * • Error display and retry * • Info dialogs (How It Works, Privacy) * * Flow modules (nickelmenu-flow.js, patches-flow.js) own their own steps * and call back into the orchestrator via `state.goToModeSelection()` and * `state.showError()` when they need to cross module boundaries. */ import { KoboDevice } from './kobo-device.js'; import { loadSoftwareUrls, getSoftwareUrl, getDevicesForVersion } from './kobo-software-urls.js'; import { PatchUI, scanAvailablePatches } from './patch-ui.js'; import { KoboPatchRunner } from './patch-runner.js'; import { NickelMenuInstaller, ALL_FEATURES } from '../nickelmenu/installer.js'; import { TL } from './strings.js'; import { isEnabled as analyticsEnabled, track } from './analytics.js'; import { $, $q, populateSelect } from './dom.js'; import { showStep, setNavLabels, setNavStep, hideNav, showNav, stepHistory, setupCardRadios } from './nav.js'; import { initNickelMenu } from './nickelmenu-flow.js'; import { initPatchesFlow } from './patches-flow.js'; // ============================================================================= // Shared state // ============================================================================= // Plain object passed by reference to flow modules so mutations are visible // everywhere. Contains service instances, mutable UI state, and cross-module // function references that are set after the functions are defined below. const state = { // Service instances (created once, used throughout the session). device: new KoboDevice(), patchUI: new PatchUI(), runner: new KoboPatchRunner(), nmInstaller: new NickelMenuInstaller(), // Mutable state that changes as the user progresses through the wizard. firmwareURL: null, // URL to download firmware from (set during device detection or manual selection) resultTgz: null, // Built KoboRoot.tgz bytes (set after successful patch/extract) resultNmZip: null, // Built NickelMenu ZIP bytes (set after NM download flow) manualMode: false, // True when user chose "manual download" instead of connecting a device selectedPrefix: null, // Kobo serial prefix identifying the device model (e.g. "N428") patchesLoaded: false, // True once patch definitions have been loaded for the detected firmware isRestore: false, // True when restoring original firmware (no patches selected) selectedMode: null, // "nickelmenu" or "patches" nickelMenuOption: null, // "preset", "nickelmenu-only", or "remove" // Cross-module callbacks — set below after the functions are defined. goToModeSelection: null, showError: null, getSoftwareUrl, }; // ============================================================================= // Eager fetches // ============================================================================= // Start loading data immediately so it's ready by the time the user reaches // a step that needs it. These promises are awaited where needed. let availablePatches = null; const softwareUrlsReady = loadSoftwareUrls(); const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; }); // Best-effort KOReader availability check. If the server has KOReader assets, // mark the feature as available so it shows up in the NickelMenu features list. // Runs in the background — failure is silently ignored. const koreaderFeature = ALL_FEATURES.find(f => f.id === 'koreader'); fetch('/koreader/release.json') .then(r => r.ok ? r.json() : null) .then(meta => { if (meta && meta.version) { koreaderFeature.available = true; koreaderFeature.version = meta.version; } }) .catch(() => {}); // ============================================================================= // DOM elements (orchestrator-only) // ============================================================================= const stepConnect = $('step-connect'); const stepManualVersion = $('step-manual-version'); const stepDevice = $('step-device'); const stepMode = $('step-mode'); const stepPatches = $('step-patches'); const stepError = $('step-error'); const btnConnect = $('btn-connect'); const btnManual = $('btn-manual'); const btnManualConfirm = $('btn-manual-confirm'); const btnManualVersionBack = $('btn-manual-version-back'); const manualVersion = $('manual-version'); const manualModel = $('manual-model'); const btnDeviceNext = $('btn-device-next'); const btnDeviceRestore = $('btn-device-restore'); const btnModeBack = $('btn-mode-back'); const btnModeNext = $('btn-mode-next'); const btnRetry = $('btn-retry'); const btnErrorBack = $('btn-error-back'); const errorMessage = $('error-message'); const errorLog = $('error-log'); const errorTitle = $('error-title'); const errorHint = $('error-hint'); const deviceStatus = $('device-status'); const deviceUnknownWarning = $('device-unknown-warning'); const deviceUnknownAck = $('device-unknown-ack'); const deviceUnknownCheckbox = $('device-unknown-checkbox'); const patchContainer = $('patch-container'); // ============================================================================= // Initialize flow modules // ============================================================================= // Each flow module receives the shared state, wires up its own event listeners, // and returns a small API of functions the orchestrator needs. const nm = initNickelMenu(state); const patches = initPatchesFlow(state); // Wire up card-radio interactivity for mode selection and NM option cards. setupCardRadios(stepMode, 'mode-card-selected'); setupCardRadios($('step-nickelmenu'), 'nm-option-selected'); // ============================================================================= // Error handling // ============================================================================= // Shared error screen used by both flows. If the user was on the patches step, // a "Go Back" button lets them return to fix their selections. function showError(message, log) { errorMessage.textContent = message; if (log) { errorLog.textContent = log; errorLog.hidden = false; requestAnimationFrame(() => { errorLog.scrollTop = errorLog.scrollHeight; }); } else { errorLog.hidden = true; } // If the user came from the patches step, offer a "Go Back" button // so they can adjust their selections and retry. const hasBackStep = stepHistory.includes(stepPatches); if (hasBackStep) { errorTitle.textContent = TL.ERROR.PATCH_FAILED; errorHint.hidden = false; btnErrorBack.hidden = false; btnRetry.classList.add('danger'); } else { errorTitle.textContent = TL.ERROR.SOMETHING_WENT_WRONG; errorHint.hidden = true; btnErrorBack.hidden = true; btnRetry.classList.remove('danger'); } hideNav(); showStep(stepError); } state.showError = showError; // ============================================================================= // Mode selection // ============================================================================= // The screen where the user picks between NickelMenu and custom patches. // In auto mode, the patches option is disabled if no patches are available // for the detected firmware version. function goToModeSelection() { nm.resetNickelMenuState(); const patchesRadio = $q('input[value="patches"]', stepMode); const patchesCard = patchesRadio.closest('.mode-card'); const autoModeNoPatchesAvailable = !state.manualMode && (!state.patchesLoaded || !state.firmwareURL); // Disable the patches card if firmware patches aren't available. const patchesHint = $('mode-patches-hint'); if (autoModeNoPatchesAvailable) { patchesRadio.disabled = true; patchesCard.classList.add('mode-card-disabled'); patchesHint.hidden = false; // Auto-select NickelMenu since it's the only available option. const nmRadio = $q('input[value="nickelmenu"]', stepMode); nmRadio.checked = true; nmRadio.dispatchEvent(new Event('change')); } else { patchesRadio.disabled = false; patchesCard.classList.remove('mode-card-disabled'); patchesHint.hidden = true; } setNavLabels(TL.NAV_DEFAULT); setNavStep(2); showStep(stepMode); } state.goToModeSelection = goToModeSelection; // ============================================================================= // Initial state // ============================================================================= // Remove the loading spinner and show the first step. const loader = $('initial-loader'); if (loader) loader.remove(); // Detect iOS (Safari/WebKit) — the File System Access API is unavailable on iOS. const isAppleMobileDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // Disable the "Connect" button if the File System Access API isn't available. const hasFileSystemAccess = KoboDevice.isSupported(); if (!hasFileSystemAccess) { btnConnect.disabled = true; $('connect-unsupported-hint').hidden = false; if (isAppleMobileDevice) { $('connect-unsupported-text').innerHTML = 'Directly connecting your Kobo is not available on iOS because Safari does not support the ' + 'native filesystem API. ' + 'For the best experience, use Chrome, Edge, or Opera on a desktop or laptop computer. ' + 'You can still use the manual download option below.'; } } setNavLabels(TL.NAV_DEFAULT); setNavStep(1); showStep(stepConnect); // ============================================================================= // Step 1: Connection method // ============================================================================= // "Manual mode" skips device detection and goes straight to mode selection. btnManual.addEventListener('click', () => { state.manualMode = true; track('flow-start', { method: 'manual' }); goToModeSelection(); }); // ============================================================================= // Manual version/model selection // ============================================================================= // In manual + patches mode, the user picks a software version and model // from dropdowns before proceeding to the patch configuration step. manualVersion.addEventListener('change', () => { const version = manualVersion.value; state.selectedPrefix = null; // Show or hide the model dropdown based on whether a version is selected. const modelHint = $('manual-model-hint'); if (!version) { manualModel.hidden = true; modelHint.hidden = true; btnManualConfirm.disabled = true; return; } // Populate the model dropdown with devices that support this version. const devices = getDevicesForVersion(version); populateSelect(manualModel, '-- Select your Kobo model --', devices.map(d => ({ value: d.prefix, text: d.model })) ); manualModel.hidden = false; modelHint.hidden = false; btnManualConfirm.disabled = true; }); manualModel.addEventListener('change', () => { state.selectedPrefix = manualModel.value || null; btnManualConfirm.disabled = !manualVersion.value || !manualModel.value; }); btnManualConfirm.addEventListener('click', async () => { const version = manualVersion.value; if (!version || !state.selectedPrefix) return; try { const loaded = await loadPatchesForVersion(version, availablePatches); if (!loaded) { showError(TL.ERROR.LOAD_PATCHES_FAILED(version)); return; } patches.configureFirmwareStep(version, state.selectedPrefix); patches.goToPatches(); } catch (err) { showError(err.message); } }); /** Show the manual version selection screen (awaits eager fetches first). */ async function enterManualVersionSelection() { await Promise.all([softwareUrlsReady, availablePatchesReady]); populateSelect(manualVersion, '-- Select software version --', availablePatches.map(p => ({ value: p.version, text: p.version, data: { filename: p.filename } })) ); populateSelect(manualModel, '-- Select your Kobo model --', []); manualModel.hidden = true; btnManualConfirm.disabled = true; setNavStep(2); showStep(stepManualVersion); } btnManualVersionBack.addEventListener('click', () => { goToModeSelection(); }); // ============================================================================= // Device connection // ============================================================================= // Uses the File System Access API to read device info from the connected Kobo. // Detects firmware version, model, and serial number. Pre-loads patches if // available for the detected firmware. /** Populate the device info display (model, serial with prefix underlined, firmware). */ function displayDeviceInfo(info) { $('device-model').textContent = info.model; const serialEl = $('device-serial'); serialEl.textContent = ''; // Underline the serial prefix to show which part identifies the model. 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))); $('device-firmware').textContent = info.firmware; } btnConnect.addEventListener('click', async () => { track('flow-start', { method: 'connect' }); try { const info = await state.device.connect(); displayDeviceInfo(info); // Block incompatible firmware versions (e.g. 5.x) with a dead-end message. if (info.isIncompatible) { deviceStatus.textContent = 'You seem to have an incompatible Kobo software version installed. ' + 'NickelMenu does not support it, and the custom patches are incompatible with this version.'; deviceStatus.classList.add('error'); btnDeviceNext.hidden = true; btnDeviceRestore.hidden = true; showStep(stepDevice); return; } state.selectedPrefix = info.serialPrefix; // Wait for eager fetches and try to match patches for this firmware. await Promise.all([softwareUrlsReady, availablePatchesReady]); const match = availablePatches.find(p => p.version === info.firmware); patches.configureFirmwareStep(info.firmware, info.serialPrefix); if (match) { await state.patchUI.loadFromURL('patches/' + match.filename); state.patchUI.render(patchContainer); patches.updatePatchCount(); state.patchesLoaded = true; } // Only show "Restore" shortcut if patches and firmware URL are available. btnDeviceRestore.hidden = !state.patchesLoaded || !state.firmwareURL; // Handle unknown models — require explicit acknowledgment before continuing. deviceStatus.classList.remove('error'); const isUnknownModel = info.model.startsWith('Unknown'); if (isUnknownModel) { deviceStatus.textContent = ''; deviceUnknownWarning.hidden = false; deviceUnknownAck.hidden = false; deviceUnknownCheckbox.checked = false; btnDeviceNext.disabled = true; } else { deviceStatus.textContent = TL.STATUS.DEVICE_RECOGNIZED; deviceUnknownWarning.hidden = true; deviceUnknownAck.hidden = true; deviceUnknownCheckbox.checked = false; btnDeviceNext.disabled = false; } btnDeviceNext.hidden = false; showStep(stepDevice); } catch (err) { // AbortError = user cancelled the file picker; not an error. if (err.name === 'AbortError') return; showError(err.message); } }); btnDeviceNext.addEventListener('click', () => { goToModeSelection(); }); // Unknown model checkbox gate — user must acknowledge the warning to proceed. deviceUnknownCheckbox.addEventListener('change', () => { btnDeviceNext.disabled = !deviceUnknownCheckbox.checked; }); // "Restore original" shortcut from the device step — skips mode/patch selection. btnDeviceRestore.addEventListener('click', () => { if (!state.patchesLoaded) return; state.selectedMode = 'patches'; state.isRestore = true; setNavLabels(TL.NAV_PATCHES); patches.goToBuild(); }); /** Load patch definitions for a given firmware version. */ async function loadPatchesForVersion(version, available) { const match = available.find(p => p.version === version); if (!match) return false; await state.patchUI.loadFromURL('patches/' + match.filename); state.patchUI.render(patchContainer); patches.updatePatchCount(); state.patchesLoaded = true; return true; } // ============================================================================= // Mode selection navigation // ============================================================================= // "Back" returns to the appropriate previous step depending on whether // the user is in manual or auto (device-connected) mode. btnModeBack.addEventListener('click', () => { setNavStep(1); if (state.manualMode) { showStep(stepConnect); } else { showStep(stepDevice); } }); // "Next" enters the selected flow (NickelMenu or custom patches). btnModeNext.addEventListener('click', async () => { const selected = $q('input[name="mode"]:checked', stepMode); if (!selected) return; state.selectedMode = selected.value; if (state.selectedMode === 'nickelmenu') { setNavLabels(TL.NAV_NICKELMENU); await nm.goToNickelMenuConfig(); } else if (state.manualMode && !state.patchesLoaded) { // Manual mode + patches: need to pick version/model first. setNavLabels(TL.NAV_PATCHES); await enterManualVersionSelection(); } else { setNavLabels(TL.NAV_PATCHES); patches.goToPatches(); } }); // ============================================================================= // Error recovery // ============================================================================= // "Go Back" on the error screen — unwinds history to the patches step // so the user can adjust selections and retry. btnErrorBack.addEventListener('click', () => { btnErrorBack.hidden = true; btnRetry.classList.remove('danger'); stepHistory.pop(); while (stepHistory.length > 0 && stepHistory[stepHistory.length - 1] !== stepPatches) { stepHistory.pop(); } showNav(); showStep(stepPatches); }); // "Start Over" — reload the page for a guaranteed clean slate. btnRetry.addEventListener('click', () => { location.reload(); }); // ============================================================================= // Dialogs // ============================================================================= // Modal dialogs for "How It Works" (disclaimer) and "Privacy" (analytics info). // Clicking the backdrop (the element itself) also closes them. /** Wire up a : open button, close button, and backdrop click to close. */ function setupDialog(dialogId, openBtnId, closeBtnId) { const dlg = $(dialogId); $(openBtnId).addEventListener('click', (e) => { e.preventDefault(); dlg.showModal(); }); $(closeBtnId).addEventListener('click', () => dlg.close()); dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); } setupDialog('how-it-works-dialog', 'btn-how-it-works', 'btn-close-dialog'); // Privacy dialog is only shown when analytics are enabled. if (analyticsEnabled()) { $('btn-privacy').hidden = false; $('privacy-link-separator').hidden = false; } setupDialog('privacy-dialog', 'btn-privacy', 'btn-close-privacy');