1
0

Refactor how NickelMenu is set up
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s

This commit is contained in:
2026-03-21 17:57:04 +01:00
parent 6f902090c1
commit 7b62c2e166
27 changed files with 553 additions and 404 deletions

View File

@@ -177,49 +177,7 @@
</div>
</label>
<div id="nm-config-options" class="nm-config-options" hidden>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-menu" checked disabled>
<div class="nm-config-text">
<span>Set up custom menu (required)</span>
<span class="nm-config-desc">Adds menu items for dark mode, screenshots, and more. A new tab will be added in the bottom navigation that is labelled "Tweak".</span>
</div>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-fonts" checked>
<div class="nm-config-text">
<span>Install Readerly fonts (recommended)</span>
<span class="nm-config-desc">Adds the Readerly font family. These fonts are optically similar to Bookerly. When you are reading a book, you will be able to select this font from the dropdown as "KF Readerly".</span>
</div>
</label>
<label class="nm-config-item" id="nm-cfg-koreader-label">
<input type="checkbox" name="nm-cfg-koreader">
<div class="nm-config-text">
<span>Install KOReader <span id="koreader-version"></span> (optional)</span>
<span class="nm-config-desc">Installs <a href="https://koreader.rocks" target="_blank">KOReader</a>, an alternative e-book reader with advanced features like PDF reflow, customizable fonts, and more.</span>
</div>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-simplify-tabs">
<div class="nm-config-text">
<span>Hide certain navigation tabs (minimalist)</span>
<span class="nm-config-desc">This will hide the Notebook and Discover tabs from the bottom navigation. For minimalists who want fewer distractions.</span>
</div>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-simplify-home">
<div class="nm-config-text">
<span>Hide certain home screen elements (minimalist)</span>
<span class="nm-config-desc">If you are reading only one book, no recommendations will appear next to your current read, and third row on your homescreen with advertisements for Kobo Plus and the Kobo Store will be hidden. For minimalists who want fewer distractions.</span>
</div>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-screensaver">
<div class="nm-config-text">
<span>Copy screensaver (optional)</span>
<span class="nm-config-desc">Copies a screensaver to <code>/.kobo/screensaver</code>. Depending on your configuration, it will now be displayed instead of your current read. You can always add your own in the <code>/.kobo/screensaver</code> folder, and choosing Tweak > Screensaver will let you toggle it off.</span>
</div>
</label>
<a href="https://github.com/nicoverbruggen/kobo-config" target="_blank" class="nm-config-link">Learn more about these customisations &#x203A;</a>
<!-- Populated dynamically from feature modules by app.js -->
</div>
<label class="nm-option">
<input type="radio" name="nm-option" value="nickelmenu-only">

View File

@@ -2,7 +2,7 @@ import { KoboDevice, KoboModels } 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 } from './nickelmenu.js';
import { NickelMenuInstaller, ALL_FEATURES } from '../nickelmenu/installer.js';
import { TL } from './strings.js';
import { isEnabled as analyticsEnabled, track } from './analytics.js';
import JSZip from 'jszip';
@@ -34,16 +34,17 @@ import JSZip from 'jszip';
const softwareUrlsReady = loadSoftwareUrls();
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// Show KOReader version in the UI (best-effort, non-blocking).
fetch('/koreader/release.json').then(r => r.ok ? r.json() : null).then(meta => {
if (meta && meta.version) {
$('koreader-version').textContent = meta.version;
} else {
$('nm-cfg-koreader-label').style.display = 'none';
}
}).catch(() => {
$('nm-cfg-koreader-label').style.display = 'none';
});
// Check KOReader availability and mark the feature (best-effort, non-blocking).
const koreaderFeature = ALL_FEATURES.find(f => f.id === 'koreader');
const koreaderVersionReady = 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(() => {});
function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
@@ -474,6 +475,46 @@ import JSZip from 'jszip';
// --- Step 2b: NickelMenu configuration ---
const nmConfigOptions = $('nm-config-options');
// Render feature checkboxes dynamically from ALL_FEATURES
function renderFeatureCheckboxes() {
nmConfigOptions.innerHTML = '';
for (const feature of ALL_FEATURES) {
// Hide unavailable features (e.g. KOReader when assets missing)
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');
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);
}
}
// Show/hide config checkboxes based on radio selection, enable Continue
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
radio.addEventListener('change', () => {
@@ -504,24 +545,24 @@ import JSZip from 'jszip';
removeOption.classList.add('nm-option-disabled');
removeDesc.textContent = TL.STATUS.NM_REMOVAL_DISABLED;
if (removeRadio.checked) {
const sampleRadio = $q('input[value="sample"]', stepNickelMenu);
sampleRadio.checked = true;
sampleRadio.dispatchEvent(new Event('change'));
const presetRadio = $q('input[value="preset"]', stepNickelMenu);
presetRadio.checked = true;
presetRadio.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,
koreader: $q('input[name="nm-cfg-koreader"]').checked,
};
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;
});
}
function goToNickelMenuConfig() {
checkNickelMenuInstalled();
renderFeatureCheckboxes();
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'preset';
btnNmNext.disabled = !currentOption;
@@ -563,13 +604,10 @@ import JSZip from 'jszip';
btnNmDownload.hidden = false;
} else {
summary.textContent = TL.STATUS.NM_WILL_BE_INSTALLED;
const items = [TL.STATUS.NM_NICKEL_ROOT_TGZ, 'Custom menu configuration'];
const cfg = getNmConfig();
if (cfg.fonts) items.push(TL.NICKEL_MENU_ITEMS.FONTS);
if (cfg.screensaver) items.push(TL.NICKEL_MENU_ITEMS.SCREENSAVER);
if (cfg.simplifyTabs) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_TABS);
if (cfg.simplifyHome) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_HOME);
if (cfg.koreader) items.push(TL.NICKEL_MENU_ITEMS.KOREADER);
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;
@@ -599,11 +637,12 @@ import JSZip from 'jszip';
async function executeNmInstall(writeToDevice) {
const nmProgress = $('nm-progress');
const progressFn = (msg) => { nmProgress.textContent = msg; };
showStep(stepNmInstalling);
try {
if (nickelMenuOption === 'remove') {
await nmInstaller.loadAssets((msg) => { nmProgress.textContent = msg; }, false);
await nmInstaller.loadNickelMenu(progressFn);
nmProgress.textContent = 'Writing KoboRoot.tgz...';
const tgz = await nmInstaller.getKoboRootTgz();
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
@@ -613,17 +652,13 @@ import JSZip from 'jszip';
return;
}
const cfg = nickelMenuOption === 'preset' ? getNmConfig() : null;
const features = nickelMenuOption === 'preset' ? getSelectedFeatures() : [];
if (writeToDevice && device.directoryHandle) {
await nmInstaller.installToDevice(device, nickelMenuOption, cfg, (msg) => {
nmProgress.textContent = msg;
});
await nmInstaller.installToDevice(device, features, progressFn);
showNmDone('written');
} else {
resultNmZip = await nmInstaller.buildDownloadZip(nickelMenuOption, cfg, (msg) => {
nmProgress.textContent = msg;
});
resultNmZip = await nmInstaller.buildDownloadZip(features, progressFn);
showNmDone('download');
}
} catch (err) {

View File

@@ -1,277 +0,0 @@
import JSZip from 'jszip';
/**
* NickelMenu installer module.
*
* Handles downloading bundled NickelMenu + kobo-config assets,
* and either writing them directly to a connected Kobo (auto mode)
* or building a zip for manual download.
*
* Options:
* 'nickelmenu-only' — just NickelMenu (KoboRoot.tgz)
* 'preset' — NickelMenu + config based on cfg flags
*
* Config flags (when option is 'preset'):
* fonts: bool — include Readerly fonts
* screensaver: bool — include custom screensaver
* simplifyTabs: bool — comment out experimental tab items in config
* simplifyHome: bool — append homescreen simplification lines
* koreader: bool — download and install latest KOReader from GitHub
*/
class NickelMenuInstaller {
constructor() {
this.nickelMenuZip = null; // JSZip instance
this.koboConfigZip = null; // JSZip instance
this.koreaderZip = null; // JSZip instance
}
/**
* Download and cache the bundled assets.
* @param {function} progressFn
* @param {boolean} [needConfig=true] - Whether to also load kobo-config.zip
*/
async loadAssets(progressFn, needConfig = true) {
if (!this.nickelMenuZip) {
progressFn('Downloading NickelMenu...');
const nmResp = await fetch('nickelmenu/NickelMenu.zip');
if (!nmResp.ok) throw new Error('Failed to download NickelMenu.zip: HTTP ' + nmResp.status);
this.nickelMenuZip = await JSZip.loadAsync(await nmResp.arrayBuffer());
}
if (needConfig && !this.koboConfigZip) {
progressFn('Downloading configuration files...');
const cfgResp = await fetch('nickelmenu/kobo-config.zip');
if (!cfgResp.ok) throw new Error('Failed to download kobo-config.zip: HTTP ' + cfgResp.status);
this.koboConfigZip = await JSZip.loadAsync(await cfgResp.arrayBuffer());
}
}
/**
* Download and cache KOReader for Kobo (served from the app's own domain
* to avoid CORS issues with GitHub release downloads).
* @param {function} progressFn
*/
async loadKoreader(progressFn) {
if (this.koreaderZip) return;
progressFn('Fetching KOReader release info...');
const metaResp = await fetch('/koreader/release.json');
if (!metaResp.ok) throw new Error('KOReader assets not available (run koreader/setup.sh)');
const meta = await metaResp.json();
progressFn('Downloading KOReader ' + meta.version + '...');
const zipResp = await fetch('/koreader/koreader-kobo.zip');
if (!zipResp.ok) throw new Error('Failed to download KOReader: HTTP ' + zipResp.status);
this.koreaderZip = await JSZip.loadAsync(await zipResp.arrayBuffer());
}
/**
* Get the KoboRoot.tgz from the NickelMenu zip.
*/
async getKoboRootTgz() {
const file = this.nickelMenuZip.file('KoboRoot.tgz');
if (!file) throw new Error('KoboRoot.tgz not found in NickelMenu.zip');
return new Uint8Array(await file.async('arraybuffer'));
}
/**
* Get KOReader files from the downloaded zip, remapped to .adds/koreader/.
* The zip contains a top-level koreader/ directory that needs to be placed
* under .adds/ on the device. Also includes a NickelMenu launcher config.
* Returns { path: string[], data: Uint8Array } entries.
*/
async getKoreaderFiles() {
const files = [];
for (const [relativePath, zipEntry] of Object.entries(this.koreaderZip.files)) {
if (zipEntry.dir) continue;
// Remap koreader/... to .adds/koreader/...
const devicePath = relativePath.startsWith('koreader/')
? '.adds/' + relativePath
: '.adds/koreader/' + relativePath;
const data = new Uint8Array(await zipEntry.async('arraybuffer'));
files.push({
path: devicePath.split('/'),
data,
});
}
// Add NickelMenu launcher config
const launcherConfig = 'menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh\n';
files.push({
path: ['.adds', 'nm', 'koreader'],
data: new TextEncoder().encode(launcherConfig),
});
return files;
}
/**
* Get config files from kobo-config.zip filtered by cfg flags.
* Returns { path: string[], data: Uint8Array } entries.
*/
async getConfigFiles(cfg) {
const files = [];
for (const [relativePath, zipEntry] of Object.entries(this.koboConfigZip.files)) {
if (zipEntry.dir) continue;
// Filter by cfg flags
if (relativePath.startsWith('fonts/') && !cfg.fonts) continue;
if (relativePath.startsWith('.kobo/screensaver/') && !cfg.screensaver) continue;
// Only include relevant directories
if (!relativePath.startsWith('.adds/') &&
!relativePath.startsWith('.kobo/screensaver/') &&
!relativePath.startsWith('fonts/')) {
continue;
}
let data = new Uint8Array(await zipEntry.async('arraybuffer'));
// Modify the NickelMenu items file based on config
if (relativePath === '.adds/nm/items') {
let text = new TextDecoder().decode(data);
// Comment out experimental lines at top if simplifyTabs is off
if (!cfg.simplifyTabs) {
text = text.split('\n').map(line => {
if (line.startsWith('experimental:') && !line.startsWith('experimental:hide_home')) {
return '#' + line;
}
return line;
}).join('\n');
}
// Append homescreen simplification lines
if (cfg.simplifyHome) {
text += '\nexperimental:hide_home_row1col2_enabled:1\nexperimental:hide_home_row3_enabled:1\n';
}
data = new TextEncoder().encode(text);
}
files.push({
path: relativePath.split('/'),
data,
});
}
return files;
}
/**
* Install to a connected Kobo device via File System Access API.
* @param {KoboDevice} device
* @param {string} option - 'preset' or 'nickelmenu-only'
* @param {object|null} cfg - config flags (when option is 'preset')
* @param {function} progressFn
*/
async installToDevice(device, option, cfg, progressFn) {
const needConfig = option !== 'nickelmenu-only';
await this.loadAssets(progressFn, needConfig);
// Always install KoboRoot.tgz
progressFn('Writing KoboRoot.tgz...');
const tgz = await this.getKoboRootTgz();
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
if (option === 'nickelmenu-only') {
progressFn('Done.');
return;
}
// Install config files
progressFn('Writing configuration files...');
const configFiles = await this.getConfigFiles(cfg);
for (const { path, data } of configFiles) {
await device.writeFile(path, data);
}
// Install KOReader if selected
if (cfg.koreader) {
await this.loadKoreader(progressFn);
progressFn('Writing KOReader files...');
const koreaderFiles = await this.getKoreaderFiles();
for (const { path, data } of koreaderFiles) {
await device.writeFile(path, data);
}
}
// Modify Kobo eReader.conf
progressFn('Updating Kobo eReader.conf...');
await this.updateEReaderConf(device);
progressFn('Done.');
}
/**
* Add ExcludeSyncFolders to Kobo eReader.conf if not already present.
*/
async updateEReaderConf(device) {
const confPath = ['.kobo', 'Kobo', 'Kobo eReader.conf'];
let content = await device.readFile(confPath) || '';
const settingLine = 'ExcludeSyncFolders=(calibre|\\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\\..+)';
if (content.includes('ExcludeSyncFolders')) {
// Already has the setting, don't duplicate
return;
}
// Add under [FeatureSettings], creating the section if needed
if (content.includes('[FeatureSettings]')) {
content = content.replace(
'[FeatureSettings]',
'[FeatureSettings]\n' + settingLine
);
} else {
content += '\n[FeatureSettings]\n' + settingLine + '\n';
}
await device.writeFile(confPath, new TextEncoder().encode(content));
}
/**
* Build a zip for manual download containing all files to copy to the Kobo.
* @param {string} option - 'preset' or 'nickelmenu-only'
* @param {object|null} cfg - config flags (when option is 'preset')
* @param {function} progressFn
* @returns {Uint8Array} zip contents
*/
async buildDownloadZip(option, cfg, progressFn) {
const needConfig = option !== 'nickelmenu-only';
await this.loadAssets(progressFn, needConfig);
progressFn('Building download package...');
const zip = new JSZip();
// Always include KoboRoot.tgz
const tgz = await this.getKoboRootTgz();
zip.file('.kobo/KoboRoot.tgz', tgz);
if (option !== 'nickelmenu-only') {
// Include config files
const configFiles = await this.getConfigFiles(cfg);
for (const { path, data } of configFiles) {
zip.file(path.join('/'), data);
}
// Include KOReader if selected
if (cfg.koreader) {
await this.loadKoreader(progressFn);
progressFn('Adding KOReader to package...');
const koreaderFiles = await this.getKoreaderFiles();
for (const { path, data } of koreaderFiles) {
zip.file(path.join('/'), data);
}
}
}
progressFn('Compressing...');
const result = await zip.generateAsync({ type: 'uint8array' });
progressFn('Done.');
return result;
}
}
export { NickelMenuInstaller };

View File

@@ -50,11 +50,4 @@ export const TL = {
NONE: 'None (do not patch)',
},
NICKEL_MENU_ITEMS: {
FONTS: 'Readerly fonts',
SCREENSAVER: 'Custom screensaver',
SIMPLIFY_TABS: 'Simplified tab menu',
SIMPLIFY_HOME: 'Simplified homescreen',
KOREADER: 'KOReader',
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,16 @@
export default {
id: 'custom-menu',
title: 'Set up custom menu',
description: 'Adds menu items for dark mode, screenshots, and more. A new tab will be added in the bottom navigation that is labelled "Tweak".',
default: true,
required: true,
async install(ctx) {
return [
{ path: '.adds/nm/items', data: await ctx.asset('items') },
{ path: '.adds/nm/.cog.png', data: await ctx.asset('.cog.png') },
{ path: '.adds/scripts/legibility_status.sh', data: await ctx.asset('scripts/legibility_status.sh') },
{ path: '.adds/scripts/toggle_wk_rendering.sh', data: await ctx.asset('scripts/toggle_wk_rendering.sh') },
];
},
};

View File

@@ -0,0 +1,39 @@
# Menu button customization (uncommented by "Hide certain navigation tabs" option)
#experimental :menu_main_15505_0_enabled: 1
#experimental :menu_main_15505_1_label: Books
#experimental :menu_main_15505_2_enabled: 1
#experimental :menu_main_15505_2_label: Stats
#experimental :menu_main_15505_3_enabled: 0
#experimental :menu_main_15505_3_label: Notes
#experimental :menu_main_15505_4_enabled: 0
#experimental :menu_main_15505_5_enabled: 1
#experimental :menu_main_15505_default: 1
#experimental :menu_main_15505_enabled: 1
# The main NickelMenu item is now called Tweak
#experimental :menu_main_15505_label :Tweak
#experimental :menu_main_15505_icon :/mnt/onboard/.adds/nm/.cog.png
menu_item :main :Screensaver :cmd_output :500 :quiet :test -e /mnt/onboard/.disabled/screensaver
chain_failure : skip : 3
chain_success : cmd_spawn : quiet: mkdir -p /mnt/onboard/.disabled && mv /mnt/onboard/.disabled/screensaver /mnt/onboard/.kobo/screensaver
chain_success : dbg_toast : Screensaver is now ON.
chain_always : skip : -1
chain_failure : cmd_spawn : quiet: mkdir -p /mnt/onboard/.disabled && mv /mnt/onboard/.kobo/screensaver /mnt/onboard/.disabled/screensaver
chain_success : dbg_toast : Screensaver is now OFF.
menu_item :main :Screenshots :nickel_setting :toggle :screenshots
menu_item :main :Auto USB :nickel_setting :toggle :auto_usb_gadget
menu_item :main :Legibility Status :cmd_output :500 :/mnt/onboard/.adds/scripts/legibility_status.sh
menu_item :main :Legibility Toggle :cmd_output :5000 :/mnt/onboard/.adds/scripts/toggle_wk_rendering.sh
menu_item :main :IP Address :cmd_output :500:/sbin/ifconfig | /usr/bin/awk '/inet addr/{print substr($2,6)}'
menu_item :main :Invert & Reboot :nickel_setting :toggle: invert
chain_success :power :reboot
menu_item :main :Sleep :power :sleep
menu_item :main :Reboot :power :reboot
menu_item :reader :Dark Mode :nickel_setting :toggle :dark_mode

View File

@@ -0,0 +1,25 @@
#!/bin/sh
CONFIG_FILE="/mnt/onboard/.kobo/Kobo/Kobo eReader.conf"
if grep -q "^webkitTextRendering=optimizeLegibility" "$CONFIG_FILE"; then
echo "Optimized legibility is ON."
echo ""
echo "- Ligatures will be displayed."
echo "- GPOS kerning works correctly."
echo "- Justified text may have some wrapping issues."
echo ""
echo "It's highly recommended to enable left-aligned"
echo "text to avoid wrapping issues in some books."
echo ""
echo "This mode renders text more correctly."
echo "Use 'Legibility Toggle' to turn this OFF."
else
echo "Optimized legibility is OFF."
echo ""
echo "- Ligatures will NOT be displayed."
echo "- Only old-style kerning works correctly."
echo ""
echo "This is the most compatible mode, and Kobo's default."
echo "Use 'Legibility Toggle' to switch to turn this ON."
fi

View File

@@ -0,0 +1,25 @@
#!/bin/sh
# Script to toggle webkitTextRendering setting.
# This causes certain font features to work in kepub files.
CONFIG_FILE="/mnt/onboard/.kobo/Kobo/Kobo eReader.conf"
# Check if the setting exists
if grep -q "^webkitTextRendering=optimizeLegibility" "$CONFIG_FILE"; then
# Remove the line
sed -i '/^webkitTextRendering=optimizeLegibility/d' "$CONFIG_FILE"
echo "Now turned OFF. Your Kobo will now reboot."
echo "(No need to press the OK button...)"
sleep 3 && reboot &
else
# Add the line below [Reading] section
if grep -q "^\[Reading\]" "$CONFIG_FILE"; then
sed -i '/^\[Reading\]/a webkitTextRendering=optimizeLegibility' "$CONFIG_FILE"
echo "Now turned ON. Your Kobo will now reboot."
echo "(No need to press the OK button...)"
sleep 3 && reboot &
else
echo "Oops. Could not find [Reading] section!"
fi
fi

View File

@@ -0,0 +1,42 @@
import JSZip from 'jszip';
export default {
id: 'koreader',
title: 'Install KOReader',
description: 'Installs KOReader, an alternative e-book reader with advanced features like PDF reflow, customizable fonts, and more.',
default: false,
available: false, // set to true at runtime if KOReader assets exist
async install(ctx) {
ctx.progress('Fetching KOReader release info...');
const metaResp = await fetch('/koreader/release.json');
if (!metaResp.ok) throw new Error('KOReader assets not available (run koreader/setup.sh)');
const meta = await metaResp.json();
ctx.progress('Downloading KOReader ' + meta.version + '...');
const zipResp = await fetch('/koreader/koreader-kobo.zip');
if (!zipResp.ok) throw new Error('Failed to download KOReader: HTTP ' + zipResp.status);
const zip = await JSZip.loadAsync(await zipResp.arrayBuffer());
ctx.progress('Extracting KOReader...');
const files = [];
for (const [relativePath, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const devicePath = relativePath.startsWith('koreader/')
? '.adds/' + relativePath
: '.adds/koreader/' + relativePath;
files.push({
path: devicePath,
data: new Uint8Array(await entry.async('arraybuffer')),
});
}
// Add NickelMenu launcher config
files.push({
path: '.adds/nm/koreader',
data: 'menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh\n',
});
return files;
},
};

View File

@@ -0,0 +1,27 @@
import JSZip from 'jszip';
export default {
id: 'readerly-fonts',
title: 'Install Readerly fonts',
description: 'Adds the Readerly font family. These fonts are optically similar to Bookerly. When you are reading a book, you will be able to select this font from the dropdown as "KF Readerly".',
default: true,
async install(ctx) {
ctx.progress('Downloading Readerly fonts...');
const resp = await fetch('/readerly/KF_Readerly.zip');
if (!resp.ok) throw new Error('Failed to download Readerly fonts: HTTP ' + resp.status);
const zip = await JSZip.loadAsync(await resp.arrayBuffer());
const files = [];
for (const [name, entry] of Object.entries(zip.files)) {
if (entry.dir || !name.endsWith('.ttf')) continue;
// Strip any directory prefix, place directly in fonts/
const filename = name.split('/').pop();
files.push({
path: 'fonts/' + filename,
data: new Uint8Array(await entry.async('arraybuffer')),
});
}
return files;
},
};

View File

@@ -0,0 +1,12 @@
export default {
id: 'screensaver',
title: 'Copy screensaver',
description: 'Copies a screensaver to .kobo/screensaver. Depending on your configuration, it will now be displayed instead of your current read. You can always add your own in the .kobo/screensaver folder, and choosing Tweak > Screensaver will let you toggle it off.',
default: false,
async install(ctx) {
return [
{ path: '.kobo/screensaver/moon.png', data: await ctx.asset('moon.png') },
];
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,15 @@
export default {
id: 'simplify-home',
title: 'Hide certain home screen elements',
description: 'If you are reading only one book, no recommendations will appear next to your current read, and third row on your homescreen with advertisements for Kobo Plus and the Kobo Store will be hidden. For minimalists who want fewer distractions.',
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\nexperimental:hide_home_row3_enabled:1\n';
return files;
},
};

View File

@@ -0,0 +1,21 @@
export default {
id: 'simplify-tabs',
title: 'Hide certain navigation tabs',
description: 'This will hide the Notebook and Discover tabs from the bottom navigation. For minimalists who want fewer distractions.',
default: false,
postProcess(files) {
const items = files.find(f => f.path === '.adds/nm/items');
if (!items || typeof items.data !== 'string') return files;
// Uncomment the experimental tab-customization lines
items.data = items.data.split('\n').map(line => {
if (line.startsWith('#experimental ')) {
return line.slice(1); // remove leading #
}
return line;
}).join('\n');
return files;
},
};

View File

@@ -0,0 +1,180 @@
import JSZip from 'jszip';
import customMenu from './features/custom-menu/index.js';
import readerlyFonts from './features/readerly-fonts/index.js';
import koreader from './features/koreader/index.js';
import simplifyTabs from './features/simplify-tabs/index.js';
import simplifyHome from './features/simplify-home/index.js';
import screensaver from './features/screensaver/index.js';
/**
* All available NickelMenu features in display order.
* Features with `required: true` are always included in the preset.
* Features with `postProcess` modify files produced by other features.
*/
export const ALL_FEATURES = [
customMenu,
readerlyFonts,
koreader,
simplifyTabs,
simplifyHome,
screensaver,
];
/**
* Create an asset-loading context for a given feature.
* Assets are fetched at runtime from the feature's directory under /nickelmenu/features/<id>/.
*/
function createContext(feature, progressFn) {
const basePath = `nickelmenu/features/${feature.id}/`;
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}`);
return new Uint8Array(await resp.arrayBuffer());
},
progress(msg) {
progressFn(msg);
},
};
}
export class NickelMenuInstaller {
constructor() {
this.nickelMenuZip = null;
}
/**
* Download and cache NickelMenu.zip (contains KoboRoot.tgz).
*/
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);
this.nickelMenuZip = await JSZip.loadAsync(await resp.arrayBuffer());
}
/**
* Get KoboRoot.tgz from the NickelMenu zip.
*/
async getKoboRootTgz() {
const file = this.nickelMenuZip.file('KoboRoot.tgz');
if (!file) throw new Error('KoboRoot.tgz not found in NickelMenu.zip');
return new Uint8Array(await file.async('arraybuffer'));
}
/**
* Run selected features and collect all files to write.
* @param {object[]} features - feature modules to run
* @param {function} progressFn
* @returns {{ path: string, data: Uint8Array|string }[]}
*/
async collectFiles(features, progressFn) {
let files = [];
// Run install() for features that have it
for (const feature of features) {
if (!feature.install) continue;
const ctx = createContext(feature, progressFn);
progressFn(`Setting up ${feature.title}...`);
const result = await feature.install(ctx);
files.push(...result);
}
// Decode binary items file to string for postProcess mutation
const itemsFile = files.find(f => f.path === '.adds/nm/items');
if (itemsFile && itemsFile.data instanceof Uint8Array) {
itemsFile.data = new TextDecoder().decode(itemsFile.data);
}
// Run postProcess() for features that have it
for (const feature of features) {
if (!feature.postProcess) continue;
files = feature.postProcess(files);
}
// Re-encode items file back to Uint8Array
if (itemsFile && typeof itemsFile.data === 'string') {
itemsFile.data = new TextEncoder().encode(itemsFile.data);
}
return files;
}
/**
* Install to a connected Kobo device via File System Access API.
*/
async installToDevice(device, features, progressFn) {
await this.loadNickelMenu(progressFn);
progressFn('Writing KoboRoot.tgz...');
const tgz = await this.getKoboRootTgz();
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
if (features.length > 0) {
const files = await this.collectFiles(features, progressFn);
progressFn('Writing files to Kobo...');
for (const { path, data } of files) {
const pathArray = path.split('/');
const fileData = typeof data === 'string' ? new TextEncoder().encode(data) : data;
await device.writeFile(pathArray, fileData);
}
progressFn('Updating Kobo eReader.conf...');
await this.updateEReaderConf(device);
}
progressFn('Done.');
}
/**
* Build a zip for manual download.
*/
async buildDownloadZip(features, progressFn) {
await this.loadNickelMenu(progressFn);
progressFn('Building download package...');
const zip = new JSZip();
const tgz = await this.getKoboRootTgz();
zip.file('.kobo/KoboRoot.tgz', tgz);
if (features.length > 0) {
const files = await this.collectFiles(features, progressFn);
for (const { path, data } of files) {
const fileData = typeof data === 'string' ? new TextEncoder().encode(data) : data;
zip.file(path, fileData);
}
}
progressFn('Compressing...');
const result = await zip.generateAsync({ type: 'uint8array' });
progressFn('Done.');
return result;
}
/**
* Add ExcludeSyncFolders to Kobo eReader.conf if not already present.
*/
async updateEReaderConf(device) {
const confPath = ['.kobo', 'Kobo', 'Kobo eReader.conf'];
let content = await device.readFile(confPath) || '';
const settingLine = 'ExcludeSyncFolders=(calibre|\\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\\..+)';
if (content.includes('ExcludeSyncFolders')) return;
if (content.includes('[FeatureSettings]')) {
content = content.replace(
'[FeatureSettings]',
'[FeatureSettings]\n' + settingLine
);
} else {
content += '\n[FeatureSettings]\n' + settingLine + '\n';
}
await device.writeFile(confPath, new TextEncoder().encode(content));
}
}

Binary file not shown.