1
0

Add KOReader option
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s

This commit is contained in:
2026-03-21 16:32:15 +01:00
parent 620d8a1929
commit aaf3bf8749
19 changed files with 659 additions and 344 deletions

View File

@@ -56,22 +56,31 @@ jobs:
run: | run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "run=true" >> "$GITHUB_OUTPUT" echo "run=true" >> "$GITHUB_OUTPUT"
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/)'; then elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/)'; then
echo "run=true" >> "$GITHUB_OUTPUT" echo "run=true" >> "$GITHUB_OUTPUT"
else else
echo "run=false" >> "$GITHUB_OUTPUT" echo "run=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Full integration test (WASM) - name: Download test firmware
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: | run: |
cd kobopatch-wasm mkdir -p tests/cached_assets
./test-integration.sh echo "Downloading firmware..."
curl -fL --progress-bar -o tests/cached_assets/kobo-update-4.45.23646.zip \
https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip
- name: Full integration test (WASM)
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: kobopatch-wasm/test-integration.sh
- name: Set up NickelMenu assets - name: Set up NickelMenu assets
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: | run: nickelmenu/setup.sh
nickelmenu/setup.sh
- name: Set up KOReader assets
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: koreader/setup.sh
- name: Full integration test (Playwright) - name: Full integration test (Playwright)
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'

5
.gitignore vendored
View File

@@ -8,7 +8,6 @@ kobopatch/
# Kobopatch WASM build artifacts # Kobopatch WASM build artifacts
kobopatch-wasm/go/ kobopatch-wasm/go/
kobopatch-wasm/kobopatch-src/ kobopatch-wasm/kobopatch-src/
kobopatch-wasm/testdata/
kobopatch-wasm/kobopatch.wasm kobopatch-wasm/kobopatch.wasm
kobopatch-wasm/wasm_exec.js kobopatch-wasm/wasm_exec.js
@@ -16,6 +15,7 @@ kobopatch-wasm/wasm_exec.js
web/src/js/wasm_exec.js web/src/js/wasm_exec.js
web/src/nickelmenu/NickelMenu.zip web/src/nickelmenu/NickelMenu.zip
web/src/nickelmenu/kobo-config.zip web/src/nickelmenu/kobo-config.zip
web/src/koreader/
# Build output # Build output
web/dist/ web/dist/
@@ -23,6 +23,9 @@ web/dist/
# Node # Node
web/node_modules/ web/node_modules/
# Cached test assets (firmware, KOReader zips)
tests/cached_assets/
# E2E tests # E2E tests
tests/e2e/node_modules/ tests/e2e/node_modules/
tests/e2e/test-results/ tests/e2e/test-results/

View File

@@ -7,6 +7,7 @@ A web application for customising Kobo e-readers. It supports two modes:
- **NickelMenu** — installs [NickelMenu](https://pgaskin.net/NickelMenu/) [fork](https://github.com/nicoverbruggen/NickelMenu) with an optional [curated configuration](https://github.com/nicoverbruggen/kobo-config) (custom menus, fonts, screensavers, UI tweaks). Works with most Kobo devices regardless of software version. Can also remove NickelMenu from a connected device. - **NickelMenu** — installs [NickelMenu](https://pgaskin.net/NickelMenu/) [fork](https://github.com/nicoverbruggen/NickelMenu) with an optional [curated configuration](https://github.com/nicoverbruggen/kobo-config) (custom menus, fonts, screensavers, UI tweaks). Works with most Kobo devices regardless of software version. Can also remove NickelMenu from a connected device.
- <u>The safest patch to install</u>. These modifications tend to persist with system updates as long as NickelMenu remains functional. - <u>The safest patch to install</u>. These modifications tend to persist with system updates as long as NickelMenu remains functional.
- You can optionally install KOReader using this method, too.
- Will automatically uninstall itself if Kobo releases an incompatible update in the future, which may happen with software v5.x at some point. - Will automatically uninstall itself if Kobo releases an incompatible update in the future, which may happen with software v5.x at some point.
- **Custom patches** — applies community [kobopatch](https://github.com/pgaskin/kobopatch) patches to your Kobo's system software. Requires a supported software version and device model, which is currently limited to Kobo Libra Color, Kobo Clara Color and Kobo Clara BW models. - **Custom patches** — applies community [kobopatch](https://github.com/pgaskin/kobopatch) patches to your Kobo's system software. Requires a supported software version and device model, which is currently limited to Kobo Libra Color, Kobo Clara Color and Kobo Clara BW models.
@@ -27,7 +28,7 @@ If you choose to apply custom patches, **patching happens fully client-side**
1. **Connect or download** — auto-detect your Kobo via File System Access API on Chromium, or choose manual download mode (any browser) 1. **Connect or download** — auto-detect your Kobo via File System Access API on Chromium, or choose manual download mode (any browser)
2. **Choose mode** — NickelMenu (install/configure/remove) or custom patches 2. **Choose mode** — NickelMenu (install/configure/remove) or custom patches
3. **Configure** — for NickelMenu: select install options (fonts, screensaver, tab/homescreen tweaks) or removal; for patches: enable/disable patches (or select none to restore original software) 3. **Configure** — for NickelMenu: select install options (fonts, screensaver, tab/homescreen tweaks, KOReader) or removal; for patches: enable/disable patches (or select none to restore original software)
4. **Review** — confirm your selections before proceeding 4. **Review** — confirm your selections before proceeding
5. **Install** — write directly to the device (Chromium auto mode) or download a ZIP/tgz for manual installation 5. **Install** — write directly to the device (Chromium auto mode) or download a ZIP/tgz for manual installation
@@ -56,17 +57,24 @@ web/
nickelmenu/ # NickelMenu assets (generated by nickelmenu/setup.sh, gitignored) nickelmenu/ # NickelMenu assets (generated by nickelmenu/setup.sh, gitignored)
NickelMenu.zip NickelMenu.zip
kobo-config.zip kobo-config.zip
koreader/ # KOReader assets (generated by koreader/setup.sh, gitignored)
koreader-kobo.zip
release.json
favicon/ favicon/
dist/ # Build output (gitignored, fully regenerable) dist/ # Build output (gitignored, fully regenerable)
bundle.js # esbuild output (minified, content-hashed) bundle.js # esbuild output (minified, content-hashed)
index.html # Generated with cache-busted references index.html # Generated with cache-busted references
css/ favicon/ patches/ nickelmenu/ wasm/ js/wasm_exec.js css/ favicon/ patches/ nickelmenu/ koreader/ wasm/ js/wasm_exec.js
build.mjs # esbuild build script + asset copy build.mjs # esbuild build script + asset copy
package.json # esbuild, jszip package.json # esbuild, jszip
nickelmenu/ nickelmenu/
setup.sh # Downloads NickelMenu.zip and bundles kobo-config.zip setup.sh # Downloads NickelMenu.zip and bundles kobo-config.zip
koreader/
setup.sh # Downloads latest KOReader release for Kobo
update.sh # Updates KOReader in web/dist/ (for production containers)
kobopatch-wasm/ kobopatch-wasm/
main.go # Go entry point main.go # Go entry point
go.mod go.sum go.mod go.sum
@@ -76,7 +84,13 @@ kobopatch-wasm/
test-integration.sh test-integration.sh
tests/ tests/
cached_assets/ # Downloaded test assets (gitignored)
e2e/ e2e/
helpers/ # Shared test utilities
assets.js # Asset availability checks, firmware symlink helpers
mock-device.js # Mock File System Access API (simulated Kobo device)
paths.js # Test asset paths, expected checksums
tar.js # Tar archive parser for output verification
integration.spec.js # Playwright E2E tests integration.spec.js # Playwright E2E tests
playwright.config.js playwright.config.js
run-e2e.sh run-e2e.sh
@@ -94,11 +108,11 @@ serve-locally.sh # Serves app at localhost:8888
## Building the WASM binary ## Building the WASM binary
Requires Go 1.21+. Requires Go 1.21+ (if Go is not installed, `setup.sh` will download it locally to `kobopatch-wasm/go/`).
```bash ```bash
cd kobopatch-wasm cd kobopatch-wasm
./setup.sh # first time only ./setup.sh # first time only — clones kobopatch source, sets up Go if needed
./build.sh # compiles WASM, copies to web/dist/wasm/ ./build.sh # compiles WASM, copies to web/dist/wasm/
``` ```
@@ -110,6 +124,22 @@ 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` and clones/updates the [kobo-config](https://github.com/nicoverbruggen/kobo-config) repo to bundle `kobo-config.zip` into `web/src/nickelmenu/`.
## Setting up KOReader assets
```bash
koreader/setup.sh
```
This downloads the latest [KOReader](https://koreader.rocks) release for Kobo into `web/src/koreader/`. The KOReader zip is served from the app's own domain (to avoid CORS issues with GitHub release downloads). The version is displayed in the UI next to the KOReader checkbox. If the assets are missing, the KOReader option is hidden.
To update KOReader on a running production container without a full rebuild:
```bash
koreader/update.sh
```
This downloads the latest release directly into `web/dist/koreader/`, skipping the build step. It's a no-op if the current version is already up to date.
## Building the frontend ## Building the frontend
The JS source lives in `web/src/js/` as ES modules. esbuild bundles them into a single `web/dist/bundle.js`. The JS source lives in `web/src/js/` as ES modules. esbuild bundles them into a single `web/dist/bundle.js`.
@@ -143,13 +173,13 @@ Run all tests (WASM integration + E2E):
./test.sh ./test.sh
``` ```
This builds the web app, compiles the WASM binary, runs the WASM integration tests, and then runs the full E2E suite. This builds the web app, compiles the WASM binary, runs the WASM integration tests, and then runs the full E2E suite. On first run it will prompt to download test assets (~190 MB total) to `tests/cached_assets/`. Tests that require missing assets are skipped.
### E2E tests (Playwright) ### E2E tests (Playwright)
The E2E tests cover all major user flows: The E2E tests cover all major user flows:
- **NickelMenu** — install with config (manual download), install NickelMenu only, remove option disabled without device - **NickelMenu** — install with config (manual download), install NickelMenu only, KOReader installation, remove option disabled without device
- **Custom patches** — full patching pipeline, restore original firmware, build failure with "Go Back" recovery - **Custom patches** — full patching pipeline, restore original firmware, build failure with "Go Back" recovery
- **Device detection** — firmware version validation (4.x supported, 5.x incompatible), unknown model warning - **Device detection** — firmware version validation (4.x supported, 5.x incompatible), unknown model warning
- **Back navigation** — verifies every back button returns to the correct previous screen in both auto and manual mode - **Back navigation** — verifies every back button returns to the correct previous screen in both auto and manual mode
@@ -157,7 +187,7 @@ The E2E tests cover all major user flows:
The simulated device tests mock the File System Access API with an in-memory filesystem that mimics a Kobo Libra Color (serial prefix N428, firmware 4.45.23646). The simulated device tests mock the File System Access API with an in-memory filesystem that mimics a Kobo Libra Color (serial prefix N428, firmware 4.45.23646).
Custom patches tests download firmware 4.45.23646 (~150MB, cached in `kobopatch-wasm/testdata/`), enable a single patch, and verify SHA1 checksums of all 4 patched binaries. This specific combination is used because the author has tested it on an actual device. Custom patches tests use firmware 4.45.23646 (~150 MB, cached in `tests/cached_assets/`), enable a single patch, and verify SHA1 checksums of all 4 patched binaries. This specific combination is used because the author has tested it on an actual device. KOReader tests use a real KOReader zip (~39 MB, also cached) to verify the full installation flow.
```bash ```bash
cd tests/e2e cd tests/e2e

View File

@@ -1,17 +1,8 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# Integration test: downloads firmware and runs the full patching pipeline # Integration test: runs the full WASM patching pipeline with SHA1 checksum
# with SHA1 checksum validation. # validation against a real firmware zip.
#
# Usage: ./test-integration.sh
#
# The firmware zip (~150MB) is cached in testdata/ to avoid re-downloading.
FIRMWARE_VERSION="4.45.23646"
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
FIRMWARE_DIR="testdata"
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -22,22 +13,12 @@ if [ -x "$LOCAL_GO_DIR/bin/go" ]; then
export PATH="$LOCAL_GO_DIR/bin:$PATH" export PATH="$LOCAL_GO_DIR/bin:$PATH"
fi fi
# Download firmware if not cached. FIRMWARE_FILE="${FIRMWARE_ZIP:-$(cd .. && pwd)/tests/cached_assets/kobo-update-4.45.23646.zip}"
if [ ! -f "$FIRMWARE_FILE" ]; then if [ ! -f "$FIRMWARE_FILE" ]; then
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..." echo "ERROR: Firmware zip not found at $FIRMWARE_FILE"
mkdir -p "$FIRMWARE_DIR" echo "Run ./test.sh from the project root to download test assets."
curl -fL --progress-bar -o "$FIRMWARE_FILE.tmp" "$FIRMWARE_URL"
# Validate it's actually a zip file.
if ! file "$FIRMWARE_FILE.tmp" | grep -q "Zip archive"; then
echo "ERROR: downloaded file is not a valid zip"
rm -f "$FIRMWARE_FILE.tmp"
exit 1 exit 1
fi fi
mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE"
echo "Downloaded to $FIRMWARE_FILE"
else
echo "Using cached firmware: $FIRMWARE_FILE"
fi
# Find the WASM test executor. # Find the WASM test executor.
GOROOT="$(go env GOROOT)" GOROOT="$(go env GOROOT)"

22
koreader/setup.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PUBLIC_DIR="$SCRIPT_DIR/../web/src/koreader"
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')))")
echo "Downloading KOReader $VERSION..."
curl -fL --progress-bar -o "$PUBLIC_DIR/koreader-kobo.zip" "$DOWNLOAD_URL"
echo " -> $(du -h "$PUBLIC_DIR/koreader-kobo.zip" | cut -f1)"
# Write release metadata so the app knows the version.
echo "{\"version\":\"$VERSION\"}" > "$PUBLIC_DIR/release.json"
echo ""
echo "Done. Assets written to: $PUBLIC_DIR"

37
koreader/update.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# Updates KOReader assets in the served dist directory.
# Run this on the production container to update KOReader
# without a full rebuild.
#
# Usage: ./koreader/update.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="$SCRIPT_DIR/../web/dist/koreader"
mkdir -p "$DIST_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')))")
# Check if we already have this version.
if [ -f "$DIST_DIR/release.json" ]; then
CURRENT=$(python3 -c "import sys,json; print(json.load(open('$DIST_DIR/release.json'))['version'])")
if [ "$CURRENT" = "$VERSION" ]; then
echo "Already up to date ($VERSION)."
exit 0
fi
echo "Updating from $CURRENT to $VERSION..."
else
echo "Installing KOReader $VERSION..."
fi
curl -fL --progress-bar -o "$DIST_DIR/koreader-kobo.zip.tmp" "$DOWNLOAD_URL"
mv "$DIST_DIR/koreader-kobo.zip.tmp" "$DIST_DIR/koreader-kobo.zip"
echo "{\"version\":\"$VERSION\"}" > "$DIST_DIR/release.json"
echo " -> $(du -h "$DIST_DIR/koreader-kobo.zip" | cut -f1)"
echo "Done. KOReader $VERSION is now being served."

View File

@@ -12,6 +12,11 @@ if [ ! -f "$SRC_DIR/nickelmenu/NickelMenu.zip" ]; then
"$SCRIPT_DIR/nickelmenu/setup.sh" "$SCRIPT_DIR/nickelmenu/setup.sh"
fi fi
if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then
echo "KOReader assets not found, downloading..."
"$SCRIPT_DIR/koreader/setup.sh"
fi
echo "Building JS bundle..." echo "Building JS bundle..."
cd "$WEB_DIR" cd "$WEB_DIR"
npm install --silent npm install --silent

28
test.sh
View File

@@ -2,6 +2,29 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CACHED_ASSETS="$SCRIPT_DIR/tests/cached_assets"
FIRMWARE_FILE="$CACHED_ASSETS/kobo-update-4.45.23646.zip"
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip"
# Check if firmware needs to be downloaded.
if [ ! -f "$FIRMWARE_FILE" ]; then
echo "Firmware test asset is not cached locally (~150 MB)."
echo ""
read -rp "Download it now? Tests that need the firmware will be skipped otherwise. [y/N] " answer
if [[ "$answer" =~ ^[Yy]$ ]]; then
mkdir -p "$CACHED_ASSETS"
echo "Downloading firmware..."
curl -fL --progress-bar -o "$FIRMWARE_FILE.tmp" "$FIRMWARE_URL"
mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE"
echo ""
fi
fi
# Set up KOReader assets if not present (served by the app, not a test-only asset).
if [ ! -f "$SCRIPT_DIR/web/src/koreader/koreader-kobo.zip" ]; then
"$SCRIPT_DIR/koreader/setup.sh"
fi
echo "=== Installing web dependencies ===" echo "=== Installing web dependencies ==="
cd "$SCRIPT_DIR/web" && npm install cd "$SCRIPT_DIR/web" && npm install
@@ -16,7 +39,11 @@ echo "=== Building WASM ==="
echo "" echo ""
echo "=== Running WASM integration test ===" echo "=== Running WASM integration test ==="
if [ -f "$FIRMWARE_FILE" ]; then
"$SCRIPT_DIR/kobopatch-wasm/test-integration.sh" "$SCRIPT_DIR/kobopatch-wasm/test-integration.sh"
else
echo "Skipped (firmware not downloaded)"
fi
echo "" echo ""
echo "=== Running E2E tests (Playwright) ===" echo "=== Running E2E tests (Playwright) ==="
@@ -25,4 +52,5 @@ if [ ! -d "node_modules" ]; then
npm install npm install
npx playwright install --with-deps npx playwright install --with-deps
fi fi
npm test npm test

View File

@@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
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'));
}
function hasKoreaderAssets() {
return fs.existsSync(path.join(WEBROOT, 'koreader', 'koreader-kobo.zip'))
&& fs.existsSync(path.join(WEBROOT, 'koreader', 'release.json'));
}
function hasFirmwareZip() {
return fs.existsSync(FIRMWARE_PATH);
}
function setupFirmwareSymlink() {
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
}
function cleanupFirmwareSymlink() {
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
}
module.exports = {
hasNickelMenuAssets,
hasKoreaderAssets,
hasFirmwareZip,
setupFirmwareSymlink,
cleanupFirmwareSymlink,
};

View File

@@ -0,0 +1,187 @@
const { expect } = require('@playwright/test');
/**
* Inject a mock File System Access API into the page, simulating a Kobo Libra Color.
* The mock provides:
* - .kobo/version file with serial N4280A0000000 and firmware 4.45.23646
* - Optionally a .adds/nm/ directory (to simulate NickelMenu being installed)
* - In-memory filesystem that tracks all writes for verification
*/
async function injectMockDevice(page, opts = {}) {
const firmware = opts.firmware || '4.45.23646';
const serial = opts.serial || 'N4280A0000000';
await page.evaluate(({ hasNickelMenu, firmware, serial }) => {
const filesystem = {
'.kobo': {
_type: 'dir',
'version': {
_type: 'file',
content: serial + ',4.9.77,' + firmware + ',4.9.77,4.9.77,00000000-0000-0000-0000-000000000390',
},
'Kobo': {
_type: 'dir',
'Kobo eReader.conf': {
_type: 'file',
content: '[General]\nsome=setting\n',
},
},
},
};
if (hasNickelMenu) {
filesystem['.adds'] = {
_type: 'dir',
'nm': {
_type: 'dir',
'items': { _type: 'file', content: 'menu_item:main:test:skip:' },
},
};
}
window.__mockFS = filesystem;
window.__mockWrittenFiles = {};
function makeFileHandle(dirNode, fileName, pathPrefix) {
return {
getFile: async () => {
const fileNode = dirNode[fileName];
const content = fileNode ? (fileNode.content || '') : '';
return {
text: async () => content,
arrayBuffer: async () => new TextEncoder().encode(content).buffer,
};
},
createWritable: async () => {
const chunks = [];
return {
write: async (chunk) => { chunks.push(chunk); },
close: async () => {
const first = chunks[0];
const bytes = first instanceof Uint8Array ? first : new TextEncoder().encode(String(first));
if (!dirNode[fileName]) dirNode[fileName] = { _type: 'file' };
dirNode[fileName].content = new TextDecoder().decode(bytes);
const fullPath = pathPrefix ? pathPrefix + '/' + fileName : fileName;
window.__mockWrittenFiles[fullPath] = true;
},
};
},
};
}
function makeDirHandle(node, name, pathPrefix) {
const currentPath = pathPrefix ? pathPrefix + '/' + name : name;
return {
name: name,
kind: 'directory',
getDirectoryHandle: async (childName, opts2) => {
if (node[childName] && node[childName]._type === 'dir') {
return makeDirHandle(node[childName], childName, currentPath);
}
if (opts2 && opts2.create) {
node[childName] = { _type: 'dir' };
return makeDirHandle(node[childName], childName, currentPath);
}
throw new DOMException('Not found: ' + childName, 'NotFoundError');
},
getFileHandle: async (childName, opts2) => {
if (node[childName] && node[childName]._type === 'file') {
return makeFileHandle(node, childName, currentPath);
}
if (opts2 && opts2.create) {
node[childName] = { _type: 'file', content: '' };
return makeFileHandle(node, childName, currentPath);
}
throw new DOMException('Not found: ' + childName, 'NotFoundError');
},
};
}
const rootHandle = makeDirHandle(filesystem, 'KOBOeReader', '');
window.showDirectoryPicker = async () => rootHandle;
}, { hasNickelMenu: opts.hasNickelMenu || false, firmware, serial });
}
/**
* Inject mock device, optionally override firmware URLs, and connect.
*/
async function connectMockDevice(page, opts = {}) {
await page.goto('/');
await expect(page.locator('h1')).toContainText('KoboPatch');
await injectMockDevice(page, opts);
if (opts.overrideFirmware) {
await overrideFirmwareURLs(page);
}
await page.click('#btn-connect');
await expect(page.locator('#step-device')).not.toBeHidden();
await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour');
await expect(page.locator('#device-firmware')).toHaveText('4.45.23646');
await expect(page.locator('#device-status')).toContainText('recognized');
}
/**
* Override firmware download URLs to point at the local test server.
*/
async function overrideFirmwareURLs(page) {
await page.evaluate(() => {
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
}
}
});
}
/**
* Navigate to manual mode.
*/
async function goToManualMode(page) {
await page.goto('/');
await expect(page.locator('h1')).toContainText('KoboPatch');
await page.click('#btn-manual');
await expect(page.locator('#step-mode')).not.toBeHidden();
}
/**
* Read a file's content from the mock filesystem.
*/
async function readMockFile(page, ...pathParts) {
return page.evaluate((parts) => {
let node = window.__mockFS;
for (const part of parts) {
if (!node || !node[part]) return null;
node = node[part];
}
return node && node._type === 'file' ? (node.content || '') : null;
}, pathParts);
}
/**
* Check whether a path exists in the mock filesystem.
*/
async function mockPathExists(page, ...pathParts) {
return page.evaluate((parts) => {
let node = window.__mockFS;
for (const part of parts) {
if (!node || !node[part]) return false;
node = node[part];
}
return true;
}, pathParts);
}
/**
* Get the list of written file paths from the mock device.
*/
async function getWrittenFiles(page) {
return page.evaluate(() => Object.keys(window.__mockWrittenFiles));
}
module.exports = {
injectMockDevice,
connectMockDevice,
overrideFirmwareURLs,
goToManualMode,
readMockFile,
mockPathExists,
getWrittenFiles,
};

View File

@@ -0,0 +1,28 @@
const path = require('path');
const CACHED_ASSETS = path.resolve(__dirname, '..', '..', 'cached_assets');
const FIRMWARE_PATH = path.join(CACHED_ASSETS, 'kobo-update-4.45.23646.zip');
const WEBROOT = path.resolve(__dirname, '..', '..', '..', 'web', 'dist');
const WEBROOT_FIRMWARE = path.join(WEBROOT, '_test_firmware.zip');
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
// with only "Remove footer (row3) on new home screen" enabled.
const EXPECTED_SHA1 = {
'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d',
'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c',
'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1',
'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5',
};
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
module.exports = {
FIRMWARE_PATH,
WEBROOT,
WEBROOT_FIRMWARE,
EXPECTED_SHA1,
ORIGINAL_TGZ_SHA1,
};

33
tests/e2e/helpers/tar.js Normal file
View File

@@ -0,0 +1,33 @@
/**
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
*/
function parseTar(buffer) {
const entries = {};
let offset = 0;
while (offset < buffer.length) {
const header = buffer.subarray(offset, offset + 512);
if (header.every(b => b === 0)) break;
let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
if (prefix) name = prefix + '/' + name;
name = name.replace(/^\.\//, '');
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
const size = parseInt(sizeStr, 8) || 0;
const typeFlag = header[156];
offset += 512;
if (typeFlag === 48 || typeFlag === 0) {
entries[name] = buffer.subarray(offset, offset + size);
}
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
module.exports = { parseTar };

View File

@@ -1,276 +1,19 @@
// @ts-check // @ts-check
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const zlib = require('zlib'); const zlib = require('zlib');
const JSZip = require('jszip'); const JSZip = require('jszip');
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646, const { FIRMWARE_PATH, EXPECTED_SHA1, ORIGINAL_TGZ_SHA1 } = require('./helpers/paths');
// with only "Remove footer (row3) on new home screen" enabled. const { hasNickelMenuAssets, hasKoreaderAssets, hasFirmwareZip, setupFirmwareSymlink, cleanupFirmwareSymlink } = require('./helpers/assets');
const EXPECTED_SHA1 = { const { injectMockDevice, connectMockDevice, overrideFirmwareURLs, goToManualMode, readMockFile, mockPathExists, getWrittenFiles } = require('./helpers/mock-device');
'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d', const { parseTar } = require('./helpers/tar');
'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c',
'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1',
'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5',
};
const FIRMWARE_PATH = process.env.FIRMWARE_ZIP
|| path.resolve(__dirname, '..', '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
const WEBROOT = path.resolve(__dirname, '..', '..', 'web', 'dist');
const WEBROOT_FIRMWARE = path.join(WEBROOT, '_test_firmware.zip');
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
/**
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
*/
function parseTar(buffer) {
const entries = {};
let offset = 0;
while (offset < buffer.length) {
const header = buffer.subarray(offset, offset + 512);
if (header.every(b => b === 0)) break;
let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
if (prefix) name = prefix + '/' + name;
name = name.replace(/^\.\//, '');
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
const size = parseInt(sizeStr, 8) || 0;
const typeFlag = header[156];
offset += 512;
if (typeFlag === 48 || typeFlag === 0) {
entries[name] = buffer.subarray(offset, offset + size);
}
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
// Clean up the symlink after each test.
test.afterEach(() => { test.afterEach(() => {
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} cleanupFirmwareSymlink();
}); });
/**
* Check that NickelMenu assets exist in webroot.
*/
function hasNickelMenuAssets() {
return fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'NickelMenu.zip'))
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'kobo-config.zip'));
}
/**
* Navigate to manual mode: click "Download files manually" on the connect step.
*/
async function goToManualMode(page) {
await page.goto('/');
await expect(page.locator('h1')).toContainText('KoboPatch');
await page.click('#btn-manual');
await expect(page.locator('#step-mode')).not.toBeHidden();
}
/**
* Override firmware download URLs to point at the local test server.
*/
async function overrideFirmwareURLs(page) {
await page.evaluate(() => {
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
}
}
});
}
/**
* Set up firmware symlink for tests that need it.
*/
function setupFirmwareSymlink() {
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
}
/**
* Inject a mock File System Access API into the page, simulating a Kobo Libra Color.
* The mock provides:
* - .kobo/version file with serial N4280A0000000 and firmware 4.45.23646
* - Optionally a .adds/nm/ directory (to simulate NickelMenu being installed)
* - In-memory filesystem that tracks all writes for verification
*
* @param {import('@playwright/test').Page} page
* @param {object} opts
* @param {boolean} [opts.hasNickelMenu=false] - Whether .adds/nm/ exists on device
* @param {string} [opts.firmware='4.45.23646'] - Firmware version to report
* @param {string} [opts.serial='N4280A0000000'] - Serial number to report
*/
async function injectMockDevice(page, opts = {}) {
const firmware = opts.firmware || '4.45.23646';
const serial = opts.serial || 'N4280A0000000';
await page.evaluate(({ hasNickelMenu, firmware, serial }) => {
// In-memory filesystem for the mock device
const filesystem = {
'.kobo': {
_type: 'dir',
'version': {
_type: 'file',
content: serial + ',4.9.77,' + firmware + ',4.9.77,4.9.77,00000000-0000-0000-0000-000000000390',
},
'Kobo': {
_type: 'dir',
'Kobo eReader.conf': {
_type: 'file',
content: '[General]\nsome=setting\n',
},
},
},
};
if (hasNickelMenu) {
filesystem['.adds'] = {
_type: 'dir',
'nm': {
_type: 'dir',
'items': { _type: 'file', content: 'menu_item:main:test:skip:' },
},
};
}
// Expose filesystem for verification from tests
window.__mockFS = filesystem;
// Track written file paths (relative path string -> true)
window.__mockWrittenFiles = {};
function makeFileHandle(dirNode, fileName, pathPrefix) {
return {
getFile: async () => {
const fileNode = dirNode[fileName];
const content = fileNode ? (fileNode.content || '') : '';
return {
text: async () => content,
arrayBuffer: async () => new TextEncoder().encode(content).buffer,
};
},
createWritable: async () => {
const chunks = [];
return {
write: async (chunk) => { chunks.push(chunk); },
close: async () => {
const first = chunks[0];
const bytes = first instanceof Uint8Array ? first : new TextEncoder().encode(String(first));
if (!dirNode[fileName]) dirNode[fileName] = { _type: 'file' };
dirNode[fileName].content = new TextDecoder().decode(bytes);
const fullPath = pathPrefix ? pathPrefix + '/' + fileName : fileName;
window.__mockWrittenFiles[fullPath] = true;
},
};
},
};
}
function makeDirHandle(node, name, pathPrefix) {
const currentPath = pathPrefix ? pathPrefix + '/' + name : name;
return {
name: name,
kind: 'directory',
getDirectoryHandle: async (childName, opts2) => {
if (node[childName] && node[childName]._type === 'dir') {
return makeDirHandle(node[childName], childName, currentPath);
}
if (opts2 && opts2.create) {
node[childName] = { _type: 'dir' };
return makeDirHandle(node[childName], childName, currentPath);
}
throw new DOMException('Not found: ' + childName, 'NotFoundError');
},
getFileHandle: async (childName, opts2) => {
if (node[childName] && node[childName]._type === 'file') {
return makeFileHandle(node, childName, currentPath);
}
if (opts2 && opts2.create) {
node[childName] = { _type: 'file', content: '' };
return makeFileHandle(node, childName, currentPath);
}
throw new DOMException('Not found: ' + childName, 'NotFoundError');
},
};
}
const rootHandle = makeDirHandle(filesystem, 'KOBOeReader', '');
// Override showDirectoryPicker
window.showDirectoryPicker = async () => rootHandle;
}, { hasNickelMenu: opts.hasNickelMenu || false, firmware: firmware, serial: serial });
}
/**
* Inject mock device, optionally override firmware URLs, and connect.
* Firmware URLs must be overridden BEFORE connecting, because the app captures
* the firmware URL during device detection (configureFirmwareStep).
*
* @param {import('@playwright/test').Page} page
* @param {object} opts
* @param {boolean} [opts.hasNickelMenu=false]
* @param {boolean} [opts.overrideFirmware=false] - Override firmware URLs before connecting
*/
async function connectMockDevice(page, opts = {}) {
await page.goto('/');
await expect(page.locator('h1')).toContainText('KoboPatch');
await injectMockDevice(page, opts);
if (opts.overrideFirmware) {
await overrideFirmwareURLs(page);
}
await page.click('#btn-connect');
await expect(page.locator('#step-device')).not.toBeHidden();
await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour');
await expect(page.locator('#device-firmware')).toHaveText('4.45.23646');
await expect(page.locator('#device-status')).toContainText('recognized');
}
/**
* Read a file's content from the mock filesystem.
*/
async function readMockFile(page, ...pathParts) {
return page.evaluate((parts) => {
let node = window.__mockFS;
for (const part of parts) {
if (!node || !node[part]) return null;
node = node[part];
}
return node && node._type === 'file' ? (node.content || '') : null;
}, pathParts);
}
/**
* Check whether a path exists in the mock filesystem.
*/
async function mockPathExists(page, ...pathParts) {
return page.evaluate((parts) => {
let node = window.__mockFS;
for (const part of parts) {
if (!node || !node[part]) return false;
node = node[part];
}
return true;
}, pathParts);
}
/**
* Get the list of written file paths from the mock device.
*/
async function getWrittenFiles(page) {
return page.evaluate(() => Object.keys(window.__mockWrittenFiles));
}
// ============================================================ // ============================================================
// NickelMenu // NickelMenu
// ============================================================ // ============================================================
@@ -300,6 +43,7 @@ test.describe('NickelMenu', () => {
await expect(page.locator('input[name="nm-cfg-screensaver"]')).not.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-tabs"]')).not.toBeChecked();
await expect(page.locator('input[name="nm-cfg-simplify-home"]')).not.toBeChecked(); await expect(page.locator('input[name="nm-cfg-simplify-home"]')).not.toBeChecked();
await expect(page.locator('input[name="nm-cfg-koreader"]')).not.toBeChecked();
// Enable simplifyHome for testing // Enable simplifyHome for testing
await page.check('input[name="nm-cfg-simplify-home"]'); await page.check('input[name="nm-cfg-simplify-home"]');
@@ -350,6 +94,86 @@ test.describe('NickelMenu', () => {
expect(itemsContent).toContain('experimental:hide_home_row3_enabled:1'); expect(itemsContent).toContain('experimental:hide_home_row3_enabled:1');
}); });
test('no device — install with KOReader via manual download', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
test.skip(!hasKoreaderAssets(), 'KOReader assets not found (run koreader/setup.sh)');
await goToManualMode(page);
// Mode selection
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).toBeChecked();
await page.click('#btn-mode-next');
// NickelMenu configure step — select "Install NickelMenu with preset"
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
await page.click('input[name="nm-option"][value="sample"]');
await expect(page.locator('#nm-config-options')).not.toBeHidden();
// KOReader checkbox should be visible and unchecked by default
await expect(page.locator('input[name="nm-cfg-koreader"]')).not.toBeChecked();
// Enable KOReader
await page.check('input[name="nm-cfg-koreader"]');
await page.click('#btn-nm-next');
// Review step — should list KOReader
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await expect(page.locator('#nm-review-list')).toContainText('KOReader');
// Download
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('#btn-nm-download'),
]);
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 60_000 });
// Verify ZIP contents include KOReader files
expect(download.suggestedFilename()).toBe('NickelMenu-install.zip');
const zipData = fs.readFileSync(await download.path());
const zip = await JSZip.loadAsync(zipData);
const zipFiles = Object.keys(zip.files);
expect(zipFiles).toContainEqual('.kobo/KoboRoot.tgz');
expect(zipFiles).toContainEqual('.adds/nm/items');
// KOReader files should be present under .adds/koreader/
expect(zipFiles.some(f => f.startsWith('.adds/koreader/'))).toBe(true);
});
test('with device — install with KOReader writes files to device', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
test.skip(!hasKoreaderAssets(), 'KOReader assets not found (run koreader/setup.sh)');
await connectMockDevice(page, { hasNickelMenu: false });
await page.click('#btn-device-next');
await page.click('#btn-mode-next');
// Select "Install NickelMenu with preset"
await page.click('input[name="nm-option"][value="sample"]');
// Enable KOReader
await page.check('input[name="nm-cfg-koreader"]');
await page.click('#btn-nm-next');
// Review step
await expect(page.locator('#nm-review-list')).toContainText('KOReader');
// Write to device
await page.click('#btn-nm-write');
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 60_000 });
await expect(page.locator('#nm-done-status')).toContainText('installed');
// Verify KOReader files were written to mock device
const writtenFiles = await getWrittenFiles(page);
expect(writtenFiles.some(f => f.includes('koreader'))).toBe(true);
// Verify the .adds/koreader directory was created in mock FS
const koreaderDirExists = await mockPathExists(page, '.adds', 'koreader');
expect(koreaderDirExists, '.adds/koreader/ should exist').toBe(true);
});
test('no device — install NickelMenu only via manual download', async ({ page }) => { test('no device — install NickelMenu only via manual download', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot'); test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
@@ -549,7 +373,7 @@ test.describe('NickelMenu', () => {
test.describe('Custom patches', () => { test.describe('Custom patches', () => {
test('no device — full manual mode patching pipeline', async ({ page }) => { test('no device — full manual mode patching pipeline', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink(); setupFirmwareSymlink();
await goToManualMode(page); await goToManualMode(page);
@@ -633,7 +457,7 @@ test.describe('Custom patches', () => {
}); });
test('no device — restore original firmware', async ({ page }) => { test('no device — restore original firmware', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink(); setupFirmwareSymlink();
await goToManualMode(page); await goToManualMode(page);
@@ -755,7 +579,7 @@ test.describe('Custom patches', () => {
}); });
test('with device — apply patches and verify checksums', async ({ page }) => { test('with device — apply patches and verify checksums', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink(); setupFirmwareSymlink();
// Override firmware URLs BEFORE connecting so the app captures the local URL // Override firmware URLs BEFORE connecting so the app captures the local URL
@@ -830,7 +654,7 @@ test.describe('Custom patches', () => {
}); });
test('with device — restore original firmware', async ({ page }) => { test('with device — restore original firmware', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink(); setupFirmwareSymlink();
// Override firmware URLs BEFORE connecting so the app captures the local URL // Override firmware URLs BEFORE connecting so the app captures the local URL
@@ -872,7 +696,7 @@ test.describe('Custom patches', () => {
}); });
test('with device — build failure shows Go Back and returns to patches', async ({ page }) => { test('with device — build failure shows Go Back and returns to patches', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink(); setupFirmwareSymlink();
await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true }); await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true });
@@ -909,7 +733,7 @@ test.describe('Custom patches', () => {
}); });
test('with device — real patch failure with Go Back (Allow rotation)', async ({ page }) => { test('with device — real patch failure with Go Back (Allow rotation)', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink(); setupFirmwareSymlink();
await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true }); await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true });

View File

@@ -11,7 +11,7 @@ set -euo pipefail
# #
# Prerequisites: # Prerequisites:
# - kobopatch.wasm built (run kobopatch-wasm/build.sh first) # - kobopatch.wasm built (run kobopatch-wasm/build.sh first)
# - Firmware zip cached at kobopatch-wasm/testdata/ (downloaded automatically) # - Test assets cached in tests/cached_assets/ (run ./test.sh to download)
# - NickelMenu assets in web/src/nickelmenu/ (set up automatically) # - NickelMenu assets in web/src/nickelmenu/ (set up automatically)
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -45,11 +45,6 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
FIRMWARE_VERSION="4.45.23646"
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
FIRMWARE_DIR="$PROJECT_ROOT/kobopatch-wasm/testdata"
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
# Check WASM is built. # Check WASM is built.
if [ ! -f "$DIST_DIR/wasm/kobopatch.wasm" ]; then if [ ! -f "$DIST_DIR/wasm/kobopatch.wasm" ]; then
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first." echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
@@ -63,20 +58,10 @@ if [ ! -f "$NM_DIR/NickelMenu.zip" ] || [ ! -f "$NM_DIR/kobo-config.zip" ]; then
"$PROJECT_ROOT/nickelmenu/setup.sh" "$PROJECT_ROOT/nickelmenu/setup.sh"
fi fi
# Download firmware if not cached. # Set up KOReader assets if not present.
if [ ! -f "$FIRMWARE_FILE" ]; then if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..." echo "Setting up KOReader assets..."
mkdir -p "$FIRMWARE_DIR" "$PROJECT_ROOT/koreader/setup.sh"
curl -fL --progress-bar -o "$FIRMWARE_FILE.tmp" "$FIRMWARE_URL"
if ! file "$FIRMWARE_FILE.tmp" | grep -q "Zip archive"; then
echo "ERROR: downloaded file is not a valid zip"
rm -f "$FIRMWARE_FILE.tmp"
exit 1
fi
mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE"
echo "Downloaded to $FIRMWARE_FILE"
else
echo "Using cached firmware: $FIRMWARE_FILE"
fi fi
# Install dependencies and browser. # Install dependencies and browser.
@@ -85,5 +70,4 @@ npx playwright install chromium
# Run the tests. # Run the tests.
echo "Running E2E integration tests..." echo "Running E2E integration tests..."
FIRMWARE_ZIP="$FIRMWARE_FILE" \
npx playwright test "${PLAYWRIGHT_ARGS[@]}" npx playwright test "${PLAYWRIGHT_ARGS[@]}"

View File

@@ -898,6 +898,10 @@ select + .fallback-hint {
/* Install instructions */ /* Install instructions */
.install-instructions { .install-instructions {
margin-top: 1rem; margin-top: 1rem;
background: var(--card-bg);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 1rem 1.25rem;
} }
.install-instructions .warning { .install-instructions .warning {
@@ -909,14 +913,25 @@ select + .fallback-hint {
} }
.install-steps { .install-steps {
margin: 0.5rem 0 0 1.25rem; margin: 0.25rem 0 0 1.25rem;
font-size: 0.88rem; font-size: 0.88rem;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.7; line-height: 1.7;
} }
.install-steps li { .install-steps li {
padding: 0.1rem 0; padding: 0.15rem 0;
}
.install-steps code {
display: inline-block;
background: var(--bg);
border: 1px solid var(--border-light);
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.82rem;
word-break: break-all;
} }
.step .info-banner { .step .info-banner {

View File

@@ -191,6 +191,13 @@
<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> <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> </div>
</label> </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"> <label class="nm-config-item">
<input type="checkbox" name="nm-cfg-simplify-tabs"> <input type="checkbox" name="nm-cfg-simplify-tabs">
<div class="nm-config-text"> <div class="nm-config-text">
@@ -269,12 +276,12 @@
<div id="nm-download-instructions" class="install-instructions" hidden> <div id="nm-download-instructions" class="install-instructions" hidden>
<ol class="install-steps"> <ol class="install-steps">
<li>Connect your Kobo via USB so it appears as a removable drive.</li> <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 id="nm-download-conf-step" hidden>Open <strong>.kobo/Kobo/Kobo eReader.conf</strong> in a text editor.<br>
<li id="nm-download-conf-step" hidden>
Open <strong>.kobo/Kobo/Kobo eReader.conf</strong> in a text editor.<br>
Find the <code>[FeatureSettings]</code> section (or add it at the end) and add the following line:<br> Find the <code>[FeatureSettings]</code> section (or add it at the end) and add the following line:<br>
<code>ExcludeSyncFolders=(calibre|\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\..+)</code><br> <code>ExcludeSyncFolders=(calibre|\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\..+)</code><br>
This prevents the Kobo from discovering books in these folders during a sync.</li> This prevents the Kobo from incorrectly identifying certain files as books in your library.</li>
<li id="nm-download-reboot-step" hidden><strong>Safely eject</strong> the Kobo, then power it off by holding the power button until it says "Powered off". Press the power button again to boot it back up. The config change takes effect after reboot. Reconnect the Kobo via USB afterwards.</li>
<li>Extract the downloaded ZIP to the <strong>root</strong> of the Kobo drive, preserving the folder structure. Make sure hidden folders like <code>.kobo</code> and <code>.adds</code> are also copied.</li>
<li><strong>Safely eject</strong> the Kobo &mdash; do not just unplug the cable.</li> <li><strong>Safely eject</strong> the Kobo &mdash; do not just unplug the cable.</li>
<li>The device will reboot and install NickelMenu automatically.</li> <li>The device will reboot and install NickelMenu automatically.</li>
</ol> </ol>

View File

@@ -23,16 +23,27 @@ import JSZip from 'jszip';
let selectedMode = null; // 'nickelmenu' | 'patches' let selectedMode = null; // 'nickelmenu' | 'patches'
let nickelMenuOption = null; // 'sample' | 'nickelmenu-only' | 'remove' let nickelMenuOption = null; // 'sample' | 'nickelmenu-only' | 'remove'
// Fetch data eagerly so it's ready when needed.
const softwareUrlsReady = loadSoftwareUrls();
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// --- Helpers --- // --- Helpers ---
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const $q = (sel, ctx = document) => ctx.querySelector(sel); const $q = (sel, ctx = document) => ctx.querySelector(sel);
const $qa = (sel, ctx = document) => ctx.querySelectorAll(sel); const $qa = (sel, ctx = document) => ctx.querySelectorAll(sel);
// Fetch data eagerly so it's ready when needed.
const softwareUrlsReady = loadSoftwareUrls();
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// Show KOReader version in the UI (best-effort, non-blocking).
fetch('/koreader/release.json').then(r => r.ok ? r.json() : null).then(meta => {
if (meta && meta.version) {
$('koreader-version').textContent = meta.version;
} else {
$('nm-cfg-koreader-label').style.display = 'none';
}
}).catch(() => {
$('nm-cfg-koreader-label').style.display = 'none';
});
function formatMB(bytes) { function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB'; return (bytes / 1024 / 1024).toFixed(1) + ' MB';
} }
@@ -502,6 +513,7 @@ import JSZip from 'jszip';
screensaver: $q('input[name="nm-cfg-screensaver"]').checked, screensaver: $q('input[name="nm-cfg-screensaver"]').checked,
simplifyTabs: $q('input[name="nm-cfg-simplify-tabs"]').checked, simplifyTabs: $q('input[name="nm-cfg-simplify-tabs"]').checked,
simplifyHome: $q('input[name="nm-cfg-simplify-home"]').checked, simplifyHome: $q('input[name="nm-cfg-simplify-home"]').checked,
koreader: $q('input[name="nm-cfg-koreader"]').checked,
}; };
} }
@@ -558,6 +570,7 @@ import JSZip from 'jszip';
if (cfg.screensaver) items.push(TL.NICKEL_MENU_ITEMS.SCREENSAVER); if (cfg.screensaver) items.push(TL.NICKEL_MENU_ITEMS.SCREENSAVER);
if (cfg.simplifyTabs) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_TABS); if (cfg.simplifyTabs) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_TABS);
if (cfg.simplifyHome) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_HOME); if (cfg.simplifyHome) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_HOME);
if (cfg.koreader) items.push(TL.NICKEL_MENU_ITEMS.KOREADER);
for (const text of items) { for (const text of items) {
const li = document.createElement('li'); const li = document.createElement('li');
li.textContent = text; li.textContent = text;
@@ -638,8 +651,10 @@ import JSZip from 'jszip';
nmDoneStatus.textContent = TL.STATUS.NM_DOWNLOAD_READY; nmDoneStatus.textContent = TL.STATUS.NM_DOWNLOAD_READY;
triggerDownload(resultNmZip, 'NickelMenu-install.zip', 'application/zip'); triggerDownload(resultNmZip, 'NickelMenu-install.zip', 'application/zip');
$('nm-download-instructions').hidden = false; $('nm-download-instructions').hidden = false;
// Show eReader.conf step only when sample config is included // Show eReader.conf + reboot steps only when sample config is included
$('nm-download-conf-step').hidden = nickelMenuOption !== 'sample'; const showConfStep = nickelMenuOption === 'sample';
$('nm-download-conf-step').hidden = !showConfStep;
$('nm-download-reboot-step').hidden = !showConfStep;
} }
setNavStep(5); setNavStep(5);

View File

@@ -16,11 +16,13 @@ import JSZip from 'jszip';
* screensaver: bool — include custom screensaver * screensaver: bool — include custom screensaver
* simplifyTabs: bool — comment out experimental tab items in config * simplifyTabs: bool — comment out experimental tab items in config
* simplifyHome: bool — append homescreen simplification lines * simplifyHome: bool — append homescreen simplification lines
* koreader: bool — download and install latest KOReader from GitHub
*/ */
class NickelMenuInstaller { class NickelMenuInstaller {
constructor() { constructor() {
this.nickelMenuZip = null; // JSZip instance this.nickelMenuZip = null; // JSZip instance
this.koboConfigZip = null; // JSZip instance this.koboConfigZip = null; // JSZip instance
this.koreaderZip = null; // JSZip instance
} }
/** /**
@@ -44,6 +46,25 @@ class NickelMenuInstaller {
} }
} }
/**
* Download and cache KOReader for Kobo (served from the app's own domain
* to avoid CORS issues with GitHub release downloads).
* @param {function} progressFn
*/
async loadKoreader(progressFn) {
if (this.koreaderZip) return;
progressFn('Fetching KOReader release info...');
const metaResp = await fetch('/koreader/release.json');
if (!metaResp.ok) throw new Error('KOReader assets not available (run koreader/setup.sh)');
const meta = await metaResp.json();
progressFn('Downloading KOReader ' + meta.version + '...');
const zipResp = await fetch('/koreader/koreader-kobo.zip');
if (!zipResp.ok) throw new Error('Failed to download KOReader: HTTP ' + zipResp.status);
this.koreaderZip = await JSZip.loadAsync(await zipResp.arrayBuffer());
}
/** /**
* Get the KoboRoot.tgz from the NickelMenu zip. * Get the KoboRoot.tgz from the NickelMenu zip.
*/ */
@@ -53,6 +74,37 @@ class NickelMenuInstaller {
return new Uint8Array(await file.async('arraybuffer')); 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. * Get config files from kobo-config.zip filtered by cfg flags.
* Returns { path: string[], data: Uint8Array } entries. * Returns { path: string[], data: Uint8Array } entries.
@@ -135,6 +187,16 @@ class NickelMenuInstaller {
await device.writeFile(path, data); 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 // Modify Kobo eReader.conf
progressFn('Updating Kobo eReader.conf...'); progressFn('Updating Kobo eReader.conf...');
await this.updateEReaderConf(device); await this.updateEReaderConf(device);
@@ -193,6 +255,16 @@ class NickelMenuInstaller {
for (const { path, data } of configFiles) { for (const { path, data } of configFiles) {
zip.file(path.join('/'), data); 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...'); progressFn('Compressing...');

View File

@@ -55,5 +55,6 @@ export const TL = {
SCREENSAVER: 'Custom screensaver', SCREENSAVER: 'Custom screensaver',
SIMPLIFY_TABS: 'Simplified tab menu', SIMPLIFY_TABS: 'Simplified tab menu',
SIMPLIFY_HOME: 'Simplified homescreen', SIMPLIFY_HOME: 'Simplified homescreen',
KOREADER: 'KOReader',
}, },
}; };