1
0
Files
kobopatch-webui/web/public/js/app.js
Nico Verbruggen dbd2f391bd
All checks were successful
Build and test project / build-and-test (push) Successful in 1m46s
Add more integration tests
2026-03-17 18:38:54 +01:00

869 lines
31 KiB
JavaScript

(() => {
const device = new KoboDevice();
const patchUI = new PatchUI();
const runner = new KobopatchRunner();
const nmInstaller = new NickelMenuInstaller();
let firmwareURL = null;
let resultTgz = null;
let resultNmZip = null;
let manualMode = false;
let selectedPrefix = null;
let patchesLoaded = false;
let isRestore = false;
let availablePatches = null;
let selectedMode = null; // 'nickelmenu' | 'patches'
let nickelMenuOption = null; // 'sample' | 'nickelmenu-only' | 'remove'
// Fetch patch index immediately so it's ready when needed.
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// --- Helpers ---
const $ = (id) => document.getElementById(id);
const $q = (sel, ctx = document) => ctx.querySelector(sel);
const $qa = (sel, ctx = document) => ctx.querySelectorAll(sel);
function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
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);
}
}
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);
}
// --- DOM elements ---
const stepNav = $('step-nav');
const stepConnect = $('step-connect');
const stepManualVersion = $('step-manual-version');
const stepDevice = $('step-device');
const stepMode = $('step-mode');
const stepNickelMenu = $('step-nickelmenu');
const stepNmInstalling = $('step-nm-installing');
const stepNmDone = $('step-nm-done');
const stepPatches = $('step-patches');
const stepFirmware = $('step-firmware');
const stepBuilding = $('step-building');
const stepDone = $('step-done');
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 btnNmBack = $('btn-nm-back');
const btnNmNext = $('btn-nm-next');
const btnNmReviewBack = $('btn-nm-review-back');
const btnNmWrite = $('btn-nm-write');
const btnNmDownload = $('btn-nm-download');
const btnPatchesBack = $('btn-patches-back');
const btnPatchesNext = $('btn-patches-next');
const btnBuildBack = $('btn-build-back');
const btnWrite = $('btn-write');
const btnDownload = $('btn-download');
const btnRetry = $('btn-retry');
const errorMessage = $('error-message');
const errorLog = $('error-log');
const deviceStatus = $('device-status');
const patchContainer = $('patch-container');
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 patchCountHint = $('patch-count-hint');
const stepNmReview = $('step-nm-review');
const allSteps = [
stepConnect, stepManualVersion, stepDevice,
stepMode, stepNickelMenu, stepNmReview, stepNmInstalling, stepNmDone,
stepPatches, stepFirmware, stepBuilding, stepDone,
stepError,
];
// --- Step navigation ---
const NAV_NICKELMENU = ['Device', 'Mode', 'Configure', 'Review', 'Install'];
const NAV_PATCHES = ['Device', 'Mode', 'Patches', 'Build', 'Install'];
const NAV_DEFAULT = ['Device', 'Mode', 'Patches', 'Build', 'Install'];
let currentNavLabels = NAV_DEFAULT;
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);
}
}
function showStep(step) {
for (const s of allSteps) {
s.hidden = (s !== step);
}
}
function setNavStep(num) {
const items = $qa('li', stepNav);
items.forEach((li, i) => {
const stepNum = i + 1;
li.classList.remove('active', 'done');
if (stepNum < num) li.classList.add('done');
else if (stepNum === num) li.classList.add('active');
});
stepNav.hidden = false;
}
function hideNav() {
stepNav.hidden = true;
}
// --- Mode selection card interactivity ---
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);
});
}
}
setupCardRadios(stepMode, 'mode-card-selected');
setupCardRadios(stepNickelMenu, 'nm-option-selected');
// --- Patch count ---
function updatePatchCount() {
const count = patchUI.getEnabledCount();
btnPatchesNext.disabled = false;
if (count === 0) {
patchCountHint.textContent = 'No patches selected \u2014 continuing will restore the original unpatched software.';
} else {
patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.';
}
}
patchUI.onChange = updatePatchCount;
// --- Firmware step config ---
function configureFirmwareStep(version, prefix) {
firmwareURL = prefix ? getFirmwareURL(prefix, version) : null;
firmwareVersionLabel.textContent = version;
firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix;
$('firmware-download-url').textContent = firmwareURL || '';
}
// --- Initial state ---
const loader = $('initial-loader');
if (loader) loader.remove();
const hasFileSystemAccess = KoboDevice.isSupported();
// Disable "Connect my Kobo" button on unsupported browsers
if (!hasFileSystemAccess) {
btnConnect.disabled = true;
$('connect-unsupported-hint').hidden = false;
}
setNavLabels(NAV_DEFAULT);
setNavStep(1);
showStep(stepConnect);
// --- Step 1: Connection method ---
// "Connect my Kobo" — triggers File System Access API
// (click handler is further below where device connection is handled)
// "Download files manually" — enter manual mode, go to mode selection
btnManual.addEventListener('click', () => {
manualMode = true;
goToModeSelection();
});
manualVersion.addEventListener('change', () => {
const version = manualVersion.value;
selectedPrefix = null;
const modelHint = $('manual-model-hint');
if (!version) {
manualModel.hidden = true;
modelHint.hidden = true;
btnManualConfirm.disabled = true;
return;
}
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', () => {
selectedPrefix = manualModel.value || null;
btnManualConfirm.disabled = !manualVersion.value || !manualModel.value;
});
// Manual confirm -> load patches -> go to patches step
btnManualConfirm.addEventListener('click', async () => {
const version = manualVersion.value;
if (!version || !selectedPrefix) return;
try {
const loaded = await loadPatchesForVersion(version, availablePatches);
if (!loaded) {
showError('Could not load patches for software version ' + version);
return;
}
configureFirmwareStep(version, selectedPrefix);
goToPatches();
} catch (err) {
showError(err.message);
}
});
// Auto connect -> show device info
btnConnect.addEventListener('click', async () => {
try {
const info = await device.connect();
$('device-model').textContent = info.model;
const serialEl = $('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)));
$('device-firmware').textContent = info.firmware;
selectedPrefix = info.serialPrefix;
await availablePatchesReady;
const match = availablePatches.find(p => p.version === info.firmware);
if (match) {
await patchUI.loadFromURL('patches/' + match.filename);
patchUI.render(patchContainer);
updatePatchCount();
patchesLoaded = true;
configureFirmwareStep(info.firmware, info.serialPrefix);
btnDeviceRestore.hidden = false;
} else {
btnDeviceRestore.hidden = true;
}
deviceStatus.textContent = 'Your device has been recognized. You can continue to the next step!';
btnDeviceNext.hidden = false;
showStep(stepDevice);
} catch (err) {
if (err.name === 'AbortError') return;
showError(err.message);
}
});
// Device info -> mode selection
btnDeviceNext.addEventListener('click', () => {
goToModeSelection();
});
btnDeviceRestore.addEventListener('click', () => {
if (!patchesLoaded) return;
selectedMode = 'patches';
isRestore = true;
setNavLabels(NAV_PATCHES);
goToBuild();
});
async function loadPatchesForVersion(version, available) {
const match = available.find(p => p.version === version);
if (!match) return false;
await patchUI.loadFromURL('patches/' + match.filename);
patchUI.render(patchContainer);
updatePatchCount();
patchesLoaded = true;
return true;
}
// --- Step 2: Mode selection ---
function goToModeSelection() {
// In auto mode, disable custom patches if firmware isn't supported
const patchesRadio = $q('input[value="patches"]', stepMode);
const patchesCard = patchesRadio.closest('.mode-card');
const autoModeNoPatchesAvailable = !manualMode && !patchesLoaded;
const patchesHint = $('mode-patches-hint');
if (autoModeNoPatchesAvailable) {
patchesRadio.disabled = true;
patchesCard.style.opacity = '0.5';
patchesCard.style.cursor = 'not-allowed';
patchesHint.hidden = false;
const nmRadio = $q('input[value="nickelmenu"]', stepMode);
nmRadio.checked = true;
nmRadio.dispatchEvent(new Event('change'));
} else {
patchesRadio.disabled = false;
patchesCard.style.opacity = '';
patchesCard.style.cursor = '';
patchesHint.hidden = true;
}
setNavLabels(NAV_DEFAULT);
setNavStep(2);
showStep(stepMode);
}
btnModeBack.addEventListener('click', () => {
setNavStep(1);
if (manualMode) {
showStep(stepConnect);
} else {
showStep(stepDevice);
}
});
btnModeNext.addEventListener('click', async () => {
const selected = $q('input[name="mode"]:checked', stepMode);
if (!selected) return;
selectedMode = selected.value;
if (selectedMode === 'nickelmenu') {
setNavLabels(NAV_NICKELMENU);
goToNickelMenuConfig();
} else if (manualMode && !patchesLoaded) {
// Manual mode: need version/model selection before patches
setNavLabels(NAV_PATCHES);
await enterManualVersionSelection();
} else {
setNavLabels(NAV_PATCHES);
goToPatches();
}
});
// --- Manual version/model selection (only for custom patches in manual mode) ---
async function enterManualVersionSelection() {
await 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;
showStep(stepManualVersion);
}
btnManualVersionBack.addEventListener('click', () => {
goToModeSelection();
});
// --- Step 2b: NickelMenu configuration ---
const nmConfigOptions = $('nm-config-options');
// Show/hide config checkboxes based on radio selection, enable Continue
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
radio.addEventListener('change', () => {
nmConfigOptions.hidden = radio.value !== 'sample' || !radio.checked;
btnNmNext.disabled = false;
});
}
async function checkNickelMenuInstalled() {
const removeOption = $('nm-option-remove');
const removeRadio = $q('input[value="remove"]', removeOption);
const removeDesc = $('nm-remove-desc');
if (!manualMode && device.directoryHandle) {
try {
const addsDir = await device.directoryHandle.getDirectoryHandle('.adds');
await addsDir.getDirectoryHandle('nm');
removeRadio.disabled = false;
removeOption.classList.remove('nm-option-disabled');
removeDesc.textContent = 'Removes NickelMenu from your device. You must restart your Kobo to complete the uninstall!';
return;
} catch {
// .adds/nm not found
}
}
removeRadio.disabled = true;
removeOption.classList.add('nm-option-disabled');
removeDesc.textContent = 'Removes NickelMenu from your device. Only available when a Kobo with NickelMenu installed is connected.';
if (removeRadio.checked) {
const sampleRadio = $q('input[value="sample"]', stepNickelMenu);
sampleRadio.checked = true;
sampleRadio.dispatchEvent(new Event('change'));
}
}
function getNmConfig() {
return {
fonts: $q('input[name="nm-cfg-fonts"]').checked,
screensaver: $q('input[name="nm-cfg-screensaver"]').checked,
simplifyTabs: $q('input[name="nm-cfg-simplify-tabs"]').checked,
simplifyHome: $q('input[name="nm-cfg-simplify-home"]').checked,
};
}
function goToNickelMenuConfig() {
checkNickelMenuInstalled();
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'sample';
btnNmNext.disabled = !currentOption;
setNavStep(3);
showStep(stepNickelMenu);
}
btnNmBack.addEventListener('click', () => {
goToModeSelection();
});
// Continue from configure to review
btnNmNext.addEventListener('click', () => {
const selected = $q('input[name="nm-option"]:checked', stepNickelMenu);
if (!selected) return;
nickelMenuOption = selected.value;
if (nickelMenuOption === 'remove') {
goToNmReview();
return;
}
goToNmReview();
});
function goToNmReview() {
const summary = $('nm-review-summary');
const list = $('nm-review-list');
list.innerHTML = '';
if (nickelMenuOption === 'remove') {
summary.textContent = 'NickelMenu will be updated and marked for removal. It will uninstall itself when your Kobo reboots.';
btnNmWrite.hidden = manualMode;
btnNmWrite.textContent = 'Remove from Kobo';
btnNmDownload.hidden = true;
} else if (nickelMenuOption === 'nickelmenu-only') {
summary.textContent = 'The following will be installed on your Kobo:';
const li = document.createElement('li');
li.textContent = 'NickelMenu (KoboRoot.tgz)';
list.appendChild(li);
btnNmWrite.hidden = false;
btnNmWrite.textContent = 'Write to Kobo';
btnNmDownload.hidden = false;
} else {
summary.textContent = 'The following will be installed on your Kobo:';
const items = ['NickelMenu (KoboRoot.tgz)', 'Custom menu configuration'];
const cfg = getNmConfig();
if (cfg.fonts) items.push('Readerly fonts');
if (cfg.screensaver) items.push('Custom screensaver');
if (cfg.simplifyTabs) items.push('Simplified tab menu');
if (cfg.simplifyHome) items.push('Simplified homescreen');
for (const text of items) {
const li = document.createElement('li');
li.textContent = text;
list.appendChild(li);
}
btnNmWrite.hidden = false;
btnNmWrite.textContent = 'Write to Kobo';
btnNmDownload.hidden = false;
}
// In manual mode, hide write button
if (manualMode || !device.directoryHandle) {
btnNmWrite.hidden = true;
}
btnNmWrite.disabled = false;
btnNmWrite.className = 'primary';
btnNmDownload.disabled = false;
setNavStep(4);
showStep(stepNmReview);
}
btnNmReviewBack.addEventListener('click', () => {
goToNickelMenuConfig();
});
async function executeNmInstall(writeToDevice) {
const nmProgress = $('nm-progress');
showStep(stepNmInstalling);
try {
if (nickelMenuOption === 'remove') {
await nmInstaller.loadAssets((msg) => { nmProgress.textContent = msg; });
nmProgress.textContent = 'Writing KoboRoot.tgz...';
const tgz = await nmInstaller.getKoboRootTgz();
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
nmProgress.textContent = 'Marking NickelMenu for removal...';
await device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0));
showNmDone('remove');
return;
}
const cfg = nickelMenuOption === 'sample' ? getNmConfig() : null;
if (writeToDevice && device.directoryHandle) {
await nmInstaller.installToDevice(device, nickelMenuOption, cfg, (msg) => {
nmProgress.textContent = msg;
});
showNmDone('written');
} else {
resultNmZip = await nmInstaller.buildDownloadZip(nickelMenuOption, cfg, (msg) => {
nmProgress.textContent = msg;
});
showNmDone('download');
}
} catch (err) {
showError('NickelMenu installation failed: ' + err.message);
}
}
btnNmWrite.addEventListener('click', () => executeNmInstall(true));
btnNmDownload.addEventListener('click', () => executeNmInstall(false));
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 = 'NickelMenu will be removed on next reboot.';
$('nm-reboot-instructions').hidden = false;
} else if (mode === 'written') {
nmDoneStatus.textContent = 'NickelMenu has been installed on your Kobo.';
$('nm-write-instructions').hidden = false;
} else {
nmDoneStatus.textContent = 'Your NickelMenu package is ready to download.';
triggerDownload(resultNmZip, 'NickelMenu-install.zip', 'application/zip');
$('nm-download-instructions').hidden = false;
// Show eReader.conf step only when sample config is included
$('nm-download-conf-step').hidden = nickelMenuOption !== 'sample';
}
setNavStep(5);
showStep(stepNmDone);
}
// --- Step 3 (patches path): Configure patches ---
function goToPatches() {
setNavStep(3);
showStep(stepPatches);
}
btnPatchesBack.addEventListener('click', () => {
if (manualMode) {
// Go back to version selection in manual mode
showStep(stepManualVersion);
} else {
goToModeSelection();
}
});
btnPatchesNext.addEventListener('click', () => {
isRestore = patchUI.getEnabledCount() === 0;
goToBuild();
});
// --- Step 4 (patches path): Review & Build ---
const btnBuild = $('btn-build');
const firmwareDescription = $('firmware-description');
function populateSelectedPatchesList() {
const patchList = $('selected-patches-list');
patchList.innerHTML = '';
const enabled = 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() {
if (isRestore) {
firmwareDescription.textContent =
'will be downloaded and extracted without modifications to restore the original unpatched software.';
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 Software';
}
populateSelectedPatchesList();
setNavStep(4);
showStep(stepFirmware);
}
btnBuildBack.addEventListener('click', () => {
goToPatches();
});
const buildProgress = $('build-progress');
const buildLog = $('build-log');
function appendLog(msg) {
buildLog.textContent += msg + '\n';
buildLog.scrollTop = buildLog.scrollHeight;
}
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) {
buildProgress.textContent = 'Downloading software update...';
return new Uint8Array(await resp.arrayBuffer());
}
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 =
`Downloading software update... ${formatMB(received)} / ${formatMB(total)} (${pct}%)`;
}
const result = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
async function extractOriginalTgz(firmwareBytes) {
buildProgress.textContent = 'Extracting KoboRoot.tgz...';
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 software update');
const tgz = new Uint8Array(await koboRoot.async('arraybuffer'));
appendLog('Extracted KoboRoot.tgz: ' + formatMB(tgz.length));
return tgz;
}
async function runPatcher(firmwareBytes) {
buildProgress.textContent = 'Applying patches...';
const configYAML = patchUI.generateConfig();
const patchFiles = patchUI.getPatchFileBytes();
const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => {
appendLog(msg);
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;
}
function showBuildResult() {
const action = isRestore ? 'Software extracted' : 'Patching complete';
const description = isRestore ? 'This will restore the original unpatched software.' : '';
const deviceName = KOBO_MODELS[selectedPrefix] || 'Kobo';
const installHint = 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(resultTgz.length) + ') is ready. ' +
(description ? description + ' ' : '') + installHint;
const doneLog = $('done-log');
doneLog.textContent = buildLog.textContent;
// Reset install step state.
btnWrite.hidden = manualMode;
btnWrite.disabled = false;
btnWrite.className = 'primary';
btnWrite.textContent = 'Write to Kobo';
btnDownload.disabled = false;
writeInstructions.hidden = true;
downloadInstructions.hidden = true;
existingTgzWarning.hidden = true;
setNavStep(5);
showStep(stepDone);
requestAnimationFrame(() => {
doneLog.scrollTop = doneLog.scrollHeight;
});
}
async function checkExistingTgz() {
if (manualMode || !device.directoryHandle) return;
try {
const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo');
await koboDir.getFileHandle('KoboRoot.tgz');
existingTgzWarning.hidden = false;
} catch {
// No existing file — that's fine.
}
}
btnBuild.addEventListener('click', async () => {
showStep(stepBuilding);
buildLog.textContent = '';
buildProgress.textContent = 'Starting...';
$('build-wait-hint').textContent = isRestore
? 'Please wait while the original software is being downloaded and extracted...'
: 'Please wait while the patch is being applied...';
try {
if (!firmwareURL) {
showError('No download URL available for this device.');
return;
}
const firmwareBytes = await downloadFirmware(firmwareURL);
appendLog('Download complete: ' + formatMB(firmwareBytes.length));
resultTgz = isRestore
? await extractOriginalTgz(firmwareBytes)
: await runPatcher(firmwareBytes);
showBuildResult();
await checkExistingTgz();
} catch (err) {
showError('Build failed: ' + err.message, buildLog.textContent);
}
});
// --- Install step (patches path) ---
btnWrite.addEventListener('click', async () => {
if (!resultTgz || !device.directoryHandle) return;
btnWrite.disabled = true;
btnWrite.textContent = 'Writing...';
downloadInstructions.hidden = true;
try {
const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo');
const fileHandle = await koboDir.getFileHandle('KoboRoot.tgz', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(resultTgz);
await writable.close();
btnWrite.textContent = 'Written';
btnWrite.className = 'btn-success';
writeInstructions.hidden = false;
} catch (err) {
btnWrite.disabled = false;
btnWrite.textContent = 'Write to Kobo';
showError('Failed to write KoboRoot.tgz: ' + err.message);
}
});
btnDownload.addEventListener('click', () => {
if (!resultTgz) return;
triggerDownload(resultTgz, 'KoboRoot.tgz', 'application/gzip');
writeInstructions.hidden = true;
downloadInstructions.hidden = false;
$('download-device-name').textContent = KOBO_MODELS[selectedPrefix] || 'Kobo';
});
// --- Error / Retry ---
function showError(message, log) {
errorMessage.textContent = message;
if (log) {
errorLog.textContent = log;
errorLog.hidden = false;
} else {
errorLog.hidden = true;
}
hideNav();
showStep(stepError);
}
btnRetry.addEventListener('click', () => {
device.disconnect();
firmwareURL = null;
resultTgz = null;
resultNmZip = null;
manualMode = false;
selectedPrefix = null;
patchesLoaded = false;
isRestore = false;
selectedMode = null;
nickelMenuOption = null;
btnDeviceNext.hidden = false;
btnDeviceRestore.hidden = false;
setNavLabels(NAV_DEFAULT);
setNavStep(1);
showStep(stepConnect);
});
// --- How it works dialog ---
const dialog = $('how-it-works-dialog');
$('btn-how-it-works').addEventListener('click', (e) => {
e.preventDefault();
dialog.showModal();
});
$('btn-close-dialog').addEventListener('click', () => {
dialog.close();
});
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
})();