1
0

Restructured JS files

This commit is contained in:
2026-03-25 14:18:28 +01:00
parent f24b1b7759
commit c83abc9e9c
17 changed files with 147 additions and 159 deletions

View File

@@ -60,17 +60,17 @@ async function build() {
logLevel: 'warning',
});
// Copy worker files from src/js/ (not bundled, served separately)
mkdirSync(join(distDir, 'js'), { recursive: true });
// Copy worker files from src/js/workers/ (not bundled, served separately)
mkdirSync(join(distDir, 'js', 'workers'), { recursive: true });
// Copy wasm_exec.js as-is
const wasmExecSrc = join(srcDir, 'js', 'wasm_exec.js');
if (existsSync(wasmExecSrc)) {
cpSync(wasmExecSrc, join(distDir, 'js', 'wasm_exec.js'));
cpSync(wasmExecSrc, join(distDir, 'js', 'workers', 'wasm_exec.js'));
}
// Copy patch-worker.js with WASM hash injected
const workerSrc = join(srcDir, 'js', 'patch-worker.js');
const workerSrc = join(srcDir, 'js', 'workers', 'patch-worker.js');
if (existsSync(workerSrc)) {
let workerContent = readFileSync(workerSrc, 'utf-8');
const wasmFile = join(distDir, 'wasm', 'kobopatch.wasm');
@@ -81,7 +81,7 @@ async function build() {
`kobopatch.wasm?h=${wasmHash}'`
);
}
writeFileSync(join(distDir, 'js', 'patch-worker.js'), workerContent);
writeFileSync(join(distDir, 'js', 'workers', 'patch-worker.js'), workerContent);
}
// Get git version string

View File

@@ -38,7 +38,7 @@ export default [
},
{
// Worker script uses importScripts, self, Go, globalThis, WebAssembly
files: ['src/js/patch-worker.js'],
files: ['src/js/workers/patch-worker.js'],
languageOptions: {
globals: {
self: 'readonly',

View File

@@ -18,17 +18,17 @@
* `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 { KoboDevice } from './services/kobo-device.js';
import { loadSoftwareUrls, getSoftwareUrl, getDevicesForVersion } from './services/kobo-software-urls.js';
import { PatchUI, scanAvailablePatches } from './ui/patch-ui.js';
import { KoboPatchRunner } from './services/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';
import { initNickelMenu } from './flows/nickelmenu-flow.js';
import { initPatchesFlow } from './flows/patches-flow.js';
// =============================================================================
// Shared state

View File

@@ -44,6 +44,59 @@ export function populateSelect(selectEl, placeholder, items) {
}
}
/**
* Render a list of checkbox items into a container.
* @param {HTMLElement} container
* @param {Array<{name: string, title: string, description: string, checked: boolean, disabled?: boolean}>} items
*/
export function renderNmCheckboxList(container, items) {
container.innerHTML = '';
for (const item of items) {
const label = document.createElement('label');
label.className = 'nm-config-item';
const input = document.createElement('input');
input.type = 'checkbox';
input.name = item.name;
input.checked = item.checked;
if (item.disabled) input.disabled = true;
const textDiv = document.createElement('div');
textDiv.className = 'nm-config-text';
const titleSpan = document.createElement('span');
titleSpan.className = 'nm-config-title';
titleSpan.textContent = item.title;
const descSpan = document.createElement('span');
descSpan.className = 'nm-config-desc';
descSpan.textContent = item.description;
textDiv.appendChild(titleSpan);
textDiv.appendChild(descSpan);
label.appendChild(input);
label.appendChild(textDiv);
container.appendChild(label);
}
}
/** Populate a <ul>/<ol> with text items, clearing existing content. */
export function populateList(listEl, items) {
listEl.innerHTML = '';
for (const text of items) {
const li = document.createElement('li');
li.textContent = text;
listEl.appendChild(li);
}
}
/** Fetch with automatic error throwing on non-OK responses. */
export async function fetchOrThrow(url, errorPrefix = 'Fetch failed') {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`${errorPrefix}: HTTP ${resp.status}`);
return resp;
}
/**
* Trigger a browser download of in-memory data.
* Creates a temporary object URL, clicks a hidden <a>, then revokes it.

View File

@@ -13,11 +13,11 @@
* `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';
import { $, $q, $qa, triggerDownload, renderNmCheckboxList, populateList } 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) {
@@ -47,42 +47,16 @@ export function initNickelMenu(state) {
// 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);
}
const items = ALL_FEATURES
.filter(f => f.available !== false)
.map(f => ({
name: 'nm-cfg-' + f.id,
title: f.title + (f.required ? ' (required)' : '') + (f.version ? ' ' + f.version : ''),
description: f.description,
checked: f.required || f.default,
disabled: f.required,
}));
renderNmCheckboxList(nmConfigOptions, items);
}
// --- Uninstall checkboxes ---
@@ -90,35 +64,17 @@ export function initNickelMenu(state) {
// (like KOReader) so the user can opt into cleaning those up too.
function renderUninstallCheckboxes() {
if (detectedUninstallFeatures.length === 0) {
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);
return;
}
const items = detectedUninstallFeatures.map(f => ({
name: 'nm-uninstall-' + f.id,
title: 'Also remove ' + f.uninstall.title,
description: f.uninstall.description,
checked: true,
}));
renderNmCheckboxList(nmUninstallOptions, items);
}
/** Clear removal state when returning to mode selection. */
@@ -263,39 +219,24 @@ export function initNickelMenu(state) {
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);
}
populateList(list, featuresToRemove.map(f => f.uninstall.title + ' will also be removed'));
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.
// "nickelmenu-only" or "preset" — both install NickelMenu.
summary.textContent = TL.STATUS.NM_WILL_BE_INSTALLED;
const items = [TL.STATUS.NM_NICKEL_ROOT_TGZ];
if (state.nickelMenuOption === 'preset') {
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);
}
populateList(list, items);
btnNmWrite.hidden = false;
btnNmWrite.textContent = TL.BUTTON.WRITE_TO_KOBO;
btnNmDownload.hidden = false;

View File

@@ -14,11 +14,11 @@
* `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 { $, formatMB, triggerDownload, populateList } from '../dom.js';
import { showStep, setNavLabels, setNavStep } from '../nav.js';
import { KoboModels } from '../services/kobo-device.js';
import { TL } from '../strings.js';
import { track } from '../analytics.js';
import JSZip from 'jszip';
export function initPatchesFlow(state) {
@@ -99,13 +99,8 @@ export function initPatchesFlow(state) {
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);
}
populateList(patchList, enabled);
const hasPatches = enabled.length > 0;
patchList.hidden = !hasPatches;
$('selected-patches-heading').hidden = !hasPatches;

View File

@@ -1,4 +1,5 @@
import { KoboModels } from './kobo-device.js';
import { fetchOrThrow } from '../dom.js';
let _data = null;
@@ -8,8 +9,7 @@ let _data = null;
*/
async function loadSoftwareUrls() {
if (_data) return _data;
const resp = await fetch('/patches/downloads.json');
if (!resp.ok) throw new Error('Failed to load download URLs');
const resp = await fetchOrThrow('/patches/downloads.json', 'Failed to load download URLs');
_data = await resp.json();
window.FIRMWARE_DOWNLOADS = _data;
return _data;

View File

@@ -17,7 +17,7 @@ class KoboPatchRunner {
*/
patchFirmware(configYAML, firmwareZip, patchFiles, onProgress) {
return new Promise((resolve, reject) => {
const worker = new Worker('js/patch-worker.js');
const worker = new Worker('js/workers/patch-worker.js');
this._worker = worker;
worker.onmessage = (e) => {

View File

@@ -1,5 +1,6 @@
import JSZip from 'jszip';
import { TL } from './strings.js';
import { TL } from '../strings.js';
import { fetchOrThrow } from '../dom.js';
/**
* Friendly display names for patch files.
@@ -208,10 +209,7 @@ class PatchUI {
* Load patches from a URL pointing to a zip file.
*/
async loadFromURL(url) {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error('Failed to fetch patch zip: ' + resp.statusText);
}
const resp = await fetchOrThrow(url, 'Failed to fetch patch zip');
const data = await resp.arrayBuffer();
await this.loadFromZip(data);
}

View File

@@ -10,7 +10,7 @@ async function loadWasm() {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch('../wasm/kobopatch.wasm'),
fetch('../../wasm/kobopatch.wasm'),
go.importObject
);
go.run(result.instance);

View File

@@ -0,0 +1,23 @@
/**
* Shared helpers for NickelMenu features that modify .adds/nm/items.
*/
/** Create a postProcess function that appends a line to .adds/nm/items. */
export function appendToNmConfig(line) {
return function postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
items.data += '\n' + line + '\n';
return files;
};
}
/** Create a postProcess function that prepends a line to .adds/nm/items. */
export function prependToNmConfig(line) {
return function postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
items.data = line + '\n\n' + items.data;
return files;
};
}

View File

@@ -1,15 +1,10 @@
import { appendToNmConfig } from '../helpers.js';
export default {
id: 'hide-notices',
title: 'Hide home screen notices',
description: 'Hides the third row on the home screen that shows notices below your books, such as reading time, release notes for updates, and Kobo Plus or Store promotions.',
default: false,
postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
items.data += '\nexperimental:hide_home_row3_enabled:1\n';
return files;
},
postProcess: appendToNmConfig('experimental:hide_home_row3_enabled:1'),
};

View File

@@ -1,15 +1,10 @@
import { appendToNmConfig } from '../helpers.js';
export default {
id: 'hide-recommendations',
title: 'Hide home screen recommendations',
description: 'Hides the recommendations next to your current read on the home screen.',
default: false,
postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
items.data += '\nexperimental:hide_home_row1col2_enabled:1\n';
return files;
},
postProcess: appendToNmConfig('experimental:hide_home_row1col2_enabled:1'),
};

View File

@@ -1,4 +1,5 @@
import JSZip from 'jszip';
import { prependToNmConfig } from '../helpers.js';
export default {
id: 'koreader',
@@ -43,12 +44,5 @@ export default {
return files;
},
postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
items.data = 'menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh\n\n' + items.data;
return files;
},
postProcess: prependToNmConfig('menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh'),
};

View File

@@ -1,3 +1,5 @@
import { prependToNmConfig } from '../helpers.js';
const TAB_CONFIG = [
'experimental :menu_main_15505_0_enabled: 1',
'experimental :menu_main_15505_1_label: Books',
@@ -17,12 +19,5 @@ export default {
description: 'Hides the "My Notebooks" and "Discover" tabs from the bottom navigation tab bar.',
default: false,
postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
items.data = TAB_CONFIG + '\n\n' + items.data;
return files;
},
postProcess: prependToNmConfig(TAB_CONFIG),
};

View File

@@ -1,4 +1,5 @@
import JSZip from 'jszip';
import { fetchOrThrow } from '../js/dom.js';
import customMenu from './features/custom-menu/index.js';
import readerlyFonts from './features/readerly-fonts/index.js';
@@ -32,8 +33,7 @@ function createContext(feature, progressFn) {
return {
async asset(relativePath) {
const url = basePath + relativePath;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Failed to load asset ${url}: HTTP ${resp.status}`);
const resp = await fetchOrThrow(url, `Failed to load asset ${url}`);
return new Uint8Array(await resp.arrayBuffer());
},
progress(msg) {
@@ -53,8 +53,7 @@ export class NickelMenuInstaller {
async loadNickelMenu(progressFn) {
if (this.nickelMenuZip) return;
progressFn('Downloading NickelMenu...');
const resp = await fetch('nickelmenu/NickelMenu.zip');
if (!resp.ok) throw new Error('Failed to download NickelMenu.zip: HTTP ' + resp.status);
const resp = await fetchOrThrow('nickelmenu/NickelMenu.zip', 'Failed to download NickelMenu.zip');
this.nickelMenuZip = await JSZip.loadAsync(await resp.arrayBuffer());
}