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

@@ -24,6 +24,9 @@ jobs:
with:
node-version: '22'
- name: Install jq
run: sudo apt-get install -y jq
- name: Clone kobopatch source
run: |
cd kobopatch-wasm
@@ -56,7 +59,7 @@ jobs:
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/)'; then
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/|readerly/)'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
@@ -82,6 +85,10 @@ jobs:
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: koreader/setup.sh
- name: Set up Readerly assets
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: readerly/setup.sh
- name: Full integration test (Playwright)
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: |

2
.gitignore vendored
View File

@@ -14,8 +14,8 @@ kobopatch-wasm/wasm_exec.js
# Generated files in src (written by build scripts, regenerated on demand)
web/src/js/wasm_exec.js
web/src/nickelmenu/NickelMenu.zip
web/src/nickelmenu/kobo-config.zip
web/src/koreader/
web/src/readerly/
# Build output
web/dist/

View File

@@ -15,6 +15,14 @@ A web application for customising Kobo e-readers. It supports two modes:
- These changes are wiped when system updates are released. Requires re-patching when system updates are installed.
- Gives you a lot of customization options, but not all of them may work correctly.
## Prerequisites
- [Node.js](https://nodejs.org/) (includes npm)
- [jq](https://jqlang.github.io/jq/) — `brew install jq` / `apt install jq`
- [Git](https://git-scm.com/)
Go is required for the WASM build but downloaded automatically if not installed.
## How it works
The app uses the **Filesystem Access API** (Chromium) to interface with connected Kobo devices, or falls back to manual model/software version selection with a downloadable ZIP on other browsers.
@@ -44,7 +52,7 @@ web/
app.js # ES module entry point: step navigation, flow orchestration
kobo-device.js # KoboModels, KoboDevice class
kobo-software-urls.js # Fetches download URLs from JSON, getSoftwareUrl, getDevicesForVersion
nickelmenu.js # NickelMenuInstaller: downloads/bundles NickelMenu + config
nickelmenu/ # NickelMenu feature modules + installer orchestrator
patch-ui.js # PatchUI: loads patches, parses YAML, renders toggle UI
patch-runner.js # KoboPatchRunner: spawns Web Worker per build
patch-worker.js # Web Worker: loads WASM, runs patchFirmware()
@@ -54,9 +62,8 @@ web/
index.json # Available patch manifest
downloads.json # Firmware download URLs by version/serial (may be auto-generated)
patches_*.zip # Patch files per firmware version
nickelmenu/ # NickelMenu assets (generated by nickelmenu/setup.sh, gitignored)
NickelMenu.zip
kobo-config.zip
nickelmenu/ # NickelMenu assets (NickelMenu.zip generated by nickelmenu/setup.sh, gitignored)
readerly/ # Readerly font assets (generated by readerly/setup.sh, gitignored)
koreader/ # KOReader assets (generated by koreader/setup.sh, gitignored)
koreader-kobo.zip
release.json
@@ -64,12 +71,15 @@ web/
dist/ # Build output (gitignored, fully regenerable)
bundle.js # esbuild output (minified, content-hashed)
index.html # Generated with cache-busted references
css/ favicon/ patches/ nickelmenu/ koreader/ wasm/ js/wasm_exec.js
css/ favicon/ patches/ nickelmenu/ readerly/ koreader/ wasm/ js/wasm_exec.js
build.mjs # esbuild build script + asset copy
package.json # esbuild, jszip
nickelmenu/
setup.sh # Downloads NickelMenu.zip and bundles kobo-config.zip
setup.sh # Downloads NickelMenu.zip
readerly/
setup.sh # Downloads latest Readerly fonts from GitHub releases
koreader/
setup.sh # Downloads latest KOReader release for Kobo
@@ -122,7 +132,15 @@ cd kobopatch-wasm
nickelmenu/setup.sh
```
This downloads `NickelMenu.zip` and clones/updates the [kobo-config](https://github.com/nicoverbruggen/kobo-config) repo to bundle `kobo-config.zip` into `web/src/nickelmenu/`.
This downloads `NickelMenu.zip` into `web/src/nickelmenu/`.
## Setting up Readerly font assets
```bash
readerly/setup.sh
```
This downloads the latest [Readerly](https://github.com/nicoverbruggen/readerly) font release (`KF_Readerly.zip`) into `web/src/readerly/`. The fonts are served from the app's own domain and downloaded by the browser at install time.
## Setting up KOReader assets
@@ -159,7 +177,7 @@ npm run dev # dev server with watch mode on :8889
This serves the app at `http://localhost:8888`. The script automatically:
1. Sets up NickelMenu assets if missing (`web/src/nickelmenu/`)
1. Sets up NickelMenu, KOReader, and Readerly assets if missing
2. Builds the JS bundle (`web/dist/bundle.js`)
3. Builds the WASM binary if missing (`web/dist/wasm/kobopatch.wasm`)

View File

@@ -8,8 +8,13 @@ mkdir -p "$PUBLIC_DIR"
echo "Fetching latest KOReader release info..."
RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/koreader/koreader/releases/latest)
VERSION=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; assets=json.load(sys.stdin)['assets']; print(next(a['browser_download_url'] for a in assets if 'koreader-kobo-' in a['name'] and a['name'].endswith('.zip')))")
VERSION=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | test("koreader-kobo-.*\\.zip$")) | .browser_download_url')
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ] || [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then
echo "Error: Could not find KOReader Kobo release"
exit 1
fi
echo "Downloading KOReader $VERSION..."
curl -fL --progress-bar -o "$PUBLIC_DIR/koreader-kobo.zip" "$DOWNLOAD_URL"

View File

@@ -12,28 +12,5 @@ echo "Downloading NickelMenu.zip..."
curl -fSL -o "$PUBLIC_DIR/NickelMenu.zip" "$NICKELMENU_URL"
echo " -> $(du -h "$PUBLIC_DIR/NickelMenu.zip" | cut -f1)"
# --- kobo-config ---
KOBO_CONFIG_DIR="$SCRIPT_DIR/kobo-config"
if [ -d "$KOBO_CONFIG_DIR" ]; then
echo "Updating kobo-config..."
cd "$KOBO_CONFIG_DIR"
git pull
else
echo "Cloning kobo-config..."
git clone https://github.com/nicoverbruggen/kobo-config.git "$KOBO_CONFIG_DIR"
fi
# Copy the relevant assets into a zip for the web app.
# Includes: .adds/, .kobo/screensaver/, fonts/
echo "Bundling kobo-config.zip..."
cd "$KOBO_CONFIG_DIR"
zip -r "$PUBLIC_DIR/kobo-config.zip" \
.adds/ \
.kobo/screensaver/ \
fonts/ \
-x "*.DS_Store"
echo " -> $(du -h "$PUBLIC_DIR/kobo-config.zip" | cut -f1)"
echo ""
echo "Done. Assets written to: $PUBLIC_DIR"

View File

@@ -1,7 +1,7 @@
providers = ["node"]
[phases.setup]
nixPkgs = ["git", "curl", "zip", "gnutar", "nginx", "nodejs", "gettext"]
nixPkgs = ["git", "curl", "zip", "gnutar", "nginx", "nodejs", "gettext", "jq"]
paths = ["/usr/local/go/bin"]
[phases.build]
@@ -9,6 +9,8 @@ cmds = [
"cd kobopatch-wasm && bash setup.sh",
"cd kobopatch-wasm && bash build.sh",
"cd nickelmenu && bash setup.sh",
"cd koreader && bash setup.sh",
"cd readerly && bash setup.sh",
"cd web && npm install && npm run build",
]

24
readerly/setup.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PUBLIC_DIR="$SCRIPT_DIR/../web/src/readerly"
mkdir -p "$PUBLIC_DIR"
# Get latest release download URL for KF_Readerly.zip
echo "Fetching latest Readerly release..."
DOWNLOAD_URL=$(curl -fsSL https://api.github.com/repos/nicoverbruggen/readerly/releases/latest \
| jq -r '.assets[] | select(.name == "KF_Readerly.zip") | .browser_download_url')
if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then
echo "Error: Could not find KF_Readerly.zip in latest release"
exit 1
fi
echo "Downloading KF_Readerly.zip..."
curl -fSL -o "$PUBLIC_DIR/KF_Readerly.zip" "$DOWNLOAD_URL"
echo " -> $(du -h "$PUBLIC_DIR/KF_Readerly.zip" | cut -f1)"
echo ""
echo "Done. Assets written to: $PUBLIC_DIR"

View File

@@ -22,6 +22,11 @@ if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then
"$SCRIPT_DIR/koreader/setup.sh"
fi
if [ ! -f "$SRC_DIR/readerly/KF_Readerly.zip" ]; then
echo "Readerly font assets not found, downloading..."
"$SCRIPT_DIR/readerly/setup.sh"
fi
echo "Building JS bundle..."
cd "$WEB_DIR"
npm install --silent

View File

@@ -4,7 +4,7 @@ const { WEBROOT, WEBROOT_FIRMWARE, FIRMWARE_PATH } = require('./paths');
function hasNickelMenuAssets() {
return fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'NickelMenu.zip'))
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'kobo-config.zip'));
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'features', 'custom-menu', 'items'));
}
function hasKoreaderAssets() {

View File

@@ -39,7 +39,7 @@ test.describe('NickelMenu', () => {
await expect(page.locator('#nm-config-options')).not.toBeHidden();
// Verify default checkbox states
await expect(page.locator('input[name="nm-cfg-fonts"]')).toBeChecked();
await expect(page.locator('input[name="nm-cfg-readerly-fonts"]')).toBeChecked();
await expect(page.locator('input[name="nm-cfg-screensaver"]')).not.toBeChecked();
await expect(page.locator('input[name="nm-cfg-simplify-tabs"]')).not.toBeChecked();
await expect(page.locator('input[name="nm-cfg-simplify-home"]')).not.toBeChecked();
@@ -256,8 +256,8 @@ test.describe('NickelMenu', () => {
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await expect(page.locator('#nm-review-list')).toContainText('NickelMenu');
await expect(page.locator('#nm-review-list')).toContainText('Readerly fonts');
await expect(page.locator('#nm-review-list')).toContainText('Simplified tab menu');
await expect(page.locator('#nm-review-list')).toContainText('Simplified homescreen');
await expect(page.locator('#nm-review-list')).toContainText('Hide certain navigation tabs');
await expect(page.locator('#nm-review-list')).toContainText('Hide certain home screen elements');
// Both buttons visible when device is connected
await expect(page.locator('#btn-nm-write')).toBeVisible();

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.