From 14e4c12b6be41b253f0b023f0717f4dd8c8ab175 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Mon, 16 Mar 2026 22:57:53 +0100 Subject: [PATCH] Change foundational structure --- .github/workflows/build.yml | 6 +- .gitignore | 6 +- README.md | 52 +++-- {e2e => tests/e2e}/integration.spec.js | 4 +- {e2e => tests/e2e}/package-lock.json | 0 {e2e => tests/e2e}/package.json | 0 {e2e => tests/e2e}/playwright.config.js | 2 +- {e2e => tests/e2e}/run-e2e-local.sh | 6 +- {e2e => tests/e2e}/run-e2e.sh | 6 +- web/public/js/app.js | 270 +++++++++++++----------- 10 files changed, 193 insertions(+), 159 deletions(-) rename {e2e => tests/e2e}/integration.spec.js (97%) rename {e2e => tests/e2e}/package-lock.json (100%) rename {e2e => tests/e2e}/package.json (100%) rename {e2e => tests/e2e}/playwright.config.js (86%) rename {e2e => tests/e2e}/run-e2e-local.sh (84%) rename {e2e => tests/e2e}/run-e2e.sh (88%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e6115d4..3be5b15 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index b78cc8b..6c0aa60 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 686bbf1..19210ad 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/e2e/integration.spec.js b/tests/e2e/integration.spec.js similarity index 97% rename from e2e/integration.spec.js rename to tests/e2e/integration.spec.js index fc7e688..b8664a7 100644 --- a/e2e/integration.spec.js +++ b/tests/e2e/integration.spec.js @@ -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. diff --git a/e2e/package-lock.json b/tests/e2e/package-lock.json similarity index 100% rename from e2e/package-lock.json rename to tests/e2e/package-lock.json diff --git a/e2e/package.json b/tests/e2e/package.json similarity index 100% rename from e2e/package.json rename to tests/e2e/package.json diff --git a/e2e/playwright.config.js b/tests/e2e/playwright.config.js similarity index 86% rename from e2e/playwright.config.js rename to tests/e2e/playwright.config.js index fd628ba..602e9de 100644 --- a/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -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, }, diff --git a/e2e/run-e2e-local.sh b/tests/e2e/run-e2e-local.sh similarity index 84% rename from e2e/run-e2e-local.sh rename to tests/e2e/run-e2e-local.sh index 159f957..505f2ca 100755 --- a/e2e/run-e2e-local.sh +++ b/tests/e2e/run-e2e-local.sh @@ -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 diff --git a/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh similarity index 88% rename from e2e/run-e2e.sh rename to tests/e2e/run-e2e.sh index 10a29a1..1373e3b 100755 --- a/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -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 diff --git a/web/public/js/app.js b/web/public/js/app.js index a0d8bfb..8b0ddc2 100644 --- a/web/public/js/app.js +++ b/web/public/js/app.js @@ -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 = ''; - 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 = ''; + populateSelect(manualModel, '-- Select your Kobo model --', []); manualModel.hidden = true; setNavStep(1); @@ -147,13 +176,9 @@ } const devices = getDevicesForVersion(version); - manualModel.innerHTML = ''; - 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 + '. KoboRoot.tgz (' + 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 + '. KoboRoot.tgz (' + 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';