1
0

WIP: working patcher?

This commit is contained in:
2026-03-15 22:27:59 +01:00
parent 2ce1772611
commit d7622e5e05
15 changed files with 1363 additions and 337 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -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."

View File

@@ -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)

View File

@@ -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);
});
})();

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kobopatch Web UI</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<h1>Kobopatch Web UI</h1>
<p class="subtitle">Custom patches for your Kobo e-reader</p>
<section id="browser-warning" class="warning" hidden>
<strong>Unsupported browser.</strong>
The File System Access API is required and only available in Chrome, Edge, and Opera.
<a href="https://caniuse.com/native-filesystem-api" target="_blank">Learn more</a>
</section>
<section id="step-connect" class="step">
<h2>Step 1: Connect your Kobo</h2>
<p>
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.
</p>
<button id="btn-connect" class="primary">Select Kobo Drive</button>
</section>
<section id="step-device" class="step" hidden>
<h2>Step 2: Device Detected</h2>
<div id="device-info" class="info-card">
<div class="info-row">
<span class="label">Model</span>
<span id="device-model" class="value"></span>
</div>
<div class="info-row">
<span class="label">Serial</span>
<span id="device-serial" class="value"></span>
</div>
<div class="info-row">
<span class="label">Firmware</span>
<span id="device-firmware" class="value"></span>
</div>
</div>
<div id="device-status"></div>
<button id="btn-disconnect" class="secondary">Disconnect &amp; Start Over</button>
</section>
<section id="step-error" class="step" hidden>
<h2>Something went wrong</h2>
<p id="error-message" class="error"></p>
<button id="btn-retry" class="secondary">Try Again</button>
</section>
</main>
<script src="kobo-device.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -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;
}

356
src/public/app.js Normal file
View File

@@ -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 = '<option value="">-- Select firmware version --</option>';
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 = '<option value="">-- Select your Kobo model --</option>';
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 = '<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);
}
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();
}
});
})();

133
src/public/index.html Normal file
View File

@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kobopatch Web UI</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
</head>
<body>
<main>
<h1>Kobopatch Web UI</h1>
<p class="subtitle">Custom patches for your Kobo e-reader</p>
<!-- Step 1: Connect device (automatic, Chromium only) -->
<section id="step-connect" class="step" hidden>
<h2>Step 1: Connect your Kobo</h2>
<p>
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.
</p>
<button id="btn-connect" class="primary">Select Kobo Drive</button>
<p class="fallback-hint">
Don't want to use Chrome?
<a href="#" id="btn-manual-from-auto">Select your firmware version manually</a> instead.
</p>
</section>
<!-- Step 1 (manual): Select firmware version -->
<section id="step-manual" class="step" hidden>
<h2>Step 1: Select Your Device</h2>
<p id="manual-chrome-hint" class="info-banner" hidden>
Tip: if you use Chrome or Edge, this tool can auto-detect your device
and write patched files directly to it. Even easier, but sadly requires a Chromium based browser.
</p>
<p>Select your Kobo model and firmware version.</p>
<select id="manual-version">
<option value="">-- Select firmware version --</option>
</select>
<select id="manual-model" hidden>
<option value="">-- Select your Kobo model --</option>
</select>
<button id="btn-manual-confirm" class="primary" disabled>Continue</button>
</section>
<!-- Step 2: Device detected, loading patches -->
<section id="step-device" class="step" hidden>
<h2>Step 2: Device Detected</h2>
<div id="device-info" class="info-card">
<div class="info-row">
<span class="label">Model</span>
<span id="device-model" class="value">--</span>
</div>
<div class="info-row">
<span class="label">Serial</span>
<span id="device-serial" class="value">--</span>
</div>
<div class="info-row">
<span class="label">Firmware</span>
<span id="device-firmware" class="value">--</span>
</div>
</div>
<div id="device-status"></div>
</section>
<!-- Step 3: Configure patches -->
<section id="step-patches" class="step" hidden>
<h2>Step 3: Configure Patches</h2>
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
<div id="patch-container"></div>
</section>
<!-- Step 4: Build -->
<section id="step-firmware" class="step" hidden>
<h2>Step 4: Build Patched Firmware</h2>
<p id="firmware-auto-info">
Firmware <strong id="firmware-version-label"></strong> will be downloaded
automatically from Kobo's servers:<br>
<code id="firmware-download-url"></code><br>
You can verify this URL on
<a href="https://help.kobo.com/hc/en-us/articles/35059171032727" target="_blank">Kobo's support page</a>.
</p>
<!--
<p id="firmware-manual-info" hidden>
No automatic download available for your device.
Please select the firmware zip file manually.
You can download it from
<a href="https://pgaskin.net/KoboStuff/kobofirmware.html" target="_blank">pgaskin.net</a>.
Make sure it matches firmware version <strong id="firmware-version-label-manual"></strong>.
</p>
<input type="file" id="firmware-input" accept=".zip" hidden>
-->
<button id="btn-build" class="primary">Build Patched Firmware</button>
</section>
<!-- Step 5: Building -->
<section id="step-building" class="step" hidden>
<h2>Building...</h2>
<p id="build-progress">Starting...</p>
<div class="spinner"></div>
<pre id="build-log" class="build-log"></pre>
</section>
<!-- Step 6: Done -->
<section id="step-done" class="step" hidden>
<h2>Done!</h2>
<div id="build-status" class="status-supported"></div>
<div id="build-actions">
<button id="btn-write" class="primary">Write to Kobo</button>
<button id="btn-download" class="secondary">Download KoboRoot.tgz</button>
</div>
<p class="hint" hidden id="write-success">
KoboRoot.tgz has been written to your Kobo.
Safely eject the device and reboot it to apply the patches.
</p>
</section>
<!-- Error state -->
<section id="step-error" class="step" hidden>
<h2>Something went wrong</h2>
<p id="error-message" class="error"></p>
<pre id="error-log" class="error-log" hidden></pre>
<button id="btn-retry" class="secondary">Try Again</button>
</section>
</main>
<script src="wasm_exec.js"></script>
<script src="kobo-device.js"></script>
<script src="kobopatch.js"></script>
<script src="patch-ui.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -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;

46
src/public/kobopatch.js Normal file
View File

@@ -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<string, Uint8Array>} 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);
}
}

357
src/public/patch-ui.js Normal file
View File

@@ -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 = `<span class="patch-file-name">${label}</span> <span class="patch-count">${enabledCount} / ${patches.length} enabled</span>`;
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;
}
}

View File

@@ -0,0 +1,6 @@
[
{
"filename": "patches_4.4523646.zip",
"version": "4.45.23646"
}
]

Binary file not shown.

353
src/public/style.css Normal file
View File

@@ -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;
}

View File

@@ -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.