+ 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.
+
+
Select your Kobo model and firmware version.
+
+
+
+
+
+
+
+
Step 2: Device Detected
+
+
+ Model
+ --
+
+
+ Serial
+ --
+
+
+ Firmware
+ --
+
+
+
+
+
+
+
+
Step 3: Configure Patches
+
Enable or disable patches below. Patches in the same group are mutually exclusive.
+
+
+
+
+
+
Step 4: Build Patched Firmware
+
+ Firmware will be downloaded
+ automatically from Kobo's servers:
+
+ You can verify this URL on
+ Kobo's support page.
+
+
+
+
+
+
+
+
Building...
+
Starting...
+
+
+
+
+
+
+
Done!
+
+
+
+
+
+
+ KoboRoot.tgz has been written to your Kobo.
+ Safely eject the device and reboot it to apply the patches.
+
+
+
+
+
+
Something went wrong
+
+
+
+
+
+
+
+
+
+
+
+
+
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 0000000..37d85d2
Binary files /dev/null and b/src/public/patches/patches_4.4523646.zip differ
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.