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/kobopatch.wasm
|
||||||
kobopatch-wasm/wasm_exec.js
|
kobopatch-wasm/wasm_exec.js
|
||||||
|
|
||||||
|
# WASM artifacts copied to webroot for serving
|
||||||
|
src/public/kobopatch.wasm
|
||||||
|
src/public/wasm_exec.js
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude
|
.claude
|
||||||
@@ -8,10 +8,16 @@ if [ ! -d "$SCRIPT_DIR/kobopatch-src" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PUBLIC_DIR="$SCRIPT_DIR/../src/public"
|
||||||
|
|
||||||
echo "Building kobopatch WASM..."
|
echo "Building kobopatch WASM..."
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
GOOS=js GOARCH=wasm go build -o kobopatch.wasm .
|
GOOS=js GOARCH=wasm go build -o kobopatch.wasm .
|
||||||
|
|
||||||
echo "WASM binary size: $(du -h kobopatch.wasm | cut -f1)"
|
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[0]: configYAML (string) - the kobopatch.yaml config content
|
||||||
// args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes
|
// args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes
|
||||||
// args[2]: patchFiles (Object) - map of filename -> Uint8Array patch file contents
|
// 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.
|
// Returns: a Promise that resolves to { tgz: Uint8Array, log: string } or rejects with an error.
|
||||||
func jsPatchFirmware(this js.Value, args []js.Value) interface{} {
|
func jsPatchFirmware(this js.Value, args []js.Value) interface{} {
|
||||||
@@ -107,17 +108,31 @@ func runPatch(args []js.Value) (*patchResult, error) {
|
|||||||
patchFiles[key] = buf
|
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.
|
// 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
|
var logBuf bytes.Buffer
|
||||||
logf := func(format string, a ...interface{}) {
|
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.
|
// Parse config.
|
||||||
|
logf("Parsing config...")
|
||||||
var config Config
|
var config Config
|
||||||
dec := yaml.NewDecoder(bytes.NewReader(configYAML))
|
dec := yaml.NewDecoder(bytes.NewReader(configYAML))
|
||||||
if err := dec.Decode(&config); err != nil {
|
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")
|
return nil, errors.New("invalid config: version and patches are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
logf("kobopatch wasm")
|
|
||||||
logf("Firmware version: %s", config.Version)
|
logf("Firmware version: %s", config.Version)
|
||||||
|
|
||||||
// Open the firmware zip from memory.
|
// 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)))
|
zipReader, err := zip.NewReader(bytes.NewReader(firmwareZip), int64(len(firmwareZip)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not open firmware zip: %w", err)
|
return nil, fmt.Errorf("could not open firmware zip: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and extract KoboRoot.tgz from the zip.
|
// Find and extract KoboRoot.tgz from the zip.
|
||||||
|
logf("Extracting KoboRoot.tgz from firmware...")
|
||||||
var koboRootTgz io.ReadCloser
|
var koboRootTgz io.ReadCloser
|
||||||
for _, f := range zipReader.File {
|
for _, f := range zipReader.File {
|
||||||
if f.Name == "KoboRoot.tgz" {
|
if f.Name == "KoboRoot.tgz" {
|
||||||
@@ -285,7 +301,7 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify consistency.
|
// Verify consistency.
|
||||||
logf("\nVerifying output KoboRoot.tgz...")
|
logf("Verifying output KoboRoot.tgz...")
|
||||||
verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes()))
|
verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not verify output: %w", err)
|
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';
|
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 {
|
class KoboDevice {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.directoryHandle = null;
|
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] Device detection proof of concept (File System Access API)
|
||||||
- [x] Serial prefix → model mapping (verified against official Kobo help page)
|
- [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] Installed Go via Homebrew (v1.26.1)
|
||||||
- [x] Verified all kobopatch tests pass natively
|
- [x] Verified all kobopatch tests pass natively + WASM
|
||||||
- [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] Created `kobopatch-wasm/` with setup.sh, build.sh, go.mod, main.go
|
- [x] Created `kobopatch-wasm/` with setup.sh, build.sh, go.mod, main.go
|
||||||
- [x] WASM wrapper compiles successfully (9.9MB)
|
- [x] WASM wrapper compiles successfully (9.9MB)
|
||||||
- [x] All kobopatch tests still pass with our module's replace directives
|
- [x] GitHub/Gitea CI workflow (build + test)
|
||||||
- [x] Cleaned up .gitignore
|
- [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)
|
## Remaining Work
|
||||||
- [ ] Test `patchFirmware()` JS function end-to-end with real firmware zip + patches
|
|
||||||
|
|
||||||
### Frontend - Patch UI
|
- [ ] 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)
|
||||||
- [ ] YAML parsing in JS (extract patch names, descriptions, enabled, PatchGroup)
|
- [ ] Loading/progress feedback during WASM load + build
|
||||||
- [ ] `patch-ui.js` — render grouped toggles per target file
|
- [ ] Better error messages for common failures
|
||||||
- [ ] PatchGroup mutual exclusion (radio buttons)
|
- [ ] Test with multiple firmware versions / patch zips
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## Future / Polish
|
## 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)
|
- [ ] Host as static site (GitHub Pages / Netlify)
|
||||||
- [ ] NickelMenu install/uninstall support (bonus feature)
|
- [ ] NickelMenu install/uninstall support
|
||||||
|
- [ ] Dark mode support
|
||||||
|
|
||||||
## Architecture Change Log
|
## Architecture Change Log
|
||||||
|
|
||||||
- **Switched from PHP backend to fully client-side WASM.**
|
- **Switched from PHP backend to fully client-side WASM.**
|
||||||
Reason: avoid storing Kobo firmware files on a server (legal risk).
|
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.
|
- **Patches served from zip files in `src/public/patches/`.**
|
||||||
No server needed — can be a static site.
|
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