Add NickelMenu installation options
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
39
nickelmenu/setup.sh
Executable 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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…</p>
|
<p>Loading…</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 > Settings > 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 > Settings > Device information</strong> > <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 > Settings > 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 > Settings > 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 ›</button>
|
<button id="btn-manual-version-back" class="secondary">‹ Back</button>
|
||||||
|
<button id="btn-manual-confirm" class="primary" disabled>Continue ›</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 ›</button>
|
<button id="btn-device-next" class="primary">Continue ›</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">‹ Back</button>
|
||||||
|
<button id="btn-mode-next" class="primary">Continue ›</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">‹ Back</button>
|
||||||
|
<button id="btn-nm-next" class="primary">Continue ›</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">‹ 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 — 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 — 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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
195
web/public/js/nickelmenu.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user