1
0

Add NickelMenu installation options

This commit is contained in:
2026-03-17 16:30:33 +01:00
parent 6d4bce8e05
commit 26c2d21fb3
8 changed files with 1058 additions and 142 deletions

4
.gitignore vendored
View File

@@ -23,5 +23,9 @@ tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
# NickelMenu build artifacts
nickelmenu/kobo-config/
web/public/nickelmenu/
# Claude
.claude

39
nickelmenu/setup.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PUBLIC_DIR="$SCRIPT_DIR/../web/public/nickelmenu"
mkdir -p "$PUBLIC_DIR"
# --- NickelMenu.zip ---
NICKELMENU_URL="https://github.com/nicoverbruggen/NickelMenu/releases/download/experimental/NickelMenu.zip"
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

@@ -4,6 +4,11 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WASM_DIR="$SCRIPT_DIR/kobopatch-wasm"
if [ ! -f "$SCRIPT_DIR/web/public/nickelmenu/NickelMenu.zip" ]; then
echo "NickelMenu assets not found, downloading..."
"$SCRIPT_DIR/nickelmenu/setup.sh"
fi
if [ ! -f "$SCRIPT_DIR/web/public/wasm/kobopatch.wasm" ]; then
echo "WASM binary not found, building..."
if [ ! -d "$WASM_DIR/kobopatch-src" ]; then

View File

@@ -162,6 +162,206 @@ h2 {
background: var(--success-text);
}
/* Mode selection cards */
.mode-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mode-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--card-bg);
border: 2px solid var(--border-light);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: var(--shadow);
}
#mode-patches-hint {
margin-top: 15px;
}
.mode-card:hover {
border-color: var(--border);
}
.mode-card-selected {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary), var(--shadow);
}
.mode-card-btn {
text-align: left;
width: 100%;
}
.mode-card-btn:hover {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary), var(--shadow);
}
.mode-card-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: var(--shadow);
}
.mode-card-btn:disabled:hover {
border-color: var(--border-light);
box-shadow: var(--shadow);
}
.mode-card input[type="radio"] {
margin-top: 0.2rem;
flex-shrink: 0;
accent-color: var(--primary);
}
.mode-card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.93rem;
color: var(--text);
margin-bottom: 0.25rem;
}
.mode-card-desc {
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
}
.recommended-pill {
margin-left: auto;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--success-text);
color: #fff;
padding: 0.2rem 0.6rem;
border-radius: 8px;
white-space: nowrap;
}
/* NickelMenu option radio cards */
.nm-options {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.nm-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: var(--card-bg);
border: 2px solid var(--border-light);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: var(--shadow);
}
.nm-option:hover {
border-color: var(--border);
}
.nm-option-selected {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary), var(--shadow);
}
.nm-option-disabled {
opacity: 0.45;
cursor: not-allowed;
}
.nm-option-disabled:hover {
border-color: var(--border-light);
}
.nm-option-remove:not(.nm-option-disabled) {
border-color: var(--error-border);
}
.nm-option-remove:not(.nm-option-disabled):hover {
border-color: var(--error-text);
}
.nm-option-remove.nm-option-selected {
border-color: var(--error-text);
box-shadow: 0 0 0 1px var(--error-text), var(--shadow);
}
.nm-option-remove input[type="radio"] {
accent-color: var(--error-text);
}
/* NickelMenu config checkboxes */
.nm-config-options {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: var(--card-bg);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: var(--shadow);
}
.nm-config-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
font-size: 0.88rem;
color: var(--text);
cursor: pointer;
}
.nm-config-item + .nm-config-item {
border-top: 1px solid var(--border-light);
}
.nm-config-item input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--primary);
}
.nm-config-item input[type="checkbox"]:disabled {
opacity: 0.6;
}
.nm-config-item span {
user-select: none;
}
.nm-option input[type="radio"] {
margin-top: 0.2rem;
flex-shrink: 0;
accent-color: var(--primary);
}
.nm-option-title {
font-weight: 500;
font-size: 0.88rem;
color: var(--text);
margin-bottom: 0.15rem;
}
.nm-option-desc {
font-size: 0.78rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* Steps */
.step {
margin-bottom: 1rem;
@@ -182,7 +382,8 @@ h2 {
margin-top: 1.25rem;
}
.step-actions .primary:first-child {
.step-actions .primary:first-child,
.step-actions > [hidden] + .primary {
margin-left: auto;
}

View File

@@ -26,7 +26,7 @@
<main>
<header class="hero">
<h1>KoboPatch <span class="hero-accent">Web UI</span> <span class="beta-pill">beta</span></h1>
<p class="subtitle">Apply patches to your Kobo Libra Colour, Kobo Clara Colour and Kobo Clara BW.</p>
<p class="subtitle">Customise your Kobo e-reader with NickelMenu or custom patches.</p>
</header>
<!-- Shown until JS initialises -->
@@ -35,60 +35,59 @@
<p>Loading&hellip;</p>
</div>
<!-- Step indicator -->
<!-- Step indicator (populated dynamically by app.js) -->
<nav id="step-nav" class="step-nav" hidden>
<ol>
<li data-step="1" class="active">Device</li>
<li data-step="2">Patches</li>
<li data-step="3">Build</li>
<li data-step="4">Install</li>
</ol>
<ol></ol>
</nav>
<!-- Step 1a: Connect device (automatic, Chromium only) -->
<!-- Step 1: Choose connection method -->
<section id="step-connect" class="step" hidden>
<div class="warning">
<b>Patching modifies system files on your Kobo, and <u>will void your warranty</u>.</b> This process allows for custom modifications to be applied, or undone. If something has gone wrong,
you may need to <a href="https://help.kobo.com/hc/en-us/articles/360017605314-Manual-reset-your-Kobo-Clara-HD-Kobo-Nia-Kobo-Elipsa-Kobo-Clara-2E-Kobo-Elipsa-2E" target="_blank">manually reset your device</a>.
</div>
<p>
Connect your Kobo e-reader via USB. It should appear as a removable drive.
Then click the button below and select the root of the Kobo drive.
</p>
<button id="btn-connect" class="primary">Select Kobo Drive</button>
<p class="fallback-hint">
Don't want to use Chrome?
<a href="#" id="btn-manual-from-auto">Select your software version manually</a> instead.
<p>How would you like to set up your Kobo?</p>
<div class="mode-cards">
<button id="btn-connect" class="mode-card mode-card-btn">
<svg class="mode-card-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16" fill="currentColor"><path d="m7.792.312-1.533 2.3A.25.25 0 0 0 6.467 3H7.5v7.319a2.5 2.5 0 0 0-.515-.298L5.909 9.56A1.5 1.5 0 0 1 5 8.18v-.266a1.5 1.5 0 1 0-1 0v.266a2.5 2.5 0 0 0 1.515 2.298l1.076.461a1.5 1.5 0 0 1 .888 1.129 2.001 2.001 0 1 0 1.021-.006v-.902a1.5 1.5 0 0 1 .756-1.303l1.484-.848A2.5 2.5 0 0 0 11.995 7h.755a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h.741a1.5 1.5 0 0 1-.747 1.142L8.76 8.99a2.584 2.584 0 0 0-.26.17V3h1.033a.25.25 0 0 0 .208-.389L8.208.312a.25.25 0 0 0-.416 0Z"/></svg>
<div class="mode-card-body">
<div class="mode-card-title">Connect my Kobo</div>
<div class="mode-card-desc">Connect your Kobo via USB and select its drive. Files will be written directly to the device.</div>
</div>
</button>
<button id="btn-manual" class="mode-card mode-card-btn">
<svg class="mode-card-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div class="mode-card-body">
<div class="mode-card-title">Download files manually</div>
<div class="mode-card-desc">Download the files and copy them to your Kobo yourself. Works in any browser.</div>
</div>
</button>
</div>
<p id="connect-unsupported-hint" class="fallback-hint" hidden>
Your browser does not support direct device access. Use Chrome or Edge to connect your Kobo directly, or choose the manual download option.
</p>
</section>
<!-- Step 1b (manual): Select device model + firmware -->
<section id="step-manual" class="step" hidden>
<div class="warning">
<b>Patching modifies system files on your Kobo, and <u>will void your warranty</u>.</b> This process allows for custom modifications to be applied, or undone. If something has gone wrong,
you may need to <a href="https://help.kobo.com/hc/en-us/articles/360017605314-Manual-reset-your-Kobo-Clara-HD-Kobo-Nia-Kobo-Elipsa-Kobo-Clara-2E-Kobo-Elipsa-2E" target="_blank">manually reset your device</a>.
</div>
<!-- Step 1b (manual): Select device model + firmware for custom patches -->
<section id="step-manual-version" class="step" hidden>
<p class="fallback-hint">
<strong>First, select the version number that is currently installed on your device.</strong> If it does not appear in this dropdown list, your software version is not supported for patching via this website.
<strong>Select the version number currently installed on your device.</strong> If it does not appear in this list, your software version is not supported for custom patching. You can go back and choose NickelMenu instead, which works with all versions.
</p>
<select id="manual-version">
<option value="">-- Select software version --</option>
</select>
<p id="manual-version-hint" class="fallback-hint">
You can identify the version number shown on your Kobo under <strong>More &gt; Settings &gt; Device information</strong> and by checking <strong>Software version</strong>. You should only apply a patch if the version number is a complete match. Only the listed version numbers are supported by this tool.
You can find the version number on your Kobo under <strong>More &gt; Settings &gt; Device information</strong> &gt; <strong>Software version</strong>.
</p>
<select id="manual-model" hidden>
<option value="">-- Select your Kobo model --</option>
</select>
<p id="manual-model-hint" class="fallback-hint" hidden>
You can identify your model by the serial number prefix shown on your Kobo under <strong>More &gt; Settings &gt; Device information</strong>. Match the first characters (e.g. N428) to the list above. If your device isn't listed, it is not supported by this tool.
</p>
<p id="manual-chrome-hint" class="info-banner" hidden>
<b>Tip</b>: if you use Chrome or Edge, this tool can auto-detect your device
and write patched files directly to it, which makes this a lot easier and less error-prone. Before continuing, make sure you've double checked if the device and model number are correct.
Match the first characters of your serial number (e.g. N428) to the list above. You can find it under <strong>More &gt; Settings &gt; Device information</strong>.
</p>
<div class="step-actions" style="margin-top: 25px">
<button id="btn-manual-confirm" class="primary" disabled>I understand, continue &#x203A;</button>
<button id="btn-manual-version-back" class="secondary">&#x2039; Back</button>
<button id="btn-manual-confirm" class="primary" disabled>Continue &#x203A;</button>
</div>
</section>
@@ -111,11 +110,134 @@
<p id="device-status"></p>
<div class="step-actions">
<button id="btn-device-restore" class="secondary">Restore Unpatched Software</button>
<button id="btn-device-next" class="primary">Configure Patches &#x203A;</button>
<button id="btn-device-next" class="primary">Continue &#x203A;</button>
</div>
</section>
<!-- Step 2: Configure patches -->
<!-- Step 2: Mode selection -->
<section id="step-mode" class="step" hidden>
<p>What would you like to do?</p>
<div class="mode-cards">
<label class="mode-card mode-card-selected">
<input type="radio" name="mode" value="nickelmenu" checked>
<div class="mode-card-body">
<div class="mode-card-title">Install or remove NickelMenu <span class="recommended-pill">recommended</span></div>
<div class="mode-card-desc">Installs a custom menu and various tweaks for your device. Works with most Kobo devices. The safest solution, as it has a lot of error checking and a failsafe mechanism which will automatically uninstall it as a last resort. </div>
</div>
</label>
<label class="mode-card">
<input type="radio" name="mode" value="patches">
<div class="mode-card-body">
<div class="mode-card-title">Custom Patches</div>
<div class="mode-card-desc">Apply community patches to your Kobo's system software. Requires a supported software version and supported device.</div>
</div>
</label>
</div>
<p id="mode-patches-hint" class="fallback-hint" hidden>Custom patches are not available for your software version. You can still install NickelMenu and choose what you want to do with your Kobo.</p>
<div class="step-actions">
<button id="btn-mode-back" class="secondary">&#x2039; Back</button>
<button id="btn-mode-next" class="primary">Continue &#x203A;</button>
</div>
</section>
<!-- Step 2b: NickelMenu configuration -->
<section id="step-nickelmenu" class="step" hidden>
<p>Choose what to do with your Kobo.</p>
<div class="nm-options">
<label class="nm-option nm-option-selected">
<input type="radio" name="nm-option" value="sample" checked>
<div class="nm-option-body">
<div class="nm-option-title">Install NickelMenu and configure</div>
<div class="nm-option-desc">Installs NickelMenu with a curated set of menu options.</div>
</div>
</label>
<label class="nm-option">
<input type="radio" name="nm-option" value="nickelmenu-only">
<div class="nm-option-body">
<div class="nm-option-title">Install NickelMenu only</div>
<div class="nm-option-desc">Installs just NickelMenu without any configuration. You can set it up yourself later.</div>
</div>
</label>
<label id="nm-option-remove" class="nm-option nm-option-disabled nm-option-remove">
<input type="radio" name="nm-option" value="remove" disabled>
<div class="nm-option-body">
<div class="nm-option-title">Remove NickelMenu</div>
<div class="nm-option-desc" id="nm-remove-desc">Removes NickelMenu from your device. Only available when a Kobo with NickelMenu installed is connected.</div>
</div>
</label>
</div>
<div id="nm-config-options" class="nm-config-options" hidden>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-menu" checked disabled>
<span>Set up custom menu</span>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-fonts" checked>
<span>Install Readerly fonts</span>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-screensaver" checked>
<span>Install screensaver</span>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-simplify-tabs">
<span>Simplify tab menu</span>
</label>
<label class="nm-config-item">
<input type="checkbox" name="nm-cfg-simplify-home">
<span>Simplify homescreen</span>
</label>
</div>
<div class="step-actions">
<button id="btn-nm-back" class="secondary">&#x2039; Back</button>
<button id="btn-nm-next" class="primary">Continue &#x203A;</button>
</div>
</section>
<!-- Step 2c: NickelMenu review -->
<section id="step-nm-review" class="step" hidden>
<p id="nm-review-summary"></p>
<ul id="nm-review-list" class="selected-patches-list"></ul>
<div id="nm-review-actions" class="step-actions">
<button id="btn-nm-review-back" class="secondary">&#x2039; Back</button>
<button id="btn-nm-write" class="primary">Write to Kobo</button>
<button id="btn-nm-download" class="secondary">Download ZIP</button>
</div>
</section>
<!-- NickelMenu installing -->
<section id="step-nm-installing" class="step" hidden>
<div class="build-header">
<div class="spinner"></div>
<p id="nm-progress">Starting...</p>
</div>
</section>
<!-- NickelMenu done -->
<section id="step-nm-done" class="step" hidden>
<p id="nm-done-status" class="install-summary"></p>
<div id="nm-write-instructions" class="install-instructions" hidden>
<p class="hint">
Files have been written to your Kobo.
<strong>Safely eject</strong> the device before unplugging the USB cable &mdash; it will reboot and install NickelMenu automatically.
</p>
</div>
<div id="nm-download-instructions" class="install-instructions" hidden>
<ol class="install-steps">
<li>Connect your Kobo via USB so it appears as a removable drive.</li>
<li>Extract the downloaded ZIP to the <strong>root</strong> of the Kobo drive, preserving the folder structure.</li>
<li><strong>Safely eject</strong> the Kobo &mdash; do not just unplug the cable.</li>
<li>The device will reboot and install NickelMenu automatically.</li>
</ol>
</div>
<div id="nm-reboot-instructions" class="install-instructions" hidden>
<p class="hint">
<strong>Safely eject</strong> your Kobo and let it reboot. NickelMenu will be automatically removed during the reboot.
</p>
</div>
</section>
<!-- Step 2 (patches path): Configure patches -->
<section id="step-patches" class="step" hidden>
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
<div id="patch-container" class="patch-container-scroll"></div>
@@ -267,6 +389,7 @@
<script src="js/kobo-device.js?ts=1773751630"></script>
<script src="js/kobopatch.js?ts=1773751630"></script>
<script src="js/patch-ui.js?ts=1773751630"></script>
<script src="js/nickelmenu.js?ts=1773751630"></script>
<script src="js/app.js?ts=1773751630"></script>
</body>
</html>

View File

@@ -2,20 +2,28 @@
const device = new KoboDevice();
const patchUI = new PatchUI();
const runner = new KobopatchRunner();
const nmInstaller = new NickelMenuInstaller();
let firmwareURL = null;
let resultTgz = null;
let resultNmZip = null;
let manualMode = false;
let selectedPrefix = null;
let patchesLoaded = false;
let isRestore = false;
let availablePatches = null;
let selectedMode = null; // 'nickelmenu' | 'patches'
let nickelMenuOption = null; // 'sample' | 'nickelmenu-only' | 'remove'
// Fetch patch index immediately so it's ready when needed.
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// --- Helpers ---
const $ = (id) => document.getElementById(id);
const $q = (sel, ctx = document) => ctx.querySelector(sel);
const $qa = (sel, ctx = document) => ctx.querySelectorAll(sel);
function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -50,46 +58,81 @@
}
// --- DOM elements ---
const stepNav = document.getElementById('step-nav');
const stepConnect = document.getElementById('step-connect');
const stepManual = document.getElementById('step-manual');
const stepDevice = document.getElementById('step-device');
const stepPatches = document.getElementById('step-patches');
const stepFirmware = document.getElementById('step-firmware');
const stepBuilding = document.getElementById('step-building');
const stepDone = document.getElementById('step-done');
const stepError = document.getElementById('step-error');
const stepNav = $('step-nav');
const stepConnect = $('step-connect');
const stepManualVersion = $('step-manual-version');
const stepDevice = $('step-device');
const stepMode = $('step-mode');
const stepNickelMenu = $('step-nickelmenu');
const stepNmInstalling = $('step-nm-installing');
const stepNmDone = $('step-nm-done');
const stepPatches = $('step-patches');
const stepFirmware = $('step-firmware');
const stepBuilding = $('step-building');
const stepDone = $('step-done');
const stepError = $('step-error');
const btnConnect = document.getElementById('btn-connect');
const btnManualFromAuto = document.getElementById('btn-manual-from-auto');
const btnManualConfirm = document.getElementById('btn-manual-confirm');
const manualVersion = document.getElementById('manual-version');
const manualModel = document.getElementById('manual-model');
const manualChromeHint = document.getElementById('manual-chrome-hint');
const btnDeviceNext = document.getElementById('btn-device-next');
const btnDeviceRestore = document.getElementById('btn-device-restore');
const btnPatchesBack = document.getElementById('btn-patches-back');
const btnPatchesNext = document.getElementById('btn-patches-next');
const btnBuildBack = document.getElementById('btn-build-back');
const btnWrite = document.getElementById('btn-write');
const btnDownload = document.getElementById('btn-download');
const btnRetry = document.getElementById('btn-retry');
const btnConnect = $('btn-connect');
const btnManual = $('btn-manual');
const btnManualConfirm = $('btn-manual-confirm');
const btnManualVersionBack = $('btn-manual-version-back');
const manualVersion = $('manual-version');
const manualModel = $('manual-model');
const btnDeviceNext = $('btn-device-next');
const btnDeviceRestore = $('btn-device-restore');
const btnModeBack = $('btn-mode-back');
const btnModeNext = $('btn-mode-next');
const btnNmBack = $('btn-nm-back');
const btnNmNext = $('btn-nm-next');
const btnNmReviewBack = $('btn-nm-review-back');
const btnNmWrite = $('btn-nm-write');
const btnNmDownload = $('btn-nm-download');
const btnPatchesBack = $('btn-patches-back');
const btnPatchesNext = $('btn-patches-next');
const btnBuildBack = $('btn-build-back');
const btnWrite = $('btn-write');
const btnDownload = $('btn-download');
const btnRetry = $('btn-retry');
const errorMessage = document.getElementById('error-message');
const errorLog = document.getElementById('error-log');
const deviceStatus = document.getElementById('device-status');
const patchContainer = document.getElementById('patch-container');
const buildStatus = document.getElementById('build-status');
const existingTgzWarning = document.getElementById('existing-tgz-warning');
const writeInstructions = document.getElementById('write-instructions');
const downloadInstructions = document.getElementById('download-instructions');
const firmwareVersionLabel = document.getElementById('firmware-version-label');
const firmwareDeviceLabel = document.getElementById('firmware-device-label');
const patchCountHint = document.getElementById('patch-count-hint');
const errorMessage = $('error-message');
const errorLog = $('error-log');
const deviceStatus = $('device-status');
const patchContainer = $('patch-container');
const buildStatus = $('build-status');
const existingTgzWarning = $('existing-tgz-warning');
const writeInstructions = $('write-instructions');
const downloadInstructions = $('download-instructions');
const firmwareVersionLabel = $('firmware-version-label');
const firmwareDeviceLabel = $('firmware-device-label');
const patchCountHint = $('patch-count-hint');
const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError];
const stepNmReview = $('step-nm-review');
const allSteps = [
stepConnect, stepManualVersion, stepDevice,
stepMode, stepNickelMenu, stepNmReview, stepNmInstalling, stepNmDone,
stepPatches, stepFirmware, stepBuilding, stepDone,
stepError,
];
// --- Step navigation ---
const NAV_NICKELMENU = ['Device', 'Mode', 'Configure', 'Review', 'Install'];
const NAV_PATCHES = ['Device', 'Mode', 'Patches', 'Build', 'Install'];
const NAV_DEFAULT = ['Device', 'Mode', 'Patches', 'Build', 'Install'];
let currentNavLabels = NAV_DEFAULT;
function setNavLabels(labels) {
currentNavLabels = labels;
const ol = $q('ol', stepNav);
ol.innerHTML = '';
for (const label of labels) {
const li = document.createElement('li');
li.textContent = label;
ol.appendChild(li);
}
}
function showStep(step) {
for (const s of allSteps) {
s.hidden = (s !== step);
@@ -97,7 +140,7 @@
}
function setNavStep(num) {
const items = stepNav.querySelectorAll('li');
const items = $qa('li', stepNav);
items.forEach((li, i) => {
const stepNum = i + 1;
li.classList.remove('active', 'done');
@@ -111,6 +154,24 @@
stepNav.hidden = true;
}
// --- Mode selection card interactivity ---
function setupCardRadios(container, selectedClass) {
const labels = $qa('label', container);
for (const label of labels) {
const radio = $q('input[type="radio"]', label);
if (!radio) continue;
radio.addEventListener('change', () => {
for (const l of labels) {
if ($q('input[type="radio"]', l)) l.classList.remove(selectedClass);
}
if (radio.checked) label.classList.add(selectedClass);
});
}
}
setupCardRadios(stepMode, 'mode-card-selected');
setupCardRadios(stepNickelMenu, 'nm-option-selected');
// --- Patch count ---
function updatePatchCount() {
const count = patchUI.getEnabledCount();
@@ -129,48 +190,40 @@
firmwareURL = prefix ? getFirmwareURL(prefix, version) : null;
firmwareVersionLabel.textContent = version;
firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix;
document.getElementById('firmware-download-url').textContent = firmwareURL || '';
$('firmware-download-url').textContent = firmwareURL || '';
}
// --- Initial state ---
const loader = document.getElementById('initial-loader');
const loader = $('initial-loader');
if (loader) loader.remove();
const hasFileSystemAccess = KoboDevice.isSupported();
if (hasFileSystemAccess) {
setNavStep(1);
showStep(stepConnect);
} else {
enterManualMode();
// Disable "Connect my Kobo" button on unsupported browsers
if (!hasFileSystemAccess) {
btnConnect.disabled = true;
$('connect-unsupported-hint').hidden = false;
}
// --- Step 1: Device selection ---
async function enterManualMode() {
setNavLabels(NAV_DEFAULT);
setNavStep(1);
showStep(stepConnect);
// --- Step 1: Connection method ---
// "Connect my Kobo" — triggers File System Access API
// (click handler is further below where device connection is handled)
// "Download files manually" — enter manual mode, go to mode selection
btnManual.addEventListener('click', () => {
manualMode = true;
manualChromeHint.hidden = false;
await availablePatchesReady;
populateSelect(manualVersion, '-- Select software version --',
availablePatches.map(p => ({ value: p.version, text: p.version, data: { filename: p.filename } }))
);
populateSelect(manualModel, '-- Select your Kobo model --', []);
manualModel.hidden = true;
setNavStep(1);
showStep(stepManual);
}
btnManualFromAuto.addEventListener('click', (e) => {
e.preventDefault();
enterManualMode();
goToModeSelection();
});
manualVersion.addEventListener('change', () => {
const version = manualVersion.value;
selectedPrefix = null;
const modelHint = document.getElementById('manual-model-hint');
const modelHint = $('manual-model-hint');
if (!version) {
manualModel.hidden = true;
modelHint.hidden = true;
@@ -192,7 +245,7 @@
btnManualConfirm.disabled = !manualVersion.value || !manualModel.value;
});
// Manual confirm -> load patches -> go to step 2
// Manual confirm -> load patches -> go to patches step
btnManualConfirm.addEventListener('click', async () => {
const version = manualVersion.value;
if (!version || !selectedPrefix) return;
@@ -215,15 +268,15 @@
try {
const info = await device.connect();
document.getElementById('device-model').textContent = info.model;
const serialEl = document.getElementById('device-serial');
$('device-model').textContent = info.model;
const serialEl = $('device-serial');
serialEl.textContent = '';
const prefixLen = info.serialPrefix.length;
const u = document.createElement('u');
u.textContent = info.serial.slice(0, prefixLen);
serialEl.appendChild(u);
serialEl.appendChild(document.createTextNode(info.serial.slice(prefixLen)));
document.getElementById('device-firmware').textContent = info.firmware;
$('device-firmware').textContent = info.firmware;
selectedPrefix = info.serialPrefix;
@@ -231,42 +284,35 @@
const match = availablePatches.find(p => p.version === info.firmware);
if (match) {
deviceStatus.className = '';
deviceStatus.textContent =
'KoboPatch Web UI currently supports this version of the software. ' +
'You can choose to customize it or simply restore the original software.';
await patchUI.loadFromURL('patches/' + match.filename);
patchUI.render(patchContainer);
updatePatchCount();
patchesLoaded = true;
configureFirmwareStep(info.firmware, info.serialPrefix);
btnDeviceNext.hidden = false;
btnDeviceRestore.hidden = false;
showStep(stepDevice);
} else {
deviceStatus.className = 'warning';
deviceStatus.textContent =
'No patch available for this specific version and model combination. Currently, only Kobo Libra Colour, Kobo Clara Colour and Kobo Clara BW can be patched via this website.';
btnDeviceNext.hidden = true;
btnDeviceRestore.hidden = true;
showStep(stepDevice);
}
deviceStatus.textContent = 'Your device has been recognized. You can continue to the next step!';
btnDeviceNext.hidden = false;
showStep(stepDevice);
} catch (err) {
if (err.name === 'AbortError') return;
showError(err.message);
}
});
// Device info -> patches
// Device info -> mode selection
btnDeviceNext.addEventListener('click', () => {
if (patchesLoaded) goToPatches();
goToModeSelection();
});
btnDeviceRestore.addEventListener('click', () => {
if (!patchesLoaded) return;
selectedMode = 'patches';
isRestore = true;
setNavLabels(NAV_PATCHES);
goToBuild();
});
@@ -281,15 +327,272 @@
return true;
}
// --- Step 2: Patches ---
function goToPatches() {
// --- Step 2: Mode selection ---
function goToModeSelection() {
// In auto mode, disable custom patches if firmware isn't supported
const patchesRadio = $q('input[value="patches"]', stepMode);
const patchesCard = patchesRadio.closest('.mode-card');
const autoModeNoPatchesAvailable = !manualMode && !patchesLoaded;
const patchesHint = $('mode-patches-hint');
if (autoModeNoPatchesAvailable) {
patchesRadio.disabled = true;
patchesCard.style.opacity = '0.5';
patchesCard.style.cursor = 'not-allowed';
patchesHint.hidden = false;
const nmRadio = $q('input[value="nickelmenu"]', stepMode);
nmRadio.checked = true;
nmRadio.dispatchEvent(new Event('change'));
} else {
patchesRadio.disabled = false;
patchesCard.style.opacity = '';
patchesCard.style.cursor = '';
patchesHint.hidden = true;
}
setNavLabels(NAV_DEFAULT);
setNavStep(2);
showStep(stepMode);
}
btnModeBack.addEventListener('click', () => {
setNavStep(1);
if (manualMode) {
showStep(stepConnect);
} else {
showStep(stepDevice);
}
});
btnModeNext.addEventListener('click', async () => {
const selected = $q('input[name="mode"]:checked', stepMode);
if (!selected) return;
selectedMode = selected.value;
if (selectedMode === 'nickelmenu') {
setNavLabels(NAV_NICKELMENU);
goToNickelMenuConfig();
} else if (manualMode && !patchesLoaded) {
// Manual mode: need version/model selection before patches
setNavLabels(NAV_PATCHES);
await enterManualVersionSelection();
} else {
setNavLabels(NAV_PATCHES);
goToPatches();
}
});
// --- Manual version/model selection (only for custom patches in manual mode) ---
async function enterManualVersionSelection() {
await availablePatchesReady;
populateSelect(manualVersion, '-- Select software version --',
availablePatches.map(p => ({ value: p.version, text: p.version, data: { filename: p.filename } }))
);
populateSelect(manualModel, '-- Select your Kobo model --', []);
manualModel.hidden = true;
btnManualConfirm.disabled = true;
showStep(stepManualVersion);
}
btnManualVersionBack.addEventListener('click', () => {
goToModeSelection();
});
// --- Step 2b: NickelMenu configuration ---
const nmConfigOptions = $('nm-config-options');
// Show/hide config checkboxes based on radio selection
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
radio.addEventListener('change', () => {
nmConfigOptions.hidden = radio.value !== 'sample' || !radio.checked;
});
}
async function checkNickelMenuInstalled() {
const removeOption = $('nm-option-remove');
const removeRadio = $q('input[value="remove"]', removeOption);
const removeDesc = $('nm-remove-desc');
if (!manualMode && device.directoryHandle) {
try {
const addsDir = await device.directoryHandle.getDirectoryHandle('.adds');
await addsDir.getDirectoryHandle('nm');
removeRadio.disabled = false;
removeOption.classList.remove('nm-option-disabled');
removeDesc.textContent = 'Removes NickelMenu from your device. You must restart your Kobo to complete the uninstall!';
return;
} catch {
// .adds/nm not found
}
}
removeRadio.disabled = true;
removeOption.classList.add('nm-option-disabled');
removeDesc.textContent = 'Removes NickelMenu from your device. Only available when a Kobo with NickelMenu installed is connected.';
if (removeRadio.checked) {
const sampleRadio = $q('input[value="sample"]', stepNickelMenu);
sampleRadio.checked = true;
sampleRadio.dispatchEvent(new Event('change'));
}
}
function getNmConfig() {
return {
fonts: $q('input[name="nm-cfg-fonts"]').checked,
screensaver: $q('input[name="nm-cfg-screensaver"]').checked,
simplifyTabs: $q('input[name="nm-cfg-simplify-tabs"]').checked,
simplifyHome: $q('input[name="nm-cfg-simplify-home"]').checked,
};
}
function goToNickelMenuConfig() {
checkNickelMenuInstalled();
// Reset config visibility based on current selection
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'sample';
setNavStep(3);
showStep(stepNickelMenu);
}
btnNmBack.addEventListener('click', () => {
goToModeSelection();
});
// Continue from configure to review
btnNmNext.addEventListener('click', () => {
const selected = $q('input[name="nm-option"]:checked', stepNickelMenu);
if (!selected) return;
nickelMenuOption = selected.value;
if (nickelMenuOption === 'remove') {
goToNmReview();
return;
}
goToNmReview();
});
function goToNmReview() {
const summary = $('nm-review-summary');
const list = $('nm-review-list');
list.innerHTML = '';
if (nickelMenuOption === 'remove') {
summary.textContent = 'NickelMenu will be removed from your device.';
btnNmWrite.hidden = manualMode;
btnNmWrite.textContent = 'Remove from Kobo';
btnNmDownload.hidden = true;
} else if (nickelMenuOption === 'nickelmenu-only') {
summary.textContent = 'The following will be installed on your Kobo:';
const li = document.createElement('li');
li.textContent = 'NickelMenu (KoboRoot.tgz)';
list.appendChild(li);
btnNmWrite.hidden = false;
btnNmWrite.textContent = 'Write to Kobo';
btnNmDownload.hidden = false;
} else {
summary.textContent = 'The following will be installed on your Kobo:';
const items = ['NickelMenu (KoboRoot.tgz)', 'Custom menu configuration'];
const cfg = getNmConfig();
if (cfg.fonts) items.push('Readerly fonts');
if (cfg.screensaver) items.push('Custom screensaver');
if (cfg.simplifyTabs) items.push('Simplified tab menu');
if (cfg.simplifyHome) items.push('Simplified homescreen');
for (const text of items) {
const li = document.createElement('li');
li.textContent = text;
list.appendChild(li);
}
btnNmWrite.hidden = false;
btnNmWrite.textContent = 'Write to Kobo';
btnNmDownload.hidden = false;
}
// In manual mode, hide write button
if (manualMode || !device.directoryHandle) {
btnNmWrite.hidden = true;
}
btnNmWrite.disabled = false;
btnNmWrite.className = 'primary';
btnNmDownload.disabled = false;
setNavStep(4);
showStep(stepNmReview);
}
btnNmReviewBack.addEventListener('click', () => {
goToNickelMenuConfig();
});
async function executeNmInstall(writeToDevice) {
const nmProgress = $('nm-progress');
showStep(stepNmInstalling);
try {
if (nickelMenuOption === 'remove') {
nmProgress.textContent = 'Removing NickelMenu...';
await device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0));
showNmDone('remove');
return;
}
const cfg = nickelMenuOption === 'sample' ? getNmConfig() : null;
if (writeToDevice && device.directoryHandle) {
await nmInstaller.installToDevice(device, nickelMenuOption, cfg, (msg) => {
nmProgress.textContent = msg;
});
showNmDone('written');
} else {
resultNmZip = await nmInstaller.buildDownloadZip(nickelMenuOption, cfg, (msg) => {
nmProgress.textContent = msg;
});
showNmDone('download');
}
} catch (err) {
showError('NickelMenu installation failed: ' + err.message);
}
}
btnNmWrite.addEventListener('click', () => executeNmInstall(true));
btnNmDownload.addEventListener('click', () => executeNmInstall(false));
function showNmDone(mode) {
const nmDoneStatus = $('nm-done-status');
$('nm-write-instructions').hidden = true;
$('nm-download-instructions').hidden = true;
$('nm-reboot-instructions').hidden = true;
if (mode === 'remove') {
nmDoneStatus.textContent = 'NickelMenu will be removed on next reboot.';
$('nm-reboot-instructions').hidden = false;
} else if (mode === 'written') {
nmDoneStatus.textContent = 'NickelMenu has been installed on your Kobo.';
$('nm-write-instructions').hidden = false;
} else {
nmDoneStatus.textContent = 'Your NickelMenu package is ready to download.';
triggerDownload(resultNmZip, 'NickelMenu-install.zip', 'application/zip');
$('nm-download-instructions').hidden = false;
}
setNavStep(5);
showStep(stepNmDone);
}
// --- Step 3 (patches path): Configure patches ---
function goToPatches() {
setNavStep(3);
showStep(stepPatches);
}
btnPatchesBack.addEventListener('click', () => {
setNavStep(1);
showStep(manualMode ? stepManual : stepDevice);
if (manualMode) {
// Go back to version selection in manual mode
showStep(stepManualVersion);
} else {
goToModeSelection();
}
});
btnPatchesNext.addEventListener('click', () => {
@@ -297,12 +600,12 @@
goToBuild();
});
// --- Step 3: Review & Build ---
const btnBuild = document.getElementById('btn-build');
const firmwareDescription = document.getElementById('firmware-description');
// --- Step 4 (patches path): Review & Build ---
const btnBuild = $('btn-build');
const firmwareDescription = $('firmware-description');
function populateSelectedPatchesList() {
const patchList = document.getElementById('selected-patches-list');
const patchList = $('selected-patches-list');
patchList.innerHTML = '';
const enabled = patchUI.getEnabledPatches();
for (const name of enabled) {
@@ -312,7 +615,7 @@
}
const hasPatches = enabled.length > 0;
patchList.hidden = !hasPatches;
document.getElementById('selected-patches-heading').hidden = !hasPatches;
$('selected-patches-heading').hidden = !hasPatches;
}
function goToBuild() {
@@ -326,7 +629,7 @@
btnBuild.textContent = 'Build Patched Software';
}
populateSelectedPatchesList();
setNavStep(3);
setNavStep(4);
showStep(stepFirmware);
}
@@ -334,8 +637,8 @@
goToPatches();
});
const buildProgress = document.getElementById('build-progress');
const buildLog = document.getElementById('build-log');
const buildProgress = $('build-progress');
const buildLog = $('build-log');
function appendLog(msg) {
buildLog.textContent += msg + '\n';
@@ -418,7 +721,7 @@
action + '. <strong>KoboRoot.tgz</strong> (' + formatMB(resultTgz.length) + ') is ready. ' +
(description ? description + ' ' : '') + installHint;
const doneLog = document.getElementById('done-log');
const doneLog = $('done-log');
doneLog.textContent = buildLog.textContent;
// Reset install step state.
@@ -431,7 +734,7 @@
downloadInstructions.hidden = true;
existingTgzWarning.hidden = true;
setNavStep(4);
setNavStep(5);
showStep(stepDone);
requestAnimationFrame(() => {
@@ -454,7 +757,7 @@
showStep(stepBuilding);
buildLog.textContent = '';
buildProgress.textContent = 'Starting...';
document.getElementById('build-wait-hint').textContent = isRestore
$('build-wait-hint').textContent = isRestore
? 'Please wait while the original software is being downloaded and extracted...'
: 'Please wait while the patch is being applied...';
@@ -478,7 +781,7 @@
}
});
// --- Install step ---
// --- Install step (patches path) ---
btnWrite.addEventListener('click', async () => {
if (!resultTgz || !device.directoryHandle) return;
@@ -508,7 +811,7 @@
triggerDownload(resultTgz, 'KoboRoot.tgz', 'application/gzip');
writeInstructions.hidden = true;
downloadInstructions.hidden = false;
document.getElementById('download-device-name').textContent = KOBO_MODELS[selectedPrefix] || 'Kobo';
$('download-device-name').textContent = KOBO_MODELS[selectedPrefix] || 'Kobo';
});
// --- Error / Retry ---
@@ -528,28 +831,28 @@
device.disconnect();
firmwareURL = null;
resultTgz = null;
resultNmZip = null;
manualMode = false;
selectedPrefix = null;
patchesLoaded = false;
isRestore = false;
selectedMode = null;
nickelMenuOption = null;
btnDeviceNext.hidden = false;
btnDeviceRestore.hidden = false;
if (hasFileSystemAccess) {
setNavStep(1);
showStep(stepConnect);
} else {
enterManualMode();
}
setNavLabels(NAV_DEFAULT);
setNavStep(1);
showStep(stepConnect);
});
// --- How it works dialog ---
const dialog = document.getElementById('how-it-works-dialog');
document.getElementById('btn-how-it-works').addEventListener('click', (e) => {
const dialog = $('how-it-works-dialog');
$('btn-how-it-works').addEventListener('click', (e) => {
e.preventDefault();
dialog.showModal();
});
document.getElementById('btn-close-dialog').addEventListener('click', () => {
$('btn-close-dialog').addEventListener('click', () => {
dialog.close();
});
dialog.addEventListener('click', (e) => {

View File

@@ -165,6 +165,52 @@ class KoboDevice {
};
}
/**
* Get a nested directory handle, creating directories as needed.
* pathParts is an array like ['.kobo', 'Kobo'].
*/
async getNestedDirectory(pathParts) {
let dir = this.directoryHandle;
for (const part of pathParts) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
return dir;
}
/**
* Write a file at a nested path relative to the device root.
* filePath is like ['.kobo', 'KoboRoot.tgz'] or ['.adds', 'nm', 'items'].
*/
async writeFile(filePath, data) {
const dirParts = filePath.slice(0, -1);
const fileName = filePath[filePath.length - 1];
const dir = dirParts.length > 0
? await this.getNestedDirectory(dirParts)
: this.directoryHandle;
const fileHandle = await dir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
/**
* Read a file at a nested path. Returns the text content, or null if not found.
*/
async readFile(filePath) {
try {
const dirParts = filePath.slice(0, -1);
const fileName = filePath[filePath.length - 1];
const dir = dirParts.length > 0
? await this.getNestedDirectory(dirParts)
: this.directoryHandle;
const fileHandle = await dir.getFileHandle(fileName);
const file = await fileHandle.getFile();
return await file.text();
} catch {
return null;
}
}
/**
* Disconnect / release the directory handle.
*/

195
web/public/js/nickelmenu.js Normal file
View File

@@ -0,0 +1,195 @@
/**
* 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)
* 'sample' — NickelMenu + config based on cfg flags
*
* Config flags (when option is 'sample'):
* 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
*/
class NickelMenuInstaller {
constructor() {
this.nickelMenuZip = null; // JSZip instance
this.koboConfigZip = null; // JSZip instance
}
/**
* Download and cache the bundled assets.
*/
async loadAssets(progressFn) {
if (this.nickelMenuZip && this.koboConfigZip) return;
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());
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());
}
/**
* 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 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 - 'sample' or 'nickelmenu-only'
* @param {object|null} cfg - config flags (when option is 'sample')
* @param {function} progressFn
*/
async installToDevice(device, option, cfg, progressFn) {
await this.loadAssets(progressFn);
// 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);
}
// 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 - 'sample' or 'nickelmenu-only'
* @param {object|null} cfg - config flags (when option is 'sample')
* @param {function} progressFn
* @returns {Uint8Array} zip contents
*/
async buildDownloadZip(option, cfg, progressFn) {
await this.loadAssets(progressFn);
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);
}
}
progressFn('Compressing...');
const result = await zip.generateAsync({ type: 'uint8array' });
progressFn('Done.');
return result;
}
}