All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m45s
459 lines
16 KiB
JavaScript
459 lines
16 KiB
JavaScript
/**
|
|
* 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 } objects.
|
|
* Each entry in index.json may list multiple versions; these are flattened
|
|
* so that each version gets its own entry pointing to the same filename.
|
|
*/
|
|
async function scanAvailablePatches() {
|
|
try {
|
|
const resp = await fetch('patches/index.json');
|
|
if (!resp.ok) return [];
|
|
const list = await resp.json();
|
|
const result = [];
|
|
for (const entry of list) {
|
|
for (const version of entry.versions) {
|
|
result.push({ filename: entry.filename, version });
|
|
}
|
|
}
|
|
return result;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
class PatchUI {
|
|
constructor() {
|
|
// Map of filename -> { raw: string, patches: Array }
|
|
this.patchFiles = {};
|
|
// Parsed from kobopatch.yaml inside the zip
|
|
this.patchConfig = {};
|
|
this.firmwareVersion = null;
|
|
this.configYAML = null;
|
|
// Called when patch selection changes
|
|
this.onChange = null;
|
|
}
|
|
|
|
/**
|
|
* Load patches from a zip file (ArrayBuffer or Uint8Array).
|
|
* The zip should contain kobopatch.yaml and src/*.yaml.
|
|
*/
|
|
async loadFromZip(zipData) {
|
|
const zip = await JSZip.loadAsync(zipData);
|
|
|
|
// Load kobopatch.yaml
|
|
const configFile = zip.file('kobopatch.yaml');
|
|
if (!configFile) {
|
|
throw new Error('Patch zip does not contain kobopatch.yaml');
|
|
}
|
|
this.configYAML = await configFile.async('string');
|
|
const { version, patches } = parsePatchConfig(this.configYAML);
|
|
this.firmwareVersion = version;
|
|
this.patchConfig = patches;
|
|
|
|
// Load each patch YAML file referenced in the config
|
|
this.patchFiles = {};
|
|
for (const filename of Object.keys(patches)) {
|
|
const yamlFile = zip.file(filename);
|
|
if (!yamlFile) {
|
|
console.warn('Patch file referenced in config but missing from zip:', filename);
|
|
continue;
|
|
}
|
|
const raw = await yamlFile.async('string');
|
|
const parsed = parsePatchYAML(raw);
|
|
this.patchFiles[filename] = { raw, patches: parsed };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load patches from a URL pointing to a zip file.
|
|
*/
|
|
async loadFromURL(url) {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) {
|
|
throw new Error('Failed to fetch patch zip: ' + resp.statusText);
|
|
}
|
|
const data = await resp.arrayBuffer();
|
|
await this.loadFromZip(data);
|
|
}
|
|
|
|
/**
|
|
* Render the patch configuration UI into a container element.
|
|
*/
|
|
render(container) {
|
|
container.innerHTML = '';
|
|
|
|
for (const [filename, { patches }] of Object.entries(this.patchFiles)) {
|
|
if (patches.length === 0) continue;
|
|
|
|
const section = document.createElement('details');
|
|
section.className = 'patch-file-section';
|
|
|
|
const summary = document.createElement('summary');
|
|
const label = PATCH_FILE_LABELS[filename] || filename;
|
|
const enabledCount = patches.filter(p => p.enabled).length;
|
|
summary.innerHTML = `<span class="patch-file-name">${label}</span> <span class="patch-count">${enabledCount} / ${patches.length} enabled</span>`;
|
|
section.appendChild(summary);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'patch-list';
|
|
|
|
// Group patches by PatchGroup for mutual exclusion
|
|
const patchGroups = {};
|
|
for (const patch of patches) {
|
|
if (patch.patchGroup) {
|
|
if (!patchGroups[patch.patchGroup]) {
|
|
patchGroups[patch.patchGroup] = [];
|
|
}
|
|
patchGroups[patch.patchGroup].push(patch);
|
|
}
|
|
}
|
|
|
|
// Sort: grouped (radio) patches first, then standalone (checkbox) patches.
|
|
const sorted = [...patches].sort((a, b) => {
|
|
const aGrouped = a.patchGroup && patchGroups[a.patchGroup].length > 1 ? 0 : 1;
|
|
const bGrouped = b.patchGroup && patchGroups[b.patchGroup].length > 1 ? 0 : 1;
|
|
return aGrouped - bGrouped;
|
|
});
|
|
|
|
const renderedGroupNone = {};
|
|
// Group wrapper elements keyed by patchGroup name.
|
|
const groupWrappers = {};
|
|
|
|
for (const patch of sorted) {
|
|
const isGrouped = patch.patchGroup && patchGroups[patch.patchGroup].length > 1;
|
|
|
|
// Create a group wrapper and "None" option before the first patch in each group.
|
|
if (isGrouped && !renderedGroupNone[patch.patchGroup]) {
|
|
renderedGroupNone[patch.patchGroup] = true;
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'patch-group';
|
|
|
|
const groupLabel = document.createElement('div');
|
|
groupLabel.className = 'patch-group-label';
|
|
groupLabel.textContent = patch.patchGroup;
|
|
wrapper.appendChild(groupLabel);
|
|
|
|
const noneItem = document.createElement('div');
|
|
noneItem.className = 'patch-item';
|
|
const noneHeader = document.createElement('label');
|
|
noneHeader.className = 'patch-header';
|
|
const noneInput = document.createElement('input');
|
|
noneInput.type = 'radio';
|
|
noneInput.name = `pg_${filename}_${patch.patchGroup}`;
|
|
noneInput.checked = !patchGroups[patch.patchGroup].some(p => p.enabled);
|
|
noneInput.addEventListener('change', () => {
|
|
for (const other of patchGroups[patch.patchGroup]) {
|
|
other.enabled = false;
|
|
}
|
|
this._updateCounts(container);
|
|
});
|
|
const noneName = document.createElement('span');
|
|
noneName.className = 'patch-name patch-name-none';
|
|
noneName.textContent = 'None (do not patch)';
|
|
noneHeader.appendChild(noneInput);
|
|
noneHeader.appendChild(noneName);
|
|
noneItem.appendChild(noneHeader);
|
|
wrapper.appendChild(noneItem);
|
|
|
|
groupWrappers[patch.patchGroup] = wrapper;
|
|
list.appendChild(wrapper);
|
|
}
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'patch-item';
|
|
|
|
const header = document.createElement('label');
|
|
header.className = 'patch-header';
|
|
|
|
const input = document.createElement('input');
|
|
|
|
if (isGrouped) {
|
|
input.type = 'radio';
|
|
input.name = `pg_${filename}_${patch.patchGroup}`;
|
|
input.checked = patch.enabled;
|
|
input.addEventListener('change', () => {
|
|
for (const other of patchGroups[patch.patchGroup]) {
|
|
other.enabled = (other === patch);
|
|
}
|
|
this._updateCounts(container);
|
|
});
|
|
} else {
|
|
input.type = 'checkbox';
|
|
input.checked = patch.enabled;
|
|
input.addEventListener('change', () => {
|
|
patch.enabled = input.checked;
|
|
this._updateCounts(container);
|
|
});
|
|
}
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'patch-name';
|
|
nameSpan.textContent = patch.name;
|
|
|
|
header.appendChild(input);
|
|
header.appendChild(nameSpan);
|
|
|
|
if (patch.description) {
|
|
const toggle = document.createElement('button');
|
|
toggle.className = 'patch-desc-toggle';
|
|
toggle.textContent = '?';
|
|
toggle.title = 'Toggle description';
|
|
toggle.type = 'button';
|
|
header.appendChild(toggle);
|
|
}
|
|
|
|
item.appendChild(header);
|
|
|
|
if (patch.description) {
|
|
const desc = document.createElement('p');
|
|
desc.className = 'patch-description';
|
|
desc.textContent = patch.description;
|
|
desc.hidden = true;
|
|
item.appendChild(desc);
|
|
|
|
const toggle = header.querySelector('.patch-desc-toggle');
|
|
toggle.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
desc.hidden = !desc.hidden;
|
|
toggle.textContent = desc.hidden ? '?' : '\u2212';
|
|
});
|
|
}
|
|
|
|
if (isGrouped) {
|
|
groupWrappers[patch.patchGroup].appendChild(item);
|
|
} else {
|
|
list.appendChild(item);
|
|
}
|
|
}
|
|
|
|
section.appendChild(list);
|
|
container.appendChild(section);
|
|
}
|
|
}
|
|
|
|
_updateCounts(container) {
|
|
const sections = container.querySelectorAll('.patch-file-section');
|
|
let idx = 0;
|
|
for (const [, { patches }] of Object.entries(this.patchFiles)) {
|
|
if (patches.length === 0) continue;
|
|
const count = patches.filter(p => p.enabled).length;
|
|
const countEl = sections[idx]?.querySelector('.patch-count');
|
|
if (countEl) countEl.textContent = `${count} / ${patches.length} enabled`;
|
|
idx++;
|
|
}
|
|
if (this.onChange) this.onChange();
|
|
}
|
|
|
|
/**
|
|
* Count total enabled patches across all files.
|
|
*/
|
|
getEnabledCount() {
|
|
let count = 0;
|
|
for (const [, { patches }] of Object.entries(this.patchFiles)) {
|
|
count += patches.filter(p => p.enabled).length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Get names of all enabled patches across all files.
|
|
*/
|
|
getEnabledPatches() {
|
|
const names = [];
|
|
for (const [, { patches }] of Object.entries(this.patchFiles)) {
|
|
for (const p of patches) {
|
|
if (p.enabled) names.push(p.name);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
}
|