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/test-results/
tests/e2e/playwright-report/ tests/e2e/playwright-report/
# NickelMenu build artifacts
nickelmenu/kobo-config/
web/public/nickelmenu/
# Claude # Claude
.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)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WASM_DIR="$SCRIPT_DIR/kobopatch-wasm" 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 if [ ! -f "$SCRIPT_DIR/web/public/wasm/kobopatch.wasm" ]; then
echo "WASM binary not found, building..." echo "WASM binary not found, building..."
if [ ! -d "$WASM_DIR/kobopatch-src" ]; then if [ ! -d "$WASM_DIR/kobopatch-src" ]; then

View File

@@ -162,6 +162,206 @@ h2 {
background: var(--success-text); 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 */ /* Steps */
.step { .step {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -182,7 +382,8 @@ h2 {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
.step-actions .primary:first-child { .step-actions .primary:first-child,
.step-actions > [hidden] + .primary {
margin-left: auto; margin-left: auto;
} }

View File

@@ -26,7 +26,7 @@
<main> <main>
<header class="hero"> <header class="hero">
<h1>KoboPatch <span class="hero-accent">Web UI</span> <span class="beta-pill">beta</span></h1> <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> </header>
<!-- Shown until JS initialises --> <!-- Shown until JS initialises -->
@@ -35,60 +35,59 @@
<p>Loading&hellip;</p> <p>Loading&hellip;</p>
</div> </div>
<!-- Step indicator --> <!-- Step indicator (populated dynamically by app.js) -->
<nav id="step-nav" class="step-nav" hidden> <nav id="step-nav" class="step-nav" hidden>
<ol> <ol></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>
</nav> </nav>
<!-- Step 1a: Connect device (automatic, Chromium only) --> <!-- Step 1: Choose connection method -->
<section id="step-connect" class="step" hidden> <section id="step-connect" class="step" hidden>
<div class="warning"> <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, <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>. 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> </div>
<p> <p>How would you like to set up your Kobo?</p>
Connect your Kobo e-reader via USB. It should appear as a removable drive. <div class="mode-cards">
Then click the button below and select the root of the Kobo drive. <button id="btn-connect" class="mode-card mode-card-btn">
</p> <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>
<button id="btn-connect" class="primary">Select Kobo Drive</button> <div class="mode-card-body">
<p class="fallback-hint"> <div class="mode-card-title">Connect my Kobo</div>
Don't want to use Chrome? <div class="mode-card-desc">Connect your Kobo via USB and select its drive. Files will be written directly to the device.</div>
<a href="#" id="btn-manual-from-auto">Select your software version manually</a> instead. </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> </p>
</section> </section>
<!-- Step 1b (manual): Select device model + firmware --> <!-- Step 1b (manual): Select device model + firmware for custom patches -->
<section id="step-manual" class="step" hidden> <section id="step-manual-version" 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 class="fallback-hint"> <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> </p>
<select id="manual-version"> <select id="manual-version">
<option value="">-- Select software version --</option> <option value="">-- Select software version --</option>
</select> </select>
<p id="manual-version-hint" class="fallback-hint"> <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> </p>
<select id="manual-model" hidden> <select id="manual-model" hidden>
<option value="">-- Select your Kobo model --</option> <option value="">-- Select your Kobo model --</option>
</select> </select>
<p id="manual-model-hint" class="fallback-hint" hidden> <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. 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>
<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.
</p> </p>
<div class="step-actions" style="margin-top: 25px"> <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> </div>
</section> </section>
@@ -111,11 +110,134 @@
<p id="device-status"></p> <p id="device-status"></p>
<div class="step-actions"> <div class="step-actions">
<button id="btn-device-restore" class="secondary">Restore Unpatched Software</button> <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> </div>
</section> </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> <section id="step-patches" class="step" hidden>
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p> <p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
<div id="patch-container" class="patch-container-scroll"></div> <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/kobo-device.js?ts=1773751630"></script>
<script src="js/kobopatch.js?ts=1773751630"></script> <script src="js/kobopatch.js?ts=1773751630"></script>
<script src="js/patch-ui.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> <script src="js/app.js?ts=1773751630"></script>
</body> </body>
</html> </html>

View File

@@ -2,20 +2,28 @@
const device = new KoboDevice(); const device = new KoboDevice();
const patchUI = new PatchUI(); const patchUI = new PatchUI();
const runner = new KobopatchRunner(); const runner = new KobopatchRunner();
const nmInstaller = new NickelMenuInstaller();
let firmwareURL = null; let firmwareURL = null;
let resultTgz = null; let resultTgz = null;
let resultNmZip = null;
let manualMode = false; let manualMode = false;
let selectedPrefix = null; let selectedPrefix = null;
let patchesLoaded = false; let patchesLoaded = false;
let isRestore = false; let isRestore = false;
let availablePatches = null; 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. // Fetch patch index immediately so it's ready when needed.
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; }); const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// --- Helpers --- // --- 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) { function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB'; return (bytes / 1024 / 1024).toFixed(1) + ' MB';
} }
@@ -50,46 +58,81 @@
} }
// --- DOM elements --- // --- DOM elements ---
const stepNav = document.getElementById('step-nav'); const stepNav = $('step-nav');
const stepConnect = document.getElementById('step-connect'); const stepConnect = $('step-connect');
const stepManual = document.getElementById('step-manual'); const stepManualVersion = $('step-manual-version');
const stepDevice = document.getElementById('step-device'); const stepDevice = $('step-device');
const stepPatches = document.getElementById('step-patches'); const stepMode = $('step-mode');
const stepFirmware = document.getElementById('step-firmware'); const stepNickelMenu = $('step-nickelmenu');
const stepBuilding = document.getElementById('step-building'); const stepNmInstalling = $('step-nm-installing');
const stepDone = document.getElementById('step-done'); const stepNmDone = $('step-nm-done');
const stepError = document.getElementById('step-error'); 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 btnConnect = $('btn-connect');
const btnManualFromAuto = document.getElementById('btn-manual-from-auto'); const btnManual = $('btn-manual');
const btnManualConfirm = document.getElementById('btn-manual-confirm'); const btnManualConfirm = $('btn-manual-confirm');
const manualVersion = document.getElementById('manual-version'); const btnManualVersionBack = $('btn-manual-version-back');
const manualModel = document.getElementById('manual-model'); const manualVersion = $('manual-version');
const manualChromeHint = document.getElementById('manual-chrome-hint'); const manualModel = $('manual-model');
const btnDeviceNext = document.getElementById('btn-device-next'); const btnDeviceNext = $('btn-device-next');
const btnDeviceRestore = document.getElementById('btn-device-restore'); const btnDeviceRestore = $('btn-device-restore');
const btnPatchesBack = document.getElementById('btn-patches-back'); const btnModeBack = $('btn-mode-back');
const btnPatchesNext = document.getElementById('btn-patches-next'); const btnModeNext = $('btn-mode-next');
const btnBuildBack = document.getElementById('btn-build-back'); const btnNmBack = $('btn-nm-back');
const btnWrite = document.getElementById('btn-write'); const btnNmNext = $('btn-nm-next');
const btnDownload = document.getElementById('btn-download'); const btnNmReviewBack = $('btn-nm-review-back');
const btnRetry = document.getElementById('btn-retry'); 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 errorMessage = $('error-message');
const errorLog = document.getElementById('error-log'); const errorLog = $('error-log');
const deviceStatus = document.getElementById('device-status'); const deviceStatus = $('device-status');
const patchContainer = document.getElementById('patch-container'); const patchContainer = $('patch-container');
const buildStatus = document.getElementById('build-status'); const buildStatus = $('build-status');
const existingTgzWarning = document.getElementById('existing-tgz-warning'); const existingTgzWarning = $('existing-tgz-warning');
const writeInstructions = document.getElementById('write-instructions'); const writeInstructions = $('write-instructions');
const downloadInstructions = document.getElementById('download-instructions'); const downloadInstructions = $('download-instructions');
const firmwareVersionLabel = document.getElementById('firmware-version-label'); const firmwareVersionLabel = $('firmware-version-label');
const firmwareDeviceLabel = document.getElementById('firmware-device-label'); const firmwareDeviceLabel = $('firmware-device-label');
const patchCountHint = document.getElementById('patch-count-hint'); 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 --- // --- 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) { function showStep(step) {
for (const s of allSteps) { for (const s of allSteps) {
s.hidden = (s !== step); s.hidden = (s !== step);
@@ -97,7 +140,7 @@
} }
function setNavStep(num) { function setNavStep(num) {
const items = stepNav.querySelectorAll('li'); const items = $qa('li', stepNav);
items.forEach((li, i) => { items.forEach((li, i) => {
const stepNum = i + 1; const stepNum = i + 1;
li.classList.remove('active', 'done'); li.classList.remove('active', 'done');
@@ -111,6 +154,24 @@
stepNav.hidden = true; 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 --- // --- Patch count ---
function updatePatchCount() { function updatePatchCount() {
const count = patchUI.getEnabledCount(); const count = patchUI.getEnabledCount();
@@ -129,48 +190,40 @@
firmwareURL = prefix ? getFirmwareURL(prefix, version) : null; firmwareURL = prefix ? getFirmwareURL(prefix, version) : null;
firmwareVersionLabel.textContent = version; firmwareVersionLabel.textContent = version;
firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix; firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix;
document.getElementById('firmware-download-url').textContent = firmwareURL || ''; $('firmware-download-url').textContent = firmwareURL || '';
} }
// --- Initial state --- // --- Initial state ---
const loader = document.getElementById('initial-loader'); const loader = $('initial-loader');
if (loader) loader.remove(); if (loader) loader.remove();
const hasFileSystemAccess = KoboDevice.isSupported(); const hasFileSystemAccess = KoboDevice.isSupported();
if (hasFileSystemAccess) {
// Disable "Connect my Kobo" button on unsupported browsers
if (!hasFileSystemAccess) {
btnConnect.disabled = true;
$('connect-unsupported-hint').hidden = false;
}
setNavLabels(NAV_DEFAULT);
setNavStep(1); setNavStep(1);
showStep(stepConnect); showStep(stepConnect);
} else {
enterManualMode();
}
// --- Step 1: Device selection --- // --- Step 1: Connection method ---
async function enterManualMode() { // "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; manualMode = true;
manualChromeHint.hidden = false; goToModeSelection();
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();
}); });
manualVersion.addEventListener('change', () => { manualVersion.addEventListener('change', () => {
const version = manualVersion.value; const version = manualVersion.value;
selectedPrefix = null; selectedPrefix = null;
const modelHint = document.getElementById('manual-model-hint'); const modelHint = $('manual-model-hint');
if (!version) { if (!version) {
manualModel.hidden = true; manualModel.hidden = true;
modelHint.hidden = true; modelHint.hidden = true;
@@ -192,7 +245,7 @@
btnManualConfirm.disabled = !manualVersion.value || !manualModel.value; 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 () => { btnManualConfirm.addEventListener('click', async () => {
const version = manualVersion.value; const version = manualVersion.value;
if (!version || !selectedPrefix) return; if (!version || !selectedPrefix) return;
@@ -215,15 +268,15 @@
try { try {
const info = await device.connect(); const info = await device.connect();
document.getElementById('device-model').textContent = info.model; $('device-model').textContent = info.model;
const serialEl = document.getElementById('device-serial'); const serialEl = $('device-serial');
serialEl.textContent = ''; serialEl.textContent = '';
const prefixLen = info.serialPrefix.length; const prefixLen = info.serialPrefix.length;
const u = document.createElement('u'); const u = document.createElement('u');
u.textContent = info.serial.slice(0, prefixLen); u.textContent = info.serial.slice(0, prefixLen);
serialEl.appendChild(u); serialEl.appendChild(u);
serialEl.appendChild(document.createTextNode(info.serial.slice(prefixLen))); serialEl.appendChild(document.createTextNode(info.serial.slice(prefixLen)));
document.getElementById('device-firmware').textContent = info.firmware; $('device-firmware').textContent = info.firmware;
selectedPrefix = info.serialPrefix; selectedPrefix = info.serialPrefix;
@@ -231,42 +284,35 @@
const match = availablePatches.find(p => p.version === info.firmware); const match = availablePatches.find(p => p.version === info.firmware);
if (match) { 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); await patchUI.loadFromURL('patches/' + match.filename);
patchUI.render(patchContainer); patchUI.render(patchContainer);
updatePatchCount(); updatePatchCount();
patchesLoaded = true; patchesLoaded = true;
configureFirmwareStep(info.firmware, info.serialPrefix); configureFirmwareStep(info.firmware, info.serialPrefix);
btnDeviceNext.hidden = false;
btnDeviceRestore.hidden = false; btnDeviceRestore.hidden = false;
showStep(stepDevice);
} else { } 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; 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) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
showError(err.message); showError(err.message);
} }
}); });
// Device info -> patches // Device info -> mode selection
btnDeviceNext.addEventListener('click', () => { btnDeviceNext.addEventListener('click', () => {
if (patchesLoaded) goToPatches(); goToModeSelection();
}); });
btnDeviceRestore.addEventListener('click', () => { btnDeviceRestore.addEventListener('click', () => {
if (!patchesLoaded) return; if (!patchesLoaded) return;
selectedMode = 'patches';
isRestore = true; isRestore = true;
setNavLabels(NAV_PATCHES);
goToBuild(); goToBuild();
}); });
@@ -281,15 +327,272 @@
return true; return true;
} }
// --- Step 2: Patches --- // --- Step 2: Mode selection ---
function goToPatches() { 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); 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); showStep(stepPatches);
} }
btnPatchesBack.addEventListener('click', () => { btnPatchesBack.addEventListener('click', () => {
setNavStep(1); if (manualMode) {
showStep(manualMode ? stepManual : stepDevice); // Go back to version selection in manual mode
showStep(stepManualVersion);
} else {
goToModeSelection();
}
}); });
btnPatchesNext.addEventListener('click', () => { btnPatchesNext.addEventListener('click', () => {
@@ -297,12 +600,12 @@
goToBuild(); goToBuild();
}); });
// --- Step 3: Review & Build --- // --- Step 4 (patches path): Review & Build ---
const btnBuild = document.getElementById('btn-build'); const btnBuild = $('btn-build');
const firmwareDescription = document.getElementById('firmware-description'); const firmwareDescription = $('firmware-description');
function populateSelectedPatchesList() { function populateSelectedPatchesList() {
const patchList = document.getElementById('selected-patches-list'); const patchList = $('selected-patches-list');
patchList.innerHTML = ''; patchList.innerHTML = '';
const enabled = patchUI.getEnabledPatches(); const enabled = patchUI.getEnabledPatches();
for (const name of enabled) { for (const name of enabled) {
@@ -312,7 +615,7 @@
} }
const hasPatches = enabled.length > 0; const hasPatches = enabled.length > 0;
patchList.hidden = !hasPatches; patchList.hidden = !hasPatches;
document.getElementById('selected-patches-heading').hidden = !hasPatches; $('selected-patches-heading').hidden = !hasPatches;
} }
function goToBuild() { function goToBuild() {
@@ -326,7 +629,7 @@
btnBuild.textContent = 'Build Patched Software'; btnBuild.textContent = 'Build Patched Software';
} }
populateSelectedPatchesList(); populateSelectedPatchesList();
setNavStep(3); setNavStep(4);
showStep(stepFirmware); showStep(stepFirmware);
} }
@@ -334,8 +637,8 @@
goToPatches(); goToPatches();
}); });
const buildProgress = document.getElementById('build-progress'); const buildProgress = $('build-progress');
const buildLog = document.getElementById('build-log'); const buildLog = $('build-log');
function appendLog(msg) { function appendLog(msg) {
buildLog.textContent += msg + '\n'; buildLog.textContent += msg + '\n';
@@ -418,7 +721,7 @@
action + '. <strong>KoboRoot.tgz</strong> (' + formatMB(resultTgz.length) + ') is ready. ' + action + '. <strong>KoboRoot.tgz</strong> (' + formatMB(resultTgz.length) + ') is ready. ' +
(description ? description + ' ' : '') + installHint; (description ? description + ' ' : '') + installHint;
const doneLog = document.getElementById('done-log'); const doneLog = $('done-log');
doneLog.textContent = buildLog.textContent; doneLog.textContent = buildLog.textContent;
// Reset install step state. // Reset install step state.
@@ -431,7 +734,7 @@
downloadInstructions.hidden = true; downloadInstructions.hidden = true;
existingTgzWarning.hidden = true; existingTgzWarning.hidden = true;
setNavStep(4); setNavStep(5);
showStep(stepDone); showStep(stepDone);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -454,7 +757,7 @@
showStep(stepBuilding); showStep(stepBuilding);
buildLog.textContent = ''; buildLog.textContent = '';
buildProgress.textContent = 'Starting...'; 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 original software is being downloaded and extracted...'
: 'Please wait while the patch is being applied...'; : 'Please wait while the patch is being applied...';
@@ -478,7 +781,7 @@
} }
}); });
// --- Install step --- // --- Install step (patches path) ---
btnWrite.addEventListener('click', async () => { btnWrite.addEventListener('click', async () => {
if (!resultTgz || !device.directoryHandle) return; if (!resultTgz || !device.directoryHandle) return;
@@ -508,7 +811,7 @@
triggerDownload(resultTgz, 'KoboRoot.tgz', 'application/gzip'); triggerDownload(resultTgz, 'KoboRoot.tgz', 'application/gzip');
writeInstructions.hidden = true; writeInstructions.hidden = true;
downloadInstructions.hidden = false; downloadInstructions.hidden = false;
document.getElementById('download-device-name').textContent = KOBO_MODELS[selectedPrefix] || 'Kobo'; $('download-device-name').textContent = KOBO_MODELS[selectedPrefix] || 'Kobo';
}); });
// --- Error / Retry --- // --- Error / Retry ---
@@ -528,28 +831,28 @@
device.disconnect(); device.disconnect();
firmwareURL = null; firmwareURL = null;
resultTgz = null; resultTgz = null;
resultNmZip = null;
manualMode = false; manualMode = false;
selectedPrefix = null; selectedPrefix = null;
patchesLoaded = false; patchesLoaded = false;
isRestore = false; isRestore = false;
selectedMode = null;
nickelMenuOption = null;
btnDeviceNext.hidden = false; btnDeviceNext.hidden = false;
btnDeviceRestore.hidden = false; btnDeviceRestore.hidden = false;
if (hasFileSystemAccess) { setNavLabels(NAV_DEFAULT);
setNavStep(1); setNavStep(1);
showStep(stepConnect); showStep(stepConnect);
} else {
enterManualMode();
}
}); });
// --- How it works dialog --- // --- How it works dialog ---
const dialog = document.getElementById('how-it-works-dialog'); const dialog = $('how-it-works-dialog');
document.getElementById('btn-how-it-works').addEventListener('click', (e) => { $('btn-how-it-works').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
dialog.showModal(); dialog.showModal();
}); });
document.getElementById('btn-close-dialog').addEventListener('click', () => { $('btn-close-dialog').addEventListener('click', () => {
dialog.close(); dialog.close();
}); });
dialog.addEventListener('click', (e) => { 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. * 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;
}
}