From d7622e5e05df2cc8f1d1ec899f915fc3aaaddf15 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Sun, 15 Mar 2026 22:27:59 +0100 Subject: [PATCH] WIP: working patcher? --- .gitignore | 4 + kobopatch-wasm/build.sh | 10 +- kobopatch-wasm/main.go | 26 +- src/frontend/app.js | 70 ----- src/frontend/index.html | 59 ---- src/frontend/style.css | 169 ----------- src/public/app.js | 356 ++++++++++++++++++++++ src/public/index.html | 133 +++++++++ src/{frontend => public}/kobo-device.js | 52 ++++ src/public/kobopatch.js | 46 +++ src/public/patch-ui.js | 357 +++++++++++++++++++++++ src/public/patches/index.json | 6 + src/public/patches/patches_4.4523646.zip | Bin 0 -> 38349 bytes src/public/style.css | 353 ++++++++++++++++++++++ wip/todo.md | 59 ++-- 15 files changed, 1363 insertions(+), 337 deletions(-) delete mode 100644 src/frontend/app.js delete mode 100644 src/frontend/index.html delete mode 100644 src/frontend/style.css create mode 100644 src/public/app.js create mode 100644 src/public/index.html rename src/{frontend => public}/kobo-device.js (63%) create mode 100644 src/public/kobopatch.js create mode 100644 src/public/patch-ui.js create mode 100644 src/public/patches/index.json create mode 100644 src/public/patches/patches_4.4523646.zip create mode 100644 src/public/style.css diff --git a/.gitignore b/.gitignore index 4adea4c..bb5bec5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,9 @@ kobopatch-wasm/kobopatch-src/ kobopatch-wasm/kobopatch.wasm kobopatch-wasm/wasm_exec.js +# WASM artifacts copied to webroot for serving +src/public/kobopatch.wasm +src/public/wasm_exec.js + # Claude .claude \ No newline at end of file diff --git a/kobopatch-wasm/build.sh b/kobopatch-wasm/build.sh index 0205c30..4cddd24 100755 --- a/kobopatch-wasm/build.sh +++ b/kobopatch-wasm/build.sh @@ -8,10 +8,16 @@ if [ ! -d "$SCRIPT_DIR/kobopatch-src" ]; then exit 1 fi +PUBLIC_DIR="$SCRIPT_DIR/../src/public" + echo "Building kobopatch WASM..." cd "$SCRIPT_DIR" GOOS=js GOARCH=wasm go build -o kobopatch.wasm . echo "WASM binary size: $(du -h kobopatch.wasm | cut -f1)" -echo "" -echo "Output: $SCRIPT_DIR/kobopatch.wasm" + +echo "Copying artifacts to $PUBLIC_DIR..." +cp kobopatch.wasm "$PUBLIC_DIR/kobopatch.wasm" +cp wasm_exec.js "$PUBLIC_DIR/wasm_exec.js" + +echo "Done." diff --git a/kobopatch-wasm/main.go b/kobopatch-wasm/main.go index 78d5a70..9468646 100644 --- a/kobopatch-wasm/main.go +++ b/kobopatch-wasm/main.go @@ -53,6 +53,7 @@ func main() { // args[0]: configYAML (string) - the kobopatch.yaml config content // args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes // args[2]: patchFiles (Object) - map of filename -> Uint8Array patch file contents +// args[3]: onProgress (Function, optional) - callback(message string) for progress updates // // Returns: a Promise that resolves to { tgz: Uint8Array, log: string } or rejects with an error. func jsPatchFirmware(this js.Value, args []js.Value) interface{} { @@ -107,17 +108,31 @@ func runPatch(args []js.Value) (*patchResult, error) { patchFiles[key] = buf } - return patchFirmware([]byte(configYAML), firmwareZip, patchFiles) + // Optional progress callback. + var progressFn func(string) + if len(args) >= 4 && args[3].Type() == js.TypeFunction { + cb := args[3] + progressFn = func(msg string) { + cb.Invoke(msg) + } + } + + return patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progressFn) } // patchFirmware runs the kobopatch patching pipeline entirely in memory. -func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte) (*patchResult, error) { +func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte, progressFn func(string)) (*patchResult, error) { var logBuf bytes.Buffer logf := func(format string, a ...interface{}) { - fmt.Fprintf(&logBuf, format+"\n", a...) + msg := fmt.Sprintf(format, a...) + logBuf.WriteString(msg + "\n") + if progressFn != nil { + progressFn(msg) + } } // Parse config. + logf("Parsing config...") var config Config dec := yaml.NewDecoder(bytes.NewReader(configYAML)) if err := dec.Decode(&config); err != nil { @@ -128,16 +143,17 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[ return nil, errors.New("invalid config: version and patches are required") } - logf("kobopatch wasm") logf("Firmware version: %s", config.Version) // Open the firmware zip from memory. + logf("Opening firmware zip (%d MB)...", len(firmwareZip)/1024/1024) zipReader, err := zip.NewReader(bytes.NewReader(firmwareZip), int64(len(firmwareZip))) if err != nil { return nil, fmt.Errorf("could not open firmware zip: %w", err) } // Find and extract KoboRoot.tgz from the zip. + logf("Extracting KoboRoot.tgz from firmware...") var koboRootTgz io.ReadCloser for _, f := range zipReader.File { if f.Name == "KoboRoot.tgz" { @@ -285,7 +301,7 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[ } // Verify consistency. - logf("\nVerifying output KoboRoot.tgz...") + logf("Verifying output KoboRoot.tgz...") verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes())) if err != nil { return nil, fmt.Errorf("could not verify output: %w", err) diff --git a/src/frontend/app.js b/src/frontend/app.js deleted file mode 100644 index dd4add9..0000000 --- a/src/frontend/app.js +++ /dev/null @@ -1,70 +0,0 @@ -(() => { - const device = new KoboDevice(); - - // DOM elements - const browserWarning = document.getElementById('browser-warning'); - const stepConnect = document.getElementById('step-connect'); - const stepDevice = document.getElementById('step-device'); - const stepError = document.getElementById('step-error'); - const btnConnect = document.getElementById('btn-connect'); - const btnDisconnect = document.getElementById('btn-disconnect'); - const btnRetry = document.getElementById('btn-retry'); - const errorMessage = document.getElementById('error-message'); - const deviceStatus = document.getElementById('device-status'); - - // Check browser support - if (!KoboDevice.isSupported()) { - browserWarning.hidden = false; - btnConnect.disabled = true; - } - - function showStep(step) { - stepConnect.hidden = true; - stepDevice.hidden = true; - stepError.hidden = true; - step.hidden = false; - } - - function showDeviceInfo(info) { - document.getElementById('device-model').textContent = info.model; - document.getElementById('device-serial').textContent = info.serial; - document.getElementById('device-firmware').textContent = info.firmware; - if (info.isSupported) { - deviceStatus.className = 'status-supported'; - deviceStatus.textContent = 'This device and firmware version are supported for patching.'; - } else { - deviceStatus.className = 'status-unsupported'; - deviceStatus.textContent = - 'Firmware ' + info.firmware + ' is not currently supported. ' + - 'Expected ' + SUPPORTED_FIRMWARE + '.'; - } - - showStep(stepDevice); - } - - function showError(message) { - errorMessage.textContent = message; - showStep(stepError); - } - - btnConnect.addEventListener('click', async () => { - try { - const info = await device.connect(); - showDeviceInfo(info); - } catch (err) { - // User cancelled the picker - if (err.name === 'AbortError') return; - showError(err.message); - } - }); - - btnDisconnect.addEventListener('click', () => { - device.disconnect(); - showStep(stepConnect); - }); - - btnRetry.addEventListener('click', () => { - device.disconnect(); - showStep(stepConnect); - }); -})(); diff --git a/src/frontend/index.html b/src/frontend/index.html deleted file mode 100644 index 5c0dece..0000000 --- a/src/frontend/index.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - Kobopatch Web UI - - - -
-

Kobopatch Web UI

-

Custom patches for your Kobo e-reader

- - - -
-

Step 1: Connect your Kobo

-

- Connect your Kobo e-reader via USB. It should appear as a removable drive. - Then click the button below and select the root of the Kobo drive. -

- -
- - - - -
- - - - - diff --git a/src/frontend/style.css b/src/frontend/style.css deleted file mode 100644 index d13a76b..0000000 --- a/src/frontend/style.css +++ /dev/null @@ -1,169 +0,0 @@ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg: #fafafa; - --card-bg: #fff; - --border: #e0e0e0; - --text: #1a1a1a; - --text-secondary: #555; - --primary: #1a6ed8; - --primary-hover: #1558b0; - --error-bg: #fef2f2; - --error-border: #fca5a5; - --error-text: #991b1b; - --warning-bg: #fffbeb; - --warning-border: #fcd34d; - --warning-text: #92400e; - --success-bg: #f0fdf4; - --success-border: #86efac; - --success-text: #166534; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.6; -} - -main { - max-width: 600px; - margin: 3rem auto; - padding: 0 1.5rem; -} - -h1 { - font-size: 1.5rem; - font-weight: 600; -} - -.subtitle { - color: var(--text-secondary); - margin-bottom: 2rem; -} - -h2 { - font-size: 1.1rem; - font-weight: 600; - margin-bottom: 0.75rem; -} - -.step { - margin-bottom: 2rem; -} - -.step p { - color: var(--text-secondary); - margin-bottom: 1rem; - font-size: 0.95rem; -} - -button { - font-size: 0.95rem; - padding: 0.6rem 1.4rem; - border-radius: 6px; - border: 1px solid var(--border); - cursor: pointer; - font-weight: 500; - transition: background 0.15s, border-color 0.15s; -} - -button.primary { - background: var(--primary); - color: #fff; - border-color: var(--primary); -} - -button.primary:hover { - background: var(--primary-hover); - border-color: var(--primary-hover); -} - -button.secondary { - background: var(--card-bg); - color: var(--text); -} - -button.secondary:hover { - background: #f0f0f0; -} - -.info-card { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem 1.25rem; - margin-bottom: 1rem; -} - -.info-row { - display: flex; - justify-content: space-between; - padding: 0.4rem 0; - border-bottom: 1px solid var(--border); -} - -.info-row:last-child { - border-bottom: none; -} - -.info-row .label { - font-weight: 500; - color: var(--text-secondary); - font-size: 0.9rem; -} - -.info-row .value { - font-family: "SF Mono", "Fira Code", monospace; - font-size: 0.9rem; -} - -.warning { - background: var(--warning-bg); - border: 1px solid var(--warning-border); - color: var(--warning-text); - padding: 1rem 1.25rem; - border-radius: 8px; - margin-bottom: 1.5rem; - font-size: 0.9rem; - line-height: 1.5; -} - -.warning a { - color: inherit; -} - -.error { - background: var(--error-bg); - border: 1px solid var(--error-border); - color: var(--error-text); - padding: 1rem 1.25rem; - border-radius: 8px; - font-size: 0.9rem; -} - -.status-supported { - background: var(--success-bg); - border: 1px solid var(--success-border); - color: var(--success-text); - padding: 0.75rem 1rem; - border-radius: 8px; - margin-bottom: 1rem; - font-size: 0.9rem; -} - -.status-unsupported { - background: var(--warning-bg); - border: 1px solid var(--warning-border); - color: var(--warning-text); - padding: 0.75rem 1rem; - border-radius: 8px; - margin-bottom: 1rem; - font-size: 0.9rem; -} diff --git a/src/public/app.js b/src/public/app.js new file mode 100644 index 0000000..ac66b6a --- /dev/null +++ b/src/public/app.js @@ -0,0 +1,356 @@ +(() => { + const device = new KoboDevice(); + const patchUI = new PatchUI(); + const runner = new KobopatchRunner(); + + let firmwareURL = null; + // let firmwareFile = null; // fallback: manual file input + let resultTgz = null; + let manualMode = false; + let selectedPrefix = null; + + // DOM elements + const stepConnect = document.getElementById('step-connect'); + const stepManual = document.getElementById('step-manual'); + const stepDevice = document.getElementById('step-device'); + const stepPatches = document.getElementById('step-patches'); + const stepFirmware = document.getElementById('step-firmware'); + const stepBuilding = document.getElementById('step-building'); + const stepDone = document.getElementById('step-done'); + const stepError = document.getElementById('step-error'); + + const btnConnect = document.getElementById('btn-connect'); + const btnManualFromAuto = document.getElementById('btn-manual-from-auto'); + const btnManualConfirm = document.getElementById('btn-manual-confirm'); + const manualVersion = document.getElementById('manual-version'); + const manualModel = document.getElementById('manual-model'); + const manualChromeHint = document.getElementById('manual-chrome-hint'); + const btnBuild = document.getElementById('btn-build'); + const btnWrite = document.getElementById('btn-write'); + const btnDownload = document.getElementById('btn-download'); + const btnRetry = document.getElementById('btn-retry'); + + // const firmwareInput = document.getElementById('firmware-input'); // fallback + const firmwareAutoInfo = document.getElementById('firmware-auto-info'); + // const firmwareManualInfo = document.getElementById('firmware-manual-info'); // fallback + const errorMessage = document.getElementById('error-message'); + const errorLog = document.getElementById('error-log'); + const deviceStatus = document.getElementById('device-status'); + const patchContainer = document.getElementById('patch-container'); + const buildStatus = document.getElementById('build-status'); + const writeSuccess = document.getElementById('write-success'); + const firmwareVersionLabel = document.getElementById('firmware-version-label'); + // const firmwareVersionLabelManual = document.getElementById('firmware-version-label-manual'); // fallback + + const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError]; + + // Decide initial step based on browser support + const hasFileSystemAccess = KoboDevice.isSupported(); + if (hasFileSystemAccess) { + showSteps(stepConnect); + } else { + // Skip straight to manual mode + enterManualMode(); + } + + function showSteps(...steps) { + for (const s of allSteps) { + s.hidden = !steps.includes(s); + } + } + + function showError(message, log) { + errorMessage.textContent = message; + if (log) { + errorLog.textContent = log; + errorLog.hidden = false; + } else { + errorLog.hidden = true; + } + showSteps(stepError); + } + + /** + * Configure the firmware step for auto-download. + */ + function configureFirmwareStep(version, prefix) { + firmwareURL = prefix ? getFirmwareURL(prefix, version) : null; + firmwareVersionLabel.textContent = version; + document.getElementById('firmware-download-url').textContent = firmwareURL || ''; + } + + async function enterManualMode() { + manualMode = true; + + // Show the Chrome hint only if the browser actually supports it + // (i.e., user chose manual mode voluntarily) + if (hasFileSystemAccess) { + manualChromeHint.hidden = false; + } + + // Populate version dropdown from available patches + const available = await scanAvailablePatches(); + manualVersion.innerHTML = ''; + for (const p of available) { + const opt = document.createElement('option'); + opt.value = p.version; + opt.textContent = p.version; + opt.dataset.filename = p.filename; + manualVersion.appendChild(opt); + } + + // Reset model dropdown + manualModel.innerHTML = ''; + manualModel.hidden = true; + + showSteps(stepManual); + } + + async function loadPatchesForVersion(version, available) { + const match = available.find(p => p.version === version); + if (!match) return false; + + await patchUI.loadFromURL('patches/' + match.filename); + patchUI.render(patchContainer); + return true; + } + + // Switch to manual mode from auto mode + btnManualFromAuto.addEventListener('click', (e) => { + e.preventDefault(); + enterManualMode(); + }); + + // Manual mode: version selected → populate model dropdown + manualVersion.addEventListener('change', () => { + const version = manualVersion.value; + selectedPrefix = null; + + if (!version) { + manualModel.hidden = true; + btnManualConfirm.disabled = true; + return; + } + + // Populate device dropdown for this firmware version + 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); + } + manualModel.hidden = false; + btnManualConfirm.disabled = true; + }); + + // Manual mode: model selected + manualModel.addEventListener('change', () => { + selectedPrefix = manualModel.value || null; + btnManualConfirm.disabled = !manualVersion.value || !manualModel.value; + }); + + // Manual mode: confirm selection + btnManualConfirm.addEventListener('click', async () => { + const version = manualVersion.value; + if (!version || !selectedPrefix) return; + + try { + const available = await scanAvailablePatches(); + const loaded = await loadPatchesForVersion(version, available); + if (!loaded) { + showError('Could not load patches for firmware ' + version); + return; + } + configureFirmwareStep(version, selectedPrefix); + showSteps(stepPatches, stepFirmware); + } catch (err) { + showError(err.message); + } + }); + + // Auto mode: connect device + btnConnect.addEventListener('click', async () => { + try { + const info = await device.connect(); + + document.getElementById('device-model').textContent = info.model; + document.getElementById('device-serial').textContent = info.serial; + document.getElementById('device-firmware').textContent = info.firmware; + + selectedPrefix = info.serialPrefix; + + const available = await scanAvailablePatches(); + const match = available.find(p => p.version === info.firmware); + + if (match) { + deviceStatus.className = 'status-supported'; + deviceStatus.textContent = 'Patches available for firmware ' + info.firmware + '.'; + + await patchUI.loadFromURL('patches/' + match.filename); + patchUI.render(patchContainer); + configureFirmwareStep(info.firmware, info.serialPrefix); + + showSteps(stepDevice, stepPatches, stepFirmware); + } else { + deviceStatus.className = 'status-unsupported'; + deviceStatus.textContent = + 'No patches available for firmware ' + info.firmware + '. ' + + 'Supported versions: ' + available.map(p => p.version).join(', '); + showSteps(stepDevice); + } + } catch (err) { + if (err.name === 'AbortError') return; + showError(err.message); + } + }); + + // // Firmware file selected (fallback for devices without auto-download URL) + // firmwareInput.addEventListener('change', () => { + // firmwareFile = firmwareInput.files[0] || null; + // }); + + const buildProgress = document.getElementById('build-progress'); + const buildLog = document.getElementById('build-log'); + + /** + * Download firmware zip from Kobo's servers with progress tracking. + * Returns Uint8Array of the zip file. + */ + async function downloadFirmware(url) { + const resp = await fetch(url); + if (!resp.ok) { + throw new Error('Firmware download failed: HTTP ' + resp.status); + } + + const contentLength = resp.headers.get('Content-Length'); + if (!contentLength || !resp.body) { + // Fallback: no streaming progress + buildProgress.textContent = 'Downloading firmware...'; + return new Uint8Array(await resp.arrayBuffer()); + } + + const total = parseInt(contentLength, 10); + const reader = resp.body.getReader(); + const chunks = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + 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 firmware... ${mb} / ${totalMB} MB (${pct}%)`; + } + + // Concatenate chunks into single Uint8Array + const result = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; + } + + function appendLog(msg) { + buildLog.textContent += msg + '\n'; + buildLog.scrollTop = buildLog.scrollHeight; + } + + // Build + btnBuild.addEventListener('click', async () => { + const stepsToShow = manualMode ? [stepBuilding] : [stepDevice, stepBuilding]; + showSteps(...stepsToShow); + buildLog.textContent = ''; + buildProgress.textContent = 'Starting...'; + + try { + if (!firmwareURL) { + showError('No firmware download URL available for this device.'); + return; + } + + const firmwareBytes = await downloadFirmware(firmwareURL); + appendLog('Firmware downloaded: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB'); + + buildProgress.textContent = 'Loading WASM patcher...'; + await runner.load(); + appendLog('WASM module loaded'); + + buildProgress.textContent = 'Applying patches...'; + const configYAML = patchUI.generateConfig(); + const patchFiles = patchUI.getPatchFileBytes(); + + const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => { + appendLog(msg); + // Update headline with the latest high-level step + if (msg.startsWith('Patching ') || msg.startsWith('Extracting ') || msg.startsWith('Verifying ')) { + buildProgress.textContent = msg; + } + }); + + resultTgz = result.tgz; + buildStatus.textContent = + 'Patching complete. KoboRoot.tgz is ' + + (resultTgz.length / 1024).toFixed(0) + ' KB.'; + writeSuccess.hidden = true; + + // In manual mode, hide the "Write to Kobo" button + btnWrite.hidden = manualMode; + + const doneSteps = manualMode ? [stepDone] : [stepDevice, stepDone]; + showSteps(...doneSteps); + } catch (err) { + showError('Build failed: ' + err.message, buildLog.textContent); + } + }); + + // Write to device (auto mode only) + btnWrite.addEventListener('click', async () => { + if (!resultTgz || !device.directoryHandle) return; + + try { + const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo'); + const fileHandle = await koboDir.getFileHandle('KoboRoot.tgz', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(resultTgz); + await writable.close(); + writeSuccess.hidden = false; + } catch (err) { + showError('Failed to write KoboRoot.tgz: ' + err.message); + } + }); + + // Download + 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); + }); + + // Retry + btnRetry.addEventListener('click', () => { + device.disconnect(); + firmwareURL = null; + resultTgz = null; + manualMode = false; + selectedPrefix = null; + btnWrite.hidden = false; + + if (hasFileSystemAccess) { + showSteps(stepConnect); + } else { + enterManualMode(); + } + }); +})(); diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 0000000..40356bd --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,133 @@ + + + + + + Kobopatch Web UI + + + + +
+

Kobopatch Web UI

+

Custom patches for your Kobo e-reader

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + diff --git a/src/frontend/kobo-device.js b/src/public/kobo-device.js similarity index 63% rename from src/frontend/kobo-device.js rename to src/public/kobo-device.js index 6b8df75..ddee152 100644 --- a/src/frontend/kobo-device.js +++ b/src/public/kobo-device.js @@ -41,6 +41,58 @@ const KOBO_MODELS = { */ const SUPPORTED_FIRMWARE = '4.45.23646'; +/** + * Firmware download URLs by version and serial prefix. + * Source: https://help.kobo.com/hc/en-us/articles/35059171032727 + * + * The kobo prefix (kobo12, kobo13, kobo14) is stable per device family. + * The date path segment (e.g. Mar2026) changes per release. + * help.kobo.com may lag behind; verify URLs when adding new versions. + */ +const FIRMWARE_DOWNLOADS = { + '4.45.23646': { + 'N428': 'https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip', + 'N365': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip', + 'N367': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip', + 'P365': 'https://ereaderfiles.kobo.com/firmwares/kobo14/Mar2026/kobo-update-4.45.23646.zip', + }, +}; + +/** + * Get the firmware download URL for a given serial prefix and firmware version. + * Returns null if no URL is available. + */ +function getFirmwareURL(serialPrefix, version) { + const versionMap = FIRMWARE_DOWNLOADS[version]; + if (!versionMap) return null; + return versionMap[serialPrefix] || null; +} + +/** + * Get all device models that have firmware downloads for a given version. + * Returns array of { prefix, model } objects. + */ +function getDevicesForVersion(version) { + const versionMap = FIRMWARE_DOWNLOADS[version]; + if (!versionMap) return []; + const devices = []; + const seen = {}; + for (const prefix of Object.keys(versionMap)) { + const model = KOBO_MODELS[prefix] || 'Unknown (' + prefix + ')'; + // Track duplicates to disambiguate with serial prefix + if (seen[model]) seen[model].push(prefix); + else seen[model] = [prefix]; + } + for (const prefix of Object.keys(versionMap)) { + const model = KOBO_MODELS[prefix] || 'Unknown (' + prefix + ')'; + const label = seen[model].length > 1 + ? model + ' (serial ' + prefix + '...)' + : model; + devices.push({ prefix, model: label }); + } + return devices; +} + class KoboDevice { constructor() { this.directoryHandle = null; diff --git a/src/public/kobopatch.js b/src/public/kobopatch.js new file mode 100644 index 0000000..4a21b36 --- /dev/null +++ b/src/public/kobopatch.js @@ -0,0 +1,46 @@ +/** + * Loads and manages the kobopatch WASM module. + */ +class KobopatchRunner { + constructor() { + this.ready = false; + this._go = null; + } + + /** + * Load the WASM module. Must be called before patchFirmware(). + */ + async load() { + if (this.ready) return; + + this._go = new Go(); + const result = await WebAssembly.instantiateStreaming( + fetch('kobopatch.wasm'), + this._go.importObject + ); + // Go WASM runs as a long-lived instance. + this._go.run(result.instance); + + // Wait for the global function to become available. + if (typeof globalThis.patchFirmware !== 'function') { + throw new Error('WASM module loaded but patchFirmware() not found'); + } + this.ready = true; + } + + /** + * Run the patching pipeline. + * + * @param {string} configYAML - kobopatch.yaml content + * @param {Uint8Array} firmwareZip - firmware zip file bytes + * @param {Object} patchFiles - map of filename -> YAML content bytes + * @param {Function} [onProgress] - optional callback(message) for progress updates + * @returns {Promise<{tgz: Uint8Array, log: string}>} + */ + async patchFirmware(configYAML, firmwareZip, patchFiles, onProgress) { + if (!this.ready) { + throw new Error('WASM module not loaded. Call load() first.'); + } + return globalThis.patchFirmware(configYAML, firmwareZip, patchFiles, onProgress || null); + } +} diff --git a/src/public/patch-ui.js b/src/public/patch-ui.js new file mode 100644 index 0000000..2f02be7 --- /dev/null +++ b/src/public/patch-ui.js @@ -0,0 +1,357 @@ +/** + * Friendly display names for patch files. + */ +const PATCH_FILE_LABELS = { + 'src/nickel.yaml': 'Nickel (UI patches)', + 'src/nickel_custom.yaml': 'Nickel Custom', + 'src/libadobe.so.yaml': 'Adobe (PDF patches)', + 'src/libnickel.so.1.0.0.yaml': 'Nickel Library (core patches)', + 'src/librmsdk.so.1.0.0.yaml': 'Adobe RMSDK (ePub patches)', + 'src/cloud_sync.yaml': 'Cloud Sync', +}; + +/** + * Parse a kobopatch YAML file and extract patch metadata. + * We only need: name, enabled, description, patchGroup. + * This is a targeted parser, not a full YAML parser. + */ +function parsePatchYAML(content) { + const patches = []; + const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Top-level key (patch name): not indented, ends with ':' + // Skip comments and blank lines + if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':')) { + const name = line.slice(0, -1).trim(); + const patch = { name, enabled: false, description: '', patchGroup: null }; + i++; + + // Parse the array items for this patch + while (i < lines.length) { + const itemLine = lines[i]; + + // Stop at next top-level key or EOF + if (itemLine.length > 0 && !itemLine.startsWith(' ') && !itemLine.startsWith('#')) { + break; + } + + const trimmed = itemLine.trim(); + + // Match "- Enabled: yes/no" + const enabledMatch = trimmed.match(/^- Enabled:\s*(yes|no)$/); + if (enabledMatch) { + patch.enabled = enabledMatch[1] === 'yes'; + i++; + continue; + } + + // Match "- PatchGroup: ..." + const pgMatch = trimmed.match(/^- PatchGroup:\s*(.+)$/); + if (pgMatch) { + patch.patchGroup = pgMatch[1].trim(); + i++; + continue; + } + + // Match "- Description: ..." (single line or multi-line block) + const descMatch = trimmed.match(/^- Description:\s*(.*)$/); + if (descMatch) { + const rest = descMatch[1].trim(); + if (rest === '|' || rest === '>') { + // Multi-line block scalar + i++; + const descLines = []; + while (i < lines.length) { + const dl = lines[i]; + // Block continues while indented more than the "- Description" level + if (dl.match(/^\s{6,}/) || dl.trim() === '') { + descLines.push(dl.trim()); + i++; + } else { + break; + } + } + patch.description = descLines.join('\n').trim(); + } else { + patch.description = rest; + i++; + } + continue; + } + + i++; + } + + patches.push(patch); + } else { + i++; + } + } + + return patches; +} + +/** + * Parse the `patches:` section from kobopatch.yaml to get the file→target mapping. + * Returns e.g. { "src/nickel.yaml": "usr/local/Kobo/nickel", ... } + */ +function parsePatchConfig(configYAML) { + const patches = {}; + let version = null; + const lines = configYAML.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + let inPatches = false; + + for (const line of lines) { + // Extract version + const versionMatch = line.match(/^version:\s*(.+)$/); + if (versionMatch) { + version = versionMatch[1].trim().replace(/['"]/g, ''); + continue; + } + + if (line.match(/^patches:\s*$/)) { + inPatches = true; + continue; + } + + // A new top-level key ends the patches section + if (inPatches && line.length > 0 && !line.startsWith(' ') && !line.startsWith('#')) { + inPatches = false; + } + + if (inPatches) { + const match = line.match(/^\s+([\w/.]+\.yaml):\s*(.+)$/); + if (match) { + patches[match[1]] = match[2].trim(); + } + } + } + + return { version, patches }; +} + +/** + * Scan the patches/ directory for available patch zips. + * Returns an array of { filename, version } sorted by version descending. + */ +async function scanAvailablePatches() { + try { + const resp = await fetch('patches/index.json'); + if (!resp.ok) return []; + const list = await resp.json(); + return list; + } catch { + return []; + } +} + +class PatchUI { + constructor() { + // Map of filename -> { raw: string, patches: Array } + this.patchFiles = {}; + // Parsed from kobopatch.yaml inside the zip + this.patchConfig = {}; + this.firmwareVersion = null; + this.configYAML = null; + } + + /** + * Load patches from a zip file (ArrayBuffer or Uint8Array). + * The zip should contain kobopatch.yaml and src/*.yaml. + */ + async loadFromZip(zipData) { + const zip = await JSZip.loadAsync(zipData); + + // Load kobopatch.yaml + const configFile = zip.file('kobopatch.yaml'); + if (!configFile) { + throw new Error('Patch zip does not contain kobopatch.yaml'); + } + this.configYAML = await configFile.async('string'); + const { version, patches } = parsePatchConfig(this.configYAML); + this.firmwareVersion = version; + this.patchConfig = patches; + + // Load each patch YAML file referenced in the config + this.patchFiles = {}; + for (const filename of Object.keys(patches)) { + const yamlFile = zip.file(filename); + if (!yamlFile) { + console.warn('Patch file referenced in config but missing from zip:', filename); + continue; + } + const raw = await yamlFile.async('string'); + const parsed = parsePatchYAML(raw); + this.patchFiles[filename] = { raw, patches: parsed }; + } + } + + /** + * Load patches from a URL pointing to a zip file. + */ + async loadFromURL(url) { + const resp = await fetch(url); + if (!resp.ok) { + throw new Error('Failed to fetch patch zip: ' + resp.statusText); + } + const data = await resp.arrayBuffer(); + await this.loadFromZip(data); + } + + /** + * Render the patch configuration UI into a container element. + */ + render(container) { + container.innerHTML = ''; + + for (const [filename, { patches }] of Object.entries(this.patchFiles)) { + if (patches.length === 0) continue; + + const section = document.createElement('details'); + section.className = 'patch-file-section'; + + const summary = document.createElement('summary'); + const label = PATCH_FILE_LABELS[filename] || filename; + const enabledCount = patches.filter(p => p.enabled).length; + summary.innerHTML = `${label} ${enabledCount} / ${patches.length} enabled`; + section.appendChild(summary); + + const list = document.createElement('div'); + list.className = 'patch-list'; + + // Group patches by PatchGroup for mutual exclusion + const patchGroups = {}; + for (const patch of patches) { + if (patch.patchGroup) { + if (!patchGroups[patch.patchGroup]) { + patchGroups[patch.patchGroup] = []; + } + patchGroups[patch.patchGroup].push(patch); + } + } + + for (const patch of patches) { + const item = document.createElement('div'); + item.className = 'patch-item'; + + const header = document.createElement('label'); + header.className = 'patch-header'; + + const input = document.createElement('input'); + const isGrouped = patch.patchGroup && patchGroups[patch.patchGroup].length > 1; + + if (isGrouped) { + input.type = 'radio'; + input.name = `pg_${filename}_${patch.patchGroup}`; + input.checked = patch.enabled; + input.addEventListener('change', () => { + for (const other of patchGroups[patch.patchGroup]) { + other.enabled = (other === patch); + } + this._updateCounts(container); + }); + } else { + input.type = 'checkbox'; + input.checked = patch.enabled; + input.addEventListener('change', () => { + patch.enabled = input.checked; + this._updateCounts(container); + }); + } + + const nameSpan = document.createElement('span'); + nameSpan.className = 'patch-name'; + nameSpan.textContent = patch.name; + + header.appendChild(input); + header.appendChild(nameSpan); + + if (patch.patchGroup) { + const groupBadge = document.createElement('span'); + groupBadge.className = 'patch-group-badge'; + groupBadge.textContent = patch.patchGroup; + header.appendChild(groupBadge); + } + + item.appendChild(header); + + if (patch.description) { + const desc = document.createElement('p'); + desc.className = 'patch-description'; + desc.textContent = patch.description; + item.appendChild(desc); + } + + list.appendChild(item); + } + + section.appendChild(list); + container.appendChild(section); + } + } + + _updateCounts(container) { + const sections = container.querySelectorAll('.patch-file-section'); + let idx = 0; + for (const [, { patches }] of Object.entries(this.patchFiles)) { + if (patches.length === 0) continue; + const count = patches.filter(p => p.enabled).length; + const countEl = sections[idx]?.querySelector('.patch-count'); + if (countEl) countEl.textContent = `${count} / ${patches.length} enabled`; + idx++; + } + } + + /** + * Build the overrides map for the WASM patcher. + */ + getOverrides() { + const overrides = {}; + for (const [filename, { patches }] of Object.entries(this.patchFiles)) { + overrides[filename] = {}; + for (const patch of patches) { + overrides[filename][patch.name] = patch.enabled; + } + } + return overrides; + } + + /** + * Generate the kobopatch.yaml config string with current overrides. + */ + generateConfig() { + const overrides = this.getOverrides(); + let yaml = `version: "${this.firmwareVersion}"\n`; + yaml += `in: firmware.zip\n`; + yaml += `out: out/KoboRoot.tgz\n`; + yaml += `log: out/log.txt\n`; + yaml += `patchFormat: kobopatch\n`; + yaml += `\npatches:\n`; + for (const [filename, target] of Object.entries(this.patchConfig)) { + yaml += ` ${filename}: ${target}\n`; + } + yaml += `\noverrides:\n`; + for (const [filename, patches] of Object.entries(overrides)) { + yaml += ` ${filename}:\n`; + for (const [name, enabled] of Object.entries(patches)) { + yaml += ` ${name}: ${enabled ? 'yes' : 'no'}\n`; + } + } + return yaml; + } + + /** + * Get raw patch file contents as a map for the WASM patcher. + */ + getPatchFileBytes() { + const files = {}; + for (const [filename, { raw }] of Object.entries(this.patchFiles)) { + files[filename] = new TextEncoder().encode(raw); + } + return files; + } +} diff --git a/src/public/patches/index.json b/src/public/patches/index.json new file mode 100644 index 0000000..1c50abb --- /dev/null +++ b/src/public/patches/index.json @@ -0,0 +1,6 @@ +[ + { + "filename": "patches_4.4523646.zip", + "version": "4.45.23646" + } +] diff --git a/src/public/patches/patches_4.4523646.zip b/src/public/patches/patches_4.4523646.zip new file mode 100644 index 0000000000000000000000000000000000000000..37d85d2504fa69e1f82968957aae6c67f06a1025 GIT binary patch literal 38349 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 BY?=T7 literal 0 HcmV?d00001 diff --git a/src/public/style.css b/src/public/style.css new file mode 100644 index 0000000..207ec51 --- /dev/null +++ b/src/public/style.css @@ -0,0 +1,353 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #fafafa; + --card-bg: #fff; + --border: #e0e0e0; + --text: #1a1a1a; + --text-secondary: #555; + --primary: #1a6ed8; + --primary-hover: #1558b0; + --error-bg: #fef2f2; + --error-border: #fca5a5; + --error-text: #991b1b; + --warning-bg: #fffbeb; + --warning-border: #fcd34d; + --warning-text: #92400e; + --success-bg: #f0fdf4; + --success-border: #86efac; + --success-text: #166534; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +main { + max-width: 600px; + margin: 3rem auto; + padding: 0 1.5rem; +} + +h1 { + font-size: 1.5rem; + font-weight: 600; +} + +.subtitle { + color: var(--text-secondary); + margin-bottom: 2rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.step { + margin-bottom: 2rem; +} + +.step p { + color: var(--text-secondary); + margin-bottom: 1rem; + font-size: 0.95rem; +} + +button { + font-size: 0.95rem; + padding: 0.6rem 1.4rem; + border-radius: 6px; + border: 1px solid var(--border); + cursor: pointer; + font-weight: 500; + transition: background 0.15s, border-color 0.15s; +} + +button.primary { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +button.primary:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +button.secondary { + background: var(--card-bg); + color: var(--text); +} + +button.secondary:hover { + background: #f0f0f0; +} + +.info-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.info-row { + display: flex; + justify-content: space-between; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-row .label { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.info-row .value { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.9rem; +} + +.warning { + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); + padding: 1rem 1.25rem; + border-radius: 8px; + margin-bottom: 1.5rem; + font-size: 0.9rem; + line-height: 1.5; +} + +.warning a { + color: inherit; +} + +.error { + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error-text); + padding: 1rem 1.25rem; + border-radius: 8px; + font-size: 0.9rem; +} + +.status-supported { + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success-text); + padding: 0.75rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.status-unsupported { + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); + padding: 0.75rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +/* Patch file sections */ +.patch-file-section { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 0.75rem; +} + +.patch-file-section summary { + padding: 0.75rem 1rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 500; + font-size: 0.95rem; + user-select: none; +} + +.patch-file-section summary:hover { + background: #f5f5f5; +} + +.patch-count { + font-weight: 400; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.patch-list { + border-top: 1px solid var(--border); + padding: 0.5rem 0; +} + +.patch-item { + padding: 0.5rem 1rem; +} + +.patch-item + .patch-item { + border-top: 1px solid #f0f0f0; +} + +.patch-header { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; +} + +.patch-header input { + flex-shrink: 0; +} + +.patch-name { + font-weight: 500; +} + +.patch-group-badge { + font-size: 0.75rem; + background: #e8e8e8; + color: var(--text-secondary); + padding: 0.1rem 0.5rem; + border-radius: 4px; + margin-left: auto; + flex-shrink: 0; +} + +.patch-description { + margin-top: 0.35rem; + margin-left: 1.6rem; + font-size: 0.8rem; + color: var(--text-secondary); + white-space: pre-line; + line-height: 1.4; +} + +/* Firmware input */ +input[type="file"] { + display: block; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +#build-actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +/* Spinner */ +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.build-log { + margin-top: 0.75rem; + padding: 0.75rem; + background: #1a1a1a; + color: #a0a0a0; + border-radius: 6px; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.75rem; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + line-height: 1.5; +} + +.hint { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success-text); + border-radius: 8px; + font-size: 0.9rem; +} + +.error-log { + margin-top: 0.75rem; + padding: 0.75rem; + background: #1a1a1a; + color: #e0e0e0; + border-radius: 6px; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.8rem; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +.step a { + color: var(--primary); +} + +.fallback-hint { + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.info-banner { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; + padding: 0.65rem 1rem; + border-radius: 8px; + font-size: 0.85rem; + margin-bottom: 1rem; +} + +#firmware-download-url { + font-size: 0.8rem; + word-break: break-all; + color: var(--text-secondary); +} + +select { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.95rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + color: var(--text); + margin-bottom: 1rem; +} diff --git a/wip/todo.md b/wip/todo.md index 5fc81ac..2f051bc 100644 --- a/wip/todo.md +++ b/wip/todo.md @@ -4,51 +4,46 @@ - [x] Device detection proof of concept (File System Access API) - [x] Serial prefix → model mapping (verified against official Kobo help page) -- [x] Architecture planning (updated: fully client-side, no PHP backend) +- [x] Architecture planning (fully client-side WASM, no backend) - [x] Installed Go via Homebrew (v1.26.1) -- [x] Verified all kobopatch tests pass natively -- [x] Verified all kobopatch tests pass under `GOOS=js GOARCH=wasm` (via Node.js) -- [x] Updated device identification doc with correct model list -- [x] Removed obsolete backend-api.md +- [x] Verified all kobopatch tests pass natively + WASM - [x] Created `kobopatch-wasm/` with setup.sh, build.sh, go.mod, main.go - [x] WASM wrapper compiles successfully (9.9MB) -- [x] All kobopatch tests still pass with our module's replace directives -- [x] Cleaned up .gitignore +- [x] GitHub/Gitea CI workflow (build + test) +- [x] Patch UI: loads patches from zip, parses YAML, renders toggles +- [x] PatchGroup mutual exclusion (radio buttons) +- [x] Full app flow: connect → detect → configure patches → upload firmware → build → write/download +- [x] Patches served from `src/public/patches/` with `index.json` for version discovery +- [x] JSZip for client-side zip extraction +- [x] Renamed `src/frontend` → `src/public` (webroot) +- [x] Moved `patches/` into `src/public/patches/` -## In Progress +## To Test -### Integration Testing +- [ ] End-to-end test in browser with real Kobo device + firmware zip +- [ ] Verify WASM loads and `patchFirmware()` works in browser (not just Node.js) +- [ ] Verify patch YAML parser handles all 6 patch files correctly +- [ ] Verify File System Access API write to `.kobo/KoboRoot.tgz` +- [ ] Verify download fallback works -- [ ] Test WASM binary in actual browser (load wasm_exec.js + kobopatch.wasm) -- [ ] Test `patchFirmware()` JS function end-to-end with real firmware zip + patches +## Remaining Work -### Frontend - Patch UI - -- [ ] YAML parsing in JS (extract patch names, descriptions, enabled, PatchGroup) -- [ ] `patch-ui.js` — render grouped toggles per target file -- [ ] PatchGroup mutual exclusion (radio buttons) -- [ ] Generate kobopatch.yaml config string from UI state - -### Frontend - Build Flow - -- [ ] User provides firmware zip (file input / drag-and-drop) -- [ ] Load WASM, call `patchFirmware()` with config + firmware + patch files -- [ ] Receive KoboRoot.tgz blob, write to `.kobo/` via File System Access API -- [ ] Fallback: download KoboRoot.tgz manually -- [ ] Bundle patch YAML files as static assets +- [ ] Copy `kobopatch.wasm` + `wasm_exec.js` to `src/public/` as part of build +- [ ] Run WASM patching in a Web Worker (avoid blocking UI during build) +- [ ] Loading/progress feedback during WASM load + build +- [ ] Better error messages for common failures +- [ ] Test with multiple firmware versions / patch zips ## Future / Polish -- [ ] Run WASM patching in a Web Worker (avoid blocking UI) -- [ ] Browser compatibility warning with detail -- [ ] Loading/progress states during build -- [ ] Error handling for common failure modes - [ ] Host as static site (GitHub Pages / Netlify) -- [ ] NickelMenu install/uninstall support (bonus feature) +- [ ] NickelMenu install/uninstall support +- [ ] Dark mode support ## Architecture Change Log - **Switched from PHP backend to fully client-side WASM.** Reason: avoid storing Kobo firmware files on a server (legal risk). - The user provides their own firmware zip. kobopatch runs as WASM in the browser. - No server needed — can be a static site. +- **Patches served from zip files in `src/public/patches/`.** + App scans `patches/index.json` to find compatible patch zips for the detected firmware. + User provides their own firmware zip. kobopatch runs as WASM in the browser.