Standardize line endings
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m45s
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m45s
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
42
LICENSE
42
LICENSE
@@ -1,21 +1,21 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Nico Verbruggen
|
Copyright (c) 2026 Nico Verbruggen
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|||||||
@@ -1,224 +1,224 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
|
|
||||||
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
|
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
|
||||||
// with only "Remove footer (row3) on new home screen" enabled.
|
// with only "Remove footer (row3) on new home screen" enabled.
|
||||||
const EXPECTED_SHA1 = {
|
const EXPECTED_SHA1 = {
|
||||||
'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d',
|
'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d',
|
||||||
'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c',
|
'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c',
|
||||||
'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1',
|
'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1',
|
||||||
'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5',
|
'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIRMWARE_PATH = process.env.FIRMWARE_ZIP
|
const FIRMWARE_PATH = process.env.FIRMWARE_ZIP
|
||||||
|| path.resolve(__dirname, '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
|
|| path.resolve(__dirname, '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
|
||||||
|
|
||||||
const WEBROOT_FIRMWARE = path.resolve(__dirname, '..', 'web', 'public', '_test_firmware.zip');
|
const WEBROOT_FIRMWARE = path.resolve(__dirname, '..', 'web', 'public', '_test_firmware.zip');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
|
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
|
||||||
*/
|
*/
|
||||||
function parseTar(buffer) {
|
function parseTar(buffer) {
|
||||||
const entries = {};
|
const entries = {};
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
while (offset < buffer.length) {
|
||||||
const header = buffer.subarray(offset, offset + 512);
|
const header = buffer.subarray(offset, offset + 512);
|
||||||
if (header.every(b => b === 0)) break;
|
if (header.every(b => b === 0)) break;
|
||||||
|
|
||||||
let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
|
let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
|
||||||
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
|
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
|
||||||
if (prefix) name = prefix + '/' + name;
|
if (prefix) name = prefix + '/' + name;
|
||||||
name = name.replace(/^\.\//, '');
|
name = name.replace(/^\.\//, '');
|
||||||
|
|
||||||
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
|
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
|
||||||
const size = parseInt(sizeStr, 8) || 0;
|
const size = parseInt(sizeStr, 8) || 0;
|
||||||
const typeFlag = header[156];
|
const typeFlag = header[156];
|
||||||
|
|
||||||
offset += 512;
|
offset += 512;
|
||||||
|
|
||||||
if (typeFlag === 48 || typeFlag === 0) {
|
if (typeFlag === 48 || typeFlag === 0) {
|
||||||
entries[name] = buffer.subarray(offset, offset + size);
|
entries[name] = buffer.subarray(offset, offset + size);
|
||||||
}
|
}
|
||||||
|
|
||||||
offset += Math.ceil(size / 512) * 512;
|
offset += Math.ceil(size / 512) * 512;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
|
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
|
||||||
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
|
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
|
||||||
|
|
||||||
// Clean up the symlink after the test.
|
// Clean up the symlink after the test.
|
||||||
test.afterEach(() => {
|
test.afterEach(() => {
|
||||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('full manual mode patching pipeline', async ({ page }) => {
|
test('full manual mode patching pipeline', async ({ page }) => {
|
||||||
if (!fs.existsSync(FIRMWARE_PATH)) {
|
if (!fs.existsSync(FIRMWARE_PATH)) {
|
||||||
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
|
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Symlink the cached firmware into the webroot so the app can fetch it locally.
|
// Symlink the cached firmware into the webroot so the app can fetch it locally.
|
||||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||||
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
|
||||||
// Override the firmware download URLs to point at the local server.
|
// Override the firmware download URLs to point at the local server.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
||||||
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
||||||
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 1: Switch to manual mode.
|
// Step 1: Switch to manual mode.
|
||||||
await page.click('#btn-manual-from-auto');
|
await page.click('#btn-manual-from-auto');
|
||||||
await expect(page.locator('#step-manual')).not.toBeHidden();
|
await expect(page.locator('#step-manual')).not.toBeHidden();
|
||||||
|
|
||||||
// Step 2: Select firmware version.
|
// Step 2: Select firmware version.
|
||||||
await page.selectOption('#manual-version', '4.45.23646');
|
await page.selectOption('#manual-version', '4.45.23646');
|
||||||
await expect(page.locator('#manual-model')).not.toBeHidden();
|
await expect(page.locator('#manual-model')).not.toBeHidden();
|
||||||
|
|
||||||
// Step 3: Select Kobo Libra Colour (N428).
|
// Step 3: Select Kobo Libra Colour (N428).
|
||||||
await page.selectOption('#manual-model', 'N428');
|
await page.selectOption('#manual-model', 'N428');
|
||||||
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
||||||
await page.click('#btn-manual-confirm');
|
await page.click('#btn-manual-confirm');
|
||||||
|
|
||||||
// Step 4: Wait for patches to load.
|
// Step 4: Wait for patches to load.
|
||||||
await expect(page.locator('#step-patches')).not.toBeHidden();
|
await expect(page.locator('#step-patches')).not.toBeHidden();
|
||||||
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
||||||
|
|
||||||
// Step 5: Enable "Remove footer (row3) on new home screen".
|
// Step 5: Enable "Remove footer (row3) on new home screen".
|
||||||
const patchName = page.locator('.patch-name', { hasText: 'Remove footer (row3) on new home screen' }).first();
|
const patchName = page.locator('.patch-name', { hasText: 'Remove footer (row3) on new home screen' }).first();
|
||||||
const patchSection = patchName.locator('xpath=ancestor::details');
|
const patchSection = patchName.locator('xpath=ancestor::details');
|
||||||
await patchSection.locator('summary').click();
|
await patchSection.locator('summary').click();
|
||||||
await expect(patchName).toBeVisible();
|
await expect(patchName).toBeVisible();
|
||||||
await patchName.locator('xpath=ancestor::label').locator('input').check();
|
await patchName.locator('xpath=ancestor::label').locator('input').check();
|
||||||
|
|
||||||
// Verify patch count updated.
|
// Verify patch count updated.
|
||||||
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
|
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
|
||||||
await expect(page.locator('#btn-patches-next')).toBeEnabled();
|
await expect(page.locator('#btn-patches-next')).toBeEnabled();
|
||||||
|
|
||||||
// Step 6: Continue to build step.
|
// Step 6: Continue to build step.
|
||||||
await page.click('#btn-patches-next');
|
await page.click('#btn-patches-next');
|
||||||
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||||
await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646');
|
await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646');
|
||||||
await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour');
|
await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour');
|
||||||
|
|
||||||
// Step 7: Build and wait for completion.
|
// Step 7: Build and wait for completion.
|
||||||
await page.click('#btn-build');
|
await page.click('#btn-build');
|
||||||
|
|
||||||
const doneOrError = await Promise.race([
|
const doneOrError = await Promise.race([
|
||||||
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
||||||
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (doneOrError === 'error') {
|
if (doneOrError === 'error') {
|
||||||
const errorMsg = await page.locator('#error-message').textContent();
|
const errorMsg = await page.locator('#error-message').textContent();
|
||||||
throw new Error(`Build failed: ${errorMsg}`);
|
throw new Error(`Build failed: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(page.locator('#build-status')).toContainText('Patching complete');
|
await expect(page.locator('#build-status')).toContainText('Patching complete');
|
||||||
|
|
||||||
// Step 8: Download KoboRoot.tgz and verify checksums.
|
// Step 8: Download KoboRoot.tgz and verify checksums.
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.click('#btn-download'),
|
page.click('#btn-download'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
||||||
const downloadPath = await download.path();
|
const downloadPath = await download.path();
|
||||||
const tgzData = fs.readFileSync(downloadPath);
|
const tgzData = fs.readFileSync(downloadPath);
|
||||||
|
|
||||||
const tarData = zlib.gunzipSync(tgzData);
|
const tarData = zlib.gunzipSync(tgzData);
|
||||||
const entries = parseTar(tarData);
|
const entries = parseTar(tarData);
|
||||||
|
|
||||||
for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) {
|
for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) {
|
||||||
const data = entries[name];
|
const data = entries[name];
|
||||||
expect(data, `missing binary in output: ${name}`).toBeDefined();
|
expect(data, `missing binary in output: ${name}`).toBeDefined();
|
||||||
const actualHash = crypto.createHash('sha1').update(data).digest('hex');
|
const actualHash = crypto.createHash('sha1').update(data).digest('hex');
|
||||||
expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash);
|
expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restore original firmware pipeline', async ({ page }) => {
|
test('restore original firmware pipeline', async ({ page }) => {
|
||||||
if (!fs.existsSync(FIRMWARE_PATH)) {
|
if (!fs.existsSync(FIRMWARE_PATH)) {
|
||||||
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
|
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Symlink the cached firmware into the webroot so the app can fetch it locally.
|
// Symlink the cached firmware into the webroot so the app can fetch it locally.
|
||||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||||
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||||
|
|
||||||
// Override the firmware download URLs to point at the local server.
|
// Override the firmware download URLs to point at the local server.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
||||||
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
||||||
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 1: Switch to manual mode.
|
// Step 1: Switch to manual mode.
|
||||||
await page.click('#btn-manual-from-auto');
|
await page.click('#btn-manual-from-auto');
|
||||||
await expect(page.locator('#step-manual')).not.toBeHidden();
|
await expect(page.locator('#step-manual')).not.toBeHidden();
|
||||||
|
|
||||||
// Step 2: Select firmware version.
|
// Step 2: Select firmware version.
|
||||||
await page.selectOption('#manual-version', '4.45.23646');
|
await page.selectOption('#manual-version', '4.45.23646');
|
||||||
await expect(page.locator('#manual-model')).not.toBeHidden();
|
await expect(page.locator('#manual-model')).not.toBeHidden();
|
||||||
|
|
||||||
// Step 3: Select Kobo Libra Colour (N428).
|
// Step 3: Select Kobo Libra Colour (N428).
|
||||||
await page.selectOption('#manual-model', 'N428');
|
await page.selectOption('#manual-model', 'N428');
|
||||||
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
|
||||||
await page.click('#btn-manual-confirm');
|
await page.click('#btn-manual-confirm');
|
||||||
|
|
||||||
// Step 4: Wait for patches to load, then continue with zero patches selected.
|
// Step 4: Wait for patches to load, then continue with zero patches selected.
|
||||||
await expect(page.locator('#step-patches')).not.toBeHidden();
|
await expect(page.locator('#step-patches')).not.toBeHidden();
|
||||||
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
||||||
await expect(page.locator('#patch-count-hint')).toContainText('restore the original');
|
await expect(page.locator('#patch-count-hint')).toContainText('restore the original');
|
||||||
await page.click('#btn-patches-next');
|
await page.click('#btn-patches-next');
|
||||||
|
|
||||||
// Step 5: Verify build step shows restore text.
|
// Step 5: Verify build step shows restore text.
|
||||||
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||||
await expect(page.locator('#firmware-description')).toContainText('without modifications');
|
await expect(page.locator('#firmware-description')).toContainText('without modifications');
|
||||||
await expect(page.locator('#btn-build')).toContainText('Restore Original Software');
|
await expect(page.locator('#btn-build')).toContainText('Restore Original Software');
|
||||||
|
|
||||||
// Step 6: Build and wait for completion.
|
// Step 6: Build and wait for completion.
|
||||||
await page.click('#btn-build');
|
await page.click('#btn-build');
|
||||||
|
|
||||||
const doneOrError = await Promise.race([
|
const doneOrError = await Promise.race([
|
||||||
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
|
||||||
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (doneOrError === 'error') {
|
if (doneOrError === 'error') {
|
||||||
const errorMsg = await page.locator('#error-message').textContent();
|
const errorMsg = await page.locator('#error-message').textContent();
|
||||||
throw new Error(`Restore failed: ${errorMsg}`);
|
throw new Error(`Restore failed: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(page.locator('#build-status')).toContainText('Software extracted');
|
await expect(page.locator('#build-status')).toContainText('Software extracted');
|
||||||
|
|
||||||
// Step 7: Download KoboRoot.tgz and verify it matches the original.
|
// Step 7: Download KoboRoot.tgz and verify it matches the original.
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.click('#btn-download'),
|
page.click('#btn-download'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
|
||||||
const downloadPath = await download.path();
|
const downloadPath = await download.path();
|
||||||
const tgzData = fs.readFileSync(downloadPath);
|
const tgzData = fs.readFileSync(downloadPath);
|
||||||
const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex');
|
const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex');
|
||||||
expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1);
|
expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
const { defineConfig } = require('@playwright/test');
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
testMatch: '*.spec.js',
|
testMatch: '*.spec.js',
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:8889',
|
baseURL: 'http://localhost:8889',
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: ['--disable-dev-shm-usage'],
|
args: ['--disable-dev-shm-usage'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'python3 -m http.server -d ../web/public 8889',
|
command: 'python3 -m http.server -d ../web/public 8889',
|
||||||
port: 8889,
|
port: 8889,
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,175 +1,175 @@
|
|||||||
//go:build js && wasm
|
//go:build js && wasm
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIntegrationPatch runs the full patching pipeline with real patch files
|
// TestIntegrationPatch runs the full patching pipeline with real patch files
|
||||||
// and validates SHA1 checksums of the patched binaries.
|
// and validates SHA1 checksums of the patched binaries.
|
||||||
//
|
//
|
||||||
// Requires the firmware zip to be present at testdata/kobo-update-4.45.23646.zip
|
// Requires the firmware zip to be present at testdata/kobo-update-4.45.23646.zip
|
||||||
// (or the path set via FIRMWARE_ZIP env var). Run test-integration.sh to download
|
// (or the path set via FIRMWARE_ZIP env var). Run test-integration.sh to download
|
||||||
// the firmware and execute this test.
|
// the firmware and execute this test.
|
||||||
func TestIntegrationPatch(t *testing.T) {
|
func TestIntegrationPatch(t *testing.T) {
|
||||||
firmwarePath := os.Getenv("FIRMWARE_ZIP")
|
firmwarePath := os.Getenv("FIRMWARE_ZIP")
|
||||||
if firmwarePath == "" {
|
if firmwarePath == "" {
|
||||||
firmwarePath = "testdata/kobo-update-4.45.23646.zip"
|
firmwarePath = "testdata/kobo-update-4.45.23646.zip"
|
||||||
}
|
}
|
||||||
|
|
||||||
firmwareZip, err := os.ReadFile(firmwarePath)
|
firmwareZip, err := os.ReadFile(firmwarePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("firmware zip not available at %s (run test-integration.sh to download): %v", firmwarePath, err)
|
t.Skipf("firmware zip not available at %s (run test-integration.sh to download): %v", firmwarePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read patch files from the patches zip.
|
// Read patch files from the patches zip.
|
||||||
patchesZipPath := "../web/public/patches/patches_4.4523646.zip"
|
patchesZipPath := "../web/public/patches/patches_4.45.23646.zip"
|
||||||
patchesZip, err := os.ReadFile(patchesZipPath)
|
patchesZip, err := os.ReadFile(patchesZipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not read patches zip: %v", err)
|
t.Fatalf("could not read patches zip: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
patchFiles, err := extractPatchFiles(patchesZip)
|
patchFiles, err := extractPatchFiles(patchesZip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not extract patch files: %v", err)
|
t.Fatalf("could not extract patch files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config: all patches at their defaults, with one override enabled.
|
// Config: all patches at their defaults, with one override enabled.
|
||||||
configYAML := `
|
configYAML := `
|
||||||
version: 4.45.23646
|
version: 4.45.23646
|
||||||
in: unused
|
in: unused
|
||||||
out: unused
|
out: unused
|
||||||
log: unused
|
log: unused
|
||||||
|
|
||||||
patches:
|
patches:
|
||||||
src/nickel.yaml: usr/local/Kobo/nickel
|
src/nickel.yaml: usr/local/Kobo/nickel
|
||||||
src/nickel_custom.yaml: usr/local/Kobo/nickel
|
src/nickel_custom.yaml: usr/local/Kobo/nickel
|
||||||
src/libadobe.so.yaml: usr/local/Kobo/libadobe.so
|
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/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/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
|
src/cloud_sync.yaml: usr/local/Kobo/libnickel.so.1.0.0
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
src/nickel.yaml:
|
src/nickel.yaml:
|
||||||
"Remove footer (row3) on new home screen": yes
|
"Remove footer (row3) on new home screen": yes
|
||||||
`
|
`
|
||||||
|
|
||||||
var logMessages []string
|
var logMessages []string
|
||||||
progress := func(msg string) {
|
progress := func(msg string) {
|
||||||
logMessages = append(logMessages, msg)
|
logMessages = append(logMessages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progress)
|
result, err := patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("patchFirmware failed: %v", err)
|
t.Fatalf("patchFirmware failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.tgzBytes) == 0 {
|
if len(result.tgzBytes) == 0 {
|
||||||
t.Fatal("patchFirmware returned empty tgz")
|
t.Fatal("patchFirmware returned empty tgz")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
|
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
|
||||||
// with only "Remove footer (row3) on new home screen" enabled.
|
// with only "Remove footer (row3) on new home screen" enabled.
|
||||||
expectedSHA1 := map[string]string{
|
expectedSHA1 := map[string]string{
|
||||||
"usr/local/Kobo/libnickel.so.1.0.0": "ef64782895a47ac85f0829f06fffa4816d23512d",
|
"usr/local/Kobo/libnickel.so.1.0.0": "ef64782895a47ac85f0829f06fffa4816d23512d",
|
||||||
"usr/local/Kobo/nickel": "80a607bac515457a6864be8be831df631a01005c",
|
"usr/local/Kobo/nickel": "80a607bac515457a6864be8be831df631a01005c",
|
||||||
"usr/local/Kobo/libadobe.so": "02dc99c71c4fef75401cd49ddc2e63f928a126e1",
|
"usr/local/Kobo/libadobe.so": "02dc99c71c4fef75401cd49ddc2e63f928a126e1",
|
||||||
"usr/local/Kobo/librmsdk.so.1.0.0": "e3819260c9fc539a53db47e9d3fe600ec11633d5",
|
"usr/local/Kobo/librmsdk.so.1.0.0": "e3819260c9fc539a53db47e9d3fe600ec11633d5",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the output tgz and check SHA1 of each patched binary.
|
// Extract the output tgz and check SHA1 of each patched binary.
|
||||||
actualSHA1, err := extractTgzSHA1(result.tgzBytes)
|
actualSHA1, err := extractTgzSHA1(result.tgzBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not extract output tgz: %v", err)
|
t.Fatalf("could not extract output tgz: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, expected := range expectedSHA1 {
|
for name, expected := range expectedSHA1 {
|
||||||
actual, ok := actualSHA1[name]
|
actual, ok := actualSHA1[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
// Try with ./ prefix (tar entries may vary).
|
// Try with ./ prefix (tar entries may vary).
|
||||||
actual, ok = actualSHA1["./"+name]
|
actual, ok = actualSHA1["./"+name]
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("missing binary in output: %s", name)
|
t.Errorf("missing binary in output: %s", name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if actual != expected {
|
if actual != expected {
|
||||||
t.Errorf("SHA1 mismatch for %s:\n expected: %s\n actual: %s", name, expected, actual)
|
t.Errorf("SHA1 mismatch for %s:\n expected: %s\n actual: %s", name, expected, actual)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("OK %s = %s", name, actual)
|
t.Logf("OK %s = %s", name, actual)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("output tgz size: %d bytes", len(result.tgzBytes))
|
t.Logf("output tgz size: %d bytes", len(result.tgzBytes))
|
||||||
t.Logf("log output:\n%s", result.log)
|
t.Logf("log output:\n%s", result.log)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPatchFiles reads a patches zip and returns a map of filename -> contents
|
// extractPatchFiles reads a patches zip and returns a map of filename -> contents
|
||||||
// for all src/*.yaml files.
|
// for all src/*.yaml files.
|
||||||
func extractPatchFiles(zipData []byte) (map[string][]byte, error) {
|
func extractPatchFiles(zipData []byte) (map[string][]byte, error) {
|
||||||
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
|
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
files := make(map[string][]byte)
|
files := make(map[string][]byte)
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
if !strings.HasPrefix(f.Name, "src/") || !strings.HasSuffix(f.Name, ".yaml") {
|
if !strings.HasPrefix(f.Name, "src/") || !strings.HasSuffix(f.Name, ".yaml") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rc, err := f.Open()
|
rc, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open %s: %w", f.Name, err)
|
return nil, fmt.Errorf("open %s: %w", f.Name, err)
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(rc)
|
data, err := io.ReadAll(rc)
|
||||||
rc.Close()
|
rc.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read %s: %w", f.Name, err)
|
return nil, fmt.Errorf("read %s: %w", f.Name, err)
|
||||||
}
|
}
|
||||||
files[f.Name] = data
|
files[f.Name] = data
|
||||||
}
|
}
|
||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractTgzSHA1 reads a tgz and returns a map of entry name -> SHA1 hex string.
|
// extractTgzSHA1 reads a tgz and returns a map of entry name -> SHA1 hex string.
|
||||||
func extractTgzSHA1(tgzData []byte) (map[string]string, error) {
|
func extractTgzSHA1(tgzData []byte) (map[string]string, error) {
|
||||||
gr, err := gzip.NewReader(bytes.NewReader(tgzData))
|
gr, err := gzip.NewReader(bytes.NewReader(tgzData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer gr.Close()
|
defer gr.Close()
|
||||||
|
|
||||||
tr := tar.NewReader(gr)
|
tr := tar.NewReader(gr)
|
||||||
sums := make(map[string]string)
|
sums := make(map[string]string)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
h, err := tr.Next()
|
h, err := tr.Next()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if h.Typeflag != tar.TypeReg {
|
if h.Typeflag != tar.TypeReg {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hasher := sha1.New()
|
hasher := sha1.New()
|
||||||
if _, err := io.Copy(hasher, tr); err != nil {
|
if _, err := io.Copy(hasher, tr); err != nil {
|
||||||
return nil, fmt.Errorf("hash %s: %w", h.Name, err)
|
return nil, fmt.Errorf("hash %s: %w", h.Name, err)
|
||||||
}
|
}
|
||||||
sums[h.Name] = fmt.Sprintf("%x", hasher.Sum(nil))
|
sums[h.Name] = fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sums, nil
|
return sums, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
providers = ["python"]
|
providers = ["python"]
|
||||||
|
|
||||||
[phases.setup]
|
[phases.setup]
|
||||||
nixPkgs = ["git", "curl", "python3Minimal"]
|
nixPkgs = ["git", "curl", "python3Minimal"]
|
||||||
paths = ["/usr/local/go/bin"]
|
paths = ["/usr/local/go/bin"]
|
||||||
cmds = [
|
cmds = [
|
||||||
"curl -sSfL https://go.dev/dl/go1.23.12.linux-amd64.tar.gz | tar -xz -C /usr/local",
|
"curl -sSfL https://go.dev/dl/go1.23.12.linux-amd64.tar.gz | tar -xz -C /usr/local",
|
||||||
"cd kobopatch-wasm && bash setup.sh",
|
"cd kobopatch-wasm && bash setup.sh",
|
||||||
"cd kobopatch-wasm && bash build.sh",
|
"cd kobopatch-wasm && bash build.sh",
|
||||||
]
|
]
|
||||||
|
|
||||||
[start]
|
[start]
|
||||||
cmd = "python3 -m http.server ${PORT:-8080} -d web/public"
|
cmd = "python3 -m http.server ${PORT:-8080} -d web/public"
|
||||||
|
|||||||
16
test.sh
Executable file
16
test.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== Building WASM ==="
|
||||||
|
"$SCRIPT_DIR/kobopatch-wasm/build.sh"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Running WASM integration test ==="
|
||||||
|
"$SCRIPT_DIR/kobopatch-wasm/test-integration.sh"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Running E2E tests (Playwright) ==="
|
||||||
|
cd "$SCRIPT_DIR/e2e"
|
||||||
|
npm test
|
||||||
1055
web/public/app.js
1055
web/public/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,254 +1,254 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>KoboPatch Web UI</title>
|
<title>KoboPatch Web UI</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||||
<link rel="stylesheet" href="style.css?ts=1773672116">
|
<link rel="stylesheet" href="style.css?ts=1773672116">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<h1>KoboPatch <span class="hero-accent">Web UI</span> <span class="beta-pill">beta</span></h1>
|
<h1>KoboPatch <span class="hero-accent">Web UI</span> <span class="beta-pill">beta</span></h1>
|
||||||
<p class="subtitle">Custom patches for your Kobo e-reader</p>
|
<p class="subtitle">Apply patches to your Kobo Libra Colour, Kobo Clara Colour and Kobo Clara BW.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Step indicator -->
|
<!-- Step indicator -->
|
||||||
<nav id="step-nav" class="step-nav" hidden>
|
<nav id="step-nav" class="step-nav" hidden>
|
||||||
<ol>
|
<ol>
|
||||||
<li data-step="1" class="active">Device</li>
|
<li data-step="1" class="active">Device</li>
|
||||||
<li data-step="2">Patches</li>
|
<li data-step="2">Patches</li>
|
||||||
<li data-step="3">Build</li>
|
<li data-step="3">Build</li>
|
||||||
<li data-step="4">Install</li>
|
<li data-step="4">Install</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Step 1a: Connect device (automatic, Chromium only) -->
|
<!-- Step 1a: Connect device (automatic, Chromium only) -->
|
||||||
<section id="step-connect" class="step" hidden>
|
<section id="step-connect" class="step" hidden>
|
||||||
<div class="warning">
|
<div class="warning">
|
||||||
<b>Patching modifies system files on your Kobo, and <u>will void your warranty</u>.</b> This process allows for custom modifications to be applied, or undone. If something has gone wrong,
|
<b>Patching modifies system files on your Kobo, and <u>will void your warranty</u>.</b> This process allows for custom modifications to be applied, or undone. If something has gone wrong,
|
||||||
you may need to <a href="https://help.kobo.com/hc/en-us/articles/360017605314-Manual-reset-your-Kobo-Clara-HD-Kobo-Nia-Kobo-Elipsa-Kobo-Clara-2E-Kobo-Elipsa-2E" target="_blank">manually reset your device</a>.
|
you may need to <a href="https://help.kobo.com/hc/en-us/articles/360017605314-Manual-reset-your-Kobo-Clara-HD-Kobo-Nia-Kobo-Elipsa-Kobo-Clara-2E-Kobo-Elipsa-2E" target="_blank">manually reset your device</a>.
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Connect your Kobo e-reader via USB. It should appear as a removable drive.
|
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.
|
Then click the button below and select the root of the Kobo drive.
|
||||||
</p>
|
</p>
|
||||||
<button id="btn-connect" class="primary">Select Kobo Drive</button>
|
<button id="btn-connect" class="primary">Select Kobo Drive</button>
|
||||||
<p class="fallback-hint">
|
<p class="fallback-hint">
|
||||||
Don't want to use Chrome?
|
Don't want to use Chrome?
|
||||||
<a href="#" id="btn-manual-from-auto">Select your software version manually</a> instead.
|
<a href="#" id="btn-manual-from-auto">Select your software version manually</a> instead.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 1b (manual): Select device model + firmware -->
|
<!-- Step 1b (manual): Select device model + firmware -->
|
||||||
<section id="step-manual" class="step" hidden>
|
<section id="step-manual" class="step" hidden>
|
||||||
<div class="warning">
|
<div class="warning">
|
||||||
<b>Patching modifies system files on your Kobo, and <u>will void your warranty</u>.</b> This process allows for custom modifications to be applied, or undone. If something has gone wrong,
|
<b>Patching modifies system files on your Kobo, and <u>will void your warranty</u>.</b> This process allows for custom modifications to be applied, or undone. If something has gone wrong,
|
||||||
you may need to <a href="https://help.kobo.com/hc/en-us/articles/360017605314-Manual-reset-your-Kobo-Clara-HD-Kobo-Nia-Kobo-Elipsa-Kobo-Clara-2E-Kobo-Elipsa-2E" target="_blank">manually reset your device</a>.
|
you may need to <a href="https://help.kobo.com/hc/en-us/articles/360017605314-Manual-reset-your-Kobo-Clara-HD-Kobo-Nia-Kobo-Elipsa-Kobo-Clara-2E-Kobo-Elipsa-2E" target="_blank">manually reset your device</a>.
|
||||||
</div>
|
</div>
|
||||||
<p class="fallback-hint">
|
<p class="fallback-hint">
|
||||||
<strong>First, select the version number that is currently installed on your device.</strong> If it does not appear in this dropdown list, your software version is not supported for patching via this website.
|
<strong>First, select the version number that is currently installed on your device.</strong> If it does not appear in this dropdown list, your software version is not supported for patching via this website.
|
||||||
</p>
|
</p>
|
||||||
<select id="manual-version">
|
<select id="manual-version">
|
||||||
<option value="">-- Select software version --</option>
|
<option value="">-- Select software version --</option>
|
||||||
</select>
|
</select>
|
||||||
<p id="manual-version-hint" class="fallback-hint">
|
<p id="manual-version-hint" class="fallback-hint">
|
||||||
You can identify the version number shown on your Kobo under <strong>More > Settings > Device information</strong> and by checking <strong>Software version</strong>. You should only apply a patch if the version number is a complete match. Only the listed version numbers are supported by this tool.
|
You can identify the version number shown on your Kobo under <strong>More > Settings > Device information</strong> and by checking <strong>Software version</strong>. You should only apply a patch if the version number is a complete match. Only the listed version numbers are supported by this tool.
|
||||||
</p>
|
</p>
|
||||||
<select id="manual-model" hidden>
|
<select id="manual-model" hidden>
|
||||||
<option value="">-- Select your Kobo model --</option>
|
<option value="">-- Select your Kobo model --</option>
|
||||||
</select>
|
</select>
|
||||||
<p id="manual-model-hint" class="fallback-hint" hidden>
|
<p id="manual-model-hint" class="fallback-hint" hidden>
|
||||||
You can identify your model by the serial number prefix shown on your Kobo under <strong>More > Settings > Device information</strong>. Match the first characters (e.g. N428) to the list above. If your device isn't listed, it is not supported by this tool.
|
You can identify your model by the serial number prefix shown on your Kobo under <strong>More > Settings > Device information</strong>. Match the first characters (e.g. N428) to the list above. If your device isn't listed, it is not supported by this tool.
|
||||||
</p>
|
</p>
|
||||||
<p id="manual-chrome-hint" class="info-banner" hidden>
|
<p id="manual-chrome-hint" class="info-banner" hidden>
|
||||||
<b>Tip</b>: if you use Chrome or Edge, this tool can auto-detect your device
|
<b>Tip</b>: if you use Chrome or Edge, this tool can auto-detect your device
|
||||||
and write patched files directly to it, which makes this a lot easier and less error-prone. Before continuing, make sure you've double checked if the device and model number are correct.
|
and write patched files directly to it, which makes this a lot easier and less error-prone. Before continuing, make sure you've double checked if the device and model number are correct.
|
||||||
</p>
|
</p>
|
||||||
<div class="step-actions" style="margin-top: 25px">
|
<div class="step-actions" style="margin-top: 25px">
|
||||||
<button id="btn-manual-confirm" class="primary" disabled>I understand, continue ›</button>
|
<button id="btn-manual-confirm" class="primary" disabled>I understand, continue ›</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 1c: Device detected (auto mode info card) -->
|
<!-- Step 1c: Device detected (auto mode info card) -->
|
||||||
<section id="step-device" class="step" hidden>
|
<section id="step-device" class="step" hidden>
|
||||||
<div id="device-info" class="info-card">
|
<div id="device-info" class="info-card">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="label">Model</span>
|
<span class="label">Model</span>
|
||||||
<span id="device-model" class="value">--</span>
|
<span id="device-model" class="value">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="label">Serial</span>
|
<span class="label">Serial</span>
|
||||||
<span id="device-serial" class="value">--</span>
|
<span id="device-serial" class="value">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="label">Software</span>
|
<span class="label">Software</span>
|
||||||
<span id="device-firmware" class="value">--</span>
|
<span id="device-firmware" class="value">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="device-status"></p>
|
<p id="device-status"></p>
|
||||||
<div class="step-actions">
|
<div class="step-actions">
|
||||||
<button id="btn-device-restore" class="secondary">Restore Unpatched Software</button>
|
<button id="btn-device-restore" class="secondary">Restore Unpatched Software</button>
|
||||||
<button id="btn-device-next" class="primary">Configure Patches ›</button>
|
<button id="btn-device-next" class="primary">Configure Patches ›</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 2: Configure patches -->
|
<!-- Step 2: Configure patches -->
|
||||||
<section id="step-patches" class="step" hidden>
|
<section id="step-patches" class="step" hidden>
|
||||||
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
|
<p>Enable or disable patches below. Patches in the same group are mutually exclusive.</p>
|
||||||
<div id="patch-container" class="patch-container-scroll"></div>
|
<div id="patch-container" class="patch-container-scroll"></div>
|
||||||
<div class="step-actions">
|
<div class="step-actions">
|
||||||
<button id="btn-patches-back" class="secondary">‹ Back</button>
|
<button id="btn-patches-back" class="secondary">‹ Back</button>
|
||||||
<button id="btn-patches-next" class="primary" disabled>Continue ›</button>
|
<button id="btn-patches-next" class="primary" disabled>Continue ›</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="patch-count-hint" class="fallback-hint"></p>
|
<p id="patch-count-hint" class="fallback-hint"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 3: Review & Build -->
|
<!-- Step 3: Review & Build -->
|
||||||
<section id="step-firmware" class="step" hidden>
|
<section id="step-firmware" class="step" hidden>
|
||||||
<p id="firmware-auto-info">
|
<p id="firmware-auto-info">
|
||||||
Software update <strong id="firmware-version-label"></strong> for
|
Software update <strong id="firmware-version-label"></strong> for
|
||||||
<strong id="firmware-device-label"></strong>
|
<strong id="firmware-device-label"></strong>
|
||||||
<span id="firmware-description">will be downloaded automatically from Kobo's servers and will be patched after the download completes.</span>
|
<span id="firmware-description">will be downloaded automatically from Kobo's servers and will be patched after the download completes.</span>
|
||||||
</p>
|
</p>
|
||||||
<p id="selected-patches-heading" hidden>The following patches will be applied:</p>
|
<p id="selected-patches-heading" hidden>The following patches will be applied:</p>
|
||||||
<ul id="selected-patches-list" class="selected-patches-list"></ul>
|
<ul id="selected-patches-list" class="selected-patches-list"></ul>
|
||||||
<details class="log-details">
|
<details class="log-details">
|
||||||
<summary>Software download URL</summary>
|
<summary>Software download URL</summary>
|
||||||
<code id="firmware-download-url"></code>
|
<code id="firmware-download-url"></code>
|
||||||
<p id="firmware-verify-notice" class="fallback-hint">
|
<p id="firmware-verify-notice" class="fallback-hint">
|
||||||
You can verify if this URL matches your Kobo's model on
|
You can verify if this URL matches your Kobo's model on
|
||||||
<a href="https://help.kobo.com/hc/en-us/articles/35059171032727" target="_blank">Kobo's support page</a>. The most important bit is that "koboXX" matches, for example "kobo13" for Kobo Libra Color.
|
<a href="https://help.kobo.com/hc/en-us/articles/35059171032727" target="_blank">Kobo's support page</a>. The most important bit is that "koboXX" matches, for example "kobo13" for Kobo Libra Color.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<div class="step-actions">
|
<div class="step-actions">
|
||||||
<button id="btn-build-back" class="secondary">‹ Back</button>
|
<button id="btn-build-back" class="secondary">‹ Back</button>
|
||||||
<button id="btn-build" class="primary">Build Patched Software</button>
|
<button id="btn-build" class="primary">Build Patched Software</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 4: Building -->
|
<!-- Step 4: Building -->
|
||||||
<section id="step-building" class="step" hidden>
|
<section id="step-building" class="step" hidden>
|
||||||
<div class="build-header">
|
<div class="build-header">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p id="build-progress">Starting...</p>
|
<p id="build-progress">Starting...</p>
|
||||||
</div>
|
</div>
|
||||||
<pre id="build-log" class="build-log"></pre>
|
<pre id="build-log" class="build-log"></pre>
|
||||||
<p id="build-wait-hint" class="fallback-hint">Please wait while the patch is being applied...</p>
|
<p id="build-wait-hint" class="fallback-hint">Please wait while the patch is being applied...</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 5: Install -->
|
<!-- Step 5: Install -->
|
||||||
<section id="step-done" class="step" hidden>
|
<section id="step-done" class="step" hidden>
|
||||||
<p id="build-status" class="install-summary"></p>
|
<p id="build-status" class="install-summary"></p>
|
||||||
<div id="existing-tgz-warning" class="warning" hidden>
|
<div id="existing-tgz-warning" class="warning" hidden>
|
||||||
An <b>existing</b> KoboRoot.tgz file was found on your Kobo. This means an update has not been applied yet. If you choose to write the new file to your Kobo, the existing one will be overwritten.
|
An <b>existing</b> KoboRoot.tgz file was found on your Kobo. This means an update has not been applied yet. If you choose to write the new file to your Kobo, the existing one will be overwritten.
|
||||||
</div>
|
</div>
|
||||||
<details class="log-details">
|
<details class="log-details">
|
||||||
<summary>Build log</summary>
|
<summary>Build log</summary>
|
||||||
<pre id="done-log" class="build-log done-log"></pre>
|
<pre id="done-log" class="build-log done-log"></pre>
|
||||||
</details>
|
</details>
|
||||||
<div id="build-actions">
|
<div id="build-actions">
|
||||||
<button id="btn-write" class="primary">Write to Kobo</button>
|
<button id="btn-write" class="primary">Write to Kobo</button>
|
||||||
<button id="btn-download" class="secondary">Download KoboRoot.tgz</button>
|
<button id="btn-download" class="secondary">Download KoboRoot.tgz</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="write-instructions" class="install-instructions" hidden>
|
<div id="write-instructions" class="install-instructions" hidden>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
KoboRoot.tgz has been written to your Kobo.
|
KoboRoot.tgz has been written to your Kobo.
|
||||||
<strong>Safely eject</strong> the device before unplugging the USB cable — it will reboot and apply the patches automatically.
|
<strong>Safely eject</strong> the device before unplugging the USB cable — it will reboot and apply the patches automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="download-instructions" class="install-instructions" hidden>
|
<div id="download-instructions" class="install-instructions" hidden>
|
||||||
<ol class="install-steps">
|
<ol class="install-steps">
|
||||||
<li>Connect your Kobo via USB so it appears as a removable drive.</li>
|
<li>Connect your Kobo via USB so it appears as a removable drive.</li>
|
||||||
<li>Copy <strong>KoboRoot.tgz</strong> into the <strong>.kobo</strong> folder on the Kobo drive.</li>
|
<li>Copy <strong>KoboRoot.tgz</strong> into the <strong>.kobo</strong> folder on the Kobo drive.</li>
|
||||||
<li><strong>Safely eject</strong> the Kobo — do not just unplug the cable, as this can corrupt data.</li>
|
<li><strong>Safely eject</strong> the Kobo — do not just unplug the cable, as this can corrupt data.</li>
|
||||||
<li>The device will reboot and apply the patches automatically.</li>
|
<li>The device will reboot and apply the patches automatically.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<section id="step-error" class="step" hidden>
|
<section id="step-error" class="step" hidden>
|
||||||
<h2>Something went wrong</h2>
|
<h2>Something went wrong</h2>
|
||||||
<p id="error-message" class="error"></p>
|
<p id="error-message" class="error"></p>
|
||||||
<pre id="error-log" class="error-log" hidden></pre>
|
<pre id="error-log" class="error-log" hidden></pre>
|
||||||
<button id="btn-retry" class="secondary">Start Over</button>
|
<button id="btn-retry" class="secondary">Start Over</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>
|
<p>
|
||||||
<a href="#" id="btn-how-it-works">How does this work?</a><br/>
|
<a href="#" id="btn-how-it-works">How does this work?</a><br/>
|
||||||
<br/>
|
<br/>
|
||||||
Built on <a href="https://github.com/pgaskin/kobopatch" target="_blank">kobopatch</a> by pgaskin.
|
Built on <a href="https://github.com/pgaskin/kobopatch" target="_blank">kobopatch</a> by pgaskin.
|
||||||
Patches and discussion on the <a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.
|
Patches and discussion on the <a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Source code available <a href="https://github.com/nicoverbruggen/kobopatch-webui" target="_blank">on GitHub</a>. This project is not affiliated with Rakuten Kobo Inc.
|
Source code available <a href="https://github.com/nicoverbruggen/kobopatch-webui" target="_blank">on GitHub</a>. This project is not affiliated with Rakuten Kobo Inc.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<dialog id="how-it-works-dialog" class="modal">
|
<dialog id="how-it-works-dialog" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>How does this work?</h2>
|
<h2>How does this work?</h2>
|
||||||
<button id="btn-close-dialog" class="modal-close" aria-label="Close">×</button>
|
<button id="btn-close-dialog" class="modal-close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>
|
<p>
|
||||||
KoboPatch Web UI is a fully client-side web application for applying custom
|
KoboPatch Web UI is a fully client-side web application for applying custom
|
||||||
<a href="https://github.com/pgaskin/kobopatch" target="_blank">kobopatch</a> patches
|
<a href="https://github.com/pgaskin/kobopatch" target="_blank">kobopatch</a> patches
|
||||||
to Kobo e-readers. Nothing is uploaded to a server — everything runs in your browser.
|
to Kobo e-readers. Nothing is uploaded to a server — everything runs in your browser.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>The patching process</h3>
|
<h3>The patching process</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>Device selection</strong> — On Chromium-based browsers (Chrome, Edge), the app can
|
<li><strong>Device selection</strong> — On Chromium-based browsers (Chrome, Edge), the app can
|
||||||
auto-detect your Kobo via the File System Access API when connected over USB.
|
auto-detect your Kobo via the File System Access API when connected over USB.
|
||||||
On other browsers, you manually select your model and software version.</li>
|
On other browsers, you manually select your model and software version.</li>
|
||||||
<li><strong>Patch configuration</strong> — You choose which patches to enable or disable.
|
<li><strong>Patch configuration</strong> — You choose which patches to enable or disable.
|
||||||
Patches in the same group are mutually exclusive (radio buttons).
|
Patches in the same group are mutually exclusive (radio buttons).
|
||||||
The patches themselves are community-contributed via the
|
The patches themselves are community-contributed via the
|
||||||
<a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.</li>
|
<a href="https://www.mobileread.com/forums/forumdisplay.php?f=247" target="_blank">MobileRead forums</a>.</li>
|
||||||
<li><strong>Build</strong> — The correct software update is downloaded directly from Kobo's servers
|
<li><strong>Build</strong> — The correct software update is downloaded directly from Kobo's servers
|
||||||
(<code>ereaderfiles.kobo.com</code>). The patcher, compiled from Go to WebAssembly, runs
|
(<code>ereaderfiles.kobo.com</code>). The patcher, compiled from Go to WebAssembly, runs
|
||||||
inside a Web Worker so the UI stays responsive. It extracts
|
inside a Web Worker so the UI stays responsive. It extracts
|
||||||
<code>KoboRoot.tgz</code>, applies your selected patches as in-place byte replacements
|
<code>KoboRoot.tgz</code>, applies your selected patches as in-place byte replacements
|
||||||
to the ELF binaries, validates the results (ELF headers, file sizes, archive consistency),
|
to the ELF binaries, validates the results (ELF headers, file sizes, archive consistency),
|
||||||
and produces a new <code>KoboRoot.tgz</code>.</li>
|
and produces a new <code>KoboRoot.tgz</code>.</li>
|
||||||
<li><strong>Install</strong> — On Chromium, the patched file is written directly to the
|
<li><strong>Install</strong> — On Chromium, the patched file is written directly to the
|
||||||
<code>.kobo</code> folder on the device. On other browsers, you download the file
|
<code>.kobo</code> folder on the device. On other browsers, you download the file
|
||||||
and copy it manually. After safely ejecting, the Kobo reboots and applies the update.</li>
|
and copy it manually. After safely ejecting, the Kobo reboots and applies the update.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>Restoring original software</h3>
|
<h3>Restoring original software</h3>
|
||||||
<p>
|
<p>
|
||||||
You can also use this tool to restore the original unpatched software. In that case,
|
You can also use this tool to restore the original unpatched software. In that case,
|
||||||
no patches are applied — the original <code>KoboRoot.tgz</code> is extracted
|
no patches are applied — the original <code>KoboRoot.tgz</code> is extracted
|
||||||
from the software update as-is.
|
from the software update as-is.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Safety</h3>
|
<h3>Safety</h3>
|
||||||
<p>
|
<p>
|
||||||
Each patched binary is validated before being included in the output: ELF magic bytes,
|
Each patched binary is validated before being included in the output: ELF magic bytes,
|
||||||
32-bit ARM architecture, file size (must match the original), and archive integrity are
|
32-bit ARM architecture, file size (must match the original), and archive integrity are
|
||||||
all checked. If anything looks wrong, the build fails with an error. That said, patching
|
all checked. If anything looks wrong, the build fails with an error. That said, patching
|
||||||
modifies system files, so you should know how to
|
modifies system files, so you should know how to
|
||||||
<a href="https://help.kobo.com/hc/en-us/articles/360017605314" target="_blank">manually reset your Kobo</a>
|
<a href="https://help.kobo.com/hc/en-us/articles/360017605314" target="_blank">manually reset your Kobo</a>
|
||||||
if needed.
|
if needed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- wasm_exec.js loaded by patch-worker.js inside the Web Worker -->
|
<!-- wasm_exec.js loaded by patch-worker.js inside the Web Worker -->
|
||||||
<script src="kobo-device.js?ts=1773672116"></script>
|
<script src="kobo-device.js?ts=1773672116"></script>
|
||||||
<script src="kobopatch.js?ts=1773672116"></script>
|
<script src="kobopatch.js?ts=1773672116"></script>
|
||||||
<script src="patch-ui.js?ts=1773672116"></script>
|
<script src="patch-ui.js?ts=1773672116"></script>
|
||||||
<script src="app.js?ts=1773672116"></script>
|
<script src="app.js?ts=1773672116"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,175 +1,175 @@
|
|||||||
/**
|
/**
|
||||||
* Known Kobo device serial prefixes mapped to model names.
|
* Known Kobo device serial prefixes mapped to model names.
|
||||||
* Source: https://help.kobo.com/hc/en-us/articles/360019676973
|
* Source: https://help.kobo.com/hc/en-us/articles/360019676973
|
||||||
* The serial number prefix (first 3-4 characters) identifies the model.
|
* The serial number prefix (first 3-4 characters) identifies the model.
|
||||||
*/
|
*/
|
||||||
const KOBO_MODELS = {
|
const KOBO_MODELS = {
|
||||||
// Current eReaders
|
// Current eReaders
|
||||||
'N428': 'Kobo Libra Colour',
|
'N428': 'Kobo Libra Colour',
|
||||||
'N367': 'Kobo Clara Colour',
|
'N367': 'Kobo Clara Colour',
|
||||||
'N365': 'Kobo Clara BW',
|
'N365': 'Kobo Clara BW',
|
||||||
'P365': 'Kobo Clara BW',
|
'P365': 'Kobo Clara BW',
|
||||||
'N605': 'Kobo Elipsa 2E',
|
'N605': 'Kobo Elipsa 2E',
|
||||||
'N506': 'Kobo Clara 2E',
|
'N506': 'Kobo Clara 2E',
|
||||||
'N778': 'Kobo Sage',
|
'N778': 'Kobo Sage',
|
||||||
'N418': 'Kobo Libra 2',
|
'N418': 'Kobo Libra 2',
|
||||||
'N604': 'Kobo Elipsa',
|
'N604': 'Kobo Elipsa',
|
||||||
'N306': 'Kobo Nia',
|
'N306': 'Kobo Nia',
|
||||||
'N873': 'Kobo Libra H2O',
|
'N873': 'Kobo Libra H2O',
|
||||||
'N782': 'Kobo Forma',
|
'N782': 'Kobo Forma',
|
||||||
'N249': 'Kobo Clara HD',
|
'N249': 'Kobo Clara HD',
|
||||||
'N867': 'Kobo Aura H2O Edition 2',
|
'N867': 'Kobo Aura H2O Edition 2',
|
||||||
'N709': 'Kobo Aura ONE',
|
'N709': 'Kobo Aura ONE',
|
||||||
'N236': 'Kobo Aura Edition 2',
|
'N236': 'Kobo Aura Edition 2',
|
||||||
'N587': 'Kobo Touch 2.0',
|
'N587': 'Kobo Touch 2.0',
|
||||||
'N437': 'Kobo Glo HD',
|
'N437': 'Kobo Glo HD',
|
||||||
'N250': 'Kobo Aura H2O',
|
'N250': 'Kobo Aura H2O',
|
||||||
'N514': 'Kobo Aura',
|
'N514': 'Kobo Aura',
|
||||||
'N613': 'Kobo Glo',
|
'N613': 'Kobo Glo',
|
||||||
'N705': 'Kobo Mini',
|
'N705': 'Kobo Mini',
|
||||||
'N416': 'Kobo Original',
|
'N416': 'Kobo Original',
|
||||||
// Older models with multiple revisions
|
// Older models with multiple revisions
|
||||||
'N905': 'Kobo Touch',
|
'N905': 'Kobo Touch',
|
||||||
'N647': 'Kobo Wireless',
|
'N647': 'Kobo Wireless',
|
||||||
'N47B': 'Kobo Wireless',
|
'N47B': 'Kobo Wireless',
|
||||||
// Aura HD uses 5-char prefix
|
// Aura HD uses 5-char prefix
|
||||||
'N204': 'Kobo Aura HD',
|
'N204': 'Kobo Aura HD',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported firmware version for patching.
|
* Firmware download URLs by version and serial prefix.
|
||||||
*/
|
* Source: https://help.kobo.com/hc/en-us/articles/35059171032727
|
||||||
const SUPPORTED_FIRMWARE = '4.45.23646';
|
*
|
||||||
|
* The kobo prefix (kobo12, kobo13, kobo14) is stable per device family.
|
||||||
/**
|
* The date path segment (e.g. Mar2026) changes per release.
|
||||||
* Firmware download URLs by version and serial prefix.
|
* help.kobo.com may lag behind; verify URLs when adding new versions.
|
||||||
* Source: https://help.kobo.com/hc/en-us/articles/35059171032727
|
*/
|
||||||
*
|
const FIRMWARE_DOWNLOADS = {
|
||||||
* The kobo prefix (kobo12, kobo13, kobo14) is stable per device family.
|
'4.45.23646': {
|
||||||
* The date path segment (e.g. Mar2026) changes per release.
|
'N428': 'https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip',
|
||||||
* help.kobo.com may lag behind; verify URLs when adding new versions.
|
'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',
|
||||||
const FIRMWARE_DOWNLOADS = {
|
'P365': 'https://ereaderfiles.kobo.com/firmwares/kobo14/Mar2026/kobo-update-4.45.23646.zip',
|
||||||
'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',
|
* Supported firmware versions for patching (derived from FIRMWARE_DOWNLOADS).
|
||||||
},
|
*/
|
||||||
};
|
const SUPPORTED_FIRMWARE = Object.keys(FIRMWARE_DOWNLOADS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the firmware download URL for a given serial prefix and firmware version.
|
* Get the firmware download URL for a given serial prefix and firmware version.
|
||||||
* Returns null if no URL is available.
|
* Returns null if no URL is available.
|
||||||
*/
|
*/
|
||||||
function getFirmwareURL(serialPrefix, version) {
|
function getFirmwareURL(serialPrefix, version) {
|
||||||
const versionMap = FIRMWARE_DOWNLOADS[version];
|
const versionMap = FIRMWARE_DOWNLOADS[version];
|
||||||
if (!versionMap) return null;
|
if (!versionMap) return null;
|
||||||
return versionMap[serialPrefix] || null;
|
return versionMap[serialPrefix] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all device models that have firmware downloads for a given version.
|
* Get all device models that have firmware downloads for a given version.
|
||||||
* Returns array of { prefix, model } objects.
|
* Returns array of { prefix, model } objects.
|
||||||
*/
|
*/
|
||||||
function getDevicesForVersion(version) {
|
function getDevicesForVersion(version) {
|
||||||
const versionMap = FIRMWARE_DOWNLOADS[version];
|
const versionMap = FIRMWARE_DOWNLOADS[version];
|
||||||
if (!versionMap) return [];
|
if (!versionMap) return [];
|
||||||
const devices = [];
|
const devices = [];
|
||||||
for (const prefix of Object.keys(versionMap)) {
|
for (const prefix of Object.keys(versionMap)) {
|
||||||
const model = KOBO_MODELS[prefix] || 'Unknown';
|
const model = KOBO_MODELS[prefix] || 'Unknown';
|
||||||
devices.push({ prefix, model: model + ' (' + prefix + ')' });
|
devices.push({ prefix, model: model + ' (' + prefix + ')' });
|
||||||
}
|
}
|
||||||
return devices;
|
return devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
class KoboDevice {
|
class KoboDevice {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.directoryHandle = null;
|
this.directoryHandle = null;
|
||||||
this.deviceInfo = null;
|
this.deviceInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the File System Access API is available.
|
* Check if the File System Access API is available.
|
||||||
*/
|
*/
|
||||||
static isSupported() {
|
static isSupported() {
|
||||||
return 'showDirectoryPicker' in window;
|
return 'showDirectoryPicker' in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt the user to select the Kobo drive root directory.
|
* Prompt the user to select the Kobo drive root directory.
|
||||||
* Validates that it looks like a Kobo by checking for .kobo/version.
|
* Validates that it looks like a Kobo by checking for .kobo/version.
|
||||||
*/
|
*/
|
||||||
async connect() {
|
async connect() {
|
||||||
this.directoryHandle = await window.showDirectoryPicker({
|
this.directoryHandle = await window.showDirectoryPicker({
|
||||||
mode: 'readwrite',
|
mode: 'readwrite',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify this looks like a Kobo root
|
// Verify this looks like a Kobo root
|
||||||
let koboDir;
|
let koboDir;
|
||||||
try {
|
try {
|
||||||
koboDir = await this.directoryHandle.getDirectoryHandle('.kobo');
|
koboDir = await this.directoryHandle.getDirectoryHandle('.kobo');
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'This does not appear to be a Kobo device. Could not find the .kobo directory.'
|
'This does not appear to be a Kobo device. Could not find the .kobo directory.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let versionFile;
|
let versionFile;
|
||||||
try {
|
try {
|
||||||
versionFile = await koboDir.getFileHandle('version');
|
versionFile = await koboDir.getFileHandle('version');
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Could not find .kobo/version. Is this the root of your Kobo drive?'
|
'Could not find .kobo/version. Is this the root of your Kobo drive?'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await versionFile.getFile();
|
const file = await versionFile.getFile();
|
||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
this.deviceInfo = KoboDevice.parseVersion(content.trim());
|
this.deviceInfo = KoboDevice.parseVersion(content.trim());
|
||||||
return this.deviceInfo;
|
return this.deviceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the .kobo/version file content.
|
* Parse the .kobo/version file content.
|
||||||
*
|
*
|
||||||
* Format: serial,version1,firmware,version3,version4,hardware_uuid
|
* 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
|
* Example: N4284B5215352,4.9.77,4.45.23646,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390
|
||||||
*/
|
*/
|
||||||
static parseVersion(content) {
|
static parseVersion(content) {
|
||||||
const parts = content.split(',');
|
const parts = content.split(',');
|
||||||
if (parts.length < 6) {
|
if (parts.length < 6) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Unexpected version file format. Expected 6 comma-separated fields, got ' + parts.length
|
'Unexpected version file format. Expected 6 comma-separated fields, got ' + parts.length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const serial = parts[0];
|
const serial = parts[0];
|
||||||
const firmware = parts[2];
|
const firmware = parts[2];
|
||||||
const hardwareId = parts[5];
|
const hardwareId = parts[5];
|
||||||
|
|
||||||
// Try matching 4-char prefix first, then 3-char for models like N204B
|
// Try matching 4-char prefix first, then 3-char for models like N204B
|
||||||
const serialPrefix = KOBO_MODELS[serial.substring(0, 4)]
|
const serialPrefix = KOBO_MODELS[serial.substring(0, 4)]
|
||||||
? serial.substring(0, 4)
|
? serial.substring(0, 4)
|
||||||
: serial.substring(0, 3);
|
: serial.substring(0, 3);
|
||||||
const model = KOBO_MODELS[serialPrefix] || 'Unknown Kobo (' + serial.substring(0, 4) + ')';
|
const model = KOBO_MODELS[serialPrefix] || 'Unknown Kobo (' + serial.substring(0, 4) + ')';
|
||||||
const isSupported = firmware === SUPPORTED_FIRMWARE;
|
const isSupported = SUPPORTED_FIRMWARE.includes(firmware);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serial,
|
serial,
|
||||||
serialPrefix,
|
serialPrefix,
|
||||||
firmware,
|
firmware,
|
||||||
hardwareId,
|
hardwareId,
|
||||||
model,
|
model,
|
||||||
isSupported,
|
isSupported,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect / release the directory handle.
|
* Disconnect / release the directory handle.
|
||||||
*/
|
*/
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.directoryHandle = null;
|
this.directoryHandle = null;
|
||||||
this.deviceInfo = null;
|
this.deviceInfo = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,14 +136,22 @@ function parsePatchConfig(configYAML) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the patches/ directory for available patch zips.
|
* Scan the patches/ directory for available patch zips.
|
||||||
* Returns an array of { filename, version } sorted by version descending.
|
* 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() {
|
async function scanAvailablePatches() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('patches/index.json');
|
const resp = await fetch('patches/index.json');
|
||||||
if (!resp.ok) return [];
|
if (!resp.ok) return [];
|
||||||
const list = await resp.json();
|
const list = await resp.json();
|
||||||
return list;
|
const result = [];
|
||||||
|
for (const entry of list) {
|
||||||
|
for (const version of entry.versions) {
|
||||||
|
result.push({ filename: entry.filename, version });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"filename": "patches_4.4523646.zip",
|
"filename": "patches_4.45.23646.zip",
|
||||||
"version": "4.45.23646"
|
"versions": ["4.45.23646"],
|
||||||
}
|
"date": "03-2026"
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
1632
web/public/style.css
1632
web/public/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user