1
0

Migrate src/public to web/public
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m41s

This commit is contained in:
2026-03-16 12:38:12 +01:00
parent d5347a7093
commit 8dde08b494
17 changed files with 15 additions and 15 deletions

411
web/public/app.js Normal file
View File

@@ -0,0 +1,411 @@
(() => {
const device = new KoboDevice();
const patchUI = new PatchUI();
const runner = new KobopatchRunner();
let firmwareURL = null;
let resultTgz = null;
let manualMode = false;
let selectedPrefix = null;
let patchesLoaded = false;
// DOM elements
const stepNav = document.getElementById('step-nav');
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 btnDeviceNext = document.getElementById('btn-device-next');
const btnPatchesBack = document.getElementById('btn-patches-back');
const btnPatchesNext = document.getElementById('btn-patches-next');
const btnBuildBack = document.getElementById('btn-build-back');
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 firmwareAutoInfo = document.getElementById('firmware-auto-info');
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 firmwareDeviceLabel = document.getElementById('firmware-device-label');
const patchCountHint = document.getElementById('patch-count-hint');
const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError];
// --- Step navigation ---
function showStep(step) {
for (const s of allSteps) {
s.hidden = (s !== step);
}
}
function setNavStep(num) {
const items = stepNav.querySelectorAll('li');
items.forEach((li, i) => {
const stepNum = i + 1;
li.classList.remove('active', 'done');
if (stepNum < num) li.classList.add('done');
else if (stepNum === num) li.classList.add('active');
});
stepNav.hidden = false;
}
function hideNav() {
stepNav.hidden = true;
}
// --- Patch count ---
function updatePatchCount() {
const count = patchUI.getEnabledCount();
btnPatchesNext.disabled = count === 0;
patchCountHint.textContent = count === 0
? 'Select at least one patch to continue.'
: count === 1
? '1 patch selected.'
: count + ' patches selected.';
}
patchUI.onChange = updatePatchCount;
// --- Firmware step config ---
function configureFirmwareStep(version, prefix) {
firmwareURL = prefix ? getFirmwareURL(prefix, version) : null;
firmwareVersionLabel.textContent = version;
firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix;
document.getElementById('firmware-download-url').textContent = firmwareURL || '';
}
// --- Initial state ---
const hasFileSystemAccess = KoboDevice.isSupported();
if (hasFileSystemAccess) {
setNavStep(1);
showStep(stepConnect);
} else {
enterManualMode();
}
// --- Step 1: Device selection ---
async function enterManualMode() {
manualMode = true;
if (hasFileSystemAccess) {
manualChromeHint.hidden = false;
}
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);
}
manualModel.innerHTML = '<option value="">-- Select your Kobo model --</option>';
manualModel.hidden = true;
setNavStep(1);
showStep(stepManual);
}
btnManualFromAuto.addEventListener('click', (e) => {
e.preventDefault();
enterManualMode();
});
manualVersion.addEventListener('change', () => {
const version = manualVersion.value;
selectedPrefix = null;
if (!version) {
manualModel.hidden = true;
btnManualConfirm.disabled = true;
return;
}
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;
});
manualModel.addEventListener('change', () => {
selectedPrefix = manualModel.value || null;
btnManualConfirm.disabled = !manualVersion.value || !manualModel.value;
});
// Manual confirm → load patches → go to step 2
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);
goToPatches();
} catch (err) {
showError(err.message);
}
});
// Auto connect → show device info
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);
updatePatchCount();
patchesLoaded = true;
configureFirmwareStep(info.firmware, info.serialPrefix);
showStep(stepDevice);
} else {
deviceStatus.className = 'status-unsupported';
deviceStatus.textContent =
'No patches available for firmware ' + info.firmware + '. ' +
'Supported versions: ' + available.map(p => p.version).join(', ');
btnDeviceNext.hidden = true;
showStep(stepDevice);
}
} catch (err) {
if (err.name === 'AbortError') return;
showError(err.message);
}
});
// Device info → patches
btnDeviceNext.addEventListener('click', () => {
if (patchesLoaded) goToPatches();
});
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);
updatePatchCount();
patchesLoaded = true;
return true;
}
// --- Step 2: Patches ---
function goToPatches() {
setNavStep(2);
showStep(stepPatches);
}
btnPatchesBack.addEventListener('click', () => {
setNavStep(1);
if (manualMode) {
showStep(stepManual);
} else {
showStep(stepDevice);
}
});
btnPatchesNext.addEventListener('click', () => {
if (patchUI.getEnabledCount() === 0) return;
goToBuild();
});
// --- Step 3: Review & Build ---
function goToBuild() {
setNavStep(3);
showStep(stepFirmware);
}
btnBuildBack.addEventListener('click', () => {
goToPatches();
});
const buildProgress = document.getElementById('build-progress');
const buildLog = document.getElementById('build-log');
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) {
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}%)`;
}
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;
}
btnBuild.addEventListener('click', async () => {
hideNav();
showStep(stepBuilding);
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 = 'Applying patches...';
const configYAML = patchUI.generateConfig();
const patchFiles = patchUI.getPatchFileBytes();
const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => {
appendLog(msg);
const trimmed = msg.trimStart();
if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') ||
trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) {
buildProgress.textContent = trimmed;
}
});
resultTgz = result.tgz;
buildStatus.textContent =
'Patching complete. KoboRoot.tgz is ' +
(resultTgz.length / 1024).toFixed(0) + ' KB.';
writeSuccess.hidden = true;
const doneLog = document.getElementById('done-log');
doneLog.textContent = buildLog.textContent;
doneLog.scrollTop = doneLog.scrollHeight;
btnWrite.hidden = manualMode;
hideNav();
showStep(stepDone);
} catch (err) {
showError('Build failed: ' + err.message, buildLog.textContent);
}
});
// --- Done step ---
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);
}
});
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);
});
// --- Error / Retry ---
function showError(message, log) {
errorMessage.textContent = message;
if (log) {
errorLog.textContent = log;
errorLog.hidden = false;
} else {
errorLog.hidden = true;
}
hideNav();
showStep(stepError);
}
btnRetry.addEventListener('click', () => {
device.disconnect();
firmwareURL = null;
resultTgz = null;
manualMode = false;
selectedPrefix = null;
patchesLoaded = false;
btnWrite.hidden = false;
btnDeviceNext.hidden = false;
if (hasFileSystemAccess) {
setNavStep(1);
showStep(stepConnect);
} else {
enterManualMode();
}
});
})();

158
web/public/index.html Normal file
View File

@@ -0,0 +1,158 @@
<!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>
<header class="hero">
<h1>KoboPatch <span class="hero-accent">Web UI</span></h1>
<p class="subtitle">Custom patches for your Kobo e-reader</p>
</header>
<!-- Step indicator -->
<nav id="step-nav" class="step-nav" hidden>
<ol>
<li data-step="1" class="active">Device</li>
<li data-step="2">Patches</li>
<li data-step="3">Build</li>
</ol>
</nav>
<!-- Step 1a: Connect device (automatic, Chromium only) -->
<section id="step-connect" class="step" hidden>
<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 1b (manual): Select device model + firmware -->
<section id="step-manual" class="step" hidden>
<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 &#x203A;</button>
</section>
<!-- Step 1c: Device detected (auto mode info card) -->
<section id="step-device" class="step" hidden>
<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>
<div class="step-actions">
<button id="btn-device-next" class="primary">Configure Patches &#x203A;</button>
</div>
</section>
<!-- Step 2: Configure patches -->
<section id="step-patches" class="step" hidden>
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
<div id="patch-container" class="patch-container-scroll"></div>
<div class="step-actions">
<button id="btn-patches-back" class="secondary">&#x2039; Back</button>
<button id="btn-patches-next" class="primary" disabled>Continue &#x203A;</button>
</div>
<p id="patch-count-hint" class="fallback-hint"></p>
</section>
<!-- Step 3: Review & Build -->
<section id="step-firmware" class="step" hidden>
<p id="firmware-auto-info">
Firmware <strong id="firmware-version-label"></strong> for
<strong id="firmware-device-label"></strong> will be downloaded
automatically from Kobo's servers and will be patched after the download completes:<br>
<code id="firmware-download-url"></code><br>
<span id="firmware-verify-notice">
You can verify if this URL matches your Kobo's model on
<a href="https://help.kobo.com/hc/en-us/articles/35059171032727" target="_blank">Kobo's support page</a>. The most important bit is that "koboXX" matches, for example "kobo13" for Kobo Libra Color.
</span>
</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>
-->
<div class="step-actions">
<button id="btn-build-back" class="secondary">&#x2039; Back</button>
<button id="btn-build" class="primary">Build Patched Firmware</button>
</div>
</section>
<!-- Step 4: Building -->
<section id="step-building" class="step" hidden>
<div class="build-header">
<div class="spinner"></div>
<p id="build-progress">Starting...</p>
</div>
<pre id="build-log" class="build-log"></pre>
</section>
<!-- Step 5: Done -->
<section id="step-done" class="step" hidden>
<div id="build-status" class="info-banner"></div>
<pre id="done-log" class="build-log done-log"></pre>
<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="fallback-hint" id="download-hint">
Place the downloaded <strong>KoboRoot.tgz</strong> in the <strong>.kobo</strong> directory
on your Kobo's USB drive, then safely eject and reboot the device.
</p>
<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">Start Over</button>
</section>
</main>
<!-- wasm_exec.js loaded by patch-worker.js inside the Web Worker -->
<script src="kobo-device.js"></script>
<script src="kobopatch.js"></script>
<script src="patch-ui.js"></script>
<script src="app.js"></script>
</body>
</html>

185
web/public/kobo-device.js Normal file
View File

@@ -0,0 +1,185 @@
/**
* Known Kobo device serial prefixes mapped to model names.
* Source: https://help.kobo.com/hc/en-us/articles/360019676973
* The serial number prefix (first 3-4 characters) identifies the model.
*/
const KOBO_MODELS = {
// Current eReaders
'N428': 'Kobo Libra Colour',
'N367': 'Kobo Clara Colour',
'N365': 'Kobo Clara BW',
'P365': 'Kobo Clara BW',
'N605': 'Kobo Elipsa 2E',
'N506': 'Kobo Clara 2E',
'N778': 'Kobo Sage',
'N418': 'Kobo Libra 2',
'N604': 'Kobo Elipsa',
'N306': 'Kobo Nia',
'N873': 'Kobo Libra H2O',
'N782': 'Kobo Forma',
'N249': 'Kobo Clara HD',
'N867': 'Kobo Aura H2O Edition 2',
'N709': 'Kobo Aura ONE',
'N236': 'Kobo Aura Edition 2',
'N587': 'Kobo Touch 2.0',
'N437': 'Kobo Glo HD',
'N250': 'Kobo Aura H2O',
'N514': 'Kobo Aura',
'N613': 'Kobo Glo',
'N705': 'Kobo Mini',
'N416': 'Kobo Original',
// Older models with multiple revisions
'N905': 'Kobo Touch',
'N647': 'Kobo Wireless',
'N47B': 'Kobo Wireless',
// Aura HD uses 5-char prefix
'N204': 'Kobo Aura HD',
};
/**
* Supported firmware version for patching.
*/
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;
this.deviceInfo = null;
}
/**
* Check if the File System Access API is available.
*/
static isSupported() {
return 'showDirectoryPicker' in window;
}
/**
* Prompt the user to select the Kobo drive root directory.
* Validates that it looks like a Kobo by checking for .kobo/version.
*/
async connect() {
this.directoryHandle = await window.showDirectoryPicker({
mode: 'readwrite',
});
// Verify this looks like a Kobo root
let koboDir;
try {
koboDir = await this.directoryHandle.getDirectoryHandle('.kobo');
} catch {
throw new Error(
'This does not appear to be a Kobo device. Could not find the .kobo directory.'
);
}
let versionFile;
try {
versionFile = await koboDir.getFileHandle('version');
} catch {
throw new Error(
'Could not find .kobo/version. Is this the root of your Kobo drive?'
);
}
const file = await versionFile.getFile();
const content = await file.text();
this.deviceInfo = KoboDevice.parseVersion(content.trim());
return this.deviceInfo;
}
/**
* Parse the .kobo/version file content.
*
* Format: serial,version1,firmware,version3,version4,hardware_uuid
* Example: N4284B5215352,4.9.77,4.45.23646,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390
*/
static parseVersion(content) {
const parts = content.split(',');
if (parts.length < 6) {
throw new Error(
'Unexpected version file format. Expected 6 comma-separated fields, got ' + parts.length
);
}
const serial = parts[0];
const firmware = parts[2];
const hardwareId = parts[5];
// Try matching 4-char prefix first, then 3-char for models like N204B
const serialPrefix = KOBO_MODELS[serial.substring(0, 4)]
? serial.substring(0, 4)
: serial.substring(0, 3);
const model = KOBO_MODELS[serialPrefix] || 'Unknown Kobo (' + serial.substring(0, 4) + ')';
const isSupported = firmware === SUPPORTED_FIRMWARE;
return {
serial,
serialPrefix,
firmware,
hardwareId,
model,
isSupported,
};
}
/**
* Disconnect / release the directory handle.
*/
disconnect() {
this.directoryHandle = null;
this.deviceInfo = null;
}
}

53
web/public/kobopatch.js Normal file
View File

@@ -0,0 +1,53 @@
/**
* Runs kobopatch WASM in a Web Worker for non-blocking UI.
*/
class KobopatchRunner {
constructor() {
this._worker = null;
}
/**
* Run the patching pipeline in a Web Worker.
*
* @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}>}
*/
patchFirmware(configYAML, firmwareZip, patchFiles, onProgress) {
return new Promise((resolve, reject) => {
const worker = new Worker('patch-worker.js');
this._worker = worker;
worker.onmessage = (e) => {
const msg = e.data;
if (msg.type === 'progress') {
if (onProgress) onProgress(msg.message);
} else if (msg.type === 'done') {
worker.terminate();
this._worker = null;
resolve({ tgz: msg.tgz, log: msg.log });
} else if (msg.type === 'error') {
worker.terminate();
this._worker = null;
reject(new Error(msg.message));
}
};
worker.onerror = (e) => {
worker.terminate();
this._worker = null;
reject(new Error('Worker error: ' + e.message));
};
// Transfer the firmwareZip buffer to avoid copying
worker.postMessage({
type: 'patch',
configYAML,
firmwareZip,
patchFiles,
}, [firmwareZip.buffer]);
});
}
}

389
web/public/patch-ui.js Normal file
View File

@@ -0,0 +1,389 @@
/**
* 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;
// Called when patch selection changes
this.onChange = 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.description) {
const toggle = document.createElement('button');
toggle.className = 'patch-desc-toggle';
toggle.textContent = '?';
toggle.title = 'Toggle description';
toggle.type = 'button';
header.appendChild(toggle);
}
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;
desc.hidden = true;
item.appendChild(desc);
const toggle = header.querySelector('.patch-desc-toggle');
toggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
desc.hidden = !desc.hidden;
toggle.textContent = desc.hidden ? '?' : '\u2212';
});
}
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++;
}
if (this.onChange) this.onChange();
}
/**
* Count total enabled patches across all files.
*/
getEnabledCount() {
let count = 0;
for (const [, { patches }] of Object.entries(this.patchFiles)) {
count += patches.filter(p => p.enabled).length;
}
return count;
}
/**
* 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,50 @@
// Web Worker for running kobopatch WASM off the main thread.
// Communicates with the main thread via postMessage.
importScripts('wasm_exec.js');
let wasmReady = false;
async function loadWasm() {
if (wasmReady) return;
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch('kobopatch.wasm?ts=1773611308'),
go.importObject
);
go.run(result.instance);
if (typeof globalThis.patchFirmware !== 'function') {
throw new Error('WASM module loaded but patchFirmware() not found');
}
wasmReady = true;
}
self.onmessage = async function(e) {
const { type, configYAML, firmwareZip, patchFiles } = e.data;
if (type !== 'patch') return;
try {
self.postMessage({ type: 'progress', message: 'Loading WASM patcher...' });
await loadWasm();
self.postMessage({ type: 'progress', message: 'WASM module loaded' });
self.postMessage({ type: 'progress', message: 'Applying patches...' });
const result = await globalThis.patchFirmware(configYAML, firmwareZip, patchFiles, (msg) => {
self.postMessage({ type: 'progress', message: msg });
});
// Transfer the tgz buffer to avoid copying
const tgzBuffer = result.tgz.buffer;
self.postMessage({
type: 'done',
tgz: result.tgz,
log: result.log,
}, [tgzBuffer]);
} catch (err) {
self.postMessage({ type: 'error', message: err.message });
}
};

View File

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

Binary file not shown.

562
web/public/style.css Normal file
View File

@@ -0,0 +1,562 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #f5f5f7;
--card-bg: #fff;
--border: #d1d5db;
--border-light: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-light: #eff6ff;
--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;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
main {
max-width: 640px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
/* Hero header */
.hero {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-light);
}
h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.hero-accent {
color: var(--primary);
font-weight: 600;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
margin-top: 0.25rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text);
}
/* Step navigation */
.step-nav {
margin-bottom: 1.5rem;
}
.step-nav ol {
display: flex;
list-style: none;
gap: 0;
counter-reset: step;
}
.step-nav li {
flex: 1;
text-align: center;
padding: 0.5rem 0;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
position: relative;
counter-increment: step;
}
.step-nav li::before {
content: counter(step);
display: block;
width: 1.6rem;
height: 1.6rem;
line-height: 1.6rem;
margin: 0 auto 0.3rem;
border-radius: 50%;
background: var(--border-light);
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
}
.step-nav li.active::before {
background: var(--primary);
color: #fff;
}
.step-nav li.active {
color: var(--primary);
font-weight: 600;
}
.step-nav li.done::before {
background: var(--success-text);
color: #fff;
content: "\2713";
}
.step-nav li.done {
color: var(--success-text);
}
/* Connector lines between steps */
.step-nav li + li::after {
content: '';
position: absolute;
top: 1.05rem;
right: 50%;
width: 100%;
height: 2px;
background: var(--border-light);
z-index: -1;
}
.step-nav li.done + li::after,
.step-nav li.done + li.active::after {
background: var(--success-text);
}
/* Steps */
.step {
margin-bottom: 1rem;
}
.step p {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.93rem;
line-height: 1.6;
}
/* Step action buttons (back/next) */
.step-actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1.25rem;
}
.step-actions .primary:first-child {
margin-left: auto;
}
/* Buttons */
button {
font-size: 0.9rem;
padding: 0.55rem 1.25rem;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
font-weight: 500;
transition: all 0.15s ease;
}
button.primary {
background: var(--primary);
color: #fff;
border-color: var(--primary);
box-shadow: var(--shadow);
}
button.primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
box-shadow: var(--shadow-md);
}
button.secondary {
background: var(--card-bg);
color: var(--text);
box-shadow: var(--shadow);
}
button.secondary:hover {
background: #f9fafb;
border-color: #9ca3af;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
box-shadow: none;
}
/* Cards */
.info-card {
background: var(--card-bg);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-light);
}
.info-row:last-child {
border-bottom: none;
}
.info-row .label {
font-weight: 500;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.info-row .value {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.88rem;
}
/* Status banners */
.warning {
background: var(--warning-bg);
border: 1px solid var(--warning-border);
color: var(--warning-text);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.88rem;
line-height: 1.5;
}
.warning a {
color: inherit;
}
.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.88rem;
}
.status-supported {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
padding: 0.65rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.88rem;
}
.status-unsupported {
background: var(--warning-bg);
border: 1px solid var(--warning-border);
color: var(--warning-text);
padding: 0.65rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.88rem;
}
/* Scrollable patch container */
.patch-container-scroll {
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 5px;
}
/* Patch file sections */
.patch-file-section {
background: var(--card-bg);
border-bottom: 1px solid var(--border-light);
}
.patch-file-section:last-child {
border-bottom: none;
}
.patch-file-section summary {
padding: 0.6rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
font-size: 0.93rem;
user-select: none;
transition: background 0.1s;
}
.patch-file-section summary:hover {
background: #f9fafb;
}
.patch-file-section[open] summary {
border-bottom: 1px solid var(--border-light);
}
.patch-count {
font-weight: 400;
font-size: 0.8rem;
background: var(--primary-light);
color: var(--primary);
padding: 0.15rem 0.6rem;
border-radius: 10px;
}
.patch-list {
padding: 0.25rem 0;
}
.patch-item {
padding: 0.4rem 1rem;
}
.patch-item + .patch-item {
border-top: 1px solid var(--border-light);
}
.patch-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.85rem;
}
.patch-header input {
flex-shrink: 0;
accent-color: var(--primary);
}
.patch-name {
font-weight: 500;
}
.patch-desc-toggle {
flex-shrink: 0;
background: none;
border: none;
padding: 0 0.3rem;
font-size: 0.7rem;
color: var(--text-secondary);
cursor: pointer;
box-shadow: none;
opacity: 0.6;
transition: opacity 0.1s;
}
.patch-desc-toggle:hover {
opacity: 1;
}
.patch-group-badge {
font-size: 0.7rem;
font-weight: 500;
background: var(--primary-light);
color: var(--primary);
padding: 0.1rem 0.5rem;
border-radius: 4px;
margin-left: auto;
flex-shrink: 0;
}
.patch-description {
margin-top: 0.3rem;
margin-left: 1.6rem;
font-size: 0.75rem;
color: var(--text-secondary);
white-space: pre-line;
line-height: 1.4;
padding: 0.25rem 0;
}
.patch-description[hidden] {
display: none;
}
/* Firmware input */
input[type="file"] {
display: block;
margin-bottom: 1rem;
font-size: 0.88rem;
}
#build-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
/* Build header (spinner + progress text) */
.build-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.build-header p {
margin-bottom: 0;
font-weight: 500;
color: var(--text);
}
/* Spinner */
.spinner {
width: 22px;
height: 22px;
border: 2.5px solid var(--border-light);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Build log terminal */
.build-log {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: #0f172a;
color: #94a3b8;
border-radius: 6px;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.73rem;
white-space: pre-wrap;
height: calc(10 * 1.5em + 1.5rem);
overflow-y: auto;
line-height: 1.5;
}
/* Done screen log is shorter */
.done-log {
height: calc(7 * 1.5em + 1.5rem);
}
.hint {
margin-top: 1rem;
padding: 0.65rem 1rem;
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
border-radius: 8px;
font-size: 0.88rem;
}
.error-log {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: #0f172a;
color: #e2e8f0;
border-radius: 6px;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.78rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.step a {
color: var(--primary);
text-decoration: none;
}
.step a:hover {
text-decoration: underline;
}
.fallback-hint {
margin-top: 0.75rem;
font-size: 0.83rem;
color: var(--text-secondary);
}
.info-banner {
background: var(--primary-light);
border: 1px solid #bfdbfe;
color: #1e40af;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.83rem;
margin-bottom: 1rem;
}
#firmware-download-url {
display: inline-block;
margin: 0.4rem 0;
padding: 0.3rem 0.6rem;
font-size: 0.7rem;
word-break: break-all;
color: #64748b;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 4px;
}
#firmware-verify-notice {
font-size: 12px;
}
select {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.93rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg);
color: var(--text);
margin-bottom: 1rem;
box-shadow: var(--shadow);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
select:focus,
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}