1
0

Initial commit

This commit is contained in:
2026-03-15 21:34:29 +01:00
commit d646a4b766
10 changed files with 740 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
firmware
kobo_usb_root
kobopatch-src
kobopatch
.claude

17
README.txt Normal file
View File

@@ -0,0 +1,17 @@
I would like to build a web application that uses the USB file system API with Chrome to interface with a Kobo Libra Color / Kobo Clara Color / Kobo Clara BW and provide a gui for custom kobo patches.
The website should make it easy to configure which patches need to be executed, run the patcher, connect to the USB device, and place the KoboRoot.tgz in the .kobo directory, after which the user will be instructed to reboot.
To verify nothing bad can happen, we should make sure that we can identify what device and operating system version a particular device is before starting. I've copied the root of my Kobo Libra Color's accessible filesystem over to the kobo_usb_root folder for further research.
So, in short, what's needed:
- Determining the operating system and device (via the browser / usb filesystem API)
- Downloading the firmware for that version from https://pgaskin.net/KoboStuff/kobofirmware.html (currently we can hardcode only the latest release? I've included the firmware in the ./firmware directory)
- Applying patches
- Copying the patch to the target device via the browser
- ... that's it?
Patches are made available via the MobileRead forums and it would be necessary to manually update this patcher when new kobo os versions come out.
As a bonus, it would be nice if we could also install NickelMenu (see https://pgaskin.net/NickelMenu/) via this method as well. (Or uninstall it, by placing the correct file in the correct location.)

70
src/frontend/app.js Normal file
View File

@@ -0,0 +1,70 @@
(() => {
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);
});
})();

59
src/frontend/index.html Normal file
View File

@@ -0,0 +1,59 @@
<!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 &amp; 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>

133
src/frontend/kobo-device.js Normal file
View File

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

169
src/frontend/style.css Normal file
View File

@@ -0,0 +1,169 @@
*,
*::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;
}

84
wip/architecture.md Normal file
View File

@@ -0,0 +1,84 @@
# Kobopatch Web UI - Architecture
## Overview
Fully client-side web app. No backend server needed — can be hosted as a static site.
kobopatch is compiled from Go to WebAssembly and runs entirely in the browser.
```
Browser
├── index.html + CSS + JS
├── kobopatch.wasm (Go compiled to WASM)
├── Patch YAML files (bundled as static assets)
└── File System Access API (read/write Kobo USB drive)
```
## User Flow
1. Connect Kobo via USB
2. Click "Select Kobo Drive" → browser reads `.kobo/version`
3. App detects model + firmware version from serial prefix
4. User provides firmware zip file (file picker / drag-and-drop)
5. App shows available patches with toggles, grouped by target binary
6. User configures patches (enable/disable)
7. Click "Build" → WASM runs kobopatch in-browser
8. App writes resulting `KoboRoot.tgz` to `.kobo/` on device
9. User ejects device and reboots Kobo
## Components
### `kobo-device.js` — Device Access
- File System Access API for reading `.kobo/version`
- Serial prefix → model name mapping
- Writing `KoboRoot.tgz` back to `.kobo/`
### `patch-ui.js` — Patch Configuration
- Parses patch YAML files (bundled or fetched)
- Renders patch list with toggles grouped by target file
- Enforces PatchGroup mutual exclusion
- Generates overrides config
### `kobopatch.wasm` — Patching Engine
- Go source in `kobopatch-src/`, compiled with `GOOS=js GOARCH=wasm`
- Custom WASM wrapper accepts in-memory inputs:
- Config YAML (generated from UI state)
- Firmware zip (from user file picker)
- Patch YAML files (bundled)
- Returns `KoboRoot.tgz` bytes
- No filesystem or exec calls — everything in-memory
### Static Assets
- Patch YAML files from `kobopatch/src/*.yaml`
- `wasm_exec.js` (Go's WASM support JS)
- The WASM binary itself
## File Structure
```
src/
frontend/
index.html # Single page app
style.css # Styling
app.js # Main controller / flow orchestration
kobo-device.js # File System Access API + device identification
patch-ui.js # Patch list rendering + toggle logic (TODO)
wasm_exec.js # Go WASM support (from Go SDK)
kobopatch.wasm # Compiled WASM binary
patches/ # Bundled patch YAML files
kobopatch-src/ # Cloned kobopatch Go source
wasm/ # WASM wrapper (to be created)
```
## Key Constraints
- **Chromium-only**: File System Access API not available in Firefox/Safari
- Fallback: offer KoboRoot.tgz as download with manual copy instructions
- **User provides firmware**: we don't host firmware files (legal reasons)
- **WASM binary size**: Go WASM builds are typically 5-15MB
- Mitigated by gzip compression on static hosting (~2-5MB)
- **Memory usage**: firmware zips are ~150MB, patching happens in-memory
- Should be fine on modern desktops (USB implies desktop use)
## Running
Any static file server, e.g.: `python3 -m http.server -d src/frontend/ 8080`

View File

@@ -0,0 +1,65 @@
# Device Identification
## Source: `.kobo/version`
The file contains a single line with 6 comma-separated fields:
```
N4284B5215352,4.9.77,4.45.23646,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390
```
| Index | Value | Meaning |
|-------|-------|---------|
| 0 | `N4284B5215352` | Device serial number |
| 1 | `4.9.77` | Unknown (kernel version?) |
| 2 | `4.45.23646` | **Firmware version** (what kobopatch matches against) |
| 3 | `4.9.77` | Unknown |
| 4 | `4.9.77` | Unknown |
| 5 | `00000000-0000-0000-0000-000000000390` | Hardware platform UUID |
## Serial Prefix → Model Mapping
The first 3-4 characters of the serial identify the device model.
Source: https://help.kobo.com/hc/en-us/articles/360019676973
### Current eReaders
| Prefix | Model |
|--------|-------|
| N428 | Kobo Libra Colour |
| N367 | Kobo Clara Colour |
| N365 / P365 | Kobo Clara BW |
| N605 | Kobo Elipsa 2E |
| N506 | Kobo Clara 2E |
| N778 | Kobo Sage |
| N418 | Kobo Libra 2 |
| N604 | Kobo Elipsa |
| N306 | Kobo Nia |
| N873 | Kobo Libra H2O |
| N782 | Kobo Forma |
| N249 | Kobo Clara HD |
### Older eReaders
| Prefix | Model |
|--------|-------|
| N867 | Kobo Aura H2O Edition 2 |
| N709 | Kobo Aura ONE |
| N236 | Kobo Aura Edition 2 |
| N587 | Kobo Touch 2.0 |
| N437 | Kobo Glo HD |
| N250 | Kobo Aura H2O |
| N514 | Kobo Aura |
| N204 | Kobo Aura HD |
| N613 | Kobo Glo |
| N705 | Kobo Mini |
| N905 | Kobo Touch |
| N416 | Kobo Original |
| N647 / N47B | Kobo Wireless |
## Firmware
- The user provides their own firmware zip (not hosted by us for legal reasons)
- Both Libra Colour and Clara BW/Colour currently use firmware `4.45.23646`
- The firmware zip filename matches what `kobopatch.yaml` references: `kobo-update-4.45.23646.zip`
- Different device families may have different firmware zips even for the same version number

85
wip/patch-format.md Normal file
View File

@@ -0,0 +1,85 @@
# Kobopatch Patch Format
## kobopatch.yaml (Main Config)
```yaml
version: 4.45.23646
in: src/kobo-update-4.45.23646.zip
out: out/KoboRoot.tgz
log: out/log.txt
patchFormat: kobopatch
patches:
src/nickel.yaml: usr/local/Kobo/nickel
src/nickel_custom.yaml: usr/local/Kobo/nickel
src/libadobe.so.yaml: usr/local/Kobo/libadobe.so
src/libnickel.so.1.0.0.yaml: usr/local/Kobo/libnickel.so.1.0.0
src/librmsdk.so.1.0.0.yaml: usr/local/Kobo/librmsdk.so.1.0.0
src/cloud_sync.yaml: usr/local/Kobo/libnickel.so.1.0.0
overrides:
src/nickel.yaml:
Patch Name Here: yes
Another Patch: no
src/libnickel.so.1.0.0.yaml:
Some Other Patch: yes
```
The `overrides` section is what the web UI generates. Everything else stays fixed.
## Patch YAML Files
Each file contains one or more patches as top-level YAML keys:
```yaml
Patch Name:
- Enabled: no
- PatchGroup: Optional Group Name # patches in same group are mutually exclusive
- Description: |
Multi-line description text.
Can span multiple lines.
- <patch instructions...> # FindZlib, ReplaceBytes, etc. (opaque to UI)
```
### Fields the UI cares about
| Field | Required | Description |
|-------|----------|-------------|
| Name | yes | Top-level YAML key |
| Enabled | yes | `yes` or `no` - default state |
| Description | no | Human-readable description (single line or multi-line `\|` block) |
| PatchGroup | no | Mutual exclusion group - only one patch per group can be enabled |
### Patch Files and Their Targets
| File | Binary Target | Patch Count |
|------|--------------|-------------|
| nickel.yaml | nickel (main UI) | ~17 patches |
| nickel_custom.yaml | nickel | ~2 patches |
| libnickel.so.1.0.0.yaml | libnickel.so | ~50+ patches (largest) |
| libadobe.so.yaml | libadobe.so | 1 patch |
| librmsdk.so.1.0.0.yaml | librmsdk.so | ~10 patches |
| cloud_sync.yaml | libnickel.so | 1 patch |
## PatchGroup Rules
Patches with the same `PatchGroup` value within a file are mutually exclusive.
Only one can be enabled at a time. The UI should render these as radio buttons.
Example from libnickel.so.1.0.0.yaml:
- "My 10 line spacing values" (PatchGroup: Line spacing values alternatives)
- "My 24 line spacing values" (PatchGroup: Line spacing values alternatives)
## YAML Parsing Strategy
PHP doesn't have `yaml_parse` available on this system. Options:
1. Use a simple line-by-line parser that extracts only the fields we need
2. Install php-yaml extension
3. Use a pure PHP YAML library (e.g., Symfony YAML component)
The patch YAML structure is regular enough for a targeted parser:
- Top-level keys (no indentation, ending with `:`) are patch names
- `- Enabled: yes/no` on the next level
- `- Description: |` followed by indented text, or `- Description: single line`
- `- PatchGroup: group name`
- Everything else can be ignored

53
wip/todo.md Normal file
View File

@@ -0,0 +1,53 @@
# TODO
## Done
- [x] Device detection proof of concept (File System Access API)
- [x] Serial prefix → model mapping (verified against official Kobo help page)
- [x] Architecture planning
- [x] Cloned kobopatch source (`kobopatch-src/`)
## In Progress
### WASM Build of kobopatch
- [ ] Write Go WASM wrapper (`kobopatch-src/wasm/`) exposing `PatchFirmware()` to JS
- Accepts: config YAML (bytes), firmware zip (bytes), patch YAML files (bytes)
- Returns: KoboRoot.tgz (bytes) or error
- All I/O in-memory, no filesystem access
- [ ] Refactor `kobopatch/kobopatch.go` main logic into reusable function
- Strip `os.Open`/`os.Create` → use `io.Reader`/`io.Writer`
- Strip `os.Chdir` → resolve paths in memory
- Strip `exec.Command` (lrelease) → skip translations
- [ ] Compile with `GOOS=js GOARCH=wasm go build`
- [ ] Test WASM binary loads and runs in browser
### Frontend - Patch UI
- [ ] `patch-ui.js` - parse patch YAML client-side, render grouped toggles
- [ ] PatchGroup mutual exclusion (radio buttons)
- [ ] Bundle patch YAML files as static assets (or fetch from known URL)
- [ ] Generate kobopatch.yaml config from UI state
### Frontend - Build Flow
- [ ] User provides firmware zip (file input or drag-and-drop)
- [ ] Load WASM module, pass firmware + config + patches
- [ ] Receive KoboRoot.tgz blob from WASM
- [ ] Write KoboRoot.tgz to device via File System Access API
- [ ] Fallback: download KoboRoot.tgz if FS Access write fails
## Future / Polish
- [ ] Browser compatibility warning with more detail
- [ ] Loading/progress states during WASM build (Web Worker?)
- [ ] Error handling for common failure modes
- [ ] Host as static site (GitHub Pages / Netlify)
- [ ] NickelMenu install/uninstall support (bonus feature)
## 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.