Migrate src/public to web/public
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m41s
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m41s
This commit is contained in:
411
web/public/app.js
Normal file
411
web/public/app.js
Normal 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
158
web/public/index.html
Normal 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 ›</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 ›</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">‹ Back</button>
|
||||
<button id="btn-patches-next" class="primary" disabled>Continue ›</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">‹ 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
185
web/public/kobo-device.js
Normal 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
53
web/public/kobopatch.js
Normal 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
389
web/public/patch-ui.js
Normal 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;
|
||||
}
|
||||
}
|
||||
50
web/public/patch-worker.js
Normal file
50
web/public/patch-worker.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
6
web/public/patches/index.json
Normal file
6
web/public/patches/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"filename": "patches_4.4523646.zip",
|
||||
"version": "4.45.23646"
|
||||
}
|
||||
]
|
||||
BIN
web/public/patches/patches_4.4523646.zip
Normal file
BIN
web/public/patches/patches_4.4523646.zip
Normal file
Binary file not shown.
562
web/public/style.css
Normal file
562
web/public/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user