Refactor how NickelMenu is set up
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s
This commit is contained in:
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -24,6 +24,9 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Clone kobopatch source
|
||||
run: |
|
||||
cd kobopatch-wasm
|
||||
@@ -56,7 +59,7 @@ jobs:
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/)'; then
|
||||
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/|readerly/)'; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -82,6 +85,10 @@ jobs:
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: koreader/setup.sh
|
||||
|
||||
- name: Set up Readerly assets
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: readerly/setup.sh
|
||||
|
||||
- name: Full integration test (Playwright)
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: |
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,8 +14,8 @@ kobopatch-wasm/wasm_exec.js
|
||||
# Generated files in src (written by build scripts, regenerated on demand)
|
||||
web/src/js/wasm_exec.js
|
||||
web/src/nickelmenu/NickelMenu.zip
|
||||
web/src/nickelmenu/kobo-config.zip
|
||||
web/src/koreader/
|
||||
web/src/readerly/
|
||||
|
||||
# Build output
|
||||
web/dist/
|
||||
|
||||
34
README.md
34
README.md
@@ -15,6 +15,14 @@ A web application for customising Kobo e-readers. It supports two modes:
|
||||
- These changes are wiped when system updates are released. Requires re-patching when system updates are installed.
|
||||
- Gives you a lot of customization options, but not all of them may work correctly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (includes npm)
|
||||
- [jq](https://jqlang.github.io/jq/) — `brew install jq` / `apt install jq`
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
Go is required for the WASM build but downloaded automatically if not installed.
|
||||
|
||||
## How it works
|
||||
|
||||
The app uses the **Filesystem Access API** (Chromium) to interface with connected Kobo devices, or falls back to manual model/software version selection with a downloadable ZIP on other browsers.
|
||||
@@ -44,7 +52,7 @@ web/
|
||||
app.js # ES module entry point: step navigation, flow orchestration
|
||||
kobo-device.js # KoboModels, KoboDevice class
|
||||
kobo-software-urls.js # Fetches download URLs from JSON, getSoftwareUrl, getDevicesForVersion
|
||||
nickelmenu.js # NickelMenuInstaller: downloads/bundles NickelMenu + config
|
||||
nickelmenu/ # NickelMenu feature modules + installer orchestrator
|
||||
patch-ui.js # PatchUI: loads patches, parses YAML, renders toggle UI
|
||||
patch-runner.js # KoboPatchRunner: spawns Web Worker per build
|
||||
patch-worker.js # Web Worker: loads WASM, runs patchFirmware()
|
||||
@@ -54,9 +62,8 @@ web/
|
||||
index.json # Available patch manifest
|
||||
downloads.json # Firmware download URLs by version/serial (may be auto-generated)
|
||||
patches_*.zip # Patch files per firmware version
|
||||
nickelmenu/ # NickelMenu assets (generated by nickelmenu/setup.sh, gitignored)
|
||||
NickelMenu.zip
|
||||
kobo-config.zip
|
||||
nickelmenu/ # NickelMenu assets (NickelMenu.zip generated by nickelmenu/setup.sh, gitignored)
|
||||
readerly/ # Readerly font assets (generated by readerly/setup.sh, gitignored)
|
||||
koreader/ # KOReader assets (generated by koreader/setup.sh, gitignored)
|
||||
koreader-kobo.zip
|
||||
release.json
|
||||
@@ -64,12 +71,15 @@ web/
|
||||
dist/ # Build output (gitignored, fully regenerable)
|
||||
bundle.js # esbuild output (minified, content-hashed)
|
||||
index.html # Generated with cache-busted references
|
||||
css/ favicon/ patches/ nickelmenu/ koreader/ wasm/ js/wasm_exec.js
|
||||
css/ favicon/ patches/ nickelmenu/ readerly/ koreader/ wasm/ js/wasm_exec.js
|
||||
build.mjs # esbuild build script + asset copy
|
||||
package.json # esbuild, jszip
|
||||
|
||||
nickelmenu/
|
||||
setup.sh # Downloads NickelMenu.zip and bundles kobo-config.zip
|
||||
setup.sh # Downloads NickelMenu.zip
|
||||
|
||||
readerly/
|
||||
setup.sh # Downloads latest Readerly fonts from GitHub releases
|
||||
|
||||
koreader/
|
||||
setup.sh # Downloads latest KOReader release for Kobo
|
||||
@@ -122,7 +132,15 @@ cd kobopatch-wasm
|
||||
nickelmenu/setup.sh
|
||||
```
|
||||
|
||||
This downloads `NickelMenu.zip` and clones/updates the [kobo-config](https://github.com/nicoverbruggen/kobo-config) repo to bundle `kobo-config.zip` into `web/src/nickelmenu/`.
|
||||
This downloads `NickelMenu.zip` into `web/src/nickelmenu/`.
|
||||
|
||||
## Setting up Readerly font assets
|
||||
|
||||
```bash
|
||||
readerly/setup.sh
|
||||
```
|
||||
|
||||
This downloads the latest [Readerly](https://github.com/nicoverbruggen/readerly) font release (`KF_Readerly.zip`) into `web/src/readerly/`. The fonts are served from the app's own domain and downloaded by the browser at install time.
|
||||
|
||||
## Setting up KOReader assets
|
||||
|
||||
@@ -159,7 +177,7 @@ npm run dev # dev server with watch mode on :8889
|
||||
|
||||
This serves the app at `http://localhost:8888`. The script automatically:
|
||||
|
||||
1. Sets up NickelMenu assets if missing (`web/src/nickelmenu/`)
|
||||
1. Sets up NickelMenu, KOReader, and Readerly assets if missing
|
||||
2. Builds the JS bundle (`web/dist/bundle.js`)
|
||||
3. Builds the WASM binary if missing (`web/dist/wasm/kobopatch.wasm`)
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ mkdir -p "$PUBLIC_DIR"
|
||||
|
||||
echo "Fetching latest KOReader release info..."
|
||||
RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/koreader/koreader/releases/latest)
|
||||
VERSION=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; assets=json.load(sys.stdin)['assets']; print(next(a['browser_download_url'] for a in assets if 'koreader-kobo-' in a['name'] and a['name'].endswith('.zip')))")
|
||||
VERSION=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | test("koreader-kobo-.*\\.zip$")) | .browser_download_url')
|
||||
|
||||
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ] || [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then
|
||||
echo "Error: Could not find KOReader Kobo release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading KOReader $VERSION..."
|
||||
curl -fL --progress-bar -o "$PUBLIC_DIR/koreader-kobo.zip" "$DOWNLOAD_URL"
|
||||
|
||||
@@ -12,28 +12,5 @@ echo "Downloading NickelMenu.zip..."
|
||||
curl -fSL -o "$PUBLIC_DIR/NickelMenu.zip" "$NICKELMENU_URL"
|
||||
echo " -> $(du -h "$PUBLIC_DIR/NickelMenu.zip" | cut -f1)"
|
||||
|
||||
# --- kobo-config ---
|
||||
KOBO_CONFIG_DIR="$SCRIPT_DIR/kobo-config"
|
||||
if [ -d "$KOBO_CONFIG_DIR" ]; then
|
||||
echo "Updating kobo-config..."
|
||||
cd "$KOBO_CONFIG_DIR"
|
||||
git pull
|
||||
else
|
||||
echo "Cloning kobo-config..."
|
||||
git clone https://github.com/nicoverbruggen/kobo-config.git "$KOBO_CONFIG_DIR"
|
||||
fi
|
||||
|
||||
# Copy the relevant assets into a zip for the web app.
|
||||
# Includes: .adds/, .kobo/screensaver/, fonts/
|
||||
echo "Bundling kobo-config.zip..."
|
||||
cd "$KOBO_CONFIG_DIR"
|
||||
zip -r "$PUBLIC_DIR/kobo-config.zip" \
|
||||
.adds/ \
|
||||
.kobo/screensaver/ \
|
||||
fonts/ \
|
||||
-x "*.DS_Store"
|
||||
|
||||
echo " -> $(du -h "$PUBLIC_DIR/kobo-config.zip" | cut -f1)"
|
||||
|
||||
echo ""
|
||||
echo "Done. Assets written to: $PUBLIC_DIR"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
providers = ["node"]
|
||||
|
||||
[phases.setup]
|
||||
nixPkgs = ["git", "curl", "zip", "gnutar", "nginx", "nodejs", "gettext"]
|
||||
nixPkgs = ["git", "curl", "zip", "gnutar", "nginx", "nodejs", "gettext", "jq"]
|
||||
paths = ["/usr/local/go/bin"]
|
||||
|
||||
[phases.build]
|
||||
@@ -9,6 +9,8 @@ cmds = [
|
||||
"cd kobopatch-wasm && bash setup.sh",
|
||||
"cd kobopatch-wasm && bash build.sh",
|
||||
"cd nickelmenu && bash setup.sh",
|
||||
"cd koreader && bash setup.sh",
|
||||
"cd readerly && bash setup.sh",
|
||||
"cd web && npm install && npm run build",
|
||||
]
|
||||
|
||||
|
||||
24
readerly/setup.sh
Executable file
24
readerly/setup.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PUBLIC_DIR="$SCRIPT_DIR/../web/src/readerly"
|
||||
|
||||
mkdir -p "$PUBLIC_DIR"
|
||||
|
||||
# Get latest release download URL for KF_Readerly.zip
|
||||
echo "Fetching latest Readerly release..."
|
||||
DOWNLOAD_URL=$(curl -fsSL https://api.github.com/repos/nicoverbruggen/readerly/releases/latest \
|
||||
| jq -r '.assets[] | select(.name == "KF_Readerly.zip") | .browser_download_url')
|
||||
|
||||
if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then
|
||||
echo "Error: Could not find KF_Readerly.zip in latest release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading KF_Readerly.zip..."
|
||||
curl -fSL -o "$PUBLIC_DIR/KF_Readerly.zip" "$DOWNLOAD_URL"
|
||||
echo " -> $(du -h "$PUBLIC_DIR/KF_Readerly.zip" | cut -f1)"
|
||||
|
||||
echo ""
|
||||
echo "Done. Assets written to: $PUBLIC_DIR"
|
||||
@@ -22,6 +22,11 @@ if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then
|
||||
"$SCRIPT_DIR/koreader/setup.sh"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SRC_DIR/readerly/KF_Readerly.zip" ]; then
|
||||
echo "Readerly font assets not found, downloading..."
|
||||
"$SCRIPT_DIR/readerly/setup.sh"
|
||||
fi
|
||||
|
||||
echo "Building JS bundle..."
|
||||
cd "$WEB_DIR"
|
||||
npm install --silent
|
||||
|
||||
@@ -4,7 +4,7 @@ const { WEBROOT, WEBROOT_FIRMWARE, FIRMWARE_PATH } = require('./paths');
|
||||
|
||||
function hasNickelMenuAssets() {
|
||||
return fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'NickelMenu.zip'))
|
||||
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'kobo-config.zip'));
|
||||
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'features', 'custom-menu', 'items'));
|
||||
}
|
||||
|
||||
function hasKoreaderAssets() {
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe('NickelMenu', () => {
|
||||
await expect(page.locator('#nm-config-options')).not.toBeHidden();
|
||||
|
||||
// Verify default checkbox states
|
||||
await expect(page.locator('input[name="nm-cfg-fonts"]')).toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-readerly-fonts"]')).toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-screensaver"]')).not.toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-simplify-tabs"]')).not.toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-simplify-home"]')).not.toBeChecked();
|
||||
@@ -256,8 +256,8 @@ test.describe('NickelMenu', () => {
|
||||
await expect(page.locator('#step-nm-review')).not.toBeHidden();
|
||||
await expect(page.locator('#nm-review-list')).toContainText('NickelMenu');
|
||||
await expect(page.locator('#nm-review-list')).toContainText('Readerly fonts');
|
||||
await expect(page.locator('#nm-review-list')).toContainText('Simplified tab menu');
|
||||
await expect(page.locator('#nm-review-list')).toContainText('Simplified homescreen');
|
||||
await expect(page.locator('#nm-review-list')).toContainText('Hide certain navigation tabs');
|
||||
await expect(page.locator('#nm-review-list')).toContainText('Hide certain home screen elements');
|
||||
|
||||
// Both buttons visible when device is connected
|
||||
await expect(page.locator('#btn-nm-write')).toBeVisible();
|
||||
|
||||
@@ -177,49 +177,7 @@
|
||||
</div>
|
||||
</label>
|
||||
<div id="nm-config-options" class="nm-config-options" hidden>
|
||||
<label class="nm-config-item">
|
||||
<input type="checkbox" name="nm-cfg-menu" checked disabled>
|
||||
<div class="nm-config-text">
|
||||
<span>Set up custom menu (required)</span>
|
||||
<span class="nm-config-desc">Adds menu items for dark mode, screenshots, and more. A new tab will be added in the bottom navigation that is labelled "Tweak".</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item">
|
||||
<input type="checkbox" name="nm-cfg-fonts" checked>
|
||||
<div class="nm-config-text">
|
||||
<span>Install Readerly fonts (recommended)</span>
|
||||
<span class="nm-config-desc">Adds the Readerly font family. These fonts are optically similar to Bookerly. When you are reading a book, you will be able to select this font from the dropdown as "KF Readerly".</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item" id="nm-cfg-koreader-label">
|
||||
<input type="checkbox" name="nm-cfg-koreader">
|
||||
<div class="nm-config-text">
|
||||
<span>Install KOReader <span id="koreader-version"></span> (optional)</span>
|
||||
<span class="nm-config-desc">Installs <a href="https://koreader.rocks" target="_blank">KOReader</a>, an alternative e-book reader with advanced features like PDF reflow, customizable fonts, and more.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item">
|
||||
<input type="checkbox" name="nm-cfg-simplify-tabs">
|
||||
<div class="nm-config-text">
|
||||
<span>Hide certain navigation tabs (minimalist)</span>
|
||||
<span class="nm-config-desc">This will hide the Notebook and Discover tabs from the bottom navigation. For minimalists who want fewer distractions.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item">
|
||||
<input type="checkbox" name="nm-cfg-simplify-home">
|
||||
<div class="nm-config-text">
|
||||
<span>Hide certain home screen elements (minimalist)</span>
|
||||
<span class="nm-config-desc">If you are reading only one book, no recommendations will appear next to your current read, and third row on your homescreen with advertisements for Kobo Plus and the Kobo Store will be hidden. For minimalists who want fewer distractions.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item">
|
||||
<input type="checkbox" name="nm-cfg-screensaver">
|
||||
<div class="nm-config-text">
|
||||
<span>Copy screensaver (optional)</span>
|
||||
<span class="nm-config-desc">Copies a screensaver to <code>/.kobo/screensaver</code>. Depending on your configuration, it will now be displayed instead of your current read. You can always add your own in the <code>/.kobo/screensaver</code> folder, and choosing Tweak > Screensaver will let you toggle it off.</span>
|
||||
</div>
|
||||
</label>
|
||||
<a href="https://github.com/nicoverbruggen/kobo-config" target="_blank" class="nm-config-link">Learn more about these customisations ›</a>
|
||||
<!-- Populated dynamically from feature modules by app.js -->
|
||||
</div>
|
||||
<label class="nm-option">
|
||||
<input type="radio" name="nm-option" value="nickelmenu-only">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { KoboDevice, KoboModels } from './kobo-device.js';
|
||||
import { loadSoftwareUrls, getSoftwareUrl, getDevicesForVersion } from './kobo-software-urls.js';
|
||||
import { PatchUI, scanAvailablePatches } from './patch-ui.js';
|
||||
import { KoboPatchRunner } from './patch-runner.js';
|
||||
import { NickelMenuInstaller } from './nickelmenu.js';
|
||||
import { NickelMenuInstaller, ALL_FEATURES } from '../nickelmenu/installer.js';
|
||||
import { TL } from './strings.js';
|
||||
import { isEnabled as analyticsEnabled, track } from './analytics.js';
|
||||
import JSZip from 'jszip';
|
||||
@@ -34,16 +34,17 @@ import JSZip from 'jszip';
|
||||
const softwareUrlsReady = loadSoftwareUrls();
|
||||
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
|
||||
|
||||
// Show KOReader version in the UI (best-effort, non-blocking).
|
||||
fetch('/koreader/release.json').then(r => r.ok ? r.json() : null).then(meta => {
|
||||
if (meta && meta.version) {
|
||||
$('koreader-version').textContent = meta.version;
|
||||
} else {
|
||||
$('nm-cfg-koreader-label').style.display = 'none';
|
||||
}
|
||||
}).catch(() => {
|
||||
$('nm-cfg-koreader-label').style.display = 'none';
|
||||
});
|
||||
// Check KOReader availability and mark the feature (best-effort, non-blocking).
|
||||
const koreaderFeature = ALL_FEATURES.find(f => f.id === 'koreader');
|
||||
const koreaderVersionReady = fetch('/koreader/release.json')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(meta => {
|
||||
if (meta && meta.version) {
|
||||
koreaderFeature.available = true;
|
||||
koreaderFeature.version = meta.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
function formatMB(bytes) {
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
@@ -474,6 +475,46 @@ import JSZip from 'jszip';
|
||||
// --- Step 2b: NickelMenu configuration ---
|
||||
const nmConfigOptions = $('nm-config-options');
|
||||
|
||||
// Render feature checkboxes dynamically from ALL_FEATURES
|
||||
function renderFeatureCheckboxes() {
|
||||
nmConfigOptions.innerHTML = '';
|
||||
for (const feature of ALL_FEATURES) {
|
||||
// Hide unavailable features (e.g. KOReader when assets missing)
|
||||
if (feature.available === false) continue;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'nm-config-item';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = 'nm-cfg-' + feature.id;
|
||||
input.checked = feature.default;
|
||||
if (feature.required) {
|
||||
input.checked = true;
|
||||
input.disabled = true;
|
||||
}
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'nm-config-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
let titleText = feature.title;
|
||||
if (feature.required) titleText += ' (required)';
|
||||
if (feature.version) titleText += ' ' + feature.version;
|
||||
titleSpan.textContent = titleText;
|
||||
|
||||
const descSpan = document.createElement('span');
|
||||
descSpan.className = 'nm-config-desc';
|
||||
descSpan.textContent = feature.description;
|
||||
|
||||
textDiv.appendChild(titleSpan);
|
||||
textDiv.appendChild(descSpan);
|
||||
label.appendChild(input);
|
||||
label.appendChild(textDiv);
|
||||
nmConfigOptions.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide config checkboxes based on radio selection, enable Continue
|
||||
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
|
||||
radio.addEventListener('change', () => {
|
||||
@@ -504,24 +545,24 @@ import JSZip from 'jszip';
|
||||
removeOption.classList.add('nm-option-disabled');
|
||||
removeDesc.textContent = TL.STATUS.NM_REMOVAL_DISABLED;
|
||||
if (removeRadio.checked) {
|
||||
const sampleRadio = $q('input[value="sample"]', stepNickelMenu);
|
||||
sampleRadio.checked = true;
|
||||
sampleRadio.dispatchEvent(new Event('change'));
|
||||
const presetRadio = $q('input[value="preset"]', stepNickelMenu);
|
||||
presetRadio.checked = true;
|
||||
presetRadio.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
function getNmConfig() {
|
||||
return {
|
||||
fonts: $q('input[name="nm-cfg-fonts"]').checked,
|
||||
screensaver: $q('input[name="nm-cfg-screensaver"]').checked,
|
||||
simplifyTabs: $q('input[name="nm-cfg-simplify-tabs"]').checked,
|
||||
simplifyHome: $q('input[name="nm-cfg-simplify-home"]').checked,
|
||||
koreader: $q('input[name="nm-cfg-koreader"]').checked,
|
||||
};
|
||||
function getSelectedFeatures() {
|
||||
return ALL_FEATURES.filter(f => {
|
||||
if (f.available === false) return false;
|
||||
if (f.required) return true;
|
||||
const checkbox = $q(`input[name="nm-cfg-${f.id}"]`);
|
||||
return checkbox && checkbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function goToNickelMenuConfig() {
|
||||
checkNickelMenuInstalled();
|
||||
renderFeatureCheckboxes();
|
||||
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
|
||||
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'preset';
|
||||
btnNmNext.disabled = !currentOption;
|
||||
@@ -563,13 +604,10 @@ import JSZip from 'jszip';
|
||||
btnNmDownload.hidden = false;
|
||||
} else {
|
||||
summary.textContent = TL.STATUS.NM_WILL_BE_INSTALLED;
|
||||
const items = [TL.STATUS.NM_NICKEL_ROOT_TGZ, 'Custom menu configuration'];
|
||||
const cfg = getNmConfig();
|
||||
if (cfg.fonts) items.push(TL.NICKEL_MENU_ITEMS.FONTS);
|
||||
if (cfg.screensaver) items.push(TL.NICKEL_MENU_ITEMS.SCREENSAVER);
|
||||
if (cfg.simplifyTabs) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_TABS);
|
||||
if (cfg.simplifyHome) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_HOME);
|
||||
if (cfg.koreader) items.push(TL.NICKEL_MENU_ITEMS.KOREADER);
|
||||
const items = [TL.STATUS.NM_NICKEL_ROOT_TGZ];
|
||||
for (const feature of getSelectedFeatures()) {
|
||||
items.push(feature.title);
|
||||
}
|
||||
for (const text of items) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = text;
|
||||
@@ -599,11 +637,12 @@ import JSZip from 'jszip';
|
||||
|
||||
async function executeNmInstall(writeToDevice) {
|
||||
const nmProgress = $('nm-progress');
|
||||
const progressFn = (msg) => { nmProgress.textContent = msg; };
|
||||
showStep(stepNmInstalling);
|
||||
|
||||
try {
|
||||
if (nickelMenuOption === 'remove') {
|
||||
await nmInstaller.loadAssets((msg) => { nmProgress.textContent = msg; }, false);
|
||||
await nmInstaller.loadNickelMenu(progressFn);
|
||||
nmProgress.textContent = 'Writing KoboRoot.tgz...';
|
||||
const tgz = await nmInstaller.getKoboRootTgz();
|
||||
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
||||
@@ -613,17 +652,13 @@ import JSZip from 'jszip';
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = nickelMenuOption === 'preset' ? getNmConfig() : null;
|
||||
const features = nickelMenuOption === 'preset' ? getSelectedFeatures() : [];
|
||||
|
||||
if (writeToDevice && device.directoryHandle) {
|
||||
await nmInstaller.installToDevice(device, nickelMenuOption, cfg, (msg) => {
|
||||
nmProgress.textContent = msg;
|
||||
});
|
||||
await nmInstaller.installToDevice(device, features, progressFn);
|
||||
showNmDone('written');
|
||||
} else {
|
||||
resultNmZip = await nmInstaller.buildDownloadZip(nickelMenuOption, cfg, (msg) => {
|
||||
nmProgress.textContent = msg;
|
||||
});
|
||||
resultNmZip = await nmInstaller.buildDownloadZip(features, progressFn);
|
||||
showNmDone('download');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
/**
|
||||
* NickelMenu installer module.
|
||||
*
|
||||
* Handles downloading bundled NickelMenu + kobo-config assets,
|
||||
* and either writing them directly to a connected Kobo (auto mode)
|
||||
* or building a zip for manual download.
|
||||
*
|
||||
* Options:
|
||||
* 'nickelmenu-only' — just NickelMenu (KoboRoot.tgz)
|
||||
* 'preset' — NickelMenu + config based on cfg flags
|
||||
*
|
||||
* Config flags (when option is 'preset'):
|
||||
* fonts: bool — include Readerly fonts
|
||||
* screensaver: bool — include custom screensaver
|
||||
* simplifyTabs: bool — comment out experimental tab items in config
|
||||
* simplifyHome: bool — append homescreen simplification lines
|
||||
* koreader: bool — download and install latest KOReader from GitHub
|
||||
*/
|
||||
class NickelMenuInstaller {
|
||||
constructor() {
|
||||
this.nickelMenuZip = null; // JSZip instance
|
||||
this.koboConfigZip = null; // JSZip instance
|
||||
this.koreaderZip = null; // JSZip instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache the bundled assets.
|
||||
* @param {function} progressFn
|
||||
* @param {boolean} [needConfig=true] - Whether to also load kobo-config.zip
|
||||
*/
|
||||
async loadAssets(progressFn, needConfig = true) {
|
||||
if (!this.nickelMenuZip) {
|
||||
progressFn('Downloading NickelMenu...');
|
||||
const nmResp = await fetch('nickelmenu/NickelMenu.zip');
|
||||
if (!nmResp.ok) throw new Error('Failed to download NickelMenu.zip: HTTP ' + nmResp.status);
|
||||
this.nickelMenuZip = await JSZip.loadAsync(await nmResp.arrayBuffer());
|
||||
}
|
||||
|
||||
if (needConfig && !this.koboConfigZip) {
|
||||
progressFn('Downloading configuration files...');
|
||||
const cfgResp = await fetch('nickelmenu/kobo-config.zip');
|
||||
if (!cfgResp.ok) throw new Error('Failed to download kobo-config.zip: HTTP ' + cfgResp.status);
|
||||
this.koboConfigZip = await JSZip.loadAsync(await cfgResp.arrayBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache KOReader for Kobo (served from the app's own domain
|
||||
* to avoid CORS issues with GitHub release downloads).
|
||||
* @param {function} progressFn
|
||||
*/
|
||||
async loadKoreader(progressFn) {
|
||||
if (this.koreaderZip) return;
|
||||
|
||||
progressFn('Fetching KOReader release info...');
|
||||
const metaResp = await fetch('/koreader/release.json');
|
||||
if (!metaResp.ok) throw new Error('KOReader assets not available (run koreader/setup.sh)');
|
||||
const meta = await metaResp.json();
|
||||
|
||||
progressFn('Downloading KOReader ' + meta.version + '...');
|
||||
const zipResp = await fetch('/koreader/koreader-kobo.zip');
|
||||
if (!zipResp.ok) throw new Error('Failed to download KOReader: HTTP ' + zipResp.status);
|
||||
this.koreaderZip = await JSZip.loadAsync(await zipResp.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KoboRoot.tgz from the NickelMenu zip.
|
||||
*/
|
||||
async getKoboRootTgz() {
|
||||
const file = this.nickelMenuZip.file('KoboRoot.tgz');
|
||||
if (!file) throw new Error('KoboRoot.tgz not found in NickelMenu.zip');
|
||||
return new Uint8Array(await file.async('arraybuffer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KOReader files from the downloaded zip, remapped to .adds/koreader/.
|
||||
* The zip contains a top-level koreader/ directory that needs to be placed
|
||||
* under .adds/ on the device. Also includes a NickelMenu launcher config.
|
||||
* Returns { path: string[], data: Uint8Array } entries.
|
||||
*/
|
||||
async getKoreaderFiles() {
|
||||
const files = [];
|
||||
for (const [relativePath, zipEntry] of Object.entries(this.koreaderZip.files)) {
|
||||
if (zipEntry.dir) continue;
|
||||
// Remap koreader/... to .adds/koreader/...
|
||||
const devicePath = relativePath.startsWith('koreader/')
|
||||
? '.adds/' + relativePath
|
||||
: '.adds/koreader/' + relativePath;
|
||||
const data = new Uint8Array(await zipEntry.async('arraybuffer'));
|
||||
files.push({
|
||||
path: devicePath.split('/'),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// Add NickelMenu launcher config
|
||||
const launcherConfig = 'menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh\n';
|
||||
files.push({
|
||||
path: ['.adds', 'nm', 'koreader'],
|
||||
data: new TextEncoder().encode(launcherConfig),
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config files from kobo-config.zip filtered by cfg flags.
|
||||
* Returns { path: string[], data: Uint8Array } entries.
|
||||
*/
|
||||
async getConfigFiles(cfg) {
|
||||
const files = [];
|
||||
|
||||
for (const [relativePath, zipEntry] of Object.entries(this.koboConfigZip.files)) {
|
||||
if (zipEntry.dir) continue;
|
||||
|
||||
// Filter by cfg flags
|
||||
if (relativePath.startsWith('fonts/') && !cfg.fonts) continue;
|
||||
if (relativePath.startsWith('.kobo/screensaver/') && !cfg.screensaver) continue;
|
||||
|
||||
// Only include relevant directories
|
||||
if (!relativePath.startsWith('.adds/') &&
|
||||
!relativePath.startsWith('.kobo/screensaver/') &&
|
||||
!relativePath.startsWith('fonts/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = new Uint8Array(await zipEntry.async('arraybuffer'));
|
||||
|
||||
// Modify the NickelMenu items file based on config
|
||||
if (relativePath === '.adds/nm/items') {
|
||||
let text = new TextDecoder().decode(data);
|
||||
|
||||
// Comment out experimental lines at top if simplifyTabs is off
|
||||
if (!cfg.simplifyTabs) {
|
||||
text = text.split('\n').map(line => {
|
||||
if (line.startsWith('experimental:') && !line.startsWith('experimental:hide_home')) {
|
||||
return '#' + line;
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
// Append homescreen simplification lines
|
||||
if (cfg.simplifyHome) {
|
||||
text += '\nexperimental:hide_home_row1col2_enabled:1\nexperimental:hide_home_row3_enabled:1\n';
|
||||
}
|
||||
|
||||
data = new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: relativePath.split('/'),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install to a connected Kobo device via File System Access API.
|
||||
* @param {KoboDevice} device
|
||||
* @param {string} option - 'preset' or 'nickelmenu-only'
|
||||
* @param {object|null} cfg - config flags (when option is 'preset')
|
||||
* @param {function} progressFn
|
||||
*/
|
||||
async installToDevice(device, option, cfg, progressFn) {
|
||||
const needConfig = option !== 'nickelmenu-only';
|
||||
await this.loadAssets(progressFn, needConfig);
|
||||
|
||||
// Always install KoboRoot.tgz
|
||||
progressFn('Writing KoboRoot.tgz...');
|
||||
const tgz = await this.getKoboRootTgz();
|
||||
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
||||
|
||||
if (option === 'nickelmenu-only') {
|
||||
progressFn('Done.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Install config files
|
||||
progressFn('Writing configuration files...');
|
||||
const configFiles = await this.getConfigFiles(cfg);
|
||||
for (const { path, data } of configFiles) {
|
||||
await device.writeFile(path, data);
|
||||
}
|
||||
|
||||
// Install KOReader if selected
|
||||
if (cfg.koreader) {
|
||||
await this.loadKoreader(progressFn);
|
||||
progressFn('Writing KOReader files...');
|
||||
const koreaderFiles = await this.getKoreaderFiles();
|
||||
for (const { path, data } of koreaderFiles) {
|
||||
await device.writeFile(path, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Modify Kobo eReader.conf
|
||||
progressFn('Updating Kobo eReader.conf...');
|
||||
await this.updateEReaderConf(device);
|
||||
|
||||
progressFn('Done.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ExcludeSyncFolders to Kobo eReader.conf if not already present.
|
||||
*/
|
||||
async updateEReaderConf(device) {
|
||||
const confPath = ['.kobo', 'Kobo', 'Kobo eReader.conf'];
|
||||
let content = await device.readFile(confPath) || '';
|
||||
|
||||
const settingLine = 'ExcludeSyncFolders=(calibre|\\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\\..+)';
|
||||
|
||||
if (content.includes('ExcludeSyncFolders')) {
|
||||
// Already has the setting, don't duplicate
|
||||
return;
|
||||
}
|
||||
|
||||
// Add under [FeatureSettings], creating the section if needed
|
||||
if (content.includes('[FeatureSettings]')) {
|
||||
content = content.replace(
|
||||
'[FeatureSettings]',
|
||||
'[FeatureSettings]\n' + settingLine
|
||||
);
|
||||
} else {
|
||||
content += '\n[FeatureSettings]\n' + settingLine + '\n';
|
||||
}
|
||||
|
||||
await device.writeFile(confPath, new TextEncoder().encode(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip for manual download containing all files to copy to the Kobo.
|
||||
* @param {string} option - 'preset' or 'nickelmenu-only'
|
||||
* @param {object|null} cfg - config flags (when option is 'preset')
|
||||
* @param {function} progressFn
|
||||
* @returns {Uint8Array} zip contents
|
||||
*/
|
||||
async buildDownloadZip(option, cfg, progressFn) {
|
||||
const needConfig = option !== 'nickelmenu-only';
|
||||
await this.loadAssets(progressFn, needConfig);
|
||||
|
||||
progressFn('Building download package...');
|
||||
const zip = new JSZip();
|
||||
|
||||
// Always include KoboRoot.tgz
|
||||
const tgz = await this.getKoboRootTgz();
|
||||
zip.file('.kobo/KoboRoot.tgz', tgz);
|
||||
|
||||
if (option !== 'nickelmenu-only') {
|
||||
// Include config files
|
||||
const configFiles = await this.getConfigFiles(cfg);
|
||||
for (const { path, data } of configFiles) {
|
||||
zip.file(path.join('/'), data);
|
||||
}
|
||||
|
||||
// Include KOReader if selected
|
||||
if (cfg.koreader) {
|
||||
await this.loadKoreader(progressFn);
|
||||
progressFn('Adding KOReader to package...');
|
||||
const koreaderFiles = await this.getKoreaderFiles();
|
||||
for (const { path, data } of koreaderFiles) {
|
||||
zip.file(path.join('/'), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progressFn('Compressing...');
|
||||
const result = await zip.generateAsync({ type: 'uint8array' });
|
||||
progressFn('Done.');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export { NickelMenuInstaller };
|
||||
@@ -50,11 +50,4 @@ export const TL = {
|
||||
NONE: 'None (do not patch)',
|
||||
},
|
||||
|
||||
NICKEL_MENU_ITEMS: {
|
||||
FONTS: 'Readerly fonts',
|
||||
SCREENSAVER: 'Custom screensaver',
|
||||
SIMPLIFY_TABS: 'Simplified tab menu',
|
||||
SIMPLIFY_HOME: 'Simplified homescreen',
|
||||
KOREADER: 'KOReader',
|
||||
},
|
||||
};
|
||||
|
||||
BIN
web/src/nickelmenu/features/custom-menu/.cog.png
Executable file
BIN
web/src/nickelmenu/features/custom-menu/.cog.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
16
web/src/nickelmenu/features/custom-menu/index.js
Normal file
16
web/src/nickelmenu/features/custom-menu/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
id: 'custom-menu',
|
||||
title: 'Set up custom menu',
|
||||
description: 'Adds menu items for dark mode, screenshots, and more. A new tab will be added in the bottom navigation that is labelled "Tweak".',
|
||||
default: true,
|
||||
required: true,
|
||||
|
||||
async install(ctx) {
|
||||
return [
|
||||
{ path: '.adds/nm/items', data: await ctx.asset('items') },
|
||||
{ path: '.adds/nm/.cog.png', data: await ctx.asset('.cog.png') },
|
||||
{ path: '.adds/scripts/legibility_status.sh', data: await ctx.asset('scripts/legibility_status.sh') },
|
||||
{ path: '.adds/scripts/toggle_wk_rendering.sh', data: await ctx.asset('scripts/toggle_wk_rendering.sh') },
|
||||
];
|
||||
},
|
||||
};
|
||||
39
web/src/nickelmenu/features/custom-menu/items
Normal file
39
web/src/nickelmenu/features/custom-menu/items
Normal file
@@ -0,0 +1,39 @@
|
||||
# Menu button customization (uncommented by "Hide certain navigation tabs" option)
|
||||
#experimental :menu_main_15505_0_enabled: 1
|
||||
#experimental :menu_main_15505_1_label: Books
|
||||
#experimental :menu_main_15505_2_enabled: 1
|
||||
#experimental :menu_main_15505_2_label: Stats
|
||||
#experimental :menu_main_15505_3_enabled: 0
|
||||
#experimental :menu_main_15505_3_label: Notes
|
||||
#experimental :menu_main_15505_4_enabled: 0
|
||||
#experimental :menu_main_15505_5_enabled: 1
|
||||
#experimental :menu_main_15505_default: 1
|
||||
#experimental :menu_main_15505_enabled: 1
|
||||
|
||||
# The main NickelMenu item is now called Tweak
|
||||
#experimental :menu_main_15505_label :Tweak
|
||||
#experimental :menu_main_15505_icon :/mnt/onboard/.adds/nm/.cog.png
|
||||
|
||||
menu_item :main :Screensaver :cmd_output :500 :quiet :test -e /mnt/onboard/.disabled/screensaver
|
||||
chain_failure : skip : 3
|
||||
chain_success : cmd_spawn : quiet: mkdir -p /mnt/onboard/.disabled && mv /mnt/onboard/.disabled/screensaver /mnt/onboard/.kobo/screensaver
|
||||
chain_success : dbg_toast : Screensaver is now ON.
|
||||
chain_always : skip : -1
|
||||
chain_failure : cmd_spawn : quiet: mkdir -p /mnt/onboard/.disabled && mv /mnt/onboard/.kobo/screensaver /mnt/onboard/.disabled/screensaver
|
||||
chain_success : dbg_toast : Screensaver is now OFF.
|
||||
|
||||
menu_item :main :Screenshots :nickel_setting :toggle :screenshots
|
||||
menu_item :main :Auto USB :nickel_setting :toggle :auto_usb_gadget
|
||||
|
||||
menu_item :main :Legibility Status :cmd_output :500 :/mnt/onboard/.adds/scripts/legibility_status.sh
|
||||
|
||||
menu_item :main :Legibility Toggle :cmd_output :5000 :/mnt/onboard/.adds/scripts/toggle_wk_rendering.sh
|
||||
|
||||
menu_item :main :IP Address :cmd_output :500:/sbin/ifconfig | /usr/bin/awk '/inet addr/{print substr($2,6)}'
|
||||
|
||||
menu_item :main :Invert & Reboot :nickel_setting :toggle: invert
|
||||
chain_success :power :reboot
|
||||
menu_item :main :Sleep :power :sleep
|
||||
menu_item :main :Reboot :power :reboot
|
||||
|
||||
menu_item :reader :Dark Mode :nickel_setting :toggle :dark_mode
|
||||
25
web/src/nickelmenu/features/custom-menu/scripts/legibility_status.sh
Executable file
25
web/src/nickelmenu/features/custom-menu/scripts/legibility_status.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
CONFIG_FILE="/mnt/onboard/.kobo/Kobo/Kobo eReader.conf"
|
||||
|
||||
if grep -q "^webkitTextRendering=optimizeLegibility" "$CONFIG_FILE"; then
|
||||
echo "Optimized legibility is ON."
|
||||
echo ""
|
||||
echo "- Ligatures will be displayed."
|
||||
echo "- GPOS kerning works correctly."
|
||||
echo "- Justified text may have some wrapping issues."
|
||||
echo ""
|
||||
echo "It's highly recommended to enable left-aligned"
|
||||
echo "text to avoid wrapping issues in some books."
|
||||
echo ""
|
||||
echo "This mode renders text more correctly."
|
||||
echo "Use 'Legibility Toggle' to turn this OFF."
|
||||
else
|
||||
echo "Optimized legibility is OFF."
|
||||
echo ""
|
||||
echo "- Ligatures will NOT be displayed."
|
||||
echo "- Only old-style kerning works correctly."
|
||||
echo ""
|
||||
echo "This is the most compatible mode, and Kobo's default."
|
||||
echo "Use 'Legibility Toggle' to switch to turn this ON."
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Script to toggle webkitTextRendering setting.
|
||||
# This causes certain font features to work in kepub files.
|
||||
|
||||
CONFIG_FILE="/mnt/onboard/.kobo/Kobo/Kobo eReader.conf"
|
||||
|
||||
# Check if the setting exists
|
||||
if grep -q "^webkitTextRendering=optimizeLegibility" "$CONFIG_FILE"; then
|
||||
# Remove the line
|
||||
sed -i '/^webkitTextRendering=optimizeLegibility/d' "$CONFIG_FILE"
|
||||
echo "Now turned OFF. Your Kobo will now reboot."
|
||||
echo "(No need to press the OK button...)"
|
||||
sleep 3 && reboot &
|
||||
else
|
||||
# Add the line below [Reading] section
|
||||
if grep -q "^\[Reading\]" "$CONFIG_FILE"; then
|
||||
sed -i '/^\[Reading\]/a webkitTextRendering=optimizeLegibility' "$CONFIG_FILE"
|
||||
echo "Now turned ON. Your Kobo will now reboot."
|
||||
echo "(No need to press the OK button...)"
|
||||
sleep 3 && reboot &
|
||||
else
|
||||
echo "Oops. Could not find [Reading] section!"
|
||||
fi
|
||||
fi
|
||||
42
web/src/nickelmenu/features/koreader/index.js
Normal file
42
web/src/nickelmenu/features/koreader/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export default {
|
||||
id: 'koreader',
|
||||
title: 'Install KOReader',
|
||||
description: 'Installs KOReader, an alternative e-book reader with advanced features like PDF reflow, customizable fonts, and more.',
|
||||
default: false,
|
||||
available: false, // set to true at runtime if KOReader assets exist
|
||||
|
||||
async install(ctx) {
|
||||
ctx.progress('Fetching KOReader release info...');
|
||||
const metaResp = await fetch('/koreader/release.json');
|
||||
if (!metaResp.ok) throw new Error('KOReader assets not available (run koreader/setup.sh)');
|
||||
const meta = await metaResp.json();
|
||||
|
||||
ctx.progress('Downloading KOReader ' + meta.version + '...');
|
||||
const zipResp = await fetch('/koreader/koreader-kobo.zip');
|
||||
if (!zipResp.ok) throw new Error('Failed to download KOReader: HTTP ' + zipResp.status);
|
||||
const zip = await JSZip.loadAsync(await zipResp.arrayBuffer());
|
||||
|
||||
ctx.progress('Extracting KOReader...');
|
||||
const files = [];
|
||||
for (const [relativePath, entry] of Object.entries(zip.files)) {
|
||||
if (entry.dir) continue;
|
||||
const devicePath = relativePath.startsWith('koreader/')
|
||||
? '.adds/' + relativePath
|
||||
: '.adds/koreader/' + relativePath;
|
||||
files.push({
|
||||
path: devicePath,
|
||||
data: new Uint8Array(await entry.async('arraybuffer')),
|
||||
});
|
||||
}
|
||||
|
||||
// Add NickelMenu launcher config
|
||||
files.push({
|
||||
path: '.adds/nm/koreader',
|
||||
data: 'menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh\n',
|
||||
});
|
||||
|
||||
return files;
|
||||
},
|
||||
};
|
||||
27
web/src/nickelmenu/features/readerly-fonts/index.js
Normal file
27
web/src/nickelmenu/features/readerly-fonts/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export default {
|
||||
id: 'readerly-fonts',
|
||||
title: 'Install Readerly fonts',
|
||||
description: 'Adds the Readerly font family. These fonts are optically similar to Bookerly. When you are reading a book, you will be able to select this font from the dropdown as "KF Readerly".',
|
||||
default: true,
|
||||
|
||||
async install(ctx) {
|
||||
ctx.progress('Downloading Readerly fonts...');
|
||||
const resp = await fetch('/readerly/KF_Readerly.zip');
|
||||
if (!resp.ok) throw new Error('Failed to download Readerly fonts: HTTP ' + resp.status);
|
||||
const zip = await JSZip.loadAsync(await resp.arrayBuffer());
|
||||
|
||||
const files = [];
|
||||
for (const [name, entry] of Object.entries(zip.files)) {
|
||||
if (entry.dir || !name.endsWith('.ttf')) continue;
|
||||
// Strip any directory prefix, place directly in fonts/
|
||||
const filename = name.split('/').pop();
|
||||
files.push({
|
||||
path: 'fonts/' + filename,
|
||||
data: new Uint8Array(await entry.async('arraybuffer')),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
},
|
||||
};
|
||||
12
web/src/nickelmenu/features/screensaver/index.js
Normal file
12
web/src/nickelmenu/features/screensaver/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
id: 'screensaver',
|
||||
title: 'Copy screensaver',
|
||||
description: 'Copies a screensaver to .kobo/screensaver. Depending on your configuration, it will now be displayed instead of your current read. You can always add your own in the .kobo/screensaver folder, and choosing Tweak > Screensaver will let you toggle it off.',
|
||||
default: false,
|
||||
|
||||
async install(ctx) {
|
||||
return [
|
||||
{ path: '.kobo/screensaver/moon.png', data: await ctx.asset('moon.png') },
|
||||
];
|
||||
},
|
||||
};
|
||||
BIN
web/src/nickelmenu/features/screensaver/moon.png
Executable file
BIN
web/src/nickelmenu/features/screensaver/moon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
15
web/src/nickelmenu/features/simplify-home/index.js
Normal file
15
web/src/nickelmenu/features/simplify-home/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
id: 'simplify-home',
|
||||
title: 'Hide certain home screen elements',
|
||||
description: 'If you are reading only one book, no recommendations will appear next to your current read, and third row on your homescreen with advertisements for Kobo Plus and the Kobo Store will be hidden. For minimalists who want fewer distractions.',
|
||||
default: false,
|
||||
|
||||
postProcess(files) {
|
||||
const items = files.find(f => f.path === '.adds/nm/items');
|
||||
if (!items || typeof items.data !== 'string') return files;
|
||||
|
||||
items.data += '\nexperimental:hide_home_row1col2_enabled:1\nexperimental:hide_home_row3_enabled:1\n';
|
||||
|
||||
return files;
|
||||
},
|
||||
};
|
||||
21
web/src/nickelmenu/features/simplify-tabs/index.js
Normal file
21
web/src/nickelmenu/features/simplify-tabs/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
id: 'simplify-tabs',
|
||||
title: 'Hide certain navigation tabs',
|
||||
description: 'This will hide the Notebook and Discover tabs from the bottom navigation. For minimalists who want fewer distractions.',
|
||||
default: false,
|
||||
|
||||
postProcess(files) {
|
||||
const items = files.find(f => f.path === '.adds/nm/items');
|
||||
if (!items || typeof items.data !== 'string') return files;
|
||||
|
||||
// Uncomment the experimental tab-customization lines
|
||||
items.data = items.data.split('\n').map(line => {
|
||||
if (line.startsWith('#experimental ')) {
|
||||
return line.slice(1); // remove leading #
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
|
||||
return files;
|
||||
},
|
||||
};
|
||||
180
web/src/nickelmenu/installer.js
Normal file
180
web/src/nickelmenu/installer.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import customMenu from './features/custom-menu/index.js';
|
||||
import readerlyFonts from './features/readerly-fonts/index.js';
|
||||
import koreader from './features/koreader/index.js';
|
||||
import simplifyTabs from './features/simplify-tabs/index.js';
|
||||
import simplifyHome from './features/simplify-home/index.js';
|
||||
import screensaver from './features/screensaver/index.js';
|
||||
|
||||
/**
|
||||
* All available NickelMenu features in display order.
|
||||
* Features with `required: true` are always included in the preset.
|
||||
* Features with `postProcess` modify files produced by other features.
|
||||
*/
|
||||
export const ALL_FEATURES = [
|
||||
customMenu,
|
||||
readerlyFonts,
|
||||
koreader,
|
||||
simplifyTabs,
|
||||
simplifyHome,
|
||||
screensaver,
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an asset-loading context for a given feature.
|
||||
* Assets are fetched at runtime from the feature's directory under /nickelmenu/features/<id>/.
|
||||
*/
|
||||
function createContext(feature, progressFn) {
|
||||
const basePath = `nickelmenu/features/${feature.id}/`;
|
||||
return {
|
||||
async asset(relativePath) {
|
||||
const url = basePath + relativePath;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`Failed to load asset ${url}: HTTP ${resp.status}`);
|
||||
return new Uint8Array(await resp.arrayBuffer());
|
||||
},
|
||||
progress(msg) {
|
||||
progressFn(msg);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class NickelMenuInstaller {
|
||||
constructor() {
|
||||
this.nickelMenuZip = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache NickelMenu.zip (contains KoboRoot.tgz).
|
||||
*/
|
||||
async loadNickelMenu(progressFn) {
|
||||
if (this.nickelMenuZip) return;
|
||||
progressFn('Downloading NickelMenu...');
|
||||
const resp = await fetch('nickelmenu/NickelMenu.zip');
|
||||
if (!resp.ok) throw new Error('Failed to download NickelMenu.zip: HTTP ' + resp.status);
|
||||
this.nickelMenuZip = await JSZip.loadAsync(await resp.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KoboRoot.tgz from the NickelMenu zip.
|
||||
*/
|
||||
async getKoboRootTgz() {
|
||||
const file = this.nickelMenuZip.file('KoboRoot.tgz');
|
||||
if (!file) throw new Error('KoboRoot.tgz not found in NickelMenu.zip');
|
||||
return new Uint8Array(await file.async('arraybuffer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run selected features and collect all files to write.
|
||||
* @param {object[]} features - feature modules to run
|
||||
* @param {function} progressFn
|
||||
* @returns {{ path: string, data: Uint8Array|string }[]}
|
||||
*/
|
||||
async collectFiles(features, progressFn) {
|
||||
let files = [];
|
||||
|
||||
// Run install() for features that have it
|
||||
for (const feature of features) {
|
||||
if (!feature.install) continue;
|
||||
const ctx = createContext(feature, progressFn);
|
||||
progressFn(`Setting up ${feature.title}...`);
|
||||
const result = await feature.install(ctx);
|
||||
files.push(...result);
|
||||
}
|
||||
|
||||
// Decode binary items file to string for postProcess mutation
|
||||
const itemsFile = files.find(f => f.path === '.adds/nm/items');
|
||||
if (itemsFile && itemsFile.data instanceof Uint8Array) {
|
||||
itemsFile.data = new TextDecoder().decode(itemsFile.data);
|
||||
}
|
||||
|
||||
// Run postProcess() for features that have it
|
||||
for (const feature of features) {
|
||||
if (!feature.postProcess) continue;
|
||||
files = feature.postProcess(files);
|
||||
}
|
||||
|
||||
// Re-encode items file back to Uint8Array
|
||||
if (itemsFile && typeof itemsFile.data === 'string') {
|
||||
itemsFile.data = new TextEncoder().encode(itemsFile.data);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install to a connected Kobo device via File System Access API.
|
||||
*/
|
||||
async installToDevice(device, features, progressFn) {
|
||||
await this.loadNickelMenu(progressFn);
|
||||
|
||||
progressFn('Writing KoboRoot.tgz...');
|
||||
const tgz = await this.getKoboRootTgz();
|
||||
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
||||
|
||||
if (features.length > 0) {
|
||||
const files = await this.collectFiles(features, progressFn);
|
||||
progressFn('Writing files to Kobo...');
|
||||
for (const { path, data } of files) {
|
||||
const pathArray = path.split('/');
|
||||
const fileData = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
await device.writeFile(pathArray, fileData);
|
||||
}
|
||||
|
||||
progressFn('Updating Kobo eReader.conf...');
|
||||
await this.updateEReaderConf(device);
|
||||
}
|
||||
|
||||
progressFn('Done.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip for manual download.
|
||||
*/
|
||||
async buildDownloadZip(features, progressFn) {
|
||||
await this.loadNickelMenu(progressFn);
|
||||
|
||||
progressFn('Building download package...');
|
||||
const zip = new JSZip();
|
||||
|
||||
const tgz = await this.getKoboRootTgz();
|
||||
zip.file('.kobo/KoboRoot.tgz', tgz);
|
||||
|
||||
if (features.length > 0) {
|
||||
const files = await this.collectFiles(features, progressFn);
|
||||
for (const { path, data } of files) {
|
||||
const fileData = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
zip.file(path, fileData);
|
||||
}
|
||||
}
|
||||
|
||||
progressFn('Compressing...');
|
||||
const result = await zip.generateAsync({ type: 'uint8array' });
|
||||
progressFn('Done.');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ExcludeSyncFolders to Kobo eReader.conf if not already present.
|
||||
*/
|
||||
async updateEReaderConf(device) {
|
||||
const confPath = ['.kobo', 'Kobo', 'Kobo eReader.conf'];
|
||||
let content = await device.readFile(confPath) || '';
|
||||
|
||||
const settingLine = 'ExcludeSyncFolders=(calibre|\\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\\..+)';
|
||||
|
||||
if (content.includes('ExcludeSyncFolders')) return;
|
||||
|
||||
if (content.includes('[FeatureSettings]')) {
|
||||
content = content.replace(
|
||||
'[FeatureSettings]',
|
||||
'[FeatureSettings]\n' + settingLine
|
||||
);
|
||||
} else {
|
||||
content += '\n[FeatureSettings]\n' + settingLine + '\n';
|
||||
}
|
||||
|
||||
await device.writeFile(confPath, new TextEncoder().encode(content));
|
||||
}
|
||||
}
|
||||
BIN
web/src/nickelmenu/kobo-config.zip
Normal file
BIN
web/src/nickelmenu/kobo-config.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user