WIP: working patcher?
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,5 +10,9 @@ kobopatch-wasm/kobopatch-src/
|
||||
kobopatch-wasm/kobopatch.wasm
|
||||
kobopatch-wasm/wasm_exec.js
|
||||
|
||||
# WASM artifacts copied to webroot for serving
|
||||
src/public/kobopatch.wasm
|
||||
src/public/wasm_exec.js
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
@@ -8,10 +8,16 @@ if [ ! -d "$SCRIPT_DIR/kobopatch-src" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PUBLIC_DIR="$SCRIPT_DIR/../src/public"
|
||||
|
||||
echo "Building kobopatch WASM..."
|
||||
cd "$SCRIPT_DIR"
|
||||
GOOS=js GOARCH=wasm go build -o kobopatch.wasm .
|
||||
|
||||
echo "WASM binary size: $(du -h kobopatch.wasm | cut -f1)"
|
||||
echo ""
|
||||
echo "Output: $SCRIPT_DIR/kobopatch.wasm"
|
||||
|
||||
echo "Copying artifacts to $PUBLIC_DIR..."
|
||||
cp kobopatch.wasm "$PUBLIC_DIR/kobopatch.wasm"
|
||||
cp wasm_exec.js "$PUBLIC_DIR/wasm_exec.js"
|
||||
|
||||
echo "Done."
|
||||
|
||||
@@ -53,6 +53,7 @@ func main() {
|
||||
// args[0]: configYAML (string) - the kobopatch.yaml config content
|
||||
// args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes
|
||||
// args[2]: patchFiles (Object) - map of filename -> Uint8Array patch file contents
|
||||
// args[3]: onProgress (Function, optional) - callback(message string) for progress updates
|
||||
//
|
||||
// Returns: a Promise that resolves to { tgz: Uint8Array, log: string } or rejects with an error.
|
||||
func jsPatchFirmware(this js.Value, args []js.Value) interface{} {
|
||||
@@ -107,17 +108,31 @@ func runPatch(args []js.Value) (*patchResult, error) {
|
||||
patchFiles[key] = buf
|
||||
}
|
||||
|
||||
return patchFirmware([]byte(configYAML), firmwareZip, patchFiles)
|
||||
// Optional progress callback.
|
||||
var progressFn func(string)
|
||||
if len(args) >= 4 && args[3].Type() == js.TypeFunction {
|
||||
cb := args[3]
|
||||
progressFn = func(msg string) {
|
||||
cb.Invoke(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progressFn)
|
||||
}
|
||||
|
||||
// patchFirmware runs the kobopatch patching pipeline entirely in memory.
|
||||
func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte) (*patchResult, error) {
|
||||
func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte, progressFn func(string)) (*patchResult, error) {
|
||||
var logBuf bytes.Buffer
|
||||
logf := func(format string, a ...interface{}) {
|
||||
fmt.Fprintf(&logBuf, format+"\n", a...)
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
logBuf.WriteString(msg + "\n")
|
||||
if progressFn != nil {
|
||||
progressFn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse config.
|
||||
logf("Parsing config...")
|
||||
var config Config
|
||||
dec := yaml.NewDecoder(bytes.NewReader(configYAML))
|
||||
if err := dec.Decode(&config); err != nil {
|
||||
@@ -128,16 +143,17 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[
|
||||
return nil, errors.New("invalid config: version and patches are required")
|
||||
}
|
||||
|
||||
logf("kobopatch wasm")
|
||||
logf("Firmware version: %s", config.Version)
|
||||
|
||||
// Open the firmware zip from memory.
|
||||
logf("Opening firmware zip (%d MB)...", len(firmwareZip)/1024/1024)
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(firmwareZip), int64(len(firmwareZip)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open firmware zip: %w", err)
|
||||
}
|
||||
|
||||
// Find and extract KoboRoot.tgz from the zip.
|
||||
logf("Extracting KoboRoot.tgz from firmware...")
|
||||
var koboRootTgz io.ReadCloser
|
||||
for _, f := range zipReader.File {
|
||||
if f.Name == "KoboRoot.tgz" {
|
||||
@@ -285,7 +301,7 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[
|
||||
}
|
||||
|
||||
// Verify consistency.
|
||||
logf("\nVerifying output KoboRoot.tgz...")
|
||||
logf("Verifying output KoboRoot.tgz...")
|
||||
verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not verify output: %w", err)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
(() => {
|
||||
const device = new KoboDevice();
|
||||
|
||||
// DOM elements
|
||||
const browserWarning = document.getElementById('browser-warning');
|
||||
const stepConnect = document.getElementById('step-connect');
|
||||
const stepDevice = document.getElementById('step-device');
|
||||
const stepError = document.getElementById('step-error');
|
||||
const btnConnect = document.getElementById('btn-connect');
|
||||
const btnDisconnect = document.getElementById('btn-disconnect');
|
||||
const btnRetry = document.getElementById('btn-retry');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const deviceStatus = document.getElementById('device-status');
|
||||
|
||||
// Check browser support
|
||||
if (!KoboDevice.isSupported()) {
|
||||
browserWarning.hidden = false;
|
||||
btnConnect.disabled = true;
|
||||
}
|
||||
|
||||
function showStep(step) {
|
||||
stepConnect.hidden = true;
|
||||
stepDevice.hidden = true;
|
||||
stepError.hidden = true;
|
||||
step.hidden = false;
|
||||
}
|
||||
|
||||
function showDeviceInfo(info) {
|
||||
document.getElementById('device-model').textContent = info.model;
|
||||
document.getElementById('device-serial').textContent = info.serial;
|
||||
document.getElementById('device-firmware').textContent = info.firmware;
|
||||
if (info.isSupported) {
|
||||
deviceStatus.className = 'status-supported';
|
||||
deviceStatus.textContent = 'This device and firmware version are supported for patching.';
|
||||
} else {
|
||||
deviceStatus.className = 'status-unsupported';
|
||||
deviceStatus.textContent =
|
||||
'Firmware ' + info.firmware + ' is not currently supported. ' +
|
||||
'Expected ' + SUPPORTED_FIRMWARE + '.';
|
||||
}
|
||||
|
||||
showStep(stepDevice);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorMessage.textContent = message;
|
||||
showStep(stepError);
|
||||
}
|
||||
|
||||
btnConnect.addEventListener('click', async () => {
|
||||
try {
|
||||
const info = await device.connect();
|
||||
showDeviceInfo(info);
|
||||
} catch (err) {
|
||||
// User cancelled the picker
|
||||
if (err.name === 'AbortError') return;
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
btnDisconnect.addEventListener('click', () => {
|
||||
device.disconnect();
|
||||
showStep(stepConnect);
|
||||
});
|
||||
|
||||
btnRetry.addEventListener('click', () => {
|
||||
device.disconnect();
|
||||
showStep(stepConnect);
|
||||
});
|
||||
})();
|
||||
@@ -1,59 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kobopatch Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Kobopatch Web UI</h1>
|
||||
<p class="subtitle">Custom patches for your Kobo e-reader</p>
|
||||
|
||||
<section id="browser-warning" class="warning" hidden>
|
||||
<strong>Unsupported browser.</strong>
|
||||
The File System Access API is required and only available in Chrome, Edge, and Opera.
|
||||
<a href="https://caniuse.com/native-filesystem-api" target="_blank">Learn more</a>
|
||||
</section>
|
||||
|
||||
<section id="step-connect" class="step">
|
||||
<h2>Step 1: Connect your Kobo</h2>
|
||||
<p>
|
||||
Connect your Kobo e-reader via USB. It should appear as a removable drive.
|
||||
Then click the button below and select the root of the Kobo drive.
|
||||
</p>
|
||||
<button id="btn-connect" class="primary">Select Kobo Drive</button>
|
||||
</section>
|
||||
|
||||
<section id="step-device" class="step" hidden>
|
||||
<h2>Step 2: Device Detected</h2>
|
||||
<div id="device-info" class="info-card">
|
||||
<div class="info-row">
|
||||
<span class="label">Model</span>
|
||||
<span id="device-model" class="value">—</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Serial</span>
|
||||
<span id="device-serial" class="value">—</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Firmware</span>
|
||||
<span id="device-firmware" class="value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-status"></div>
|
||||
<button id="btn-disconnect" class="secondary">Disconnect & Start Over</button>
|
||||
</section>
|
||||
|
||||
<section id="step-error" class="step" hidden>
|
||||
<h2>Something went wrong</h2>
|
||||
<p id="error-message" class="error"></p>
|
||||
<button id="btn-retry" class="secondary">Try Again</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="kobo-device.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,169 +0,0 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--card-bg: #fff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-secondary: #555;
|
||||
--primary: #1a6ed8;
|
||||
--primary-hover: #1558b0;
|
||||
--error-bg: #fef2f2;
|
||||
--error-border: #fca5a5;
|
||||
--error-text: #991b1b;
|
||||
--warning-bg: #fffbeb;
|
||||
--warning-border: #fcd34d;
|
||||
--warning-text: #92400e;
|
||||
--success-bg: #f0fdf4;
|
||||
--success-border: #86efac;
|
||||
--success-text: #166534;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 600px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.step p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warning a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-supported {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-unsupported {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
356
src/public/app.js
Normal file
356
src/public/app.js
Normal file
@@ -0,0 +1,356 @@
|
||||
(() => {
|
||||
const device = new KoboDevice();
|
||||
const patchUI = new PatchUI();
|
||||
const runner = new KobopatchRunner();
|
||||
|
||||
let firmwareURL = null;
|
||||
// let firmwareFile = null; // fallback: manual file input
|
||||
let resultTgz = null;
|
||||
let manualMode = false;
|
||||
let selectedPrefix = null;
|
||||
|
||||
// DOM elements
|
||||
const stepConnect = document.getElementById('step-connect');
|
||||
const stepManual = document.getElementById('step-manual');
|
||||
const stepDevice = document.getElementById('step-device');
|
||||
const stepPatches = document.getElementById('step-patches');
|
||||
const stepFirmware = document.getElementById('step-firmware');
|
||||
const stepBuilding = document.getElementById('step-building');
|
||||
const stepDone = document.getElementById('step-done');
|
||||
const stepError = document.getElementById('step-error');
|
||||
|
||||
const btnConnect = document.getElementById('btn-connect');
|
||||
const btnManualFromAuto = document.getElementById('btn-manual-from-auto');
|
||||
const btnManualConfirm = document.getElementById('btn-manual-confirm');
|
||||
const manualVersion = document.getElementById('manual-version');
|
||||
const manualModel = document.getElementById('manual-model');
|
||||
const manualChromeHint = document.getElementById('manual-chrome-hint');
|
||||
const btnBuild = document.getElementById('btn-build');
|
||||
const btnWrite = document.getElementById('btn-write');
|
||||
const btnDownload = document.getElementById('btn-download');
|
||||
const btnRetry = document.getElementById('btn-retry');
|
||||
|
||||
// const firmwareInput = document.getElementById('firmware-input'); // fallback
|
||||
const firmwareAutoInfo = document.getElementById('firmware-auto-info');
|
||||
// const firmwareManualInfo = document.getElementById('firmware-manual-info'); // fallback
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const errorLog = document.getElementById('error-log');
|
||||
const deviceStatus = document.getElementById('device-status');
|
||||
const patchContainer = document.getElementById('patch-container');
|
||||
const buildStatus = document.getElementById('build-status');
|
||||
const writeSuccess = document.getElementById('write-success');
|
||||
const firmwareVersionLabel = document.getElementById('firmware-version-label');
|
||||
// const firmwareVersionLabelManual = document.getElementById('firmware-version-label-manual'); // fallback
|
||||
|
||||
const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError];
|
||||
|
||||
// Decide initial step based on browser support
|
||||
const hasFileSystemAccess = KoboDevice.isSupported();
|
||||
if (hasFileSystemAccess) {
|
||||
showSteps(stepConnect);
|
||||
} else {
|
||||
// Skip straight to manual mode
|
||||
enterManualMode();
|
||||
}
|
||||
|
||||
function showSteps(...steps) {
|
||||
for (const s of allSteps) {
|
||||
s.hidden = !steps.includes(s);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message, log) {
|
||||
errorMessage.textContent = message;
|
||||
if (log) {
|
||||
errorLog.textContent = log;
|
||||
errorLog.hidden = false;
|
||||
} else {
|
||||
errorLog.hidden = true;
|
||||
}
|
||||
showSteps(stepError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the firmware step for auto-download.
|
||||
*/
|
||||
function configureFirmwareStep(version, prefix) {
|
||||
firmwareURL = prefix ? getFirmwareURL(prefix, version) : null;
|
||||
firmwareVersionLabel.textContent = version;
|
||||
document.getElementById('firmware-download-url').textContent = firmwareURL || '';
|
||||
}
|
||||
|
||||
async function enterManualMode() {
|
||||
manualMode = true;
|
||||
|
||||
// Show the Chrome hint only if the browser actually supports it
|
||||
// (i.e., user chose manual mode voluntarily)
|
||||
if (hasFileSystemAccess) {
|
||||
manualChromeHint.hidden = false;
|
||||
}
|
||||
|
||||
// Populate version dropdown from available patches
|
||||
const available = await scanAvailablePatches();
|
||||
manualVersion.innerHTML = '<option value="">-- Select firmware version --</option>';
|
||||
for (const p of available) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.version;
|
||||
opt.textContent = p.version;
|
||||
opt.dataset.filename = p.filename;
|
||||
manualVersion.appendChild(opt);
|
||||
}
|
||||
|
||||
// Reset model dropdown
|
||||
manualModel.innerHTML = '<option value="">-- Select your Kobo model --</option>';
|
||||
manualModel.hidden = true;
|
||||
|
||||
showSteps(stepManual);
|
||||
}
|
||||
|
||||
async function loadPatchesForVersion(version, available) {
|
||||
const match = available.find(p => p.version === version);
|
||||
if (!match) return false;
|
||||
|
||||
await patchUI.loadFromURL('patches/' + match.filename);
|
||||
patchUI.render(patchContainer);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Switch to manual mode from auto mode
|
||||
btnManualFromAuto.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
enterManualMode();
|
||||
});
|
||||
|
||||
// Manual mode: version selected → populate model dropdown
|
||||
manualVersion.addEventListener('change', () => {
|
||||
const version = manualVersion.value;
|
||||
selectedPrefix = null;
|
||||
|
||||
if (!version) {
|
||||
manualModel.hidden = true;
|
||||
btnManualConfirm.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate device dropdown for this firmware version
|
||||
const devices = getDevicesForVersion(version);
|
||||
manualModel.innerHTML = '<option value="">-- Select your Kobo model --</option>';
|
||||
for (const d of devices) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.prefix;
|
||||
opt.textContent = d.model;
|
||||
manualModel.appendChild(opt);
|
||||
}
|
||||
manualModel.hidden = false;
|
||||
btnManualConfirm.disabled = true;
|
||||
});
|
||||
|
||||
// Manual mode: model selected
|
||||
manualModel.addEventListener('change', () => {
|
||||
selectedPrefix = manualModel.value || null;
|
||||
btnManualConfirm.disabled = !manualVersion.value || !manualModel.value;
|
||||
});
|
||||
|
||||
// Manual mode: confirm selection
|
||||
btnManualConfirm.addEventListener('click', async () => {
|
||||
const version = manualVersion.value;
|
||||
if (!version || !selectedPrefix) return;
|
||||
|
||||
try {
|
||||
const available = await scanAvailablePatches();
|
||||
const loaded = await loadPatchesForVersion(version, available);
|
||||
if (!loaded) {
|
||||
showError('Could not load patches for firmware ' + version);
|
||||
return;
|
||||
}
|
||||
configureFirmwareStep(version, selectedPrefix);
|
||||
showSteps(stepPatches, stepFirmware);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto mode: connect device
|
||||
btnConnect.addEventListener('click', async () => {
|
||||
try {
|
||||
const info = await device.connect();
|
||||
|
||||
document.getElementById('device-model').textContent = info.model;
|
||||
document.getElementById('device-serial').textContent = info.serial;
|
||||
document.getElementById('device-firmware').textContent = info.firmware;
|
||||
|
||||
selectedPrefix = info.serialPrefix;
|
||||
|
||||
const available = await scanAvailablePatches();
|
||||
const match = available.find(p => p.version === info.firmware);
|
||||
|
||||
if (match) {
|
||||
deviceStatus.className = 'status-supported';
|
||||
deviceStatus.textContent = 'Patches available for firmware ' + info.firmware + '.';
|
||||
|
||||
await patchUI.loadFromURL('patches/' + match.filename);
|
||||
patchUI.render(patchContainer);
|
||||
configureFirmwareStep(info.firmware, info.serialPrefix);
|
||||
|
||||
showSteps(stepDevice, stepPatches, stepFirmware);
|
||||
} else {
|
||||
deviceStatus.className = 'status-unsupported';
|
||||
deviceStatus.textContent =
|
||||
'No patches available for firmware ' + info.firmware + '. ' +
|
||||
'Supported versions: ' + available.map(p => p.version).join(', ');
|
||||
showSteps(stepDevice);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// // Firmware file selected (fallback for devices without auto-download URL)
|
||||
// firmwareInput.addEventListener('change', () => {
|
||||
// firmwareFile = firmwareInput.files[0] || null;
|
||||
// });
|
||||
|
||||
const buildProgress = document.getElementById('build-progress');
|
||||
const buildLog = document.getElementById('build-log');
|
||||
|
||||
/**
|
||||
* Download firmware zip from Kobo's servers with progress tracking.
|
||||
* Returns Uint8Array of the zip file.
|
||||
*/
|
||||
async function downloadFirmware(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error('Firmware download failed: HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
const contentLength = resp.headers.get('Content-Length');
|
||||
if (!contentLength || !resp.body) {
|
||||
// Fallback: no streaming progress
|
||||
buildProgress.textContent = 'Downloading firmware...';
|
||||
return new Uint8Array(await resp.arrayBuffer());
|
||||
}
|
||||
|
||||
const total = parseInt(contentLength, 10);
|
||||
const reader = resp.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
const pct = ((received / total) * 100).toFixed(0);
|
||||
const mb = (received / 1024 / 1024).toFixed(1);
|
||||
const totalMB = (total / 1024 / 1024).toFixed(1);
|
||||
buildProgress.textContent = `Downloading firmware... ${mb} / ${totalMB} MB (${pct}%)`;
|
||||
}
|
||||
|
||||
// Concatenate chunks into single Uint8Array
|
||||
const result = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function appendLog(msg) {
|
||||
buildLog.textContent += msg + '\n';
|
||||
buildLog.scrollTop = buildLog.scrollHeight;
|
||||
}
|
||||
|
||||
// Build
|
||||
btnBuild.addEventListener('click', async () => {
|
||||
const stepsToShow = manualMode ? [stepBuilding] : [stepDevice, stepBuilding];
|
||||
showSteps(...stepsToShow);
|
||||
buildLog.textContent = '';
|
||||
buildProgress.textContent = 'Starting...';
|
||||
|
||||
try {
|
||||
if (!firmwareURL) {
|
||||
showError('No firmware download URL available for this device.');
|
||||
return;
|
||||
}
|
||||
|
||||
const firmwareBytes = await downloadFirmware(firmwareURL);
|
||||
appendLog('Firmware downloaded: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB');
|
||||
|
||||
buildProgress.textContent = 'Loading WASM patcher...';
|
||||
await runner.load();
|
||||
appendLog('WASM module loaded');
|
||||
|
||||
buildProgress.textContent = 'Applying patches...';
|
||||
const configYAML = patchUI.generateConfig();
|
||||
const patchFiles = patchUI.getPatchFileBytes();
|
||||
|
||||
const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => {
|
||||
appendLog(msg);
|
||||
// Update headline with the latest high-level step
|
||||
if (msg.startsWith('Patching ') || msg.startsWith('Extracting ') || msg.startsWith('Verifying ')) {
|
||||
buildProgress.textContent = msg;
|
||||
}
|
||||
});
|
||||
|
||||
resultTgz = result.tgz;
|
||||
buildStatus.textContent =
|
||||
'Patching complete. KoboRoot.tgz is ' +
|
||||
(resultTgz.length / 1024).toFixed(0) + ' KB.';
|
||||
writeSuccess.hidden = true;
|
||||
|
||||
// In manual mode, hide the "Write to Kobo" button
|
||||
btnWrite.hidden = manualMode;
|
||||
|
||||
const doneSteps = manualMode ? [stepDone] : [stepDevice, stepDone];
|
||||
showSteps(...doneSteps);
|
||||
} catch (err) {
|
||||
showError('Build failed: ' + err.message, buildLog.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
// Write to device (auto mode only)
|
||||
btnWrite.addEventListener('click', async () => {
|
||||
if (!resultTgz || !device.directoryHandle) return;
|
||||
|
||||
try {
|
||||
const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo');
|
||||
const fileHandle = await koboDir.getFileHandle('KoboRoot.tgz', { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(resultTgz);
|
||||
await writable.close();
|
||||
writeSuccess.hidden = false;
|
||||
} catch (err) {
|
||||
showError('Failed to write KoboRoot.tgz: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Download
|
||||
btnDownload.addEventListener('click', () => {
|
||||
if (!resultTgz) return;
|
||||
const blob = new Blob([resultTgz], { type: 'application/gzip' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'KoboRoot.tgz';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Retry
|
||||
btnRetry.addEventListener('click', () => {
|
||||
device.disconnect();
|
||||
firmwareURL = null;
|
||||
resultTgz = null;
|
||||
manualMode = false;
|
||||
selectedPrefix = null;
|
||||
btnWrite.hidden = false;
|
||||
|
||||
if (hasFileSystemAccess) {
|
||||
showSteps(stepConnect);
|
||||
} else {
|
||||
enterManualMode();
|
||||
}
|
||||
});
|
||||
})();
|
||||
133
src/public/index.html
Normal file
133
src/public/index.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kobopatch Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Kobopatch Web UI</h1>
|
||||
<p class="subtitle">Custom patches for your Kobo e-reader</p>
|
||||
|
||||
<!-- Step 1: Connect device (automatic, Chromium only) -->
|
||||
<section id="step-connect" class="step" hidden>
|
||||
<h2>Step 1: Connect your Kobo</h2>
|
||||
<p>
|
||||
Connect your Kobo e-reader via USB. It should appear as a removable drive.
|
||||
Then click the button below and select the root of the Kobo drive.
|
||||
</p>
|
||||
<button id="btn-connect" class="primary">Select Kobo Drive</button>
|
||||
<p class="fallback-hint">
|
||||
Don't want to use Chrome?
|
||||
<a href="#" id="btn-manual-from-auto">Select your firmware version manually</a> instead.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Step 1 (manual): Select firmware version -->
|
||||
<section id="step-manual" class="step" hidden>
|
||||
<h2>Step 1: Select Your Device</h2>
|
||||
<p id="manual-chrome-hint" class="info-banner" hidden>
|
||||
Tip: if you use Chrome or Edge, this tool can auto-detect your device
|
||||
and write patched files directly to it. Even easier, but sadly requires a Chromium based browser.
|
||||
</p>
|
||||
<p>Select your Kobo model and firmware version.</p>
|
||||
<select id="manual-version">
|
||||
<option value="">-- Select firmware version --</option>
|
||||
</select>
|
||||
<select id="manual-model" hidden>
|
||||
<option value="">-- Select your Kobo model --</option>
|
||||
</select>
|
||||
<button id="btn-manual-confirm" class="primary" disabled>Continue</button>
|
||||
</section>
|
||||
|
||||
<!-- Step 2: Device detected, loading patches -->
|
||||
<section id="step-device" class="step" hidden>
|
||||
<h2>Step 2: Device Detected</h2>
|
||||
<div id="device-info" class="info-card">
|
||||
<div class="info-row">
|
||||
<span class="label">Model</span>
|
||||
<span id="device-model" class="value">--</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Serial</span>
|
||||
<span id="device-serial" class="value">--</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Firmware</span>
|
||||
<span id="device-firmware" class="value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-status"></div>
|
||||
</section>
|
||||
|
||||
<!-- Step 3: Configure patches -->
|
||||
<section id="step-patches" class="step" hidden>
|
||||
<h2>Step 3: Configure Patches</h2>
|
||||
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
|
||||
<div id="patch-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Step 4: Build -->
|
||||
<section id="step-firmware" class="step" hidden>
|
||||
<h2>Step 4: Build Patched Firmware</h2>
|
||||
<p id="firmware-auto-info">
|
||||
Firmware <strong id="firmware-version-label"></strong> will be downloaded
|
||||
automatically from Kobo's servers:<br>
|
||||
<code id="firmware-download-url"></code><br>
|
||||
You can verify this URL on
|
||||
<a href="https://help.kobo.com/hc/en-us/articles/35059171032727" target="_blank">Kobo's support page</a>.
|
||||
</p>
|
||||
<!--
|
||||
<p id="firmware-manual-info" hidden>
|
||||
No automatic download available for your device.
|
||||
Please select the firmware zip file manually.
|
||||
You can download it from
|
||||
<a href="https://pgaskin.net/KoboStuff/kobofirmware.html" target="_blank">pgaskin.net</a>.
|
||||
Make sure it matches firmware version <strong id="firmware-version-label-manual"></strong>.
|
||||
</p>
|
||||
<input type="file" id="firmware-input" accept=".zip" hidden>
|
||||
-->
|
||||
<button id="btn-build" class="primary">Build Patched Firmware</button>
|
||||
</section>
|
||||
|
||||
<!-- Step 5: Building -->
|
||||
<section id="step-building" class="step" hidden>
|
||||
<h2>Building...</h2>
|
||||
<p id="build-progress">Starting...</p>
|
||||
<div class="spinner"></div>
|
||||
<pre id="build-log" class="build-log"></pre>
|
||||
</section>
|
||||
|
||||
<!-- Step 6: Done -->
|
||||
<section id="step-done" class="step" hidden>
|
||||
<h2>Done!</h2>
|
||||
<div id="build-status" class="status-supported"></div>
|
||||
<div id="build-actions">
|
||||
<button id="btn-write" class="primary">Write to Kobo</button>
|
||||
<button id="btn-download" class="secondary">Download KoboRoot.tgz</button>
|
||||
</div>
|
||||
<p class="hint" hidden id="write-success">
|
||||
KoboRoot.tgz has been written to your Kobo.
|
||||
Safely eject the device and reboot it to apply the patches.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Error state -->
|
||||
<section id="step-error" class="step" hidden>
|
||||
<h2>Something went wrong</h2>
|
||||
<p id="error-message" class="error"></p>
|
||||
<pre id="error-log" class="error-log" hidden></pre>
|
||||
<button id="btn-retry" class="secondary">Try Again</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="wasm_exec.js"></script>
|
||||
<script src="kobo-device.js"></script>
|
||||
<script src="kobopatch.js"></script>
|
||||
<script src="patch-ui.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -41,6 +41,58 @@ const KOBO_MODELS = {
|
||||
*/
|
||||
const SUPPORTED_FIRMWARE = '4.45.23646';
|
||||
|
||||
/**
|
||||
* Firmware download URLs by version and serial prefix.
|
||||
* Source: https://help.kobo.com/hc/en-us/articles/35059171032727
|
||||
*
|
||||
* The kobo prefix (kobo12, kobo13, kobo14) is stable per device family.
|
||||
* The date path segment (e.g. Mar2026) changes per release.
|
||||
* help.kobo.com may lag behind; verify URLs when adding new versions.
|
||||
*/
|
||||
const FIRMWARE_DOWNLOADS = {
|
||||
'4.45.23646': {
|
||||
'N428': 'https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip',
|
||||
'N365': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip',
|
||||
'N367': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip',
|
||||
'P365': 'https://ereaderfiles.kobo.com/firmwares/kobo14/Mar2026/kobo-update-4.45.23646.zip',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the firmware download URL for a given serial prefix and firmware version.
|
||||
* Returns null if no URL is available.
|
||||
*/
|
||||
function getFirmwareURL(serialPrefix, version) {
|
||||
const versionMap = FIRMWARE_DOWNLOADS[version];
|
||||
if (!versionMap) return null;
|
||||
return versionMap[serialPrefix] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all device models that have firmware downloads for a given version.
|
||||
* Returns array of { prefix, model } objects.
|
||||
*/
|
||||
function getDevicesForVersion(version) {
|
||||
const versionMap = FIRMWARE_DOWNLOADS[version];
|
||||
if (!versionMap) return [];
|
||||
const devices = [];
|
||||
const seen = {};
|
||||
for (const prefix of Object.keys(versionMap)) {
|
||||
const model = KOBO_MODELS[prefix] || 'Unknown (' + prefix + ')';
|
||||
// Track duplicates to disambiguate with serial prefix
|
||||
if (seen[model]) seen[model].push(prefix);
|
||||
else seen[model] = [prefix];
|
||||
}
|
||||
for (const prefix of Object.keys(versionMap)) {
|
||||
const model = KOBO_MODELS[prefix] || 'Unknown (' + prefix + ')';
|
||||
const label = seen[model].length > 1
|
||||
? model + ' (serial ' + prefix + '...)'
|
||||
: model;
|
||||
devices.push({ prefix, model: label });
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
class KoboDevice {
|
||||
constructor() {
|
||||
this.directoryHandle = null;
|
||||
46
src/public/kobopatch.js
Normal file
46
src/public/kobopatch.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Loads and manages the kobopatch WASM module.
|
||||
*/
|
||||
class KobopatchRunner {
|
||||
constructor() {
|
||||
this.ready = false;
|
||||
this._go = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the WASM module. Must be called before patchFirmware().
|
||||
*/
|
||||
async load() {
|
||||
if (this.ready) return;
|
||||
|
||||
this._go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(
|
||||
fetch('kobopatch.wasm'),
|
||||
this._go.importObject
|
||||
);
|
||||
// Go WASM runs as a long-lived instance.
|
||||
this._go.run(result.instance);
|
||||
|
||||
// Wait for the global function to become available.
|
||||
if (typeof globalThis.patchFirmware !== 'function') {
|
||||
throw new Error('WASM module loaded but patchFirmware() not found');
|
||||
}
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the patching pipeline.
|
||||
*
|
||||
* @param {string} configYAML - kobopatch.yaml content
|
||||
* @param {Uint8Array} firmwareZip - firmware zip file bytes
|
||||
* @param {Object<string, Uint8Array>} patchFiles - map of filename -> YAML content bytes
|
||||
* @param {Function} [onProgress] - optional callback(message) for progress updates
|
||||
* @returns {Promise<{tgz: Uint8Array, log: string}>}
|
||||
*/
|
||||
async patchFirmware(configYAML, firmwareZip, patchFiles, onProgress) {
|
||||
if (!this.ready) {
|
||||
throw new Error('WASM module not loaded. Call load() first.');
|
||||
}
|
||||
return globalThis.patchFirmware(configYAML, firmwareZip, patchFiles, onProgress || null);
|
||||
}
|
||||
}
|
||||
357
src/public/patch-ui.js
Normal file
357
src/public/patch-ui.js
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Friendly display names for patch files.
|
||||
*/
|
||||
const PATCH_FILE_LABELS = {
|
||||
'src/nickel.yaml': 'Nickel (UI patches)',
|
||||
'src/nickel_custom.yaml': 'Nickel Custom',
|
||||
'src/libadobe.so.yaml': 'Adobe (PDF patches)',
|
||||
'src/libnickel.so.1.0.0.yaml': 'Nickel Library (core patches)',
|
||||
'src/librmsdk.so.1.0.0.yaml': 'Adobe RMSDK (ePub patches)',
|
||||
'src/cloud_sync.yaml': 'Cloud Sync',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a kobopatch YAML file and extract patch metadata.
|
||||
* We only need: name, enabled, description, patchGroup.
|
||||
* This is a targeted parser, not a full YAML parser.
|
||||
*/
|
||||
function parsePatchYAML(content) {
|
||||
const patches = [];
|
||||
const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Top-level key (patch name): not indented, ends with ':'
|
||||
// Skip comments and blank lines
|
||||
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':')) {
|
||||
const name = line.slice(0, -1).trim();
|
||||
const patch = { name, enabled: false, description: '', patchGroup: null };
|
||||
i++;
|
||||
|
||||
// Parse the array items for this patch
|
||||
while (i < lines.length) {
|
||||
const itemLine = lines[i];
|
||||
|
||||
// Stop at next top-level key or EOF
|
||||
if (itemLine.length > 0 && !itemLine.startsWith(' ') && !itemLine.startsWith('#')) {
|
||||
break;
|
||||
}
|
||||
|
||||
const trimmed = itemLine.trim();
|
||||
|
||||
// Match "- Enabled: yes/no"
|
||||
const enabledMatch = trimmed.match(/^- Enabled:\s*(yes|no)$/);
|
||||
if (enabledMatch) {
|
||||
patch.enabled = enabledMatch[1] === 'yes';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match "- PatchGroup: ..."
|
||||
const pgMatch = trimmed.match(/^- PatchGroup:\s*(.+)$/);
|
||||
if (pgMatch) {
|
||||
patch.patchGroup = pgMatch[1].trim();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match "- Description: ..." (single line or multi-line block)
|
||||
const descMatch = trimmed.match(/^- Description:\s*(.*)$/);
|
||||
if (descMatch) {
|
||||
const rest = descMatch[1].trim();
|
||||
if (rest === '|' || rest === '>') {
|
||||
// Multi-line block scalar
|
||||
i++;
|
||||
const descLines = [];
|
||||
while (i < lines.length) {
|
||||
const dl = lines[i];
|
||||
// Block continues while indented more than the "- Description" level
|
||||
if (dl.match(/^\s{6,}/) || dl.trim() === '') {
|
||||
descLines.push(dl.trim());
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
patch.description = descLines.join('\n').trim();
|
||||
} else {
|
||||
patch.description = rest;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
patches.push(patch);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `patches:` section from kobopatch.yaml to get the file→target mapping.
|
||||
* Returns e.g. { "src/nickel.yaml": "usr/local/Kobo/nickel", ... }
|
||||
*/
|
||||
function parsePatchConfig(configYAML) {
|
||||
const patches = {};
|
||||
let version = null;
|
||||
const lines = configYAML.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
||||
let inPatches = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Extract version
|
||||
const versionMatch = line.match(/^version:\s*(.+)$/);
|
||||
if (versionMatch) {
|
||||
version = versionMatch[1].trim().replace(/['"]/g, '');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.match(/^patches:\s*$/)) {
|
||||
inPatches = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A new top-level key ends the patches section
|
||||
if (inPatches && line.length > 0 && !line.startsWith(' ') && !line.startsWith('#')) {
|
||||
inPatches = false;
|
||||
}
|
||||
|
||||
if (inPatches) {
|
||||
const match = line.match(/^\s+([\w/.]+\.yaml):\s*(.+)$/);
|
||||
if (match) {
|
||||
patches[match[1]] = match[2].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version, patches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the patches/ directory for available patch zips.
|
||||
* Returns an array of { filename, version } sorted by version descending.
|
||||
*/
|
||||
async function scanAvailablePatches() {
|
||||
try {
|
||||
const resp = await fetch('patches/index.json');
|
||||
if (!resp.ok) return [];
|
||||
const list = await resp.json();
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class PatchUI {
|
||||
constructor() {
|
||||
// Map of filename -> { raw: string, patches: Array }
|
||||
this.patchFiles = {};
|
||||
// Parsed from kobopatch.yaml inside the zip
|
||||
this.patchConfig = {};
|
||||
this.firmwareVersion = null;
|
||||
this.configYAML = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load patches from a zip file (ArrayBuffer or Uint8Array).
|
||||
* The zip should contain kobopatch.yaml and src/*.yaml.
|
||||
*/
|
||||
async loadFromZip(zipData) {
|
||||
const zip = await JSZip.loadAsync(zipData);
|
||||
|
||||
// Load kobopatch.yaml
|
||||
const configFile = zip.file('kobopatch.yaml');
|
||||
if (!configFile) {
|
||||
throw new Error('Patch zip does not contain kobopatch.yaml');
|
||||
}
|
||||
this.configYAML = await configFile.async('string');
|
||||
const { version, patches } = parsePatchConfig(this.configYAML);
|
||||
this.firmwareVersion = version;
|
||||
this.patchConfig = patches;
|
||||
|
||||
// Load each patch YAML file referenced in the config
|
||||
this.patchFiles = {};
|
||||
for (const filename of Object.keys(patches)) {
|
||||
const yamlFile = zip.file(filename);
|
||||
if (!yamlFile) {
|
||||
console.warn('Patch file referenced in config but missing from zip:', filename);
|
||||
continue;
|
||||
}
|
||||
const raw = await yamlFile.async('string');
|
||||
const parsed = parsePatchYAML(raw);
|
||||
this.patchFiles[filename] = { raw, patches: parsed };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load patches from a URL pointing to a zip file.
|
||||
*/
|
||||
async loadFromURL(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to fetch patch zip: ' + resp.statusText);
|
||||
}
|
||||
const data = await resp.arrayBuffer();
|
||||
await this.loadFromZip(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the patch configuration UI into a container element.
|
||||
*/
|
||||
render(container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [filename, { patches }] of Object.entries(this.patchFiles)) {
|
||||
if (patches.length === 0) continue;
|
||||
|
||||
const section = document.createElement('details');
|
||||
section.className = 'patch-file-section';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
const label = PATCH_FILE_LABELS[filename] || filename;
|
||||
const enabledCount = patches.filter(p => p.enabled).length;
|
||||
summary.innerHTML = `<span class="patch-file-name">${label}</span> <span class="patch-count">${enabledCount} / ${patches.length} enabled</span>`;
|
||||
section.appendChild(summary);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'patch-list';
|
||||
|
||||
// Group patches by PatchGroup for mutual exclusion
|
||||
const patchGroups = {};
|
||||
for (const patch of patches) {
|
||||
if (patch.patchGroup) {
|
||||
if (!patchGroups[patch.patchGroup]) {
|
||||
patchGroups[patch.patchGroup] = [];
|
||||
}
|
||||
patchGroups[patch.patchGroup].push(patch);
|
||||
}
|
||||
}
|
||||
|
||||
for (const patch of patches) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'patch-item';
|
||||
|
||||
const header = document.createElement('label');
|
||||
header.className = 'patch-header';
|
||||
|
||||
const input = document.createElement('input');
|
||||
const isGrouped = patch.patchGroup && patchGroups[patch.patchGroup].length > 1;
|
||||
|
||||
if (isGrouped) {
|
||||
input.type = 'radio';
|
||||
input.name = `pg_${filename}_${patch.patchGroup}`;
|
||||
input.checked = patch.enabled;
|
||||
input.addEventListener('change', () => {
|
||||
for (const other of patchGroups[patch.patchGroup]) {
|
||||
other.enabled = (other === patch);
|
||||
}
|
||||
this._updateCounts(container);
|
||||
});
|
||||
} else {
|
||||
input.type = 'checkbox';
|
||||
input.checked = patch.enabled;
|
||||
input.addEventListener('change', () => {
|
||||
patch.enabled = input.checked;
|
||||
this._updateCounts(container);
|
||||
});
|
||||
}
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'patch-name';
|
||||
nameSpan.textContent = patch.name;
|
||||
|
||||
header.appendChild(input);
|
||||
header.appendChild(nameSpan);
|
||||
|
||||
if (patch.patchGroup) {
|
||||
const groupBadge = document.createElement('span');
|
||||
groupBadge.className = 'patch-group-badge';
|
||||
groupBadge.textContent = patch.patchGroup;
|
||||
header.appendChild(groupBadge);
|
||||
}
|
||||
|
||||
item.appendChild(header);
|
||||
|
||||
if (patch.description) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'patch-description';
|
||||
desc.textContent = patch.description;
|
||||
item.appendChild(desc);
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
_updateCounts(container) {
|
||||
const sections = container.querySelectorAll('.patch-file-section');
|
||||
let idx = 0;
|
||||
for (const [, { patches }] of Object.entries(this.patchFiles)) {
|
||||
if (patches.length === 0) continue;
|
||||
const count = patches.filter(p => p.enabled).length;
|
||||
const countEl = sections[idx]?.querySelector('.patch-count');
|
||||
if (countEl) countEl.textContent = `${count} / ${patches.length} enabled`;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the overrides map for the WASM patcher.
|
||||
*/
|
||||
getOverrides() {
|
||||
const overrides = {};
|
||||
for (const [filename, { patches }] of Object.entries(this.patchFiles)) {
|
||||
overrides[filename] = {};
|
||||
for (const patch of patches) {
|
||||
overrides[filename][patch.name] = patch.enabled;
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the kobopatch.yaml config string with current overrides.
|
||||
*/
|
||||
generateConfig() {
|
||||
const overrides = this.getOverrides();
|
||||
let yaml = `version: "${this.firmwareVersion}"\n`;
|
||||
yaml += `in: firmware.zip\n`;
|
||||
yaml += `out: out/KoboRoot.tgz\n`;
|
||||
yaml += `log: out/log.txt\n`;
|
||||
yaml += `patchFormat: kobopatch\n`;
|
||||
yaml += `\npatches:\n`;
|
||||
for (const [filename, target] of Object.entries(this.patchConfig)) {
|
||||
yaml += ` ${filename}: ${target}\n`;
|
||||
}
|
||||
yaml += `\noverrides:\n`;
|
||||
for (const [filename, patches] of Object.entries(overrides)) {
|
||||
yaml += ` ${filename}:\n`;
|
||||
for (const [name, enabled] of Object.entries(patches)) {
|
||||
yaml += ` ${name}: ${enabled ? 'yes' : 'no'}\n`;
|
||||
}
|
||||
}
|
||||
return yaml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw patch file contents as a map for the WASM patcher.
|
||||
*/
|
||||
getPatchFileBytes() {
|
||||
const files = {};
|
||||
for (const [filename, { raw }] of Object.entries(this.patchFiles)) {
|
||||
files[filename] = new TextEncoder().encode(raw);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
}
|
||||
6
src/public/patches/index.json
Normal file
6
src/public/patches/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"filename": "patches_4.4523646.zip",
|
||||
"version": "4.45.23646"
|
||||
}
|
||||
]
|
||||
BIN
src/public/patches/patches_4.4523646.zip
Normal file
BIN
src/public/patches/patches_4.4523646.zip
Normal file
Binary file not shown.
353
src/public/style.css
Normal file
353
src/public/style.css
Normal file
@@ -0,0 +1,353 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--card-bg: #fff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-secondary: #555;
|
||||
--primary: #1a6ed8;
|
||||
--primary-hover: #1558b0;
|
||||
--error-bg: #fef2f2;
|
||||
--error-border: #fca5a5;
|
||||
--error-text: #991b1b;
|
||||
--warning-bg: #fffbeb;
|
||||
--warning-border: #fcd34d;
|
||||
--warning-text: #92400e;
|
||||
--success-bg: #f0fdf4;
|
||||
--success-border: #86efac;
|
||||
--success-text: #166534;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 600px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.step p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warning a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-supported {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-unsupported {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Patch file sections */
|
||||
.patch-file-section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.patch-file-section summary {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.patch-file-section summary:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.patch-count {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.patch-list {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.patch-item {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.patch-item + .patch-item {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.patch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.patch-header input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.patch-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.patch-group-badge {
|
||||
font-size: 0.75rem;
|
||||
background: #e8e8e8;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.patch-description {
|
||||
margin-top: 0.35rem;
|
||||
margin-left: 1.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-line;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Firmware input */
|
||||
input[type="file"] {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#build-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.build-log {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #1a1a1a;
|
||||
color: #a0a0a0;
|
||||
border-radius: 6px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-log {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.step a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.fallback-hint {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e40af;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#firmware-download-url {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
59
wip/todo.md
59
wip/todo.md
@@ -4,51 +4,46 @@
|
||||
|
||||
- [x] Device detection proof of concept (File System Access API)
|
||||
- [x] Serial prefix → model mapping (verified against official Kobo help page)
|
||||
- [x] Architecture planning (updated: fully client-side, no PHP backend)
|
||||
- [x] Architecture planning (fully client-side WASM, no backend)
|
||||
- [x] Installed Go via Homebrew (v1.26.1)
|
||||
- [x] Verified all kobopatch tests pass natively
|
||||
- [x] Verified all kobopatch tests pass under `GOOS=js GOARCH=wasm` (via Node.js)
|
||||
- [x] Updated device identification doc with correct model list
|
||||
- [x] Removed obsolete backend-api.md
|
||||
- [x] Verified all kobopatch tests pass natively + WASM
|
||||
- [x] Created `kobopatch-wasm/` with setup.sh, build.sh, go.mod, main.go
|
||||
- [x] WASM wrapper compiles successfully (9.9MB)
|
||||
- [x] All kobopatch tests still pass with our module's replace directives
|
||||
- [x] Cleaned up .gitignore
|
||||
- [x] GitHub/Gitea CI workflow (build + test)
|
||||
- [x] Patch UI: loads patches from zip, parses YAML, renders toggles
|
||||
- [x] PatchGroup mutual exclusion (radio buttons)
|
||||
- [x] Full app flow: connect → detect → configure patches → upload firmware → build → write/download
|
||||
- [x] Patches served from `src/public/patches/` with `index.json` for version discovery
|
||||
- [x] JSZip for client-side zip extraction
|
||||
- [x] Renamed `src/frontend` → `src/public` (webroot)
|
||||
- [x] Moved `patches/` into `src/public/patches/`
|
||||
|
||||
## In Progress
|
||||
## To Test
|
||||
|
||||
### Integration Testing
|
||||
- [ ] End-to-end test in browser with real Kobo device + firmware zip
|
||||
- [ ] Verify WASM loads and `patchFirmware()` works in browser (not just Node.js)
|
||||
- [ ] Verify patch YAML parser handles all 6 patch files correctly
|
||||
- [ ] Verify File System Access API write to `.kobo/KoboRoot.tgz`
|
||||
- [ ] Verify download fallback works
|
||||
|
||||
- [ ] Test WASM binary in actual browser (load wasm_exec.js + kobopatch.wasm)
|
||||
- [ ] Test `patchFirmware()` JS function end-to-end with real firmware zip + patches
|
||||
## Remaining Work
|
||||
|
||||
### Frontend - Patch UI
|
||||
|
||||
- [ ] YAML parsing in JS (extract patch names, descriptions, enabled, PatchGroup)
|
||||
- [ ] `patch-ui.js` — render grouped toggles per target file
|
||||
- [ ] PatchGroup mutual exclusion (radio buttons)
|
||||
- [ ] Generate kobopatch.yaml config string from UI state
|
||||
|
||||
### Frontend - Build Flow
|
||||
|
||||
- [ ] User provides firmware zip (file input / drag-and-drop)
|
||||
- [ ] Load WASM, call `patchFirmware()` with config + firmware + patch files
|
||||
- [ ] Receive KoboRoot.tgz blob, write to `.kobo/` via File System Access API
|
||||
- [ ] Fallback: download KoboRoot.tgz manually
|
||||
- [ ] Bundle patch YAML files as static assets
|
||||
- [ ] Copy `kobopatch.wasm` + `wasm_exec.js` to `src/public/` as part of build
|
||||
- [ ] Run WASM patching in a Web Worker (avoid blocking UI during build)
|
||||
- [ ] Loading/progress feedback during WASM load + build
|
||||
- [ ] Better error messages for common failures
|
||||
- [ ] Test with multiple firmware versions / patch zips
|
||||
|
||||
## Future / Polish
|
||||
|
||||
- [ ] Run WASM patching in a Web Worker (avoid blocking UI)
|
||||
- [ ] Browser compatibility warning with detail
|
||||
- [ ] Loading/progress states during build
|
||||
- [ ] Error handling for common failure modes
|
||||
- [ ] Host as static site (GitHub Pages / Netlify)
|
||||
- [ ] NickelMenu install/uninstall support (bonus feature)
|
||||
- [ ] NickelMenu install/uninstall support
|
||||
- [ ] Dark mode support
|
||||
|
||||
## Architecture Change Log
|
||||
|
||||
- **Switched from PHP backend to fully client-side WASM.**
|
||||
Reason: avoid storing Kobo firmware files on a server (legal risk).
|
||||
The user provides their own firmware zip. kobopatch runs as WASM in the browser.
|
||||
No server needed — can be a static site.
|
||||
- **Patches served from zip files in `src/public/patches/`.**
|
||||
App scans `patches/index.json` to find compatible patch zips for the detected firmware.
|
||||
User provides their own firmware zip. kobopatch runs as WASM in the browser.
|
||||
|
||||
Reference in New Issue
Block a user