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