Add more integration tests
All checks were successful
Build and test project / build-and-test (push) Successful in 1m46s
All checks were successful
Build and test project / build-and-test (push) Successful in 1m46s
This commit is contained in:
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
@@ -52,24 +52,29 @@ jobs:
|
|||||||
GOOS=js GOARCH=wasm go test -exec="$EXEC" ./...
|
GOOS=js GOARCH=wasm go test -exec="$EXEC" ./...
|
||||||
|
|
||||||
- name: Check if integration test is needed
|
- name: Check if integration test is needed
|
||||||
id: check-wasm
|
id: check-e2e
|
||||||
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 -q '^kobopatch-wasm/'; then
|
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/)'; 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: Full integration test (WASM)
|
||||||
if: steps.check-wasm.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||||
run: |
|
run: |
|
||||||
cd kobopatch-wasm
|
cd kobopatch-wasm
|
||||||
./test-integration.sh
|
./test-integration.sh
|
||||||
|
|
||||||
|
- name: Set up NickelMenu assets
|
||||||
|
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||||
|
run: |
|
||||||
|
nickelmenu/setup.sh
|
||||||
|
|
||||||
- name: Full integration test (Playwright)
|
- name: Full integration test (Playwright)
|
||||||
if: steps.check-wasm.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||||
run: |
|
run: |
|
||||||
cd tests/e2e
|
cd tests/e2e
|
||||||
./run-e2e.sh
|
./run-e2e.sh
|
||||||
|
|||||||
88
README.md
88
README.md
@@ -3,9 +3,12 @@
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **This is an experiment**, mostly created with the help of Claude and some very precise instructions. It currently only supports the latest version of Kobo's software, and only for the Kobo Libra Color, Kobo Clara Color and Kobo Clara BW models. Further support may be added at a later date.
|
> **This is an experiment**, mostly created with the help of Claude and some very precise instructions. It currently only supports the latest version of Kobo's software, and only for the Kobo Libra Color, Kobo Clara Color and Kobo Clara BW models. Further support may be added at a later date.
|
||||||
|
|
||||||
A web application that provides a GUI for applying custom [kobopatch](https://github.com/pgaskin/kobopatch) patches to Kobo e-readers. It uses the File System Access API (Chromium) to interface with connected Kobo devices, or falls back to manual model/software version selection on other browsers.
|
A web application for customising Kobo e-readers. It supports two modes:
|
||||||
|
|
||||||
The app makes it easy to configure which patches to apply, downloads the correct software update from Kobo's servers, runs the patcher (compiled to WebAssembly), and places the resulting `KoboRoot.tgz` on the device. The user then safely ejects and reboots to apply. It can also restore the original unpatched software.
|
- **NickelMenu** — installs [NickelMenu](https://pgaskin.net/NickelMenu/) with an optional curated configuration (custom menus, fonts, screensavers, UI tweaks). Works with most Kobo devices regardless of software version. Can also remove NickelMenu from a connected device.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
The app uses the File System 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.
|
||||||
|
|
||||||
Fully client-side — no backend needed, can be hosted as a static site. Patches are community-contributed via the [MobileRead forums](https://www.mobileread.com/forums/forumdisplay.php?f=247) and need to be manually updated when new Kobo software versions come out.
|
Fully client-side — no backend needed, can be hosted as a static site. Patches are community-contributed via the [MobileRead forums](https://www.mobileread.com/forums/forumdisplay.php?f=247) and need to be manually updated when new Kobo software versions come out.
|
||||||
|
|
||||||
@@ -14,51 +17,57 @@ Fully client-side — no backend needed, can be hosted as a static site. Patches
|
|||||||
|
|
||||||
## User flow
|
## User flow
|
||||||
|
|
||||||
1. **Select device** — auto-detect via File System Access API on Chromium, or manual dropdowns on 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. **Configure patches** — enable/disable patches (PatchGroup mutual exclusion via radio buttons), or select none to restore original unpatched software
|
2. **Choose mode** — NickelMenu (install/configure/remove) or custom patches
|
||||||
3. **Build** — software update is auto-downloaded from Kobo's CDN (`ereaderfiles.kobo.com`, CORS open) and either patched via WASM in a Web Worker, or the original `KoboRoot.tgz` is extracted as-is for restoring
|
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)
|
||||||
4. **Install** — write `KoboRoot.tgz` directly to the device (Chromium auto mode) or download for manual installation
|
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
|
||||||
|
|
||||||
## File structure
|
## File structure
|
||||||
|
|
||||||
```
|
```
|
||||||
web/public/ # Webroot — serve this directory
|
web/public/ # Webroot — serve this directory
|
||||||
index.html # Single-page app, 4-step wizard (Device → Patches → Build → Install)
|
index.html # Single-page app, multi-step wizard
|
||||||
css/
|
css/
|
||||||
style.css
|
style.css
|
||||||
js/
|
js/
|
||||||
app.js # Step navigation, flow orchestration, firmware download with progress
|
app.js # Step navigation, flow orchestration, firmware download with progress
|
||||||
kobo-device.js # KOBO_MODELS (serial prefix → name), FIRMWARE_DOWNLOADS (version+prefix → URL),
|
kobo-device.js # KOBO_MODELS (serial prefix → name), FIRMWARE_DOWNLOADS (version+prefix → URL),
|
||||||
# getDevicesForVersion(), getFirmwareURL(), KoboDevice class (File System Access API)
|
# getDevicesForVersion(), getFirmwareURL(), KoboDevice class (File System Access API)
|
||||||
|
nickelmenu.js # NickelMenuInstaller: downloads NickelMenu.zip + kobo-config.zip, installs to
|
||||||
|
# device or builds download ZIP, handles config file filtering and modification
|
||||||
patch-ui.js # PatchUI class: loads patch zips (JSZip), parses YAML, renders toggle UI,
|
patch-ui.js # PatchUI class: loads patch zips (JSZip), parses YAML, renders toggle UI,
|
||||||
# generates kobopatch.yaml config with overrides
|
# generates kobopatch.yaml config with overrides
|
||||||
kobopatch.js # KobopatchRunner: spawns Web Worker per build, handles progress/done/error messages
|
kobopatch.js # KobopatchRunner: spawns Web Worker per build, handles progress/done/error messages
|
||||||
patch-worker.js # Web Worker: loads wasm_exec.js + kobopatch.wasm, runs patchFirmware(),
|
patch-worker.js # Web Worker: loads wasm_exec.js + kobopatch.wasm, runs patchFirmware(),
|
||||||
# posts progress back, transfers result buffer zero-copy
|
# posts progress back, transfers result buffer zero-copy
|
||||||
wasm_exec.js # Go WASM support runtime (copied from Go SDK by setup.sh, gitignored)
|
wasm_exec.js # Go WASM support runtime (copied from Go SDK by setup.sh, gitignored)
|
||||||
|
jszip.min.js # Bundled JSZip library
|
||||||
wasm/
|
wasm/
|
||||||
kobopatch.wasm # Compiled WASM binary (built by build.sh, gitignored)
|
kobopatch.wasm # Compiled WASM binary (built by build.sh, gitignored)
|
||||||
patches/
|
patches/
|
||||||
index.json # Contains a list of available patches
|
index.json # Contains a list of available patches
|
||||||
patches_*.zip # Each contains kobopatch.yaml + src/*.yaml patch files
|
patches_*.zip # Each contains kobopatch.yaml + src/*.yaml patch files
|
||||||
|
nickelmenu/ # NickelMenu assets (built by nickelmenu/setup.sh, gitignored)
|
||||||
|
NickelMenu.zip # NickelMenu release
|
||||||
|
kobo-config.zip # Curated configuration files (fonts, screensaver, menu items)
|
||||||
|
|
||||||
|
nickelmenu/
|
||||||
|
setup.sh # Downloads NickelMenu.zip and bundles kobo-config.zip from kobo-config repo
|
||||||
|
|
||||||
kobopatch-wasm/ # WASM build
|
kobopatch-wasm/ # WASM build
|
||||||
main.go # Go entry point: jsPatchFirmware() → patchFirmware() pipeline
|
main.go # Go entry point: jsPatchFirmware() → patchFirmware() pipeline
|
||||||
# Accepts configYAML, firmwareZip, patchFiles, optional progressFn
|
|
||||||
# Returns { tgz: Uint8Array, log: string }
|
|
||||||
go.mod
|
go.mod
|
||||||
setup.sh # Clones kobopatch source, copies wasm_exec.js
|
setup.sh # Clones kobopatch source, copies wasm_exec.js
|
||||||
build.sh # GOOS=js GOARCH=wasm go build, copies .wasm to web/public/wasm/,
|
build.sh # GOOS=js GOARCH=wasm go build, copies .wasm to web/public/wasm/
|
||||||
# sets ?ts= cache-bust timestamp in js/patch-worker.js
|
|
||||||
integration_test.go # Go integration test: validates SHA1 checksums of patched binaries
|
integration_test.go # Go integration test: validates SHA1 checksums of patched binaries
|
||||||
test-integration.sh # Downloads firmware and runs integration_test.go
|
test-integration.sh # Downloads firmware and runs integration_test.go
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
e2e/ # Playwright E2E tests
|
e2e/ # Playwright E2E tests
|
||||||
integration.spec.js # Full browser pipeline test (patch + restore)
|
integration.spec.js # Full browser tests: NickelMenu flows, custom patches, mock device
|
||||||
playwright.config.js
|
playwright.config.js
|
||||||
run-e2e.sh # Headless E2E runner (downloads firmware, installs browser)
|
run-e2e.sh # E2E runner (downloads firmware, sets up NickelMenu assets, installs browser)
|
||||||
run-e2e-local.sh # Headed E2E runner (visible browser window)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding a new software version
|
## Adding a new software version
|
||||||
@@ -77,41 +86,60 @@ cd kobopatch-wasm
|
|||||||
./build.sh # compiles WASM, copies to web/public/wasm/
|
./build.sh # compiles WASM, copies to web/public/wasm/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Setting up NickelMenu assets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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/public/nickelmenu/`.
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run-locally.sh
|
./run-locally.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This serves the app at `http://localhost:8888`. If the WASM binary hasn't been built yet, the script automatically runs `setup.sh` and `build.sh` first.
|
This serves the app at `http://localhost:8888`. If the WASM binary or NickelMenu assets haven't been set up yet, the script handles that automatically.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
To further validate the patched `KoboRoot.tgz` packages are identical to what a local version of `kobopatch` would generate, two integration tests have been added.
|
### E2E tests (Playwright)
|
||||||
|
|
||||||
Both integration tests run the full patching pipeline with software version 4.45.23646 (Kobo Libra Color), enable a single patch, and verify SHA1 checksums of all 4 patched binaries. The software update zip (~150MB) is downloaded once and cached in `kobopatch-wasm/testdata/`.
|
The E2E tests cover all major user flows:
|
||||||
|
|
||||||
The reason this particular combination is used is simple: the author has actually used that specific version on an actual device before and it's a known, working, patched version of the software. So comparing hashes against it seems like a good idea.
|
- **NickelMenu** — install with config (manual download), install NickelMenu only, remove option disabled without device
|
||||||
|
- **Custom patches** — full patching pipeline, restore original firmware
|
||||||
|
- **With simulated Kobo Libra Color** — install NickelMenu with config, remove NickelMenu, install custom patches, restore firmware
|
||||||
|
|
||||||
**WASM integration test** — calls `patchFirmware()` directly in Go/WASM via Node.js:
|
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).
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
cd kobopatch-wasm
|
|
||||||
./test-integration.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Playwright E2E test** — drives the full browser UI (manual mode, headless):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tests/e2e
|
cd tests/e2e
|
||||||
./run-e2e.sh
|
./run-e2e.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
To run the Playwright test with a visible browser window:
|
To run with a visible browser window:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tests/e2e
|
./run-e2e.sh --headed
|
||||||
./run-e2e-local.sh
|
```
|
||||||
|
|
||||||
|
Extra Playwright arguments can be passed after `--`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run-e2e.sh --headed -- --grep "NickelMenu"
|
||||||
|
```
|
||||||
|
|
||||||
|
### WASM integration test
|
||||||
|
|
||||||
|
Calls `patchFirmware()` directly in Go/WASM via Node.js:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kobopatch-wasm
|
||||||
|
./test-integration.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output validation
|
## Output validation
|
||||||
@@ -124,8 +152,8 @@ The WASM patcher performs several checks on each patched binary before including
|
|||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Built on [kobopatch](https://github.com/pgaskin/kobopatch) by pgaskin. Patches and discussion on the [MobileRead forums](https://www.mobileread.com/forums/forumdisplay.php?f=247).
|
Built on [kobopatch](https://github.com/pgaskin/kobopatch) and [NickelMenu](https://pgaskin.net/NickelMenu/) by pgaskin. Uses [JSZip](https://stuk.github.io/jszip/) for client-side ZIP handling. Software patches and discussion on the [MobileRead forums](https://www.mobileread.com/forums/forumdisplay.php?f=247).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE).
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ const EXPECTED_SHA1 = {
|
|||||||
const FIRMWARE_PATH = process.env.FIRMWARE_ZIP
|
const FIRMWARE_PATH = process.env.FIRMWARE_ZIP
|
||||||
|| path.resolve(__dirname, '..', '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
|
|| path.resolve(__dirname, '..', '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
|
||||||
|
|
||||||
const WEBROOT_FIRMWARE = path.resolve(__dirname, '..', '..', 'web', 'public', '_test_firmware.zip');
|
const WEBROOT = path.resolve(__dirname, '..', '..', 'web', 'public');
|
||||||
|
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.
|
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
|
||||||
@@ -51,27 +55,33 @@ function parseTar(buffer) {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
|
// Clean up the symlink after each test.
|
||||||
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
|
|
||||||
|
|
||||||
// Clean up the symlink after the test.
|
|
||||||
test.afterEach(() => {
|
test.afterEach(() => {
|
||||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('full manual mode patching pipeline', async ({ page }) => {
|
/**
|
||||||
if (!fs.existsSync(FIRMWARE_PATH)) {
|
* Check that NickelMenu assets exist in webroot.
|
||||||
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
|
*/
|
||||||
}
|
function hasNickelMenuAssets() {
|
||||||
|
return fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'NickelMenu.zip'))
|
||||||
// Symlink the cached firmware into the webroot so the app can fetch it locally.
|
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'kobo-config.zip'));
|
||||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
}
|
||||||
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to manual mode: click "Download files manually" on the connect step.
|
||||||
|
*/
|
||||||
|
async function goToManualMode(page) {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
await page.click('#btn-manual');
|
||||||
|
await expect(page.locator('#step-mode')).not.toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
// Override the firmware download URLs to point at the local server.
|
/**
|
||||||
|
* Override firmware download URLs to point at the local test server.
|
||||||
|
*/
|
||||||
|
async function overrideFirmwareURLs(page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
||||||
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
||||||
@@ -79,149 +89,630 @@ test('full manual mode patching pipeline', async ({ page }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Switch to manual mode.
|
/**
|
||||||
await page.click('#btn-manual-from-auto');
|
* Set up firmware symlink for tests that need it.
|
||||||
await expect(page.locator('#step-manual')).not.toBeHidden();
|
*/
|
||||||
|
function setupFirmwareSymlink() {
|
||||||
// Step 2: Select firmware version.
|
|
||||||
await page.selectOption('#manual-version', '4.45.23646');
|
|
||||||
await expect(page.locator('#manual-model')).not.toBeHidden();
|
|
||||||
|
|
||||||
// Step 3: Select Kobo Libra Colour (N428).
|
|
||||||
await page.selectOption('#manual-model', 'N428');
|
|
||||||
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
|
||||||
await page.click('#btn-manual-confirm');
|
|
||||||
|
|
||||||
// Step 4: Wait for patches to load.
|
|
||||||
await expect(page.locator('#step-patches')).not.toBeHidden();
|
|
||||||
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
|
||||||
|
|
||||||
// Step 5: Enable "Remove footer (row3) on new home screen".
|
|
||||||
const patchName = page.locator('.patch-name', { hasText: 'Remove footer (row3) on new home screen' }).first();
|
|
||||||
const patchSection = patchName.locator('xpath=ancestor::details');
|
|
||||||
await patchSection.locator('summary').click();
|
|
||||||
await expect(patchName).toBeVisible();
|
|
||||||
await patchName.locator('xpath=ancestor::label').locator('input').check();
|
|
||||||
|
|
||||||
// Verify patch count updated.
|
|
||||||
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
|
|
||||||
await expect(page.locator('#btn-patches-next')).toBeEnabled();
|
|
||||||
|
|
||||||
// Step 6: Continue to build step.
|
|
||||||
await page.click('#btn-patches-next');
|
|
||||||
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
|
||||||
await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646');
|
|
||||||
await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour');
|
|
||||||
|
|
||||||
// Step 7: Build and wait for completion.
|
|
||||||
await page.click('#btn-build');
|
|
||||||
|
|
||||||
const doneOrError = await Promise.race([
|
|
||||||
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
|
||||||
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (doneOrError === 'error') {
|
|
||||||
const errorMsg = await page.locator('#error-message').textContent();
|
|
||||||
throw new Error(`Build failed: ${errorMsg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.locator('#build-status')).toContainText('Patching complete');
|
|
||||||
await expect(page.locator('#build-status')).toContainText('Kobo Libra Colour');
|
|
||||||
|
|
||||||
// Step 8: Download KoboRoot.tgz and verify checksums.
|
|
||||||
const [download] = await Promise.all([
|
|
||||||
page.waitForEvent('download'),
|
|
||||||
page.click('#btn-download'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
|
||||||
await expect(page.locator('#download-device-name')).toHaveText('Kobo Libra Colour');
|
|
||||||
|
|
||||||
const downloadPath = await download.path();
|
|
||||||
const tgzData = fs.readFileSync(downloadPath);
|
|
||||||
|
|
||||||
const tarData = zlib.gunzipSync(tgzData);
|
|
||||||
const entries = parseTar(tarData);
|
|
||||||
|
|
||||||
for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) {
|
|
||||||
const data = entries[name];
|
|
||||||
expect(data, `missing binary in output: ${name}`).toBeDefined();
|
|
||||||
const actualHash = crypto.createHash('sha1').update(data).digest('hex');
|
|
||||||
expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('restore original firmware pipeline', async ({ page }) => {
|
|
||||||
if (!fs.existsSync(FIRMWARE_PATH)) {
|
|
||||||
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Symlink the cached firmware into the webroot so the app can fetch it locally.
|
|
||||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||||
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
||||||
|
}
|
||||||
|
|
||||||
await page.goto('/');
|
// ============================================================
|
||||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
// NickelMenu tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// Override the firmware download URLs to point at the local server.
|
test.describe('NickelMenu', () => {
|
||||||
await page.evaluate(() => {
|
test('no device — install NickelMenu with config via manual download', async ({ page }) => {
|
||||||
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||||
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
|
||||||
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
await goToManualMode(page);
|
||||||
}
|
|
||||||
|
// Mode selection: NickelMenu should be pre-selected (checked in HTML)
|
||||||
|
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).toBeChecked();
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
|
||||||
|
// NickelMenu configure step
|
||||||
|
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
|
||||||
|
|
||||||
|
// No option pre-selected — Continue should be disabled
|
||||||
|
await expect(page.locator('#btn-nm-next')).toBeDisabled();
|
||||||
|
|
||||||
|
// Select "Install NickelMenu and configure"
|
||||||
|
await page.click('input[name="nm-option"][value="sample"]');
|
||||||
|
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-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();
|
||||||
|
|
||||||
|
await expect(page.locator('#btn-nm-next')).toBeEnabled();
|
||||||
|
await page.click('#btn-nm-next');
|
||||||
|
|
||||||
|
// Review step
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Write button should be hidden in manual mode
|
||||||
|
await expect(page.locator('#btn-nm-write')).toBeHidden();
|
||||||
|
// Download button visible
|
||||||
|
await expect(page.locator('#btn-nm-download')).toBeVisible();
|
||||||
|
|
||||||
|
// Click download and wait for done step
|
||||||
|
await page.click('#btn-nm-download');
|
||||||
|
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('#nm-done-status')).toContainText('ready to download');
|
||||||
|
|
||||||
|
// Download instructions should be visible, and include eReader.conf step for sample config
|
||||||
|
await expect(page.locator('#nm-download-instructions')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#nm-download-conf-step')).not.toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no device — install NickelMenu only via manual download', async ({ page }) => {
|
||||||
|
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||||
|
|
||||||
|
await goToManualMode(page);
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Select "Install NickelMenu only"
|
||||||
|
await page.click('input[name="nm-option"][value="nickelmenu-only"]');
|
||||||
|
await expect(page.locator('#nm-config-options')).toBeHidden();
|
||||||
|
|
||||||
|
await page.click('#btn-nm-next');
|
||||||
|
|
||||||
|
// Review step
|
||||||
|
await expect(page.locator('#step-nm-review')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#nm-review-list')).toContainText('NickelMenu (KoboRoot.tgz)');
|
||||||
|
|
||||||
|
// Download
|
||||||
|
await page.click('#btn-nm-download');
|
||||||
|
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('#nm-done-status')).toContainText('ready to download');
|
||||||
|
|
||||||
|
// eReader.conf step should be hidden for nickelmenu-only
|
||||||
|
await expect(page.locator('#nm-download-conf-step')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no device — remove NickelMenu option is disabled in manual mode', async ({ page }) => {
|
||||||
|
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||||
|
|
||||||
|
await goToManualMode(page);
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Remove option should be disabled (no device connected)
|
||||||
|
await expect(page.locator('#nm-option-remove')).toHaveClass(/nm-option-disabled/);
|
||||||
|
await expect(page.locator('input[name="nm-option"][value="remove"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Custom patches tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test.describe('Custom patches', () => {
|
||||||
|
test('no device — full manual mode patching pipeline', async ({ page }) => {
|
||||||
|
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||||
|
|
||||||
|
setupFirmwareSymlink();
|
||||||
|
await goToManualMode(page);
|
||||||
|
|
||||||
|
// Select "Custom Patches" mode
|
||||||
|
await page.click('input[name="mode"][value="patches"]');
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
|
||||||
|
// Manual version/model selection
|
||||||
|
await expect(page.locator('#step-manual-version')).not.toBeHidden();
|
||||||
|
|
||||||
|
await overrideFirmwareURLs(page);
|
||||||
|
|
||||||
|
// Select firmware version
|
||||||
|
await page.selectOption('#manual-version', '4.45.23646');
|
||||||
|
await expect(page.locator('#manual-model')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Select Kobo Libra Colour (N428)
|
||||||
|
await page.selectOption('#manual-model', 'N428');
|
||||||
|
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
||||||
|
await page.click('#btn-manual-confirm');
|
||||||
|
|
||||||
|
// Wait for patches to load
|
||||||
|
await expect(page.locator('#step-patches')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
// Enable "Remove footer (row3) on new home screen"
|
||||||
|
const patchName = page.locator('.patch-name', { hasText: 'Remove footer (row3) on new home screen' }).first();
|
||||||
|
const patchSection = patchName.locator('xpath=ancestor::details');
|
||||||
|
await patchSection.locator('summary').click();
|
||||||
|
await expect(patchName).toBeVisible();
|
||||||
|
await patchName.locator('xpath=ancestor::label').locator('input').check();
|
||||||
|
|
||||||
|
// Verify patch count
|
||||||
|
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
|
||||||
|
await expect(page.locator('#btn-patches-next')).toBeEnabled();
|
||||||
|
|
||||||
|
// Continue to build step
|
||||||
|
await page.click('#btn-patches-next');
|
||||||
|
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646');
|
||||||
|
await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour');
|
||||||
|
|
||||||
|
// Build and wait for completion
|
||||||
|
await page.click('#btn-build');
|
||||||
|
|
||||||
|
const doneOrError = await Promise.race([
|
||||||
|
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
||||||
|
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (doneOrError === 'error') {
|
||||||
|
const errorMsg = await page.locator('#error-message').textContent();
|
||||||
|
throw new Error(`Build failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('#build-status')).toContainText('Patching complete');
|
||||||
|
await expect(page.locator('#build-status')).toContainText('Kobo Libra Colour');
|
||||||
|
|
||||||
|
// Download KoboRoot.tgz and verify checksums
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.click('#btn-download'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
||||||
|
await expect(page.locator('#download-device-name')).toHaveText('Kobo Libra Colour');
|
||||||
|
|
||||||
|
const downloadPath = await download.path();
|
||||||
|
const tgzData = fs.readFileSync(downloadPath);
|
||||||
|
|
||||||
|
const tarData = zlib.gunzipSync(tgzData);
|
||||||
|
const entries = parseTar(tarData);
|
||||||
|
|
||||||
|
for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) {
|
||||||
|
const data = entries[name];
|
||||||
|
expect(data, `missing binary in output: ${name}`).toBeDefined();
|
||||||
|
const actualHash = crypto.createHash('sha1').update(data).digest('hex');
|
||||||
|
expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 1: Switch to manual mode.
|
test('no device — restore original firmware pipeline', async ({ page }) => {
|
||||||
await page.click('#btn-manual-from-auto');
|
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||||
await expect(page.locator('#step-manual')).not.toBeHidden();
|
|
||||||
|
|
||||||
// Step 2: Select firmware version.
|
setupFirmwareSymlink();
|
||||||
await page.selectOption('#manual-version', '4.45.23646');
|
await goToManualMode(page);
|
||||||
await expect(page.locator('#manual-model')).not.toBeHidden();
|
|
||||||
|
|
||||||
// Step 3: Select Kobo Libra Colour (N428).
|
// Select "Custom Patches" mode
|
||||||
await page.selectOption('#manual-model', 'N428');
|
await page.click('input[name="mode"][value="patches"]');
|
||||||
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
await page.click('#btn-mode-next');
|
||||||
await page.click('#btn-manual-confirm');
|
|
||||||
|
|
||||||
// Step 4: Wait for patches to load, then continue with zero patches selected.
|
// Manual version/model selection
|
||||||
await expect(page.locator('#step-patches')).not.toBeHidden();
|
await expect(page.locator('#step-manual-version')).not.toBeHidden();
|
||||||
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
|
||||||
await expect(page.locator('#patch-count-hint')).toContainText('restore the original');
|
|
||||||
await page.click('#btn-patches-next');
|
|
||||||
|
|
||||||
// Step 5: Verify build step shows restore text.
|
await overrideFirmwareURLs(page);
|
||||||
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
|
||||||
await expect(page.locator('#firmware-description')).toContainText('without modifications');
|
|
||||||
await expect(page.locator('#btn-build')).toContainText('Restore Original Software');
|
|
||||||
|
|
||||||
// Step 6: Build and wait for completion.
|
await page.selectOption('#manual-version', '4.45.23646');
|
||||||
await page.click('#btn-build');
|
await page.selectOption('#manual-model', 'N428');
|
||||||
|
await page.click('#btn-manual-confirm');
|
||||||
|
|
||||||
const doneOrError = await Promise.race([
|
// Wait for patches to load, then continue with zero patches
|
||||||
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
await expect(page.locator('#step-patches')).not.toBeHidden();
|
||||||
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
||||||
]);
|
await expect(page.locator('#patch-count-hint')).toContainText('restore the original');
|
||||||
|
await page.click('#btn-patches-next');
|
||||||
|
|
||||||
if (doneOrError === 'error') {
|
// Verify build step shows restore text
|
||||||
const errorMsg = await page.locator('#error-message').textContent();
|
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||||
throw new Error(`Restore failed: ${errorMsg}`);
|
await expect(page.locator('#firmware-description')).toContainText('without modifications');
|
||||||
}
|
await expect(page.locator('#btn-build')).toContainText('Restore Original Software');
|
||||||
|
|
||||||
await expect(page.locator('#build-status')).toContainText('Software extracted');
|
// Build and wait for completion
|
||||||
|
await page.click('#btn-build');
|
||||||
|
|
||||||
// Step 7: Download KoboRoot.tgz and verify it matches the original.
|
const doneOrError = await Promise.race([
|
||||||
const [download] = await Promise.all([
|
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
||||||
page.waitForEvent('download'),
|
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
||||||
page.click('#btn-download'),
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
if (doneOrError === 'error') {
|
||||||
const downloadPath = await download.path();
|
const errorMsg = await page.locator('#error-message').textContent();
|
||||||
const tgzData = fs.readFileSync(downloadPath);
|
throw new Error(`Restore failed: ${errorMsg}`);
|
||||||
const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex');
|
}
|
||||||
expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1);
|
|
||||||
|
await expect(page.locator('#build-status')).toContainText('Software extracted');
|
||||||
|
|
||||||
|
// Download KoboRoot.tgz and verify it matches the original
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.click('#btn-download'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
||||||
|
const downloadPath = await download.path();
|
||||||
|
const tgzData = fs.readFileSync(downloadPath);
|
||||||
|
const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex');
|
||||||
|
expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no device — custom patches not available disables patches card', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// In manual mode, NickelMenu should always be available,
|
||||||
|
// but custom patches card should be selectable in mode screen
|
||||||
|
await page.click('#btn-manual');
|
||||||
|
await expect(page.locator('#step-mode')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Both modes should be available in manual mode
|
||||||
|
await expect(page.locator('input[name="mode"][value="patches"]')).not.toBeDisabled();
|
||||||
|
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Simulated device tests (mock File System Access API)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
* - writeFile tracking to verify what was written
|
||||||
|
*
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {boolean} [opts.hasNickelMenu=false] - Whether .adds/nm/ exists on device
|
||||||
|
*/
|
||||||
|
async function injectMockDevice(page, opts = {}) {
|
||||||
|
await page.evaluate(({ hasNickelMenu }) => {
|
||||||
|
// In-memory filesystem for the mock device
|
||||||
|
const filesystem = {
|
||||||
|
'.kobo': {
|
||||||
|
_type: 'dir',
|
||||||
|
'version': {
|
||||||
|
_type: 'file',
|
||||||
|
content: 'N4280A0000000,4.9.77,4.45.23646,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:' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track files written to device
|
||||||
|
window.__mockWrittenFiles = {};
|
||||||
|
|
||||||
|
function getNode(pathParts) {
|
||||||
|
let node = filesystem;
|
||||||
|
for (const part of pathParts) {
|
||||||
|
if (!node[part]) return null;
|
||||||
|
node = node[part];
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(pathParts) {
|
||||||
|
let node = filesystem;
|
||||||
|
for (const part of pathParts) {
|
||||||
|
if (!node[part]) {
|
||||||
|
node[part] = { _type: 'dir' };
|
||||||
|
}
|
||||||
|
node = node[part];
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFileHandle(dirNode, fileName) {
|
||||||
|
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);
|
||||||
|
window.__mockWrittenFiles[fileName] = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDirHandle(node, name) {
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
kind: 'directory',
|
||||||
|
getDirectoryHandle: async (childName, opts2) => {
|
||||||
|
if (node[childName] && node[childName]._type === 'dir') {
|
||||||
|
return makeDirHandle(node[childName], childName);
|
||||||
|
}
|
||||||
|
if (opts2 && opts2.create) {
|
||||||
|
node[childName] = { _type: 'dir' };
|
||||||
|
return makeDirHandle(node[childName], childName);
|
||||||
|
}
|
||||||
|
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
||||||
|
},
|
||||||
|
getFileHandle: async (childName, opts2) => {
|
||||||
|
if (node[childName] && node[childName]._type === 'file') {
|
||||||
|
return makeFileHandle(node, childName);
|
||||||
|
}
|
||||||
|
if (opts2 && opts2.create) {
|
||||||
|
node[childName] = { _type: 'file', content: '' };
|
||||||
|
return makeFileHandle(node, childName);
|
||||||
|
}
|
||||||
|
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootHandle = makeDirHandle(filesystem, 'KOBOeReader');
|
||||||
|
|
||||||
|
// Override showDirectoryPicker
|
||||||
|
window.showDirectoryPicker = async () => rootHandle;
|
||||||
|
}, { hasNickelMenu: opts.hasNickelMenu || false });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('With Kobo Libra Color', () => {
|
||||||
|
test('installing NickelMenu with config', async ({ page }) => {
|
||||||
|
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
|
||||||
|
await injectMockDevice(page, { hasNickelMenu: false });
|
||||||
|
|
||||||
|
// Connect to device
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Continue to mode selection
|
||||||
|
await page.click('#btn-device-next');
|
||||||
|
await expect(page.locator('#step-mode')).not.toBeHidden();
|
||||||
|
|
||||||
|
// NickelMenu is pre-selected
|
||||||
|
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).toBeChecked();
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
|
||||||
|
// NickelMenu configure step
|
||||||
|
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Remove option should be disabled (no NickelMenu installed)
|
||||||
|
await expect(page.locator('#nm-option-remove')).toHaveClass(/nm-option-disabled/);
|
||||||
|
|
||||||
|
// Select "Install NickelMenu and configure"
|
||||||
|
await page.click('input[name="nm-option"][value="sample"]');
|
||||||
|
await expect(page.locator('#nm-config-options')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Enable all options for testing
|
||||||
|
await page.check('input[name="nm-cfg-simplify-tabs"]');
|
||||||
|
await page.check('input[name="nm-cfg-simplify-home"]');
|
||||||
|
|
||||||
|
await page.click('#btn-nm-next');
|
||||||
|
|
||||||
|
// Review step
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Both buttons visible when device is connected
|
||||||
|
await expect(page.locator('#btn-nm-write')).toBeVisible();
|
||||||
|
await expect(page.locator('#btn-nm-download')).toBeVisible();
|
||||||
|
|
||||||
|
// Write to device
|
||||||
|
await page.click('#btn-nm-write');
|
||||||
|
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('#nm-done-status')).toContainText('installed');
|
||||||
|
await expect(page.locator('#nm-write-instructions')).not.toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing NickelMenu', async ({ page }) => {
|
||||||
|
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
|
||||||
|
await injectMockDevice(page, { hasNickelMenu: true });
|
||||||
|
|
||||||
|
// Connect to device
|
||||||
|
await page.click('#btn-connect');
|
||||||
|
await expect(page.locator('#step-device')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour');
|
||||||
|
|
||||||
|
// Continue to mode selection
|
||||||
|
await page.click('#btn-device-next');
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
|
||||||
|
// NickelMenu configure step
|
||||||
|
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Remove option should be enabled (NickelMenu is installed)
|
||||||
|
await expect(page.locator('#nm-option-remove')).not.toHaveClass(/nm-option-disabled/);
|
||||||
|
await expect(page.locator('input[name="nm-option"][value="remove"]')).not.toBeDisabled();
|
||||||
|
|
||||||
|
// Select remove
|
||||||
|
await page.click('input[name="nm-option"][value="remove"]');
|
||||||
|
await page.click('#btn-nm-next');
|
||||||
|
|
||||||
|
// Review step
|
||||||
|
await expect(page.locator('#step-nm-review')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#nm-review-summary')).toContainText('removal');
|
||||||
|
|
||||||
|
// Download should be hidden for remove
|
||||||
|
await expect(page.locator('#btn-nm-download')).toBeHidden();
|
||||||
|
// Write should show "Remove from Kobo"
|
||||||
|
await expect(page.locator('#btn-nm-write')).toContainText('Remove from Kobo');
|
||||||
|
|
||||||
|
// Execute removal
|
||||||
|
await page.click('#btn-nm-write');
|
||||||
|
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('#nm-done-status')).toContainText('removed');
|
||||||
|
await expect(page.locator('#nm-reboot-instructions')).not.toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installing custom patches', async ({ page }) => {
|
||||||
|
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||||
|
|
||||||
|
setupFirmwareSymlink();
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
|
||||||
|
await injectMockDevice(page, { hasNickelMenu: false });
|
||||||
|
await overrideFirmwareURLs(page);
|
||||||
|
|
||||||
|
// Connect to device
|
||||||
|
await page.click('#btn-connect');
|
||||||
|
await expect(page.locator('#step-device')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour');
|
||||||
|
|
||||||
|
// Continue to mode selection
|
||||||
|
await page.click('#btn-device-next');
|
||||||
|
await expect(page.locator('#step-mode')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Both modes should be available (firmware is supported)
|
||||||
|
await expect(page.locator('input[name="mode"][value="patches"]')).not.toBeDisabled();
|
||||||
|
|
||||||
|
// Select Custom Patches
|
||||||
|
await page.click('input[name="mode"][value="patches"]');
|
||||||
|
await page.click('#btn-mode-next');
|
||||||
|
|
||||||
|
// Patches step (patches should already be loaded from device detection)
|
||||||
|
await expect(page.locator('#step-patches')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
// Enable a patch
|
||||||
|
const patchName = page.locator('.patch-name', { hasText: 'Remove footer (row3) on new home screen' }).first();
|
||||||
|
const patchSection = patchName.locator('xpath=ancestor::details');
|
||||||
|
await patchSection.locator('summary').click();
|
||||||
|
await expect(patchName).toBeVisible();
|
||||||
|
await patchName.locator('xpath=ancestor::label').locator('input').check();
|
||||||
|
|
||||||
|
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
|
||||||
|
await page.click('#btn-patches-next');
|
||||||
|
|
||||||
|
// Build step
|
||||||
|
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646');
|
||||||
|
await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour');
|
||||||
|
|
||||||
|
await page.click('#btn-build');
|
||||||
|
|
||||||
|
const doneOrError = await Promise.race([
|
||||||
|
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
||||||
|
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (doneOrError === 'error') {
|
||||||
|
const errorMsg = await page.locator('#error-message').textContent();
|
||||||
|
throw new Error(`Build failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('#build-status')).toContainText('Patching complete');
|
||||||
|
|
||||||
|
// Both write and download should be visible with device connected
|
||||||
|
await expect(page.locator('#btn-write')).toBeVisible();
|
||||||
|
await expect(page.locator('#btn-download')).toBeVisible();
|
||||||
|
|
||||||
|
// Download and verify checksums
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.click('#btn-download'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
||||||
|
const downloadPath = await download.path();
|
||||||
|
const tgzData = fs.readFileSync(downloadPath);
|
||||||
|
const tarData = zlib.gunzipSync(tgzData);
|
||||||
|
const entries = parseTar(tarData);
|
||||||
|
|
||||||
|
for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) {
|
||||||
|
const data = entries[name];
|
||||||
|
expect(data, `missing binary in output: ${name}`).toBeDefined();
|
||||||
|
const actualHash = crypto.createHash('sha1').update(data).digest('hex');
|
||||||
|
expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring original firmware', async ({ page }) => {
|
||||||
|
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||||
|
|
||||||
|
setupFirmwareSymlink();
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
|
||||||
|
await injectMockDevice(page, { hasNickelMenu: false });
|
||||||
|
await overrideFirmwareURLs(page);
|
||||||
|
|
||||||
|
// Connect to device
|
||||||
|
await page.click('#btn-connect');
|
||||||
|
await expect(page.locator('#step-device')).not.toBeHidden();
|
||||||
|
|
||||||
|
// Use the "Restore Unpatched Software" shortcut button on device screen
|
||||||
|
await page.click('#btn-device-restore');
|
||||||
|
|
||||||
|
// Build step should show restore mode
|
||||||
|
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||||
|
await expect(page.locator('#firmware-description')).toContainText('without modifications');
|
||||||
|
await expect(page.locator('#btn-build')).toContainText('Restore Original Software');
|
||||||
|
|
||||||
|
await page.click('#btn-build');
|
||||||
|
|
||||||
|
const doneOrError = await Promise.race([
|
||||||
|
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
||||||
|
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (doneOrError === 'error') {
|
||||||
|
const errorMsg = await page.locator('#error-message').textContent();
|
||||||
|
throw new Error(`Restore failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('#build-status')).toContainText('Software extracted');
|
||||||
|
|
||||||
|
// Download and verify original
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.click('#btn-download'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
||||||
|
const downloadPath = await download.path();
|
||||||
|
const tgzData = fs.readFileSync(downloadPath);
|
||||||
|
const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex');
|
||||||
|
expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Run the E2E integration test with a visible browser window.
|
|
||||||
# Usage: ./run-e2e-local.sh
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
FIRMWARE_VERSION="4.45.23646"
|
|
||||||
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
|
|
||||||
FIRMWARE_DIR="../../kobopatch-wasm/testdata"
|
|
||||||
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
|
|
||||||
|
|
||||||
if [ ! -f "../../web/public/wasm/kobopatch.wasm" ]; then
|
|
||||||
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$FIRMWARE_FILE" ]; then
|
|
||||||
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..."
|
|
||||||
mkdir -p "$FIRMWARE_DIR"
|
|
||||||
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"
|
|
||||||
else
|
|
||||||
echo "Using cached firmware: $FIRMWARE_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
npm install --silent 2>/dev/null
|
|
||||||
npx playwright install chromium 2>/dev/null
|
|
||||||
|
|
||||||
echo "Running E2E integration test (headed)..."
|
|
||||||
FIRMWARE_ZIP="$(cd ../.. && pwd)/kobopatch-wasm/testdata/kobo-update-${FIRMWARE_VERSION}.zip" \
|
|
||||||
npx playwright test --reporter=list --headed
|
|
||||||
@@ -1,17 +1,41 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# E2E integration test: runs the full manual-mode patching flow in a browser
|
# E2E integration test: runs the full UI flows in a browser
|
||||||
# and verifies SHA1 checksums of the patched binaries.
|
# and verifies correct behavior for NickelMenu and custom patches.
|
||||||
#
|
#
|
||||||
# Usage: ./run-e2e.sh
|
# Usage: ./run-e2e.sh [--headed] [-- <extra playwright args>]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --headed Run with a visible browser window
|
||||||
#
|
#
|
||||||
# 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)
|
# - Firmware zip cached at kobopatch-wasm/testdata/ (downloaded automatically)
|
||||||
|
# - NickelMenu assets in web/public/nickelmenu/ (set up automatically)
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PLAYWRIGHT_ARGS=("--reporter=list")
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--headed)
|
||||||
|
PLAYWRIGHT_ARGS+=("--headed")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
PLAYWRIGHT_ARGS+=("$@")
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
PLAYWRIGHT_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
FIRMWARE_VERSION="4.45.23646"
|
FIRMWARE_VERSION="4.45.23646"
|
||||||
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
|
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
|
||||||
FIRMWARE_DIR="../../kobopatch-wasm/testdata"
|
FIRMWARE_DIR="../../kobopatch-wasm/testdata"
|
||||||
@@ -23,6 +47,13 @@ if [ ! -f "../../web/public/wasm/kobopatch.wasm" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Set up NickelMenu assets if not present.
|
||||||
|
NM_DIR="../../web/public/nickelmenu"
|
||||||
|
if [ ! -f "$NM_DIR/NickelMenu.zip" ] || [ ! -f "$NM_DIR/kobo-config.zip" ]; then
|
||||||
|
echo "Setting up NickelMenu assets..."
|
||||||
|
../../nickelmenu/setup.sh
|
||||||
|
fi
|
||||||
|
|
||||||
# Download firmware if not cached.
|
# Download firmware if not cached.
|
||||||
if [ ! -f "$FIRMWARE_FILE" ]; then
|
if [ ! -f "$FIRMWARE_FILE" ]; then
|
||||||
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..."
|
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..."
|
||||||
@@ -43,7 +74,7 @@ fi
|
|||||||
npm install --silent
|
npm install --silent
|
||||||
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
|
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
|
||||||
|
|
||||||
# Run the test.
|
# Run the tests.
|
||||||
echo "Running E2E integration test..."
|
echo "Running E2E integration tests..."
|
||||||
FIRMWARE_ZIP="$(cd ../.. && pwd)/kobopatch-wasm/testdata/kobo-update-${FIRMWARE_VERSION}.zip" \
|
FIRMWARE_ZIP="$(cd ../.. && pwd)/kobopatch-wasm/testdata/kobo-update-${FIRMWARE_VERSION}.zip" \
|
||||||
npx playwright test --reporter=list
|
npx playwright test "${PLAYWRIGHT_ARGS[@]}"
|
||||||
|
|||||||
@@ -308,19 +308,15 @@ h2 {
|
|||||||
|
|
||||||
/* NickelMenu config checkboxes */
|
/* NickelMenu config checkboxes */
|
||||||
.nm-config-options {
|
.nm-config-options {
|
||||||
margin-top: 0.75rem;
|
padding: 0 0.25rem;
|
||||||
padding: 0.75rem 1rem;
|
margin-left: 1.5rem;
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nm-config-item {
|
.nm-config-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.4rem 0;
|
padding: 0.5rem 0;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -332,6 +328,7 @@ h2 {
|
|||||||
|
|
||||||
.nm-config-item input[type="checkbox"] {
|
.nm-config-item input[type="checkbox"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.15rem;
|
||||||
accent-color: var(--primary);
|
accent-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,8 +336,32 @@ h2 {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nm-config-item span {
|
.nm-config-text {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-config-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-config-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-config-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nm-option input[type="radio"] {
|
.nm-option input[type="radio"] {
|
||||||
@@ -387,6 +408,12 @@ h2 {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-actions-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
button {
|
button {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<svg class="mode-card-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16" fill="currentColor"><path d="m7.792.312-1.533 2.3A.25.25 0 0 0 6.467 3H7.5v7.319a2.5 2.5 0 0 0-.515-.298L5.909 9.56A1.5 1.5 0 0 1 5 8.18v-.266a1.5 1.5 0 1 0-1 0v.266a2.5 2.5 0 0 0 1.515 2.298l1.076.461a1.5 1.5 0 0 1 .888 1.129 2.001 2.001 0 1 0 1.021-.006v-.902a1.5 1.5 0 0 1 .756-1.303l1.484-.848A2.5 2.5 0 0 0 11.995 7h.755a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h.741a1.5 1.5 0 0 1-.747 1.142L8.76 8.99a2.584 2.584 0 0 0-.26.17V3h1.033a.25.25 0 0 0 .208-.389L8.208.312a.25.25 0 0 0-.416 0Z"/></svg>
|
<svg class="mode-card-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16" fill="currentColor"><path d="m7.792.312-1.533 2.3A.25.25 0 0 0 6.467 3H7.5v7.319a2.5 2.5 0 0 0-.515-.298L5.909 9.56A1.5 1.5 0 0 1 5 8.18v-.266a1.5 1.5 0 1 0-1 0v.266a2.5 2.5 0 0 0 1.515 2.298l1.076.461a1.5 1.5 0 0 1 .888 1.129 2.001 2.001 0 1 0 1.021-.006v-.902a1.5 1.5 0 0 1 .756-1.303l1.484-.848A2.5 2.5 0 0 0 11.995 7h.755a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h.741a1.5 1.5 0 0 1-.747 1.142L8.76 8.99a2.584 2.584 0 0 0-.26.17V3h1.033a.25.25 0 0 0 .208-.389L8.208.312a.25.25 0 0 0-.416 0Z"/></svg>
|
||||||
<div class="mode-card-body">
|
<div class="mode-card-body">
|
||||||
<div class="mode-card-title">Connect my Kobo</div>
|
<div class="mode-card-title">Connect my Kobo</div>
|
||||||
<div class="mode-card-desc">Connect your Kobo via USB and select its drive. Files will be written directly to the device.</div>
|
<div class="mode-card-desc">Connect your Kobo via USB and select its drive. You will have the option to apply these changes directly, or you can download a ZIP.</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-manual" class="mode-card mode-card-btn">
|
<button id="btn-manual" class="mode-card mode-card-btn">
|
||||||
@@ -144,13 +144,51 @@
|
|||||||
<section id="step-nickelmenu" class="step" hidden>
|
<section id="step-nickelmenu" class="step" hidden>
|
||||||
<p>Choose what to do with your Kobo.</p>
|
<p>Choose what to do with your Kobo.</p>
|
||||||
<div class="nm-options">
|
<div class="nm-options">
|
||||||
<label class="nm-option nm-option-selected">
|
<label class="nm-option">
|
||||||
<input type="radio" name="nm-option" value="sample" checked>
|
<input type="radio" name="nm-option" value="sample">
|
||||||
<div class="nm-option-body">
|
<div class="nm-option-body">
|
||||||
<div class="nm-option-title">Install NickelMenu and configure</div>
|
<div class="nm-option-title">Install NickelMenu and configure</div>
|
||||||
<div class="nm-option-desc">Installs NickelMenu with a curated set of menu options.</div>
|
<div class="nm-option-desc">Installs NickelMenu with a curated set of menu options.</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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</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</span>
|
||||||
|
<span class="nm-config-desc">Adds the free Readerly fonts, which are visually similar to Bookerly.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="nm-config-item">
|
||||||
|
<input type="checkbox" name="nm-cfg-screensaver">
|
||||||
|
<div class="nm-config-text">
|
||||||
|
<span>Install screensaver</span>
|
||||||
|
<span class="nm-config-desc">Replaces the default sleep screen with a custom screensaver image.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="nm-config-item">
|
||||||
|
<input type="checkbox" name="nm-cfg-simplify-tabs">
|
||||||
|
<div class="nm-config-text">
|
||||||
|
<span>Hide Notebook & Store tabs</span>
|
||||||
|
<span class="nm-config-desc">This will hide the Notebook and Store tabs from the bottom navigation.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="nm-config-item">
|
||||||
|
<input type="checkbox" name="nm-cfg-simplify-home">
|
||||||
|
<div class="nm-config-text">
|
||||||
|
<span>Simpler home screen</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.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<a href="https://github.com/nicoverbruggen/kobo-config" target="_blank" class="nm-config-link">Learn more about these customisations ›</a>
|
||||||
|
</div>
|
||||||
<label class="nm-option">
|
<label class="nm-option">
|
||||||
<input type="radio" name="nm-option" value="nickelmenu-only">
|
<input type="radio" name="nm-option" value="nickelmenu-only">
|
||||||
<div class="nm-option-body">
|
<div class="nm-option-body">
|
||||||
@@ -166,28 +204,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="nm-config-options" class="nm-config-options" hidden>
|
|
||||||
<label class="nm-config-item">
|
|
||||||
<input type="checkbox" name="nm-cfg-menu" checked disabled>
|
|
||||||
<span>Set up custom menu</span>
|
|
||||||
</label>
|
|
||||||
<label class="nm-config-item">
|
|
||||||
<input type="checkbox" name="nm-cfg-fonts" checked>
|
|
||||||
<span>Install Readerly fonts</span>
|
|
||||||
</label>
|
|
||||||
<label class="nm-config-item">
|
|
||||||
<input type="checkbox" name="nm-cfg-screensaver" checked>
|
|
||||||
<span>Install screensaver</span>
|
|
||||||
</label>
|
|
||||||
<label class="nm-config-item">
|
|
||||||
<input type="checkbox" name="nm-cfg-simplify-tabs">
|
|
||||||
<span>Simplify tab menu</span>
|
|
||||||
</label>
|
|
||||||
<label class="nm-config-item">
|
|
||||||
<input type="checkbox" name="nm-cfg-simplify-home">
|
|
||||||
<span>Simplify homescreen</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="step-actions">
|
<div class="step-actions">
|
||||||
<button id="btn-nm-back" class="secondary">‹ Back</button>
|
<button id="btn-nm-back" class="secondary">‹ Back</button>
|
||||||
<button id="btn-nm-next" class="primary">Continue ›</button>
|
<button id="btn-nm-next" class="primary">Continue ›</button>
|
||||||
@@ -200,8 +216,10 @@
|
|||||||
<ul id="nm-review-list" class="selected-patches-list"></ul>
|
<ul id="nm-review-list" class="selected-patches-list"></ul>
|
||||||
<div id="nm-review-actions" class="step-actions">
|
<div id="nm-review-actions" class="step-actions">
|
||||||
<button id="btn-nm-review-back" class="secondary">‹ Back</button>
|
<button id="btn-nm-review-back" class="secondary">‹ Back</button>
|
||||||
<button id="btn-nm-write" class="primary">Write to Kobo</button>
|
<div class="step-actions-right">
|
||||||
<button id="btn-nm-download" class="secondary">Download ZIP</button>
|
<button id="btn-nm-download" class="secondary">Download ZIP</button>
|
||||||
|
<button id="btn-nm-write" class="primary">Write to Kobo</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -226,6 +244,9 @@
|
|||||||
<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>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. 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>
|
||||||
|
This prevents the Kobo from removing the custom folders during a sync.</li>
|
||||||
<li><strong>Safely eject</strong> the Kobo — do not just unplug the cable.</li>
|
<li><strong>Safely eject</strong> the Kobo — do not just unplug the cable.</li>
|
||||||
<li>The device will reboot and install NickelMenu automatically.</li>
|
<li>The device will reboot and install NickelMenu automatically.</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -340,18 +361,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>
|
<p>
|
||||||
KoboPatch Web UI is a fully client-side web application for applying custom
|
KoboPatch Web UI is a fully client-side web application for customising
|
||||||
<a href="https://github.com/pgaskin/kobopatch" target="_blank">kobopatch</a> patches
|
Kobo e-readers. Nothing is uploaded to a server — everything runs in your browser.
|
||||||
to Kobo e-readers. Nothing is uploaded to a server — everything runs in your browser.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>The patching process</h3>
|
<h3>NickelMenu</h3>
|
||||||
|
<p>
|
||||||
|
<a href="https://pgaskin.net/NickelMenu/" target="_blank">NickelMenu</a> adds custom menu items
|
||||||
|
and tweaks to your Kobo. This tool can install NickelMenu along with an optional curated
|
||||||
|
configuration (custom menus, fonts, screensavers) or remove it. NickelMenu works with
|
||||||
|
most Kobo devices and includes a failsafe that automatically uninstalls itself if
|
||||||
|
something goes wrong.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Custom patches</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>Device selection</strong> — On Chromium-based browsers (Chrome, Edge), the app can
|
<li><strong>Device selection</strong> — On Chromium-based browsers (Chrome, Edge), the app can
|
||||||
auto-detect your Kobo via the File System Access API when connected over USB.
|
auto-detect your Kobo via the File System Access API when connected over USB.
|
||||||
On other browsers, you manually select your model and software version.</li>
|
On other browsers, you manually select your model and software version.</li>
|
||||||
<li><strong>Patch configuration</strong> — You choose which patches to enable or disable.
|
<li><strong>Patch configuration</strong> — You choose which
|
||||||
Patches in the same group are mutually exclusive (radio buttons).
|
<a href="https://github.com/pgaskin/kobopatch" target="_blank">kobopatch</a> patches
|
||||||
|
to enable or disable. Patches in the same group are mutually exclusive.
|
||||||
The patches themselves are community-contributed via the
|
The patches themselves are community-contributed via the
|
||||||
<a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.</li>
|
<a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.</li>
|
||||||
<li><strong>Build</strong> — The correct software update is downloaded directly from Kobo's servers
|
<li><strong>Build</strong> — The correct software update is downloaded directly from Kobo's servers
|
||||||
@@ -374,10 +404,11 @@
|
|||||||
|
|
||||||
<h3>Safety</h3>
|
<h3>Safety</h3>
|
||||||
<p>
|
<p>
|
||||||
Each patched binary is validated before being included in the output: ELF magic bytes,
|
NickelMenu includes a built-in failsafe mechanism. For custom patches, each patched binary
|
||||||
|
is validated before being included in the output: ELF magic bytes,
|
||||||
32-bit ARM architecture, file size (must match the original), and archive integrity are
|
32-bit ARM architecture, file size (must match the original), and archive integrity are
|
||||||
all checked. If anything looks wrong, the build fails with an error. That said, patching
|
all checked. If anything looks wrong, the build fails with an error. That said, both
|
||||||
modifies system files, so you should know how to
|
NickelMenu and custom patches modify system files, so you should know how to
|
||||||
<a href="https://help.kobo.com/hc/en-us/articles/360017605314" target="_blank">manually reset your Kobo</a>
|
<a href="https://help.kobo.com/hc/en-us/articles/360017605314" target="_blank">manually reset your Kobo</a>
|
||||||
if needed.
|
if needed.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -401,10 +401,11 @@
|
|||||||
// --- Step 2b: NickelMenu configuration ---
|
// --- Step 2b: NickelMenu configuration ---
|
||||||
const nmConfigOptions = $('nm-config-options');
|
const nmConfigOptions = $('nm-config-options');
|
||||||
|
|
||||||
// Show/hide config checkboxes based on radio selection
|
// Show/hide config checkboxes based on radio selection, enable Continue
|
||||||
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
|
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
|
||||||
radio.addEventListener('change', () => {
|
radio.addEventListener('change', () => {
|
||||||
nmConfigOptions.hidden = radio.value !== 'sample' || !radio.checked;
|
nmConfigOptions.hidden = radio.value !== 'sample' || !radio.checked;
|
||||||
|
btnNmNext.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,9 +448,9 @@
|
|||||||
|
|
||||||
function goToNickelMenuConfig() {
|
function goToNickelMenuConfig() {
|
||||||
checkNickelMenuInstalled();
|
checkNickelMenuInstalled();
|
||||||
// Reset config visibility based on current selection
|
|
||||||
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
|
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
|
||||||
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'sample';
|
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'sample';
|
||||||
|
btnNmNext.disabled = !currentOption;
|
||||||
setNavStep(3);
|
setNavStep(3);
|
||||||
showStep(stepNickelMenu);
|
showStep(stepNickelMenu);
|
||||||
}
|
}
|
||||||
@@ -478,7 +479,7 @@
|
|||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
if (nickelMenuOption === 'remove') {
|
if (nickelMenuOption === 'remove') {
|
||||||
summary.textContent = 'NickelMenu will be removed from your device.';
|
summary.textContent = 'NickelMenu will be updated and marked for removal. It will uninstall itself when your Kobo reboots.';
|
||||||
btnNmWrite.hidden = manualMode;
|
btnNmWrite.hidden = manualMode;
|
||||||
btnNmWrite.textContent = 'Remove from Kobo';
|
btnNmWrite.textContent = 'Remove from Kobo';
|
||||||
btnNmDownload.hidden = true;
|
btnNmDownload.hidden = true;
|
||||||
@@ -531,7 +532,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (nickelMenuOption === 'remove') {
|
if (nickelMenuOption === 'remove') {
|
||||||
nmProgress.textContent = 'Removing NickelMenu...';
|
await nmInstaller.loadAssets((msg) => { nmProgress.textContent = msg; });
|
||||||
|
nmProgress.textContent = 'Writing KoboRoot.tgz...';
|
||||||
|
const tgz = await nmInstaller.getKoboRootTgz();
|
||||||
|
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
||||||
|
nmProgress.textContent = 'Marking NickelMenu for removal...';
|
||||||
await device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0));
|
await device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0));
|
||||||
showNmDone('remove');
|
showNmDone('remove');
|
||||||
return;
|
return;
|
||||||
@@ -574,6 +579,8 @@
|
|||||||
nmDoneStatus.textContent = 'Your NickelMenu package is ready to download.';
|
nmDoneStatus.textContent = 'Your NickelMenu package is ready to download.';
|
||||||
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
|
||||||
|
$('nm-download-conf-step').hidden = nickelMenuOption !== 'sample';
|
||||||
}
|
}
|
||||||
|
|
||||||
setNavStep(5);
|
setNavStep(5);
|
||||||
|
|||||||
Reference in New Issue
Block a user