- Connect your Kobo via USB so it appears as a removable drive.
- - Extract the downloaded ZIP to the root of the Kobo drive, preserving the folder structure.
- -
- Open .kobo/Kobo/Kobo eReader.conf in a text editor.
+ - Open .kobo/Kobo/Kobo eReader.conf in a text editor.
Find the [FeatureSettings] section (or add it at the end) and add the following line:
ExcludeSyncFolders=(calibre|\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\..+)
- This prevents the Kobo from discovering books in these folders during a sync.
+ This prevents the Kobo from incorrectly identifying certain files as books in your library.
+ - Safely eject the Kobo, then power it off by holding the power button until it says "Powered off". Press the power button again to boot it back up. The config change takes effect after reboot. Reconnect the Kobo via USB afterwards.
+ - Extract the downloaded ZIP to the root of the Kobo drive, preserving the folder structure. Make sure hidden folders like
.kobo and .adds are also copied.
- Safely eject the Kobo — do not just unplug the cable.
- The device will reboot and install NickelMenu automatically.
diff --git a/web/src/js/app.js b/web/src/js/app.js
index f271ebf..9d9945c 100644
--- a/web/src/js/app.js
+++ b/web/src/js/app.js
@@ -23,16 +23,27 @@ import JSZip from 'jszip';
let selectedMode = null; // 'nickelmenu' | 'patches'
let nickelMenuOption = null; // 'sample' | 'nickelmenu-only' | 'remove'
- // Fetch data eagerly so it's ready when needed.
- const softwareUrlsReady = loadSoftwareUrls();
- 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);
+ // Fetch data eagerly so it's ready when needed.
+ 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';
+ });
+
function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -502,6 +513,7 @@ import JSZip from 'jszip';
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,
};
}
@@ -558,6 +570,7 @@ import JSZip from 'jszip';
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);
for (const text of items) {
const li = document.createElement('li');
li.textContent = text;
@@ -638,8 +651,10 @@ import JSZip from 'jszip';
nmDoneStatus.textContent = TL.STATUS.NM_DOWNLOAD_READY;
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';
+ // Show eReader.conf + reboot steps only when sample config is included
+ const showConfStep = nickelMenuOption === 'sample';
+ $('nm-download-conf-step').hidden = !showConfStep;
+ $('nm-download-reboot-step').hidden = !showConfStep;
}
setNavStep(5);
diff --git a/web/src/js/nickelmenu.js b/web/src/js/nickelmenu.js
index 204c710..a268ccd 100644
--- a/web/src/js/nickelmenu.js
+++ b/web/src/js/nickelmenu.js
@@ -16,11 +16,13 @@ import JSZip from 'jszip';
* 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
}
/**
@@ -44,6 +46,25 @@ class NickelMenuInstaller {
}
}
+ /**
+ * 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.
*/
@@ -53,6 +74,37 @@ class NickelMenuInstaller {
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.
@@ -135,6 +187,16 @@ class NickelMenuInstaller {
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);
@@ -193,6 +255,16 @@ class NickelMenuInstaller {
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...');
diff --git a/web/src/js/strings.js b/web/src/js/strings.js
index 15b9a4f..df0f4f6 100644
--- a/web/src/js/strings.js
+++ b/web/src/js/strings.js
@@ -55,5 +55,6 @@ export const TL = {
SCREENSAVER: 'Custom screensaver',
SIMPLIFY_TABS: 'Simplified tab menu',
SIMPLIFY_HOME: 'Simplified homescreen',
+ KOREADER: 'KOReader',
},
};