diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 997ef59..9ef1365 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,22 +56,31 @@ jobs: run: | if [[ "${{ github.ref }}" == refs/tags/* ]]; then echo "run=true" >> "$GITHUB_OUTPUT" - elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/)'; then + elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/)'; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" fi - - name: Full integration test (WASM) + - name: Download test firmware if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' run: | - cd kobopatch-wasm - ./test-integration.sh + mkdir -p tests/cached_assets + 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 if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' - run: | - nickelmenu/setup.sh + run: 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) if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' diff --git a/.gitignore b/.gitignore index baeb6c0..53a6e31 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ kobopatch/ # Kobopatch WASM build artifacts kobopatch-wasm/go/ kobopatch-wasm/kobopatch-src/ -kobopatch-wasm/testdata/ kobopatch-wasm/kobopatch.wasm kobopatch-wasm/wasm_exec.js @@ -16,6 +15,7 @@ kobopatch-wasm/wasm_exec.js web/src/js/wasm_exec.js web/src/nickelmenu/NickelMenu.zip web/src/nickelmenu/kobo-config.zip +web/src/koreader/ # Build output web/dist/ @@ -23,6 +23,9 @@ web/dist/ # Node web/node_modules/ +# Cached test assets (firmware, KOReader zips) +tests/cached_assets/ + # E2E tests tests/e2e/node_modules/ tests/e2e/test-results/ diff --git a/README.md b/README.md index 305cbb7..6259229 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ 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. - The safest patch to install. 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. - **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) 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 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.zip kobo-config.zip + koreader/ # KOReader assets (generated by koreader/setup.sh, gitignored) + koreader-kobo.zip + release.json favicon/ dist/ # Build output (gitignored, fully regenerable) bundle.js # esbuild output (minified, content-hashed) 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 package.json # esbuild, jszip nickelmenu/ 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/ main.go # Go entry point go.mod go.sum @@ -76,8 +84,14 @@ kobopatch-wasm/ test-integration.sh tests/ + cached_assets/ # Downloaded test assets (gitignored) e2e/ - integration.spec.js # Playwright E2E tests + 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 playwright.config.js run-e2e.sh @@ -94,11 +108,11 @@ serve-locally.sh # Serves app at localhost:8888 ## 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 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/ ``` @@ -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/`. +## 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 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 ``` -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) 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 - **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 @@ -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). -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 cd tests/e2e diff --git a/kobopatch-wasm/test-integration.sh b/kobopatch-wasm/test-integration.sh index 6afa957..a9486e2 100755 --- a/kobopatch-wasm/test-integration.sh +++ b/kobopatch-wasm/test-integration.sh @@ -1,17 +1,8 @@ #!/bin/bash set -euo pipefail -# Integration test: downloads firmware and runs the full patching pipeline -# with SHA1 checksum validation. -# -# 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" +# Integration test: runs the full WASM patching pipeline with SHA1 checksum +# validation against a real firmware zip. cd "$(dirname "$0")" @@ -22,21 +13,11 @@ if [ -x "$LOCAL_GO_DIR/bin/go" ]; then export PATH="$LOCAL_GO_DIR/bin:$PATH" 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 - echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..." - mkdir -p "$FIRMWARE_DIR" - 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 - fi - mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE" - echo "Downloaded to $FIRMWARE_FILE" -else - echo "Using cached firmware: $FIRMWARE_FILE" + echo "ERROR: Firmware zip not found at $FIRMWARE_FILE" + echo "Run ./test.sh from the project root to download test assets." + exit 1 fi # Find the WASM test executor. diff --git a/koreader/setup.sh b/koreader/setup.sh new file mode 100755 index 0000000..423667e --- /dev/null +++ b/koreader/setup.sh @@ -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" diff --git a/koreader/update.sh b/koreader/update.sh new file mode 100755 index 0000000..771cd0e --- /dev/null +++ b/koreader/update.sh @@ -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." diff --git a/serve-locally.sh b/serve-locally.sh index 57f6e80..ef7ae80 100755 --- a/serve-locally.sh +++ b/serve-locally.sh @@ -12,6 +12,11 @@ if [ ! -f "$SRC_DIR/nickelmenu/NickelMenu.zip" ]; then "$SCRIPT_DIR/nickelmenu/setup.sh" 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..." cd "$WEB_DIR" npm install --silent diff --git a/test.sh b/test.sh index df95de7..0dbe498 100755 --- a/test.sh +++ b/test.sh @@ -2,6 +2,29 @@ set -euo pipefail 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 ===" cd "$SCRIPT_DIR/web" && npm install @@ -16,7 +39,11 @@ echo "=== Building WASM ===" echo "" echo "=== Running WASM integration test ===" -"$SCRIPT_DIR/kobopatch-wasm/test-integration.sh" +if [ -f "$FIRMWARE_FILE" ]; then + "$SCRIPT_DIR/kobopatch-wasm/test-integration.sh" +else + echo "Skipped (firmware not downloaded)" +fi echo "" echo "=== Running E2E tests (Playwright) ===" @@ -25,4 +52,5 @@ if [ ! -d "node_modules" ]; then npm install npx playwright install --with-deps fi + npm test diff --git a/tests/e2e/helpers/assets.js b/tests/e2e/helpers/assets.js new file mode 100644 index 0000000..53fa713 --- /dev/null +++ b/tests/e2e/helpers/assets.js @@ -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, +}; diff --git a/tests/e2e/helpers/mock-device.js b/tests/e2e/helpers/mock-device.js new file mode 100644 index 0000000..95e7d38 --- /dev/null +++ b/tests/e2e/helpers/mock-device.js @@ -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, +}; diff --git a/tests/e2e/helpers/paths.js b/tests/e2e/helpers/paths.js new file mode 100644 index 0000000..e819949 --- /dev/null +++ b/tests/e2e/helpers/paths.js @@ -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, +}; diff --git a/tests/e2e/helpers/tar.js b/tests/e2e/helpers/tar.js new file mode 100644 index 0000000..f8c39e0 --- /dev/null +++ b/tests/e2e/helpers/tar.js @@ -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 }; diff --git a/tests/e2e/integration.spec.js b/tests/e2e/integration.spec.js index 1898b86..f46eb99 100644 --- a/tests/e2e/integration.spec.js +++ b/tests/e2e/integration.spec.js @@ -1,276 +1,19 @@ // @ts-check const { test, expect } = require('@playwright/test'); const fs = require('fs'); -const path = require('path'); const crypto = require('crypto'); const zlib = require('zlib'); const JSZip = require('jszip'); -// 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', -}; +const { FIRMWARE_PATH, EXPECTED_SHA1, ORIGINAL_TGZ_SHA1 } = require('./helpers/paths'); +const { hasNickelMenuAssets, hasKoreaderAssets, hasFirmwareZip, setupFirmwareSymlink, cleanupFirmwareSymlink } = require('./helpers/assets'); +const { injectMockDevice, connectMockDevice, overrideFirmwareURLs, goToManualMode, readMockFile, mockPathExists, getWrittenFiles } = require('./helpers/mock-device'); +const { parseTar } = require('./helpers/tar'); -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(() => { - 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 // ============================================================ @@ -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-simplify-tabs"]')).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 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'); }); + 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.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot'); @@ -549,7 +373,7 @@ test.describe('NickelMenu', () => { 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}`); + test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`); setupFirmwareSymlink(); await goToManualMode(page); @@ -633,7 +457,7 @@ test.describe('Custom patches', () => { }); 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(); await goToManualMode(page); @@ -755,7 +579,7 @@ test.describe('Custom patches', () => { }); 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(); // 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.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); + test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`); setupFirmwareSymlink(); // 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.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); + test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`); setupFirmwareSymlink(); 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.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`); + test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`); setupFirmwareSymlink(); await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true }); diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index 3373a12..72f42e6 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -11,7 +11,7 @@ set -euo pipefail # # Prerequisites: # - 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) cd "$(dirname "$0")" @@ -45,11 +45,6 @@ while [[ $# -gt 0 ]]; do esac 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. if [ ! -f "$DIST_DIR/wasm/kobopatch.wasm" ]; then 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" fi -# Download firmware if not cached. -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" - echo "Downloaded to $FIRMWARE_FILE" -else - echo "Using cached firmware: $FIRMWARE_FILE" +# Set up KOReader assets if not present. +if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then + echo "Setting up KOReader assets..." + "$PROJECT_ROOT/koreader/setup.sh" fi # Install dependencies and browser. @@ -85,5 +70,4 @@ npx playwright install chromium # Run the tests. echo "Running E2E integration tests..." -FIRMWARE_ZIP="$FIRMWARE_FILE" \ - npx playwright test "${PLAYWRIGHT_ARGS[@]}" +npx playwright test "${PLAYWRIGHT_ARGS[@]}" diff --git a/web/src/css/style.css b/web/src/css/style.css index c0f5d84..195003e 100644 --- a/web/src/css/style.css +++ b/web/src/css/style.css @@ -898,6 +898,10 @@ select + .fallback-hint { /* Install instructions */ .install-instructions { margin-top: 1rem; + background: var(--card-bg); + border: 1px solid var(--border-light); + border-radius: 10px; + padding: 1rem 1.25rem; } .install-instructions .warning { @@ -909,14 +913,25 @@ select + .fallback-hint { } .install-steps { - margin: 0.5rem 0 0 1.25rem; + margin: 0.25rem 0 0 1.25rem; font-size: 0.88rem; color: var(--text-secondary); line-height: 1.7; } .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 { diff --git a/web/src/index.html b/web/src/index.html index 9a3e220..d89759f 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -191,6 +191,13 @@ 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". +