1
0

Change foundational structure
Some checks failed
Build & Test WASM / build-and-test (push) Failing after 1m12s

This commit is contained in:
2026-03-16 22:57:53 +01:00
parent 25834ed5e3
commit 14e4c12b6b
10 changed files with 193 additions and 159 deletions

View File

@@ -2,10 +2,8 @@ name: Build & Test WASM
on:
push:
branches: [main]
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [main]
jobs:
build-and-test:
@@ -73,5 +71,5 @@ jobs:
- name: Full integration test (Playwright)
if: steps.check-wasm.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: |
cd e2e
cd tests/e2e
./run-e2e.sh

6
.gitignore vendored
View File

@@ -19,9 +19,9 @@ web/public/wasm/kobopatch.wasm
web/public/js/wasm_exec.js
# E2E tests
e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
# Claude
.claude

View File

@@ -14,29 +14,32 @@ Fully client-side — no backend needed, can be hosted as a static site. Patches
## User flow
1. Select device (auto-detect via File System Access API on Chromium, or manual dropdowns on any browser)
2. Configure patches (enable/disable, PatchGroup mutual exclusion via radio buttons) or select none to restore original software
3. Build — software update auto-downloaded from Kobo's CDN (`ereaderfiles.kobo.com`, CORS open), patched via WASM in a Web Worker
4. Write `KoboRoot.tgz` to device (Chromium auto mode) or download manually
1. **Select device** auto-detect via File System Access API on Chromium, or manual dropdowns on any browser
2. **Configure patches** enable/disable patches (PatchGroup mutual exclusion via radio buttons), or select none to restore original unpatched software
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
4. **Install** — write `KoboRoot.tgz` directly to the device (Chromium auto mode) or download for manual installation
## File structure
```
web/public/ # Webroot — serve this directory
index.html # Single-page app, 3-step wizard (Device → Patches → Build)
style.css
app.js # Step navigation, flow orchestration, firmware download with progress
kobo-device.js # KOBO_MODELS (serial prefix → name), FIRMWARE_DOWNLOADS (version+prefix → URL),
index.html # Single-page app, 4-step wizard (Device → Patches → Build → Install)
css/
style.css
js/
app.js # Step navigation, flow orchestration, firmware download with progress
kobo-device.js # KOBO_MODELS (serial prefix → name), FIRMWARE_DOWNLOADS (version+prefix → URL),
# getDevicesForVersion(), getFirmwareURL(), KoboDevice class (File System Access API)
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
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(),
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(),
# posts progress back, transfers result buffer zero-copy
wasm_exec.js # Go WASM support runtime (copied from Go SDK by setup.sh, gitignored)
kobopatch.wasm # Compiled WASM binary (built by build.sh, gitignored)
wasm_exec.js # Go WASM support runtime (copied from Go SDK by setup.sh, gitignored)
wasm/
kobopatch.wasm # Compiled WASM binary (built by build.sh, gitignored)
patches/
index.json # [{ "version": "4.45.23646", "filename": "patches_4.45.23646.zip" }]
index.json # Contains a list of available patches
patches_*.zip # Each contains kobopatch.yaml + src/*.yaml patch files
kobopatch-wasm/ # WASM build
@@ -45,14 +48,23 @@ kobopatch-wasm/ # WASM build
# Returns { tgz: Uint8Array, log: string }
go.mod
setup.sh # Clones kobopatch source, copies wasm_exec.js
build.sh # GOOS=js GOARCH=wasm go build, copies .wasm to web/public/,
# sets ?ts= cache-bust timestamp in patch-worker.js
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
test-integration.sh # Downloads firmware and runs integration_test.go
tests/
e2e/ # Playwright E2E tests
integration.spec.js # Full browser pipeline test (patch + restore)
playwright.config.js
run-e2e.sh # Headless E2E runner (downloads firmware, installs browser)
run-e2e-local.sh # Headed E2E runner (visible browser window)
```
## Adding a new software version
1. Add the patch zip to `web/public/patches/` and update `index.json`
2. Add download URLs to `FIRMWARE_DOWNLOADS` in `kobo-device.js` (keyed by version then serial prefix)
2. Add download URLs to `FIRMWARE_DOWNLOADS` in `js/kobo-device.js` (keyed by version then serial prefix)
3. The Kobo CDN prefix per device family (e.g. `kobo12`, `kobo13`) is stable; the date path segment changes per release
## Building the WASM binary
@@ -62,7 +74,7 @@ Requires Go 1.21+.
```bash
cd kobopatch-wasm
./setup.sh # first time only
./build.sh # compiles WASM, copies to web/public/
./build.sh # compiles WASM, copies to web/public/wasm/
```
## Running locally
@@ -89,14 +101,14 @@ cd kobopatch-wasm
**Playwright E2E test** — drives the full browser UI (manual mode, headless):
```bash
cd e2e
cd tests/e2e
./run-e2e.sh
```
To run the Playwright test with a visible browser window:
```bash
cd e2e
cd tests/e2e
./run-e2e-local.sh
```

View File

@@ -15,9 +15,9 @@ const EXPECTED_SHA1 = {
};
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_FIRMWARE = path.resolve(__dirname, '..', '..', 'web', 'public', '_test_firmware.zip');
/**
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.

View File

@@ -16,7 +16,7 @@ module.exports = defineConfig({
},
},
webServer: {
command: 'python3 -m http.server -d ../web/public 8889',
command: 'python3 -m http.server -d ../../web/public 8889',
port: 8889,
reuseExistingServer: true,
},

View File

@@ -8,10 +8,10 @@ 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_DIR="../../kobopatch-wasm/testdata"
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
if [ ! -f "../web/public/wasm/kobopatch.wasm" ]; then
if [ ! -f "../../web/public/wasm/kobopatch.wasm" ]; then
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
exit 1
fi
@@ -34,5 +34,5 @@ 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" \
FIRMWARE_ZIP="$(cd ../.. && pwd)/kobopatch-wasm/testdata/kobo-update-${FIRMWARE_VERSION}.zip" \
npx playwright test --reporter=list --headed

View File

@@ -14,11 +14,11 @@ 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_DIR="../../kobopatch-wasm/testdata"
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
# Check WASM is built.
if [ ! -f "../web/public/wasm/kobopatch.wasm" ]; then
if [ ! -f "../../web/public/wasm/kobopatch.wasm" ]; then
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
exit 1
fi
@@ -45,5 +45,5 @@ npx playwright install chromium --with-deps 2>/dev/null || npx playwright instal
# Run the test.
echo "Running E2E integration test..."
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

View File

@@ -14,7 +14,42 @@
// Fetch patch index immediately so it's ready when needed.
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
// DOM elements
// --- Helpers ---
function formatMB(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
function populateSelect(selectEl, placeholder, items) {
selectEl.innerHTML = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = placeholder;
selectEl.appendChild(defaultOpt);
for (const { value, text, data } of items) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = text;
if (data) {
for (const [k, v] of Object.entries(data)) {
opt.dataset[k] = v;
}
}
selectEl.appendChild(opt);
}
}
function triggerDownload(data, filename, mimeType) {
const blob = new Blob([data], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// --- DOM elements ---
const stepNav = document.getElementById('step-nav');
const stepConnect = document.getElementById('step-connect');
const stepManual = document.getElementById('step-manual');
@@ -40,7 +75,6 @@
const btnDownload = document.getElementById('btn-download');
const btnRetry = document.getElementById('btn-retry');
const firmwareAutoInfo = document.getElementById('firmware-auto-info');
const errorMessage = document.getElementById('error-message');
const errorLog = document.getElementById('error-log');
const deviceStatus = document.getElementById('device-status');
@@ -82,7 +116,7 @@
const count = patchUI.getEnabledCount();
btnPatchesNext.disabled = false;
if (count === 0) {
patchCountHint.textContent = 'No patches selected continuing will restore the original unpatched software.';
patchCountHint.textContent = 'No patches selected \u2014 continuing will restore the original unpatched software.';
} else {
patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.';
}
@@ -113,16 +147,11 @@
manualChromeHint.hidden = false;
await availablePatchesReady;
manualVersion.innerHTML = '<option value="">-- Select software version --</option>';
for (const p of availablePatches) {
const opt = document.createElement('option');
opt.value = p.version;
opt.textContent = p.version;
opt.dataset.filename = p.filename;
manualVersion.appendChild(opt);
}
populateSelect(manualVersion, '-- Select software version --',
availablePatches.map(p => ({ value: p.version, text: p.version, data: { filename: p.filename } }))
);
manualModel.innerHTML = '<option value="">-- Select your Kobo model --</option>';
populateSelect(manualModel, '-- Select your Kobo model --', []);
manualModel.hidden = true;
setNavStep(1);
@@ -147,13 +176,9 @@
}
const devices = getDevicesForVersion(version);
manualModel.innerHTML = '<option value="">-- Select your Kobo model --</option>';
for (const d of devices) {
const opt = document.createElement('option');
opt.value = d.prefix;
opt.textContent = d.model;
manualModel.appendChild(opt);
}
populateSelect(manualModel, '-- Select your Kobo model --',
devices.map(d => ({ value: d.prefix, text: d.model }))
);
manualModel.hidden = false;
modelHint.hidden = false;
btnManualConfirm.disabled = true;
@@ -164,7 +189,7 @@
btnManualConfirm.disabled = !manualVersion.value || !manualModel.value;
});
// Manual confirm load patches go to step 2
// Manual confirm -> load patches -> go to step 2
btnManualConfirm.addEventListener('click', async () => {
const version = manualVersion.value;
if (!version || !selectedPrefix) return;
@@ -182,7 +207,7 @@
}
});
// Auto connect show device info
// Auto connect -> show device info
btnConnect.addEventListener('click', async () => {
try {
const info = await device.connect();
@@ -231,7 +256,7 @@
}
});
// Device info patches
// Device info -> patches
btnDeviceNext.addEventListener('click', () => {
if (patchesLoaded) goToPatches();
});
@@ -261,11 +286,7 @@
btnPatchesBack.addEventListener('click', () => {
setNavStep(1);
if (manualMode) {
showStep(stepManual);
} else {
showStep(stepDevice);
}
showStep(manualMode ? stepManual : stepDevice);
});
btnPatchesNext.addEventListener('click', () => {
@@ -277,6 +298,20 @@
const btnBuild = document.getElementById('btn-build');
const firmwareDescription = document.getElementById('firmware-description');
function populateSelectedPatchesList() {
const patchList = document.getElementById('selected-patches-list');
patchList.innerHTML = '';
const enabled = patchUI.getEnabledPatches();
for (const name of enabled) {
const li = document.createElement('li');
li.textContent = name;
patchList.appendChild(li);
}
const hasPatches = enabled.length > 0;
patchList.hidden = !hasPatches;
document.getElementById('selected-patches-heading').hidden = !hasPatches;
}
function goToBuild() {
if (isRestore) {
firmwareDescription.textContent =
@@ -287,21 +322,7 @@
'will be downloaded automatically from Kobo\u2019s servers and will be patched after the download completes.';
btnBuild.textContent = 'Build Patched Software';
}
// Populate selected patches list.
const patchList = document.getElementById('selected-patches-list');
patchList.innerHTML = '';
const enabled = patchUI.getEnabledPatches();
if (enabled.length > 0) {
for (const name of enabled) {
const li = document.createElement('li');
li.textContent = name;
patchList.appendChild(li);
}
}
const hasPatches = enabled.length > 0;
patchList.hidden = !hasPatches;
document.getElementById('selected-patches-heading').hidden = !hasPatches;
populateSelectedPatchesList();
setNavStep(3);
showStep(stepFirmware);
}
@@ -313,6 +334,11 @@
const buildProgress = document.getElementById('build-progress');
const buildLog = document.getElementById('build-log');
function appendLog(msg) {
buildLog.textContent += msg + '\n';
buildLog.scrollTop = buildLog.scrollHeight;
}
async function downloadFirmware(url) {
const resp = await fetch(url);
if (!resp.ok) {
@@ -336,9 +362,8 @@
chunks.push(value);
received += value.length;
const pct = ((received / total) * 100).toFixed(0);
const mb = (received / 1024 / 1024).toFixed(1);
const totalMB = (total / 1024 / 1024).toFixed(1);
buildProgress.textContent = `Downloading software update... ${mb} / ${totalMB} MB (${pct}%)`;
buildProgress.textContent =
`Downloading software update... ${formatMB(received)} / ${formatMB(total)} (${pct}%)`;
}
const result = new Uint8Array(received);
@@ -350,9 +375,76 @@
return result;
}
function appendLog(msg) {
buildLog.textContent += msg + '\n';
buildLog.scrollTop = buildLog.scrollHeight;
async function extractOriginalTgz(firmwareBytes) {
buildProgress.textContent = 'Extracting KoboRoot.tgz...';
appendLog('Extracting original KoboRoot.tgz from software update...');
const zip = await JSZip.loadAsync(firmwareBytes);
const koboRoot = zip.file('KoboRoot.tgz');
if (!koboRoot) throw new Error('KoboRoot.tgz not found in software update');
const tgz = new Uint8Array(await koboRoot.async('arraybuffer'));
appendLog('Extracted KoboRoot.tgz: ' + formatMB(tgz.length));
return tgz;
}
async function runPatcher(firmwareBytes) {
buildProgress.textContent = 'Applying patches...';
const configYAML = patchUI.generateConfig();
const patchFiles = patchUI.getPatchFileBytes();
const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => {
appendLog(msg);
const trimmed = msg.trimStart();
if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') ||
trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) {
buildProgress.textContent = trimmed;
}
});
return result.tgz;
}
function showBuildResult() {
const action = isRestore ? 'Software extracted' : 'Patching complete';
const description = isRestore ? 'This will restore the original unpatched software.' : '';
const deviceName = KOBO_MODELS[selectedPrefix] || 'Kobo';
const installHint = manualMode
? 'Download the file and copy it to your ' + deviceName + '.'
: 'Write it directly to your connected Kobo, or download for manual installation.';
buildStatus.innerHTML =
action + '. <strong>KoboRoot.tgz</strong> (' + formatMB(resultTgz.length) + ') is ready. ' +
(description ? description + ' ' : '') + installHint;
const doneLog = document.getElementById('done-log');
doneLog.textContent = buildLog.textContent;
// Reset install step state.
btnWrite.hidden = manualMode;
btnWrite.disabled = false;
btnWrite.className = 'primary';
btnWrite.textContent = 'Write to Kobo';
btnDownload.disabled = false;
writeInstructions.hidden = true;
downloadInstructions.hidden = true;
existingTgzWarning.hidden = true;
setNavStep(4);
showStep(stepDone);
requestAnimationFrame(() => {
doneLog.scrollTop = doneLog.scrollHeight;
});
}
async function checkExistingTgz() {
if (manualMode || !device.directoryHandle) return;
try {
const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo');
await koboDir.getFileHandle('KoboRoot.tgz');
existingTgzWarning.hidden = false;
} catch {
// No existing file — that's fine.
}
}
btnBuild.addEventListener('click', async () => {
@@ -370,75 +462,14 @@
}
const firmwareBytes = await downloadFirmware(firmwareURL);
appendLog('Download complete: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB');
appendLog('Download complete: ' + formatMB(firmwareBytes.length));
if (isRestore) {
buildProgress.textContent = 'Extracting KoboRoot.tgz...';
appendLog('Extracting original KoboRoot.tgz from software update...');
const zip = await JSZip.loadAsync(firmwareBytes);
const koboRoot = zip.file('KoboRoot.tgz');
if (!koboRoot) throw new Error('KoboRoot.tgz not found in software update');
resultTgz = new Uint8Array(await koboRoot.async('arraybuffer'));
appendLog('Extracted KoboRoot.tgz: ' + (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB');
} else {
buildProgress.textContent = 'Applying patches...';
const configYAML = patchUI.generateConfig();
const patchFiles = patchUI.getPatchFileBytes();
resultTgz = isRestore
? await extractOriginalTgz(firmwareBytes)
: await runPatcher(firmwareBytes);
const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => {
appendLog(msg);
const trimmed = msg.trimStart();
if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') ||
trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) {
buildProgress.textContent = trimmed;
}
});
resultTgz = result.tgz;
}
const sizeTxt = (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB';
const action = isRestore ? 'Software extracted' : 'Patching complete';
const description = isRestore
? 'This will restore the original unpatched software.'
: '';
buildStatus.innerHTML =
action + '. <strong>KoboRoot.tgz</strong> (' + sizeTxt + ') is ready. ' +
(description ? description + ' ' : '') +
(manualMode
? 'Download the file and copy it to your ' + (KOBO_MODELS[selectedPrefix] || 'Kobo') + '.'
: 'Write it directly to your connected Kobo, or download for manual installation.');
const doneLog = document.getElementById('done-log');
doneLog.textContent = buildLog.textContent;
// Reset install step state.
btnWrite.hidden = manualMode;
btnWrite.disabled = false;
btnWrite.className = 'primary';
btnWrite.textContent = 'Write to Kobo';
btnDownload.disabled = false;
writeInstructions.hidden = true;
downloadInstructions.hidden = true;
existingTgzWarning.hidden = true;
// Check if a KoboRoot.tgz already exists on the device.
if (!manualMode && device.directoryHandle) {
try {
const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo');
await koboDir.getFileHandle('KoboRoot.tgz');
existingTgzWarning.hidden = false;
} catch {
// No existing file — that's fine.
}
}
setNavStep(4);
showStep(stepDone);
// Scroll log to bottom after the step becomes visible.
requestAnimationFrame(() => {
doneLog.scrollTop = doneLog.scrollHeight;
});
showBuildResult();
await checkExistingTgz();
} catch (err) {
showError('Build failed: ' + err.message, buildLog.textContent);
}
@@ -471,14 +502,7 @@
btnDownload.addEventListener('click', () => {
if (!resultTgz) return;
const blob = new Blob([resultTgz], { type: 'application/gzip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'KoboRoot.tgz';
a.click();
URL.revokeObjectURL(url);
triggerDownload(resultTgz, 'KoboRoot.tgz', 'application/gzip');
writeInstructions.hidden = true;
downloadInstructions.hidden = false;
document.getElementById('download-device-name').textContent = KOBO_MODELS[selectedPrefix] || 'Kobo';