From 91f09b42a8e3312335ee3e5e59af8fa773170d1b Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Mon, 16 Mar 2026 20:43:13 +0100 Subject: [PATCH] Standardize line endings --- .gitattributes | 2 + LICENSE | 42 +- e2e/integration.spec.js | 448 ++--- e2e/playwright.config.js | 38 +- kobopatch-wasm/integration_test.go | 350 ++-- nixpacks.toml | 26 +- test.sh | 16 + web/public/app.js | 1055 ++++++----- web/public/index.html | 508 ++--- web/public/kobo-device.js | 350 ++-- web/public/patch-ui.js | 12 +- web/public/patches/index.json | 7 +- ...s_4.4523646.zip => patches_4.45.23646.zip} | Bin web/public/style.css | 1632 ++++++++--------- 14 files changed, 2256 insertions(+), 2230 deletions(-) create mode 100644 .gitattributes create mode 100755 test.sh rename web/public/patches/{patches_4.4523646.zip => patches_4.45.23646.zip} (100%) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..efdba87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf diff --git a/LICENSE b/LICENSE index aea9d4c..ace836d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Nico Verbruggen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -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 -SOFTWARE. +MIT License + +Copyright (c) 2026 Nico Verbruggen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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 +SOFTWARE. diff --git a/e2e/integration.spec.js b/e2e/integration.spec.js index 8a03324..c37e47d 100644 --- a/e2e/integration.spec.js +++ b/e2e/integration.spec.js @@ -1,224 +1,224 @@ -// @ts-check -const { test, expect } = require('@playwright/test'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const zlib = require('zlib'); - -// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646, -// with only "Remove footer (row3) on new home screen" enabled. -const EXPECTED_SHA1 = { - 'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d', - 'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c', - 'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1', - 'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5', -}; - -const FIRMWARE_PATH = process.env.FIRMWARE_ZIP - || path.resolve(__dirname, '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.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. - */ -function parseTar(buffer) { - const entries = {}; - let offset = 0; - - while (offset < buffer.length) { - const header = buffer.subarray(offset, offset + 512); - if (header.every(b => b === 0)) break; - - let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, ''); - const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, ''); - if (prefix) name = prefix + '/' + name; - name = name.replace(/^\.\//, ''); - - const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim(); - const size = parseInt(sizeStr, 8) || 0; - const typeFlag = header[156]; - - offset += 512; - - if (typeFlag === 48 || typeFlag === 0) { - entries[name] = buffer.subarray(offset, offset + size); - } - - offset += Math.ceil(size / 512) * 512; - } - - return entries; -} - -// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646. -const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4'; - -// Clean up the symlink after the test. -test.afterEach(() => { - try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} -}); - -test('full manual mode patching pipeline', async ({ page }) => { - if (!fs.existsSync(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. - try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} - fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE); - - await page.goto('/'); - await expect(page.locator('h1')).toContainText('KoboPatch'); - - // Override the firmware download URLs to point at the local server. - await page.evaluate(() => { - for (const version of Object.keys(FIRMWARE_DOWNLOADS)) { - for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) { - FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip'; - } - } - }); - - // Step 1: Switch to manual mode. - await page.click('#btn-manual-from-auto'); - await expect(page.locator('#step-manual')).not.toBeHidden(); - - // Step 2: Select firmware version. - await page.selectOption('#manual-version', '4.45.23646'); - await expect(page.locator('#manual-model')).not.toBeHidden(); - - // Step 3: Select Kobo Libra Colour (N428). - await page.selectOption('#manual-model', 'N428'); - await expect(page.locator('#btn-manual-confirm')).toBeEnabled(); - await page.click('#btn-manual-confirm'); - - // Step 4: Wait for patches to load. - await expect(page.locator('#step-patches')).not.toBeHidden(); - await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0); - - // 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 patchSection = patchName.locator('xpath=ancestor::details'); - await patchSection.locator('summary').click(); - await expect(patchName).toBeVisible(); - await patchName.locator('xpath=ancestor::label').locator('input').check(); - - // Verify patch count updated. - await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected'); - await expect(page.locator('#btn-patches-next')).toBeEnabled(); - - // Step 6: Continue to build step. - await page.click('#btn-patches-next'); - await expect(page.locator('#step-firmware')).not.toBeHidden(); - await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646'); - await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour'); - - // Step 7: Build and wait for completion. - await page.click('#btn-build'); - - const doneOrError = await Promise.race([ - page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'), - page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'), - ]); - - if (doneOrError === 'error') { - const errorMsg = await page.locator('#error-message').textContent(); - throw new Error(`Build failed: ${errorMsg}`); - } - - await expect(page.locator('#build-status')).toContainText('Patching complete'); - - // Step 8: Download KoboRoot.tgz and verify checksums. - const [download] = await Promise.all([ - page.waitForEvent('download'), - page.click('#btn-download'), - ]); - - expect(download.suggestedFilename()).toBe('KoboRoot.tgz'); - const downloadPath = await download.path(); - const tgzData = fs.readFileSync(downloadPath); - - const tarData = zlib.gunzipSync(tgzData); - const entries = parseTar(tarData); - - for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) { - const data = entries[name]; - expect(data, `missing binary in output: ${name}`).toBeDefined(); - const actualHash = crypto.createHash('sha1').update(data).digest('hex'); - expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash); - } -}); - -test('restore original firmware pipeline', async ({ page }) => { - if (!fs.existsSync(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. - try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} - fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE); - - await page.goto('/'); - await expect(page.locator('h1')).toContainText('KoboPatch'); - - // Override the firmware download URLs to point at the local server. - await page.evaluate(() => { - for (const version of Object.keys(FIRMWARE_DOWNLOADS)) { - for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) { - FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip'; - } - } - }); - - // Step 1: Switch to manual mode. - await page.click('#btn-manual-from-auto'); - await expect(page.locator('#step-manual')).not.toBeHidden(); - - // Step 2: Select firmware version. - await page.selectOption('#manual-version', '4.45.23646'); - await expect(page.locator('#manual-model')).not.toBeHidden(); - - // Step 3: Select Kobo Libra Colour (N428). - await page.selectOption('#manual-model', 'N428'); - await expect(page.locator('#btn-manual-confirm')).toBeEnabled(); - await page.click('#btn-manual-confirm'); - - // 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('#patch-container .patch-file-section')).not.toHaveCount(0); - await expect(page.locator('#patch-count-hint')).toContainText('restore the original'); - await page.click('#btn-patches-next'); - - // Step 5: Verify build step shows restore text. - await expect(page.locator('#step-firmware')).not.toBeHidden(); - await expect(page.locator('#firmware-description')).toContainText('without modifications'); - await expect(page.locator('#btn-build')).toContainText('Restore Original Software'); - - // Step 6: Build and wait for completion. - await page.click('#btn-build'); - - const doneOrError = await Promise.race([ - page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'), - page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'), - ]); - - if (doneOrError === 'error') { - const errorMsg = await page.locator('#error-message').textContent(); - throw new Error(`Restore failed: ${errorMsg}`); - } - - await expect(page.locator('#build-status')).toContainText('Software extracted'); - - // Step 7: Download KoboRoot.tgz and verify it matches the original. - const [download] = await Promise.all([ - page.waitForEvent('download'), - page.click('#btn-download'), - ]); - - expect(download.suggestedFilename()).toBe('KoboRoot.tgz'); - const downloadPath = await download.path(); - const tgzData = fs.readFileSync(downloadPath); - const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex'); - expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1); -}); +// @ts-check +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const zlib = require('zlib'); + +// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646, +// with only "Remove footer (row3) on new home screen" enabled. +const EXPECTED_SHA1 = { + 'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d', + 'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c', + 'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1', + 'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5', +}; + +const FIRMWARE_PATH = process.env.FIRMWARE_ZIP + || path.resolve(__dirname, '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.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. + */ +function parseTar(buffer) { + const entries = {}; + let offset = 0; + + while (offset < buffer.length) { + const header = buffer.subarray(offset, offset + 512); + if (header.every(b => b === 0)) break; + + let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, ''); + const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, ''); + if (prefix) name = prefix + '/' + name; + name = name.replace(/^\.\//, ''); + + const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim(); + const size = parseInt(sizeStr, 8) || 0; + const typeFlag = header[156]; + + offset += 512; + + if (typeFlag === 48 || typeFlag === 0) { + entries[name] = buffer.subarray(offset, offset + size); + } + + offset += Math.ceil(size / 512) * 512; + } + + return entries; +} + +// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646. +const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4'; + +// Clean up the symlink after the test. +test.afterEach(() => { + try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} +}); + +test('full manual mode patching pipeline', async ({ page }) => { + if (!fs.existsSync(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. + try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} + fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE); + + await page.goto('/'); + await expect(page.locator('h1')).toContainText('KoboPatch'); + + // Override the firmware download URLs to point at the local server. + await page.evaluate(() => { + for (const version of Object.keys(FIRMWARE_DOWNLOADS)) { + for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) { + FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip'; + } + } + }); + + // Step 1: Switch to manual mode. + await page.click('#btn-manual-from-auto'); + await expect(page.locator('#step-manual')).not.toBeHidden(); + + // Step 2: Select firmware version. + await page.selectOption('#manual-version', '4.45.23646'); + await expect(page.locator('#manual-model')).not.toBeHidden(); + + // Step 3: Select Kobo Libra Colour (N428). + await page.selectOption('#manual-model', 'N428'); + await expect(page.locator('#btn-manual-confirm')).toBeEnabled(); + await page.click('#btn-manual-confirm'); + + // Step 4: Wait for patches to load. + await expect(page.locator('#step-patches')).not.toBeHidden(); + await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0); + + // 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 patchSection = patchName.locator('xpath=ancestor::details'); + await patchSection.locator('summary').click(); + await expect(patchName).toBeVisible(); + await patchName.locator('xpath=ancestor::label').locator('input').check(); + + // Verify patch count updated. + await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected'); + await expect(page.locator('#btn-patches-next')).toBeEnabled(); + + // Step 6: Continue to build step. + await page.click('#btn-patches-next'); + await expect(page.locator('#step-firmware')).not.toBeHidden(); + await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646'); + await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour'); + + // Step 7: Build and wait for completion. + await page.click('#btn-build'); + + const doneOrError = await Promise.race([ + page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'), + page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'), + ]); + + if (doneOrError === 'error') { + const errorMsg = await page.locator('#error-message').textContent(); + throw new Error(`Build failed: ${errorMsg}`); + } + + await expect(page.locator('#build-status')).toContainText('Patching complete'); + + // Step 8: Download KoboRoot.tgz and verify checksums. + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('#btn-download'), + ]); + + expect(download.suggestedFilename()).toBe('KoboRoot.tgz'); + const downloadPath = await download.path(); + const tgzData = fs.readFileSync(downloadPath); + + const tarData = zlib.gunzipSync(tgzData); + const entries = parseTar(tarData); + + for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) { + const data = entries[name]; + expect(data, `missing binary in output: ${name}`).toBeDefined(); + const actualHash = crypto.createHash('sha1').update(data).digest('hex'); + expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash); + } +}); + +test('restore original firmware pipeline', async ({ page }) => { + if (!fs.existsSync(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. + try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {} + fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE); + + await page.goto('/'); + await expect(page.locator('h1')).toContainText('KoboPatch'); + + // Override the firmware download URLs to point at the local server. + await page.evaluate(() => { + for (const version of Object.keys(FIRMWARE_DOWNLOADS)) { + for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) { + FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip'; + } + } + }); + + // Step 1: Switch to manual mode. + await page.click('#btn-manual-from-auto'); + await expect(page.locator('#step-manual')).not.toBeHidden(); + + // Step 2: Select firmware version. + await page.selectOption('#manual-version', '4.45.23646'); + await expect(page.locator('#manual-model')).not.toBeHidden(); + + // Step 3: Select Kobo Libra Colour (N428). + await page.selectOption('#manual-model', 'N428'); + await expect(page.locator('#btn-manual-confirm')).toBeEnabled(); + await page.click('#btn-manual-confirm'); + + // 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('#patch-container .patch-file-section')).not.toHaveCount(0); + await expect(page.locator('#patch-count-hint')).toContainText('restore the original'); + await page.click('#btn-patches-next'); + + // Step 5: Verify build step shows restore text. + await expect(page.locator('#step-firmware')).not.toBeHidden(); + await expect(page.locator('#firmware-description')).toContainText('without modifications'); + await expect(page.locator('#btn-build')).toContainText('Restore Original Software'); + + // Step 6: Build and wait for completion. + await page.click('#btn-build'); + + const doneOrError = await Promise.race([ + page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'), + page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'), + ]); + + if (doneOrError === 'error') { + const errorMsg = await page.locator('#error-message').textContent(); + throw new Error(`Restore failed: ${errorMsg}`); + } + + await expect(page.locator('#build-status')).toContainText('Software extracted'); + + // Step 7: Download KoboRoot.tgz and verify it matches the original. + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('#btn-download'), + ]); + + expect(download.suggestedFilename()).toBe('KoboRoot.tgz'); + const downloadPath = await download.path(); + const tgzData = fs.readFileSync(downloadPath); + const actualHash = crypto.createHash('sha1').update(tgzData).digest('hex'); + expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1); +}); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 248d664..3aae8bb 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -1,19 +1,19 @@ -const { defineConfig } = require('@playwright/test'); - -module.exports = defineConfig({ - testDir: '.', - testMatch: '*.spec.js', - timeout: 300_000, - retries: 0, - use: { - baseURL: 'http://localhost:8889', - launchOptions: { - args: ['--disable-dev-shm-usage'], - }, - }, - webServer: { - command: 'python3 -m http.server -d ../web/public 8889', - port: 8889, - reuseExistingServer: true, - }, -}); +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: '.', + testMatch: '*.spec.js', + timeout: 300_000, + retries: 0, + use: { + baseURL: 'http://localhost:8889', + launchOptions: { + args: ['--disable-dev-shm-usage'], + }, + }, + webServer: { + command: 'python3 -m http.server -d ../web/public 8889', + port: 8889, + reuseExistingServer: true, + }, +}); diff --git a/kobopatch-wasm/integration_test.go b/kobopatch-wasm/integration_test.go index 7465886..636fa0b 100644 --- a/kobopatch-wasm/integration_test.go +++ b/kobopatch-wasm/integration_test.go @@ -1,175 +1,175 @@ -//go:build js && wasm - -package main - -import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "crypto/sha1" - "fmt" - "io" - "os" - "strings" - "testing" -) - -// TestIntegrationPatch runs the full patching pipeline with real patch files -// and validates SHA1 checksums of the patched binaries. -// -// 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 -// the firmware and execute this test. -func TestIntegrationPatch(t *testing.T) { - firmwarePath := os.Getenv("FIRMWARE_ZIP") - if firmwarePath == "" { - firmwarePath = "testdata/kobo-update-4.45.23646.zip" - } - - firmwareZip, err := os.ReadFile(firmwarePath) - if err != nil { - t.Skipf("firmware zip not available at %s (run test-integration.sh to download): %v", firmwarePath, err) - } - - // Read patch files from the patches zip. - patchesZipPath := "../web/public/patches/patches_4.4523646.zip" - patchesZip, err := os.ReadFile(patchesZipPath) - if err != nil { - t.Fatalf("could not read patches zip: %v", err) - } - - patchFiles, err := extractPatchFiles(patchesZip) - if err != nil { - t.Fatalf("could not extract patch files: %v", err) - } - - // Config: all patches at their defaults, with one override enabled. - configYAML := ` -version: 4.45.23646 -in: unused -out: unused -log: unused - -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: - "Remove footer (row3) on new home screen": yes -` - - var logMessages []string - progress := func(msg string) { - logMessages = append(logMessages, msg) - } - - result, err := patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progress) - if err != nil { - t.Fatalf("patchFirmware failed: %v", err) - } - - if len(result.tgzBytes) == 0 { - t.Fatal("patchFirmware returned empty tgz") - } - - // Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646, - // with only "Remove footer (row3) on new home screen" enabled. - expectedSHA1 := map[string]string{ - "usr/local/Kobo/libnickel.so.1.0.0": "ef64782895a47ac85f0829f06fffa4816d23512d", - "usr/local/Kobo/nickel": "80a607bac515457a6864be8be831df631a01005c", - "usr/local/Kobo/libadobe.so": "02dc99c71c4fef75401cd49ddc2e63f928a126e1", - "usr/local/Kobo/librmsdk.so.1.0.0": "e3819260c9fc539a53db47e9d3fe600ec11633d5", - } - - // Extract the output tgz and check SHA1 of each patched binary. - actualSHA1, err := extractTgzSHA1(result.tgzBytes) - if err != nil { - t.Fatalf("could not extract output tgz: %v", err) - } - - for name, expected := range expectedSHA1 { - actual, ok := actualSHA1[name] - if !ok { - // Try with ./ prefix (tar entries may vary). - actual, ok = actualSHA1["./"+name] - } - if !ok { - t.Errorf("missing binary in output: %s", name) - continue - } - if actual != expected { - t.Errorf("SHA1 mismatch for %s:\n expected: %s\n actual: %s", name, expected, actual) - } else { - t.Logf("OK %s = %s", name, actual) - } - } - - t.Logf("output tgz size: %d bytes", len(result.tgzBytes)) - t.Logf("log output:\n%s", result.log) -} - -// extractPatchFiles reads a patches zip and returns a map of filename -> contents -// for all src/*.yaml files. -func extractPatchFiles(zipData []byte) (map[string][]byte, error) { - r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) - if err != nil { - return nil, err - } - - files := make(map[string][]byte) - for _, f := range r.File { - if !strings.HasPrefix(f.Name, "src/") || !strings.HasSuffix(f.Name, ".yaml") { - continue - } - rc, err := f.Open() - if err != nil { - return nil, fmt.Errorf("open %s: %w", f.Name, err) - } - data, err := io.ReadAll(rc) - rc.Close() - if err != nil { - return nil, fmt.Errorf("read %s: %w", f.Name, err) - } - files[f.Name] = data - } - return files, nil -} - -// extractTgzSHA1 reads a tgz and returns a map of entry name -> SHA1 hex string. -func extractTgzSHA1(tgzData []byte) (map[string]string, error) { - gr, err := gzip.NewReader(bytes.NewReader(tgzData)) - if err != nil { - return nil, err - } - defer gr.Close() - - tr := tar.NewReader(gr) - sums := make(map[string]string) - - for { - h, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - if h.Typeflag != tar.TypeReg { - continue - } - - hasher := sha1.New() - if _, err := io.Copy(hasher, tr); err != nil { - return nil, fmt.Errorf("hash %s: %w", h.Name, err) - } - sums[h.Name] = fmt.Sprintf("%x", hasher.Sum(nil)) - } - - return sums, nil -} +//go:build js && wasm + +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha1" + "fmt" + "io" + "os" + "strings" + "testing" +) + +// TestIntegrationPatch runs the full patching pipeline with real patch files +// and validates SHA1 checksums of the patched binaries. +// +// 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 +// the firmware and execute this test. +func TestIntegrationPatch(t *testing.T) { + firmwarePath := os.Getenv("FIRMWARE_ZIP") + if firmwarePath == "" { + firmwarePath = "testdata/kobo-update-4.45.23646.zip" + } + + firmwareZip, err := os.ReadFile(firmwarePath) + if err != nil { + t.Skipf("firmware zip not available at %s (run test-integration.sh to download): %v", firmwarePath, err) + } + + // Read patch files from the patches zip. + patchesZipPath := "../web/public/patches/patches_4.45.23646.zip" + patchesZip, err := os.ReadFile(patchesZipPath) + if err != nil { + t.Fatalf("could not read patches zip: %v", err) + } + + patchFiles, err := extractPatchFiles(patchesZip) + if err != nil { + t.Fatalf("could not extract patch files: %v", err) + } + + // Config: all patches at their defaults, with one override enabled. + configYAML := ` +version: 4.45.23646 +in: unused +out: unused +log: unused + +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: + "Remove footer (row3) on new home screen": yes +` + + var logMessages []string + progress := func(msg string) { + logMessages = append(logMessages, msg) + } + + result, err := patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progress) + if err != nil { + t.Fatalf("patchFirmware failed: %v", err) + } + + if len(result.tgzBytes) == 0 { + t.Fatal("patchFirmware returned empty tgz") + } + + // Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646, + // with only "Remove footer (row3) on new home screen" enabled. + expectedSHA1 := map[string]string{ + "usr/local/Kobo/libnickel.so.1.0.0": "ef64782895a47ac85f0829f06fffa4816d23512d", + "usr/local/Kobo/nickel": "80a607bac515457a6864be8be831df631a01005c", + "usr/local/Kobo/libadobe.so": "02dc99c71c4fef75401cd49ddc2e63f928a126e1", + "usr/local/Kobo/librmsdk.so.1.0.0": "e3819260c9fc539a53db47e9d3fe600ec11633d5", + } + + // Extract the output tgz and check SHA1 of each patched binary. + actualSHA1, err := extractTgzSHA1(result.tgzBytes) + if err != nil { + t.Fatalf("could not extract output tgz: %v", err) + } + + for name, expected := range expectedSHA1 { + actual, ok := actualSHA1[name] + if !ok { + // Try with ./ prefix (tar entries may vary). + actual, ok = actualSHA1["./"+name] + } + if !ok { + t.Errorf("missing binary in output: %s", name) + continue + } + if actual != expected { + t.Errorf("SHA1 mismatch for %s:\n expected: %s\n actual: %s", name, expected, actual) + } else { + t.Logf("OK %s = %s", name, actual) + } + } + + t.Logf("output tgz size: %d bytes", len(result.tgzBytes)) + t.Logf("log output:\n%s", result.log) +} + +// extractPatchFiles reads a patches zip and returns a map of filename -> contents +// for all src/*.yaml files. +func extractPatchFiles(zipData []byte) (map[string][]byte, error) { + r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return nil, err + } + + files := make(map[string][]byte) + for _, f := range r.File { + if !strings.HasPrefix(f.Name, "src/") || !strings.HasSuffix(f.Name, ".yaml") { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("open %s: %w", f.Name, err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + files[f.Name] = data + } + return files, nil +} + +// extractTgzSHA1 reads a tgz and returns a map of entry name -> SHA1 hex string. +func extractTgzSHA1(tgzData []byte) (map[string]string, error) { + gr, err := gzip.NewReader(bytes.NewReader(tgzData)) + if err != nil { + return nil, err + } + defer gr.Close() + + tr := tar.NewReader(gr) + sums := make(map[string]string) + + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if h.Typeflag != tar.TypeReg { + continue + } + + hasher := sha1.New() + if _, err := io.Copy(hasher, tr); err != nil { + return nil, fmt.Errorf("hash %s: %w", h.Name, err) + } + sums[h.Name] = fmt.Sprintf("%x", hasher.Sum(nil)) + } + + return sums, nil +} diff --git a/nixpacks.toml b/nixpacks.toml index e30ff7e..e30a18d 100644 --- a/nixpacks.toml +++ b/nixpacks.toml @@ -1,13 +1,13 @@ -providers = ["python"] - -[phases.setup] -nixPkgs = ["git", "curl", "python3Minimal"] -paths = ["/usr/local/go/bin"] -cmds = [ - "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 build.sh", -] - -[start] -cmd = "python3 -m http.server ${PORT:-8080} -d web/public" +providers = ["python"] + +[phases.setup] +nixPkgs = ["git", "curl", "python3Minimal"] +paths = ["/usr/local/go/bin"] +cmds = [ + "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 build.sh", +] + +[start] +cmd = "python3 -m http.server ${PORT:-8080} -d web/public" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..3d09d82 --- /dev/null +++ b/test.sh @@ -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 diff --git a/web/public/app.js b/web/public/app.js index 12e730f..0895a6e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -1,528 +1,527 @@ -(() => { - const device = new KoboDevice(); - const patchUI = new PatchUI(); - const runner = new KobopatchRunner(); - - let firmwareURL = null; - let resultTgz = null; - let manualMode = false; - let selectedPrefix = null; - let patchesLoaded = false; - let isRestore = false; - - // DOM elements - const stepNav = document.getElementById('step-nav'); - const stepConnect = document.getElementById('step-connect'); - const stepManual = document.getElementById('step-manual'); - const stepDevice = document.getElementById('step-device'); - const stepPatches = document.getElementById('step-patches'); - const stepFirmware = document.getElementById('step-firmware'); - const stepBuilding = document.getElementById('step-building'); - const stepDone = document.getElementById('step-done'); - const stepError = document.getElementById('step-error'); - - const btnConnect = document.getElementById('btn-connect'); - const btnManualFromAuto = document.getElementById('btn-manual-from-auto'); - const btnManualConfirm = document.getElementById('btn-manual-confirm'); - const manualVersion = document.getElementById('manual-version'); - const manualModel = document.getElementById('manual-model'); - const manualChromeHint = document.getElementById('manual-chrome-hint'); - const btnDeviceNext = document.getElementById('btn-device-next'); - const btnDeviceRestore = document.getElementById('btn-device-restore'); - const btnPatchesBack = document.getElementById('btn-patches-back'); - const btnPatchesNext = document.getElementById('btn-patches-next'); - const btnBuildBack = document.getElementById('btn-build-back'); - const btnWrite = document.getElementById('btn-write'); - const btnDownload = document.getElementById('btn-download'); - const btnRetry = document.getElementById('btn-retry'); - - const firmwareAutoInfo = document.getElementById('firmware-auto-info'); - const errorMessage = document.getElementById('error-message'); - const errorLog = document.getElementById('error-log'); - const deviceStatus = document.getElementById('device-status'); - const patchContainer = document.getElementById('patch-container'); - const buildStatus = document.getElementById('build-status'); - const existingTgzWarning = document.getElementById('existing-tgz-warning'); - const writeInstructions = document.getElementById('write-instructions'); - const downloadInstructions = document.getElementById('download-instructions'); - const firmwareVersionLabel = document.getElementById('firmware-version-label'); - const firmwareDeviceLabel = document.getElementById('firmware-device-label'); - const patchCountHint = document.getElementById('patch-count-hint'); - - const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError]; - - // --- Step navigation --- - function showStep(step) { - for (const s of allSteps) { - s.hidden = (s !== step); - } - } - - function setNavStep(num) { - const items = stepNav.querySelectorAll('li'); - items.forEach((li, i) => { - const stepNum = i + 1; - li.classList.remove('active', 'done'); - if (stepNum < num) li.classList.add('done'); - else if (stepNum === num) li.classList.add('active'); - }); - stepNav.hidden = false; - } - - function hideNav() { - stepNav.hidden = true; - } - - // --- Patch count --- - function updatePatchCount() { - const count = patchUI.getEnabledCount(); - btnPatchesNext.disabled = false; - if (count === 0) { - patchCountHint.textContent = 'No patches selected — continuing will restore the original unpatched software.'; - } else { - patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.'; - } - } - - patchUI.onChange = updatePatchCount; - - // --- Firmware step config --- - function configureFirmwareStep(version, prefix) { - firmwareURL = prefix ? getFirmwareURL(prefix, version) : null; - firmwareVersionLabel.textContent = version; - firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix; - document.getElementById('firmware-download-url').textContent = firmwareURL || ''; - } - - // --- Initial state --- - const hasFileSystemAccess = KoboDevice.isSupported(); - if (hasFileSystemAccess) { - setNavStep(1); - showStep(stepConnect); - } else { - enterManualMode(); - } - - // --- Step 1: Device selection --- - async function enterManualMode() { - manualMode = true; - manualChromeHint.hidden = false; - - const available = await scanAvailablePatches(); - manualVersion.innerHTML = ''; - for (const p of available) { - const opt = document.createElement('option'); - opt.value = p.version; - opt.textContent = p.version; - opt.dataset.filename = p.filename; - manualVersion.appendChild(opt); - } - - manualModel.innerHTML = ''; - manualModel.hidden = true; - - setNavStep(1); - showStep(stepManual); - } - - btnManualFromAuto.addEventListener('click', (e) => { - e.preventDefault(); - enterManualMode(); - }); - - manualVersion.addEventListener('change', () => { - const version = manualVersion.value; - selectedPrefix = null; - - const modelHint = document.getElementById('manual-model-hint'); - if (!version) { - manualModel.hidden = true; - modelHint.hidden = true; - btnManualConfirm.disabled = true; - return; - } - - const devices = getDevicesForVersion(version); - manualModel.innerHTML = ''; - for (const d of devices) { - const opt = document.createElement('option'); - opt.value = d.prefix; - opt.textContent = d.model; - manualModel.appendChild(opt); - } - manualModel.hidden = false; - modelHint.hidden = false; - btnManualConfirm.disabled = true; - }); - - manualModel.addEventListener('change', () => { - selectedPrefix = manualModel.value || null; - btnManualConfirm.disabled = !manualVersion.value || !manualModel.value; - }); - - // Manual confirm → load patches → go to step 2 - btnManualConfirm.addEventListener('click', async () => { - const version = manualVersion.value; - if (!version || !selectedPrefix) return; - - try { - const available = await scanAvailablePatches(); - const loaded = await loadPatchesForVersion(version, available); - if (!loaded) { - showError('Could not load patches for software version ' + version); - return; - } - configureFirmwareStep(version, selectedPrefix); - goToPatches(); - } catch (err) { - showError(err.message); - } - }); - - // Auto connect → show device info - btnConnect.addEventListener('click', async () => { - try { - const info = await device.connect(); - - document.getElementById('device-model').textContent = info.model; - const serialEl = document.getElementById('device-serial'); - serialEl.textContent = ''; - const prefixLen = info.serialPrefix.length; - const u = document.createElement('u'); - u.textContent = info.serial.slice(0, prefixLen); - serialEl.appendChild(u); - serialEl.appendChild(document.createTextNode(info.serial.slice(prefixLen))); - document.getElementById('device-firmware').textContent = info.firmware; - - selectedPrefix = info.serialPrefix; - - const available = await scanAvailablePatches(); - const match = available.find(p => p.version === info.firmware); - - if (match) { - deviceStatus.className = ''; - deviceStatus.textContent = - 'KoboPatch Web UI currently supports this version of the software. ' + - 'You can choose to customize it or simply restore the original software.'; - - await patchUI.loadFromURL('patches/' + match.filename); - patchUI.render(patchContainer); - updatePatchCount(); - patchesLoaded = true; - configureFirmwareStep(info.firmware, info.serialPrefix); - - btnDeviceNext.hidden = false; - btnDeviceRestore.hidden = false; - showStep(stepDevice); - } else { - deviceStatus.className = 'status-unsupported'; - deviceStatus.textContent = - 'No patches available for software version ' + info.firmware + '. ' + - 'Supported versions: ' + available.map(p => p.version).join(', '); - btnDeviceNext.hidden = true; - btnDeviceRestore.hidden = true; - showStep(stepDevice); - } - } catch (err) { - if (err.name === 'AbortError') return; - showError(err.message); - } - }); - - // Device info → patches - btnDeviceNext.addEventListener('click', () => { - if (patchesLoaded) goToPatches(); - }); - - btnDeviceRestore.addEventListener('click', () => { - if (!patchesLoaded) return; - isRestore = true; - goToBuild(); - }); - - async function loadPatchesForVersion(version, available) { - const match = available.find(p => p.version === version); - if (!match) return false; - - await patchUI.loadFromURL('patches/' + match.filename); - patchUI.render(patchContainer); - updatePatchCount(); - patchesLoaded = true; - return true; - } - - // --- Step 2: Patches --- - function goToPatches() { - setNavStep(2); - showStep(stepPatches); - } - - btnPatchesBack.addEventListener('click', () => { - setNavStep(1); - if (manualMode) { - showStep(stepManual); - } else { - showStep(stepDevice); - } - }); - - btnPatchesNext.addEventListener('click', () => { - isRestore = patchUI.getEnabledCount() === 0; - goToBuild(); - }); - - // --- Step 3: Review & Build --- - const btnBuild = document.getElementById('btn-build'); - const firmwareDescription = document.getElementById('firmware-description'); - - function goToBuild() { - if (isRestore) { - firmwareDescription.textContent = - 'will be downloaded and extracted without modifications to restore the original unpatched software.'; - btnBuild.textContent = 'Restore Original Software'; - } else { - firmwareDescription.textContent = - 'will be downloaded automatically from Kobo\u2019s servers and will be patched after the download completes.'; - btnBuild.textContent = 'Build Patched Software'; - } - // Populate selected patches list. - const patchList = document.getElementById('selected-patches-list'); - patchList.innerHTML = ''; - const enabled = patchUI.getEnabledPatches(); - if (enabled.length > 0) { - for (const name of enabled) { - const li = document.createElement('li'); - li.textContent = name; - patchList.appendChild(li); - } - } - const hasPatches = enabled.length > 0; - patchList.hidden = !hasPatches; - document.getElementById('selected-patches-heading').hidden = !hasPatches; - - setNavStep(3); - showStep(stepFirmware); - } - - btnBuildBack.addEventListener('click', () => { - goToPatches(); - }); - - const buildProgress = document.getElementById('build-progress'); - const buildLog = document.getElementById('build-log'); - - async function downloadFirmware(url) { - const resp = await fetch(url); - if (!resp.ok) { - throw new Error('Download failed: HTTP ' + resp.status); - } - - const contentLength = resp.headers.get('Content-Length'); - if (!contentLength || !resp.body) { - buildProgress.textContent = 'Downloading software update...'; - return new Uint8Array(await resp.arrayBuffer()); - } - - const total = parseInt(contentLength, 10); - const reader = resp.body.getReader(); - const chunks = []; - let received = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - received += value.length; - const pct = ((received / total) * 100).toFixed(0); - const mb = (received / 1024 / 1024).toFixed(1); - const totalMB = (total / 1024 / 1024).toFixed(1); - buildProgress.textContent = `Downloading software update... ${mb} / ${totalMB} MB (${pct}%)`; - } - - const result = new Uint8Array(received); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; - } - - function appendLog(msg) { - buildLog.textContent += msg + '\n'; - buildLog.scrollTop = buildLog.scrollHeight; - } - - btnBuild.addEventListener('click', async () => { - showStep(stepBuilding); - buildLog.textContent = ''; - buildProgress.textContent = 'Starting...'; - document.getElementById('build-wait-hint').textContent = isRestore - ? 'Please wait while the original software is being downloaded and extracted...' - : 'Please wait while the patch is being applied...'; - - try { - if (!firmwareURL) { - showError('No download URL available for this device.'); - return; - } - - const firmwareBytes = await downloadFirmware(firmwareURL); - appendLog('Download complete: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB'); - - if (isRestore) { - buildProgress.textContent = 'Extracting KoboRoot.tgz...'; - appendLog('Extracting original KoboRoot.tgz from software update...'); - const zip = await JSZip.loadAsync(firmwareBytes); - const koboRoot = zip.file('KoboRoot.tgz'); - if (!koboRoot) throw new Error('KoboRoot.tgz not found in software update'); - resultTgz = new Uint8Array(await koboRoot.async('arraybuffer')); - appendLog('Extracted KoboRoot.tgz: ' + (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'); - } else { - buildProgress.textContent = 'Applying patches...'; - const configYAML = patchUI.generateConfig(); - const patchFiles = patchUI.getPatchFileBytes(); - - const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => { - appendLog(msg); - const trimmed = msg.trimStart(); - if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') || - trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) { - buildProgress.textContent = trimmed; - } - }); - - resultTgz = result.tgz; - } - const sizeTxt = (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'; - const action = isRestore ? 'Software extracted' : 'Patching complete'; - const description = isRestore - ? 'This will restore the original unpatched software.' - : ''; - buildStatus.innerHTML = - action + '. KoboRoot.tgz (' + sizeTxt + ') is ready. ' + - (description ? description + ' ' : '') + - (manualMode - ? 'Download the file and copy it to your Kobo.' - : 'Write it directly to your connected Kobo, or download for manual installation.'); - - const doneLog = document.getElementById('done-log'); - doneLog.textContent = buildLog.textContent; - - // Reset install step state. - btnWrite.hidden = manualMode; - btnWrite.disabled = false; - btnWrite.className = 'primary'; - btnWrite.textContent = 'Write to Kobo'; - btnDownload.disabled = false; - writeInstructions.hidden = true; - downloadInstructions.hidden = true; - existingTgzWarning.hidden = true; - - // Check if a KoboRoot.tgz already exists on the device. - if (!manualMode && device.directoryHandle) { - try { - const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo'); - await koboDir.getFileHandle('KoboRoot.tgz'); - existingTgzWarning.hidden = false; - } catch { - // No existing file — that's fine. - } - } - - setNavStep(4); - showStep(stepDone); - - // Scroll log to bottom after the step becomes visible. - requestAnimationFrame(() => { - doneLog.scrollTop = doneLog.scrollHeight; - }); - } catch (err) { - showError('Build failed: ' + err.message, buildLog.textContent); - } - }); - - // --- Install step --- - btnWrite.addEventListener('click', async () => { - if (!resultTgz || !device.directoryHandle) return; - - btnWrite.disabled = true; - btnWrite.textContent = 'Writing...'; - downloadInstructions.hidden = true; - - try { - const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo'); - const fileHandle = await koboDir.getFileHandle('KoboRoot.tgz', { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(resultTgz); - await writable.close(); - - btnWrite.textContent = 'Written'; - btnWrite.className = 'btn-success'; - writeInstructions.hidden = false; - } catch (err) { - btnWrite.disabled = false; - btnWrite.textContent = 'Write to Kobo'; - showError('Failed to write KoboRoot.tgz: ' + err.message); - } - }); - - btnDownload.addEventListener('click', () => { - if (!resultTgz) return; - const blob = new Blob([resultTgz], { type: 'application/gzip' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'KoboRoot.tgz'; - a.click(); - URL.revokeObjectURL(url); - - writeInstructions.hidden = true; - downloadInstructions.hidden = false; - }); - - // --- Error / Retry --- - function showError(message, log) { - errorMessage.textContent = message; - if (log) { - errorLog.textContent = log; - errorLog.hidden = false; - } else { - errorLog.hidden = true; - } - hideNav(); - showStep(stepError); - } - - btnRetry.addEventListener('click', () => { - device.disconnect(); - firmwareURL = null; - resultTgz = null; - manualMode = false; - selectedPrefix = null; - patchesLoaded = false; - isRestore = false; - btnDeviceNext.hidden = false; - btnDeviceRestore.hidden = false; - - if (hasFileSystemAccess) { - setNavStep(1); - showStep(stepConnect); - } else { - enterManualMode(); - } - }); - - // --- How it works dialog --- - const dialog = document.getElementById('how-it-works-dialog'); - document.getElementById('btn-how-it-works').addEventListener('click', (e) => { - e.preventDefault(); - dialog.showModal(); - }); - document.getElementById('btn-close-dialog').addEventListener('click', () => { - dialog.close(); - }); - dialog.addEventListener('click', (e) => { - if (e.target === dialog) dialog.close(); - }); -})(); +(() => { + const device = new KoboDevice(); + const patchUI = new PatchUI(); + const runner = new KobopatchRunner(); + + let firmwareURL = null; + let resultTgz = null; + let manualMode = false; + let selectedPrefix = null; + let patchesLoaded = false; + let isRestore = false; + + // DOM elements + const stepNav = document.getElementById('step-nav'); + const stepConnect = document.getElementById('step-connect'); + const stepManual = document.getElementById('step-manual'); + const stepDevice = document.getElementById('step-device'); + const stepPatches = document.getElementById('step-patches'); + const stepFirmware = document.getElementById('step-firmware'); + const stepBuilding = document.getElementById('step-building'); + const stepDone = document.getElementById('step-done'); + const stepError = document.getElementById('step-error'); + + const btnConnect = document.getElementById('btn-connect'); + const btnManualFromAuto = document.getElementById('btn-manual-from-auto'); + const btnManualConfirm = document.getElementById('btn-manual-confirm'); + const manualVersion = document.getElementById('manual-version'); + const manualModel = document.getElementById('manual-model'); + const manualChromeHint = document.getElementById('manual-chrome-hint'); + const btnDeviceNext = document.getElementById('btn-device-next'); + const btnDeviceRestore = document.getElementById('btn-device-restore'); + const btnPatchesBack = document.getElementById('btn-patches-back'); + const btnPatchesNext = document.getElementById('btn-patches-next'); + const btnBuildBack = document.getElementById('btn-build-back'); + const btnWrite = document.getElementById('btn-write'); + const btnDownload = document.getElementById('btn-download'); + const btnRetry = document.getElementById('btn-retry'); + + const firmwareAutoInfo = document.getElementById('firmware-auto-info'); + const errorMessage = document.getElementById('error-message'); + const errorLog = document.getElementById('error-log'); + const deviceStatus = document.getElementById('device-status'); + const patchContainer = document.getElementById('patch-container'); + const buildStatus = document.getElementById('build-status'); + const existingTgzWarning = document.getElementById('existing-tgz-warning'); + const writeInstructions = document.getElementById('write-instructions'); + const downloadInstructions = document.getElementById('download-instructions'); + const firmwareVersionLabel = document.getElementById('firmware-version-label'); + const firmwareDeviceLabel = document.getElementById('firmware-device-label'); + const patchCountHint = document.getElementById('patch-count-hint'); + + const allSteps = [stepConnect, stepManual, stepDevice, stepPatches, stepFirmware, stepBuilding, stepDone, stepError]; + + // --- Step navigation --- + function showStep(step) { + for (const s of allSteps) { + s.hidden = (s !== step); + } + } + + function setNavStep(num) { + const items = stepNav.querySelectorAll('li'); + items.forEach((li, i) => { + const stepNum = i + 1; + li.classList.remove('active', 'done'); + if (stepNum < num) li.classList.add('done'); + else if (stepNum === num) li.classList.add('active'); + }); + stepNav.hidden = false; + } + + function hideNav() { + stepNav.hidden = true; + } + + // --- Patch count --- + function updatePatchCount() { + const count = patchUI.getEnabledCount(); + btnPatchesNext.disabled = false; + if (count === 0) { + patchCountHint.textContent = 'No patches selected — continuing will restore the original unpatched software.'; + } else { + patchCountHint.textContent = count === 1 ? '1 patch selected.' : count + ' patches selected.'; + } + } + + patchUI.onChange = updatePatchCount; + + // --- Firmware step config --- + function configureFirmwareStep(version, prefix) { + firmwareURL = prefix ? getFirmwareURL(prefix, version) : null; + firmwareVersionLabel.textContent = version; + firmwareDeviceLabel.textContent = KOBO_MODELS[prefix] || prefix; + document.getElementById('firmware-download-url').textContent = firmwareURL || ''; + } + + // --- Initial state --- + const hasFileSystemAccess = KoboDevice.isSupported(); + if (hasFileSystemAccess) { + setNavStep(1); + showStep(stepConnect); + } else { + enterManualMode(); + } + + // --- Step 1: Device selection --- + async function enterManualMode() { + manualMode = true; + manualChromeHint.hidden = false; + + const available = await scanAvailablePatches(); + manualVersion.innerHTML = ''; + for (const p of available) { + const opt = document.createElement('option'); + opt.value = p.version; + opt.textContent = p.version; + opt.dataset.filename = p.filename; + manualVersion.appendChild(opt); + } + + manualModel.innerHTML = ''; + manualModel.hidden = true; + + setNavStep(1); + showStep(stepManual); + } + + btnManualFromAuto.addEventListener('click', (e) => { + e.preventDefault(); + enterManualMode(); + }); + + manualVersion.addEventListener('change', () => { + const version = manualVersion.value; + selectedPrefix = null; + + const modelHint = document.getElementById('manual-model-hint'); + if (!version) { + manualModel.hidden = true; + modelHint.hidden = true; + btnManualConfirm.disabled = true; + return; + } + + const devices = getDevicesForVersion(version); + manualModel.innerHTML = ''; + for (const d of devices) { + const opt = document.createElement('option'); + opt.value = d.prefix; + opt.textContent = d.model; + manualModel.appendChild(opt); + } + manualModel.hidden = false; + modelHint.hidden = false; + btnManualConfirm.disabled = true; + }); + + manualModel.addEventListener('change', () => { + selectedPrefix = manualModel.value || null; + btnManualConfirm.disabled = !manualVersion.value || !manualModel.value; + }); + + // Manual confirm → load patches → go to step 2 + btnManualConfirm.addEventListener('click', async () => { + const version = manualVersion.value; + if (!version || !selectedPrefix) return; + + try { + const available = await scanAvailablePatches(); + const loaded = await loadPatchesForVersion(version, available); + if (!loaded) { + showError('Could not load patches for software version ' + version); + return; + } + configureFirmwareStep(version, selectedPrefix); + goToPatches(); + } catch (err) { + showError(err.message); + } + }); + + // Auto connect → show device info + btnConnect.addEventListener('click', async () => { + try { + const info = await device.connect(); + + document.getElementById('device-model').textContent = info.model; + const serialEl = document.getElementById('device-serial'); + serialEl.textContent = ''; + const prefixLen = info.serialPrefix.length; + const u = document.createElement('u'); + u.textContent = info.serial.slice(0, prefixLen); + serialEl.appendChild(u); + serialEl.appendChild(document.createTextNode(info.serial.slice(prefixLen))); + document.getElementById('device-firmware').textContent = info.firmware; + + selectedPrefix = info.serialPrefix; + + const available = await scanAvailablePatches(); + const match = available.find(p => p.version === info.firmware); + + if (match) { + deviceStatus.className = ''; + deviceStatus.textContent = + 'KoboPatch Web UI currently supports this version of the software. ' + + 'You can choose to customize it or simply restore the original software.'; + + await patchUI.loadFromURL('patches/' + match.filename); + patchUI.render(patchContainer); + updatePatchCount(); + patchesLoaded = true; + configureFirmwareStep(info.firmware, info.serialPrefix); + + btnDeviceNext.hidden = false; + btnDeviceRestore.hidden = false; + showStep(stepDevice); + } else { + deviceStatus.className = 'warning'; + deviceStatus.textContent = + 'No patch available for this specific version and model combination. Currently, only Kobo Libra Colour, Kobo Clara Colour and Kobo Clara BW can be patched via this website.'; + btnDeviceNext.hidden = true; + btnDeviceRestore.hidden = true; + showStep(stepDevice); + } + } catch (err) { + if (err.name === 'AbortError') return; + showError(err.message); + } + }); + + // Device info → patches + btnDeviceNext.addEventListener('click', () => { + if (patchesLoaded) goToPatches(); + }); + + btnDeviceRestore.addEventListener('click', () => { + if (!patchesLoaded) return; + isRestore = true; + goToBuild(); + }); + + async function loadPatchesForVersion(version, available) { + const match = available.find(p => p.version === version); + if (!match) return false; + + await patchUI.loadFromURL('patches/' + match.filename); + patchUI.render(patchContainer); + updatePatchCount(); + patchesLoaded = true; + return true; + } + + // --- Step 2: Patches --- + function goToPatches() { + setNavStep(2); + showStep(stepPatches); + } + + btnPatchesBack.addEventListener('click', () => { + setNavStep(1); + if (manualMode) { + showStep(stepManual); + } else { + showStep(stepDevice); + } + }); + + btnPatchesNext.addEventListener('click', () => { + isRestore = patchUI.getEnabledCount() === 0; + goToBuild(); + }); + + // --- Step 3: Review & Build --- + const btnBuild = document.getElementById('btn-build'); + const firmwareDescription = document.getElementById('firmware-description'); + + function goToBuild() { + if (isRestore) { + firmwareDescription.textContent = + 'will be downloaded and extracted without modifications to restore the original unpatched software.'; + btnBuild.textContent = 'Restore Original Software'; + } else { + firmwareDescription.textContent = + 'will be downloaded automatically from Kobo\u2019s servers and will be patched after the download completes.'; + btnBuild.textContent = 'Build Patched Software'; + } + // Populate selected patches list. + const patchList = document.getElementById('selected-patches-list'); + patchList.innerHTML = ''; + const enabled = patchUI.getEnabledPatches(); + if (enabled.length > 0) { + for (const name of enabled) { + const li = document.createElement('li'); + li.textContent = name; + patchList.appendChild(li); + } + } + const hasPatches = enabled.length > 0; + patchList.hidden = !hasPatches; + document.getElementById('selected-patches-heading').hidden = !hasPatches; + + setNavStep(3); + showStep(stepFirmware); + } + + btnBuildBack.addEventListener('click', () => { + goToPatches(); + }); + + const buildProgress = document.getElementById('build-progress'); + const buildLog = document.getElementById('build-log'); + + async function downloadFirmware(url) { + const resp = await fetch(url); + if (!resp.ok) { + throw new Error('Download failed: HTTP ' + resp.status); + } + + const contentLength = resp.headers.get('Content-Length'); + if (!contentLength || !resp.body) { + buildProgress.textContent = 'Downloading software update...'; + return new Uint8Array(await resp.arrayBuffer()); + } + + const total = parseInt(contentLength, 10); + const reader = resp.body.getReader(); + const chunks = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + const pct = ((received / total) * 100).toFixed(0); + const mb = (received / 1024 / 1024).toFixed(1); + const totalMB = (total / 1024 / 1024).toFixed(1); + buildProgress.textContent = `Downloading software update... ${mb} / ${totalMB} MB (${pct}%)`; + } + + const result = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; + } + + function appendLog(msg) { + buildLog.textContent += msg + '\n'; + buildLog.scrollTop = buildLog.scrollHeight; + } + + btnBuild.addEventListener('click', async () => { + showStep(stepBuilding); + buildLog.textContent = ''; + buildProgress.textContent = 'Starting...'; + document.getElementById('build-wait-hint').textContent = isRestore + ? 'Please wait while the original software is being downloaded and extracted...' + : 'Please wait while the patch is being applied...'; + + try { + if (!firmwareURL) { + showError('No download URL available for this device.'); + return; + } + + const firmwareBytes = await downloadFirmware(firmwareURL); + appendLog('Download complete: ' + (firmwareBytes.length / 1024 / 1024).toFixed(1) + ' MB'); + + if (isRestore) { + buildProgress.textContent = 'Extracting KoboRoot.tgz...'; + appendLog('Extracting original KoboRoot.tgz from software update...'); + const zip = await JSZip.loadAsync(firmwareBytes); + const koboRoot = zip.file('KoboRoot.tgz'); + if (!koboRoot) throw new Error('KoboRoot.tgz not found in software update'); + resultTgz = new Uint8Array(await koboRoot.async('arraybuffer')); + appendLog('Extracted KoboRoot.tgz: ' + (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'); + } else { + buildProgress.textContent = 'Applying patches...'; + const configYAML = patchUI.generateConfig(); + const patchFiles = patchUI.getPatchFileBytes(); + + const result = await runner.patchFirmware(configYAML, firmwareBytes, patchFiles, (msg) => { + appendLog(msg); + const trimmed = msg.trimStart(); + if (trimmed.startsWith('Patching ') || trimmed.startsWith('Checking ') || + trimmed.startsWith('Loading WASM') || trimmed.startsWith('WASM module')) { + buildProgress.textContent = trimmed; + } + }); + + resultTgz = result.tgz; + } + const sizeTxt = (resultTgz.length / 1024 / 1024).toFixed(1) + ' MB'; + const action = isRestore ? 'Software extracted' : 'Patching complete'; + const description = isRestore + ? 'This will restore the original unpatched software.' + : ''; + buildStatus.innerHTML = + action + '. KoboRoot.tgz (' + sizeTxt + ') is ready. ' + + (description ? description + ' ' : '') + + (manualMode + ? 'Download the file and copy it to your Kobo.' + : 'Write it directly to your connected Kobo, or download for manual installation.'); + + const doneLog = document.getElementById('done-log'); + doneLog.textContent = buildLog.textContent; + + // Reset install step state. + btnWrite.hidden = manualMode; + btnWrite.disabled = false; + btnWrite.className = 'primary'; + btnWrite.textContent = 'Write to Kobo'; + btnDownload.disabled = false; + writeInstructions.hidden = true; + downloadInstructions.hidden = true; + existingTgzWarning.hidden = true; + + // Check if a KoboRoot.tgz already exists on the device. + if (!manualMode && device.directoryHandle) { + try { + const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo'); + await koboDir.getFileHandle('KoboRoot.tgz'); + existingTgzWarning.hidden = false; + } catch { + // No existing file — that's fine. + } + } + + setNavStep(4); + showStep(stepDone); + + // Scroll log to bottom after the step becomes visible. + requestAnimationFrame(() => { + doneLog.scrollTop = doneLog.scrollHeight; + }); + } catch (err) { + showError('Build failed: ' + err.message, buildLog.textContent); + } + }); + + // --- Install step --- + btnWrite.addEventListener('click', async () => { + if (!resultTgz || !device.directoryHandle) return; + + btnWrite.disabled = true; + btnWrite.textContent = 'Writing...'; + downloadInstructions.hidden = true; + + try { + const koboDir = await device.directoryHandle.getDirectoryHandle('.kobo'); + const fileHandle = await koboDir.getFileHandle('KoboRoot.tgz', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(resultTgz); + await writable.close(); + + btnWrite.textContent = 'Written'; + btnWrite.className = 'btn-success'; + writeInstructions.hidden = false; + } catch (err) { + btnWrite.disabled = false; + btnWrite.textContent = 'Write to Kobo'; + showError('Failed to write KoboRoot.tgz: ' + err.message); + } + }); + + btnDownload.addEventListener('click', () => { + if (!resultTgz) return; + const blob = new Blob([resultTgz], { type: 'application/gzip' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'KoboRoot.tgz'; + a.click(); + URL.revokeObjectURL(url); + + writeInstructions.hidden = true; + downloadInstructions.hidden = false; + }); + + // --- Error / Retry --- + function showError(message, log) { + errorMessage.textContent = message; + if (log) { + errorLog.textContent = log; + errorLog.hidden = false; + } else { + errorLog.hidden = true; + } + hideNav(); + showStep(stepError); + } + + btnRetry.addEventListener('click', () => { + device.disconnect(); + firmwareURL = null; + resultTgz = null; + manualMode = false; + selectedPrefix = null; + patchesLoaded = false; + isRestore = false; + btnDeviceNext.hidden = false; + btnDeviceRestore.hidden = false; + + if (hasFileSystemAccess) { + setNavStep(1); + showStep(stepConnect); + } else { + enterManualMode(); + } + }); + + // --- How it works dialog --- + const dialog = document.getElementById('how-it-works-dialog'); + document.getElementById('btn-how-it-works').addEventListener('click', (e) => { + e.preventDefault(); + dialog.showModal(); + }); + document.getElementById('btn-close-dialog').addEventListener('click', () => { + dialog.close(); + }); + dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.close(); + }); +})(); diff --git a/web/public/index.html b/web/public/index.html index 2dbd127..bbdbc63 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -1,254 +1,254 @@ - - - - - - KoboPatch Web UI - - - - - - - -
-
-

KoboPatch Web UI beta

-

Custom patches for your Kobo e-reader

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - + + + + + + KoboPatch Web UI + + + + + + + +
+
+

KoboPatch Web UI beta

+

Apply patches to your Kobo Libra Colour, Kobo Clara Colour and Kobo Clara BW.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/web/public/kobo-device.js b/web/public/kobo-device.js index 52f6022..2047da9 100644 --- a/web/public/kobo-device.js +++ b/web/public/kobo-device.js @@ -1,175 +1,175 @@ -/** - * 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'; - -/** - * Firmware download URLs by version and serial prefix. - * Source: https://help.kobo.com/hc/en-us/articles/35059171032727 - * - * The kobo prefix (kobo12, kobo13, kobo14) is stable per device family. - * The date path segment (e.g. Mar2026) changes per release. - * help.kobo.com may lag behind; verify URLs when adding new versions. - */ -const FIRMWARE_DOWNLOADS = { - '4.45.23646': { - 'N428': 'https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip', - 'N365': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip', - 'N367': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip', - 'P365': 'https://ereaderfiles.kobo.com/firmwares/kobo14/Mar2026/kobo-update-4.45.23646.zip', - }, -}; - -/** - * Get the firmware download URL for a given serial prefix and firmware version. - * Returns null if no URL is available. - */ -function getFirmwareURL(serialPrefix, version) { - const versionMap = FIRMWARE_DOWNLOADS[version]; - if (!versionMap) return null; - return versionMap[serialPrefix] || null; -} - -/** - * Get all device models that have firmware downloads for a given version. - * Returns array of { prefix, model } objects. - */ -function getDevicesForVersion(version) { - const versionMap = FIRMWARE_DOWNLOADS[version]; - if (!versionMap) return []; - const devices = []; - for (const prefix of Object.keys(versionMap)) { - const model = KOBO_MODELS[prefix] || 'Unknown'; - devices.push({ prefix, model: model + ' (' + prefix + ')' }); - } - return devices; -} - -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; - } -} +/** + * 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', +}; + +/** + * Firmware download URLs by version and serial prefix. + * Source: https://help.kobo.com/hc/en-us/articles/35059171032727 + * + * The kobo prefix (kobo12, kobo13, kobo14) is stable per device family. + * The date path segment (e.g. Mar2026) changes per release. + * help.kobo.com may lag behind; verify URLs when adding new versions. + */ +const FIRMWARE_DOWNLOADS = { + '4.45.23646': { + 'N428': 'https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip', + 'N365': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip', + 'N367': 'https://ereaderfiles.kobo.com/firmwares/kobo12/Mar2026/kobo-update-4.45.23646.zip', + 'P365': 'https://ereaderfiles.kobo.com/firmwares/kobo14/Mar2026/kobo-update-4.45.23646.zip', + }, +}; + +/** + * 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. + * Returns null if no URL is available. + */ +function getFirmwareURL(serialPrefix, version) { + const versionMap = FIRMWARE_DOWNLOADS[version]; + if (!versionMap) return null; + return versionMap[serialPrefix] || null; +} + +/** + * Get all device models that have firmware downloads for a given version. + * Returns array of { prefix, model } objects. + */ +function getDevicesForVersion(version) { + const versionMap = FIRMWARE_DOWNLOADS[version]; + if (!versionMap) return []; + const devices = []; + for (const prefix of Object.keys(versionMap)) { + const model = KOBO_MODELS[prefix] || 'Unknown'; + devices.push({ prefix, model: model + ' (' + prefix + ')' }); + } + return devices; +} + +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 = SUPPORTED_FIRMWARE.includes(firmware); + + return { + serial, + serialPrefix, + firmware, + hardwareId, + model, + isSupported, + }; + } + + /** + * Disconnect / release the directory handle. + */ + disconnect() { + this.directoryHandle = null; + this.deviceInfo = null; + } +} diff --git a/web/public/patch-ui.js b/web/public/patch-ui.js index 4d948c2..46110eb 100644 --- a/web/public/patch-ui.js +++ b/web/public/patch-ui.js @@ -136,14 +136,22 @@ function parsePatchConfig(configYAML) { /** * 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() { try { const resp = await fetch('patches/index.json'); if (!resp.ok) return []; 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 { return []; } diff --git a/web/public/patches/index.json b/web/public/patches/index.json index 1c50abb..fe0dbb3 100644 --- a/web/public/patches/index.json +++ b/web/public/patches/index.json @@ -1,6 +1,7 @@ [ { - "filename": "patches_4.4523646.zip", - "version": "4.45.23646" - } + "filename": "patches_4.45.23646.zip", + "versions": ["4.45.23646"], + "date": "03-2026" + }, ] diff --git a/web/public/patches/patches_4.4523646.zip b/web/public/patches/patches_4.45.23646.zip similarity index 100% rename from web/public/patches/patches_4.4523646.zip rename to web/public/patches/patches_4.45.23646.zip diff --git a/web/public/style.css b/web/public/style.css index 25060cf..2f617f9 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -1,816 +1,816 @@ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg: #f5f5f7; - --card-bg: #fff; - --border: #d1d5db; - --border-light: #e5e7eb; - --text: #111827; - --text-secondary: #6b7280; - --primary: #2563eb; - --primary-hover: #1d4ed8; - --primary-light: #eff6ff; - --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; - --shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05); -} - -body { - font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.6; - -webkit-font-smoothing: antialiased; -} - -main { - max-width: 640px; - margin: 0 auto; - padding: 2rem 1.5rem 4rem; -} - -/* Hero header */ -.hero { - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-light); -} - -h1 { - font-size: 1.75rem; - font-weight: 700; - letter-spacing: -0.02em; -} - -.hero-accent { - color: var(--primary); - font-weight: 600; -} - -.beta-pill { - font-size: 0.55rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - background: var(--error-text); - color: #fff; - padding: 0.15rem 0.5rem; - border-radius: 10px; - vertical-align: middle; - position: relative; - top: -0.15rem; -} - -.subtitle { - color: var(--text-secondary); - font-size: 0.95rem; - margin-top: 0.25rem; -} - -h2 { - font-size: 1.1rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--text); -} - -/* Step navigation */ -.step-nav { - margin-bottom: 1.5rem; -} - -.step-nav ol { - display: flex; - list-style: none; - gap: 0; - counter-reset: step; -} - -.step-nav li { - flex: 1; - text-align: center; - padding: 0.5rem 0; - font-size: 0.8rem; - font-weight: 500; - color: var(--text-secondary); - position: relative; - counter-increment: step; -} - -.step-nav li::before { - content: counter(step); - display: block; - width: 1.6rem; - height: 1.6rem; - line-height: 1.6rem; - margin: 0 auto 0.3rem; - border-radius: 50%; - background: var(--border-light); - color: var(--text-secondary); - font-size: 0.75rem; - font-weight: 600; -} - -.step-nav li.active::before { - background: var(--primary); - color: #fff; -} - -.step-nav li.active { - color: var(--primary); - font-weight: 600; -} - -.step-nav li.done::before { - background: var(--success-text); - color: #fff; - content: "\2713"; -} - -.step-nav li.done { - color: var(--success-text); -} - -/* Connector lines between steps */ -.step-nav li + li::after { - content: ''; - position: absolute; - top: 1.3rem; - right: 50%; - width: 100%; - height: 2px; - background: var(--border-light); - z-index: -1; -} - -.step-nav li.done + li::after, -.step-nav li.done + li.active::after { - background: var(--success-text); -} - -/* Steps */ -.step { - margin-bottom: 1rem; -} - -.step p { - color: var(--text-secondary); - margin-bottom: 1rem; - font-size: 0.93rem; - line-height: 1.6; -} - -/* Step action buttons (back/next) */ -.step-actions { - display: flex; - justify-content: space-between; - gap: 0.75rem; - margin-top: 1.25rem; -} - -.step-actions .primary:first-child { - margin-left: auto; -} - -/* Buttons */ -button { - font-size: 0.9rem; - padding: 0.55rem 1.25rem; - border-radius: 8px; - border: 1px solid var(--border); - cursor: pointer; - font-weight: 500; - transition: all 0.15s ease; -} - -button.primary { - background: var(--primary); - color: #fff; - border-color: var(--primary); - box-shadow: var(--shadow); -} - -button.primary:hover { - background: var(--primary-hover); - border-color: var(--primary-hover); - box-shadow: var(--shadow-md); -} - -button.secondary { - background: var(--card-bg); - color: var(--text); - box-shadow: var(--shadow); -} - -button.secondary:hover { - background: #f9fafb; - border-color: #9ca3af; -} - -button:disabled { - opacity: 0.4; - cursor: not-allowed; - box-shadow: none; -} - -button.btn-success, -button.btn-success:hover { - background: var(--success-text); - border-color: var(--success-text); - color: #fff; - cursor: default; - opacity: 0.7; -} - -/* Cards */ -.info-card { - background: var(--card-bg); - border: 1px solid var(--border-light); - border-radius: 10px; - padding: 0.75rem 1.25rem; - margin-bottom: 1rem; - box-shadow: var(--shadow); -} - -.info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - border-bottom: 1px solid var(--border-light); -} - -.info-row:last-child { - border-bottom: none; -} - -.info-row .label { - font-weight: 500; - color: var(--text-secondary); - font-size: 0.85rem; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.info-row .value { - font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; - font-size: 0.88rem; -} - -/* Status banners */ -.warning { - background: var(--warning-bg); - border: 1px solid var(--warning-border); - color: var(--warning-text); - padding: 0.75rem 1rem; - border-radius: 8px; - margin-bottom: 1.5rem; - font-size: 0.88rem; - line-height: 1.5; -} - -.warning a { - color: inherit; -} - -.error { - background: var(--error-bg); - border: 1px solid var(--error-border); - color: var(--error-text); - padding: 0.75rem 1rem; - border-radius: 8px; - font-size: 0.88rem; -} - -.status-supported { - background: var(--success-bg); - border: 1px solid var(--success-border); - color: var(--success-text); - padding: 0.65rem 1rem; - border-radius: 8px; - margin-bottom: 1rem; - font-size: 0.88rem; -} - -.status-unsupported { - background: var(--warning-bg); - border: 1px solid var(--warning-border); - color: var(--warning-text); - padding: 0.65rem 1rem; - border-radius: 8px; - margin-bottom: 1rem; - font-size: 0.88rem; -} - -/* Scrollable patch container */ -.patch-container-scroll { - max-height: 50vh; - overflow-y: auto; - border: 1px solid var(--border); - border-radius: 5px; -} - -/* Patch file sections */ -.patch-file-section { - background: var(--card-bg); - border-bottom: 1px solid var(--border-light); -} - -.patch-file-section:last-child { - border-bottom: none; -} - -.patch-file-section summary { - padding: 0.6rem 0.75rem; - cursor: pointer; - display: flex; - align-items: center; - font-weight: 500; - font-size: 0.93rem; - user-select: none; - transition: background 0.1s; - list-style: none; -} - -.patch-file-section summary::-webkit-details-marker { - display: none; -} - -.patch-file-section summary::before { - content: "\203A"; - display: inline-block; - width: 1rem; - margin-right: 0.35rem; - flex-shrink: 0; - text-align: center; - font-size: 1.1rem; - font-weight: 600; - color: var(--text-secondary); - transition: transform 0.15s ease; -} - -.patch-file-section[open] summary::before { - transform: rotate(90deg) translateX(0.1rem); -} - -.patch-file-section summary .patch-count { - margin-left: auto; -} - -.patch-file-section summary:hover { - background: #f9fafb; -} - -.patch-file-section[open] summary { - border-bottom: 1px solid var(--border-light); -} - -.patch-count { - font-weight: 400; - font-size: 0.8rem; - background: var(--primary-light); - color: var(--primary); - padding: 0.15rem 0.6rem; - border-radius: 10px; -} - -.patch-list { - padding: 0.25rem 0; -} - -.patch-item { - padding: 0.4rem 1rem; -} - -.patch-item + .patch-item { - border-top: 1px solid var(--border-light); -} - -.patch-header { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-size: 0.85rem; -} - -.patch-header input { - flex-shrink: 0; - accent-color: var(--primary); -} - -.patch-name { - font-weight: 500; -} - -.patch-name-none { - color: var(--text-secondary); -} - -.patch-desc-toggle { - flex-shrink: 0; - background: none; - border: none; - padding: 0 0.3rem; - font-size: 0.7rem; - color: var(--text-secondary); - cursor: pointer; - box-shadow: none; - opacity: 0.6; - transition: opacity 0.1s; -} - -.patch-desc-toggle:hover { - opacity: 1; -} - -/* Visual grouping for mutually exclusive patches */ -.patch-group { - background: #f8fafc; - border-left: 3px solid var(--primary); - margin: 0.35rem 0.5rem; - border-radius: 0 6px 6px 0; -} - -.patch-group .patch-item { - padding: 0.4rem 0.75rem; -} - -.patch-group-label { - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--primary); - padding: 0.45rem 0.75rem 0; -} - -.step .patch-description { - margin-top: 0.3rem; - margin-left: 1.6rem; - margin-bottom: 0; - font-size: 0.72rem; - color: var(--text-secondary); - white-space: pre-line; - line-height: 1.4; - padding: 0.25rem 0; -} - -.patch-description[hidden] { - display: none; -} - -/* Firmware input */ -input[type="file"] { - display: block; - margin-bottom: 1rem; - font-size: 0.88rem; -} - -#build-actions { - display: flex; - justify-content: space-between; - gap: 0.75rem; - margin-top: 1.25rem; -} - -/* Build header (spinner + progress text) */ -.build-header { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.25rem; -} - -.build-header p { - margin-bottom: 0; - font-weight: 500; - color: var(--text); -} - -/* Spinner */ -.spinner { - width: 22px; - height: 22px; - border: 2.5px solid var(--border-light); - border-top-color: var(--primary); - border-radius: 50%; - animation: spin 0.7s linear infinite; - flex-shrink: 0; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Build log terminal */ -.build-log { - margin-top: 0.75rem; - padding: 0.75rem 1rem; - background: #0f172a; - color: #94a3b8; - border-radius: 6px; - font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; - font-size: 0.73rem; - white-space: pre-wrap; - height: calc(10 * 1.5em + 1.5rem); - overflow-y: auto; - line-height: 1.5; -} - -/* Collapsible log on install step */ -.log-details { - margin-top: 0.5rem; - margin-bottom: 0.25rem; -} - -.log-details summary { - font-size: 0.83rem; - color: var(--text-secondary); - cursor: pointer; - padding: 0.25rem 0; -} - -.log-details summary:hover { - color: var(--text); -} - -/* Done screen log is shorter */ -.done-log { - height: calc(7 * 1.5em + 1.5rem); -} - -.step .hint { - margin-top: 1rem; - padding: 0.65rem 1rem; - background: var(--success-bg); - border: 1px solid var(--success-border); - color: var(--success-text); - border-radius: 8px; - font-size: 0.88rem; -} - -.error-log { - margin-top: 0.75rem; - padding: 0.75rem 1rem; - background: #0f172a; - color: #e2e8f0; - border-radius: 6px; - font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; - font-size: 0.78rem; - white-space: pre-wrap; - max-height: 300px; - overflow-y: auto; -} - -.step a { - color: var(--primary); - text-decoration: none; -} - -.step a:hover { - text-decoration: underline; -} - -.step .fallback-hint { - margin-top: 0.5rem; - margin-bottom: 1rem; - font-size: 0.78rem; - color: var(--text-secondary); - line-height: 1.5; -} - -select + .fallback-hint { - margin-top: -0.5rem; -} - -/* Install summary */ -.step .install-summary { - font-size: 0.93rem; - color: var(--text); - line-height: 1.6; - margin-bottom: 0.5rem; -} - -/* Install instructions */ -.install-instructions { - margin-top: 1rem; -} - -.install-instructions .warning { - margin-bottom: 0.75rem; -} - -.step .install-instructions .hint { - margin-top: 0; -} - -.install-steps { - margin: 0.5rem 0 0 1.25rem; - font-size: 0.88rem; - color: var(--text-secondary); - line-height: 1.7; -} - -.install-steps li { - padding: 0.1rem 0; -} - -.step .info-banner { - background: var(--primary-light); - border: 1px solid #bfdbfe; - color: #1e40af; - padding: 0.6rem 1rem; - border-radius: 8px; - font-size: 0.83rem; - margin-bottom: 1rem; -} - -/* Selected patches summary on build step */ -.selected-patches-list { - margin: 0 0 0.75rem 1.25rem; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.7; -} - -.selected-patches-list li { - padding: 0.05rem 0; -} - -#firmware-download-url { - display: inline-block; - margin: 0.4rem 0; - padding: 0.3rem 0.6rem; - font-size: 0.7rem; - word-break: break-all; - color: #64748b; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 4px; -} - -#firmware-verify-notice { - font-size: 12px; -} - -[hidden] { - display: none !important; -} - -select { - display: block; - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.93rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--card-bg); - color: var(--text); - margin-bottom: 1rem; - box-shadow: var(--shadow); - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - padding-right: 2rem; -} - -select:focus, -button:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; -} - -/* Footer */ -.site-footer { - max-width: 640px; - margin: 0 auto; - padding: 1.5rem 1.5rem 2rem; - border-top: 1px solid var(--border-light); - text-align: center; - font-size: 0.8rem; - color: var(--text-secondary); -} - -.site-footer a { - color: var(--text-secondary); - text-decoration: underline; -} - -.site-footer a:hover { - color: var(--text); -} - -/* Modal dialog */ -.modal { - border: none; - border-radius: 12px; - padding: 0; - max-width: 560px; - width: calc(100% - 2rem); - max-height: 80vh; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - margin: 0; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); -} - -.modal::backdrop { - background: rgba(0, 0, 0, 0.4); -} - -.modal-content { - display: flex; - flex-direction: column; - max-height: 80vh; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--border-light); - flex-shrink: 0; -} - -.modal-header h2 { - margin-bottom: 0; - font-size: 1rem; -} - -.modal-close { - background: none; - border: none; - font-size: 1.4rem; - color: var(--text-secondary); - cursor: pointer; - padding: 0 0.25rem; - line-height: 1; - box-shadow: none; -} - -.modal-close:hover { - color: var(--text); -} - -.modal-body { - padding: 1.25rem; - overflow-y: auto; - font-size: 0.88rem; - color: var(--text-secondary); - line-height: 1.7; -} - -.modal-body h3 { - font-size: 0.88rem; - font-weight: 600; - color: var(--text); - margin-top: 1.25rem; - margin-bottom: 0.5rem; -} - -.modal-body p { - margin-bottom: 0.75rem; -} - -.modal-body ol { - margin: 0 0 0.75rem 1.25rem; -} - -.modal-body li { - margin-bottom: 0.5rem; -} - -.modal-body a { - color: var(--primary); - text-decoration: none; -} - -.modal-body a:hover { - text-decoration: underline; -} - -.modal-body code { - font-size: 0.8rem; - background: #f1f5f9; - padding: 0.1rem 0.35rem; - border-radius: 3px; -} +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #f5f5f7; + --card-bg: #fff; + --border: #d1d5db; + --border-light: #e5e7eb; + --text: #111827; + --text-secondary: #6b7280; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --primary-light: #eff6ff; + --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; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05); +} + +body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +main { + max-width: 640px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +/* Hero header */ +.hero { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-light); +} + +h1 { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.hero-accent { + color: var(--primary); + font-weight: 600; +} + +.beta-pill { + font-size: 0.55rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--error-text); + color: #fff; + padding: 0.15rem 0.5rem; + border-radius: 10px; + vertical-align: middle; + position: relative; + top: -0.15rem; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.95rem; + margin-top: 0.25rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--text); +} + +/* Step navigation */ +.step-nav { + margin-bottom: 1.5rem; +} + +.step-nav ol { + display: flex; + list-style: none; + gap: 0; + counter-reset: step; +} + +.step-nav li { + flex: 1; + text-align: center; + padding: 0.5rem 0; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + position: relative; + counter-increment: step; +} + +.step-nav li::before { + content: counter(step); + display: block; + width: 1.6rem; + height: 1.6rem; + line-height: 1.6rem; + margin: 0 auto 0.3rem; + border-radius: 50%; + background: var(--border-light); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; +} + +.step-nav li.active::before { + background: var(--primary); + color: #fff; +} + +.step-nav li.active { + color: var(--primary); + font-weight: 600; +} + +.step-nav li.done::before { + background: var(--success-text); + color: #fff; + content: "\2713"; +} + +.step-nav li.done { + color: var(--success-text); +} + +/* Connector lines between steps */ +.step-nav li + li::after { + content: ''; + position: absolute; + top: 1.3rem; + right: 50%; + width: 100%; + height: 2px; + background: var(--border-light); + z-index: -1; +} + +.step-nav li.done + li::after, +.step-nav li.done + li.active::after { + background: var(--success-text); +} + +/* Steps */ +.step { + margin-bottom: 1rem; +} + +.step p { + color: var(--text-secondary); + margin-bottom: 1rem; + font-size: 0.93rem; + line-height: 1.6; +} + +/* Step action buttons (back/next) */ +.step-actions { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1.25rem; +} + +.step-actions .primary:first-child { + margin-left: auto; +} + +/* Buttons */ +button { + font-size: 0.9rem; + padding: 0.55rem 1.25rem; + border-radius: 8px; + border: 1px solid var(--border); + cursor: pointer; + font-weight: 500; + transition: all 0.15s ease; +} + +button.primary { + background: var(--primary); + color: #fff; + border-color: var(--primary); + box-shadow: var(--shadow); +} + +button.primary:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); + box-shadow: var(--shadow-md); +} + +button.secondary { + background: var(--card-bg); + color: var(--text); + box-shadow: var(--shadow); +} + +button.secondary:hover { + background: #f9fafb; + border-color: #9ca3af; +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; + box-shadow: none; +} + +button.btn-success, +button.btn-success:hover { + background: var(--success-text); + border-color: var(--success-text); + color: #fff; + cursor: default; + opacity: 0.7; +} + +/* Cards */ +.info-card { + background: var(--card-bg); + border: 1px solid var(--border-light); + border-radius: 10px; + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; + box-shadow: var(--shadow); +} + +.info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-light); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-row .label { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.info-row .value { + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 0.88rem; +} + +/* Status banners */ +.warning { + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); + padding: 0.75rem 1rem; + border-radius: 8px; + margin-bottom: 1.5rem; + font-size: 0.88rem; + line-height: 1.5; +} + +.warning a { + color: inherit; +} + +.error { + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error-text); + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.88rem; +} + +.status-supported { + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success-text); + padding: 0.65rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.88rem; +} + +.status-unsupported { + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); + padding: 0.65rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.88rem; +} + +/* Scrollable patch container */ +.patch-container-scroll { + max-height: 50vh; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 5px; +} + +/* Patch file sections */ +.patch-file-section { + background: var(--card-bg); + border-bottom: 1px solid var(--border-light); +} + +.patch-file-section:last-child { + border-bottom: none; +} + +.patch-file-section summary { + padding: 0.6rem 0.75rem; + cursor: pointer; + display: flex; + align-items: center; + font-weight: 500; + font-size: 0.93rem; + user-select: none; + transition: background 0.1s; + list-style: none; +} + +.patch-file-section summary::-webkit-details-marker { + display: none; +} + +.patch-file-section summary::before { + content: "\203A"; + display: inline-block; + width: 1rem; + margin-right: 0.35rem; + flex-shrink: 0; + text-align: center; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-secondary); + transition: transform 0.15s ease; +} + +.patch-file-section[open] summary::before { + transform: rotate(90deg) translateX(0.1rem); +} + +.patch-file-section summary .patch-count { + margin-left: auto; +} + +.patch-file-section summary:hover { + background: #f9fafb; +} + +.patch-file-section[open] summary { + border-bottom: 1px solid var(--border-light); +} + +.patch-count { + font-weight: 400; + font-size: 0.8rem; + background: var(--primary-light); + color: var(--primary); + padding: 0.15rem 0.6rem; + border-radius: 10px; +} + +.patch-list { + padding: 0.25rem 0; +} + +.patch-item { + padding: 0.4rem 1rem; +} + +.patch-item + .patch-item { + border-top: 1px solid var(--border-light); +} + +.patch-header { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.85rem; +} + +.patch-header input { + flex-shrink: 0; + accent-color: var(--primary); +} + +.patch-name { + font-weight: 500; +} + +.patch-name-none { + color: var(--text-secondary); +} + +.patch-desc-toggle { + flex-shrink: 0; + background: none; + border: none; + padding: 0 0.3rem; + font-size: 0.7rem; + color: var(--text-secondary); + cursor: pointer; + box-shadow: none; + opacity: 0.6; + transition: opacity 0.1s; +} + +.patch-desc-toggle:hover { + opacity: 1; +} + +/* Visual grouping for mutually exclusive patches */ +.patch-group { + background: #f8fafc; + border-left: 3px solid var(--primary); + margin: 0.35rem 0.5rem; + border-radius: 0 6px 6px 0; +} + +.patch-group .patch-item { + padding: 0.4rem 0.75rem; +} + +.patch-group-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--primary); + padding: 0.45rem 0.75rem 0; +} + +.step .patch-description { + margin-top: 0.3rem; + margin-left: 1.6rem; + margin-bottom: 0; + font-size: 0.72rem; + color: var(--text-secondary); + white-space: pre-line; + line-height: 1.4; + padding: 0.25rem 0; +} + +.patch-description[hidden] { + display: none; +} + +/* Firmware input */ +input[type="file"] { + display: block; + margin-bottom: 1rem; + font-size: 0.88rem; +} + +#build-actions { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1.25rem; +} + +/* Build header (spinner + progress text) */ +.build-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.25rem; +} + +.build-header p { + margin-bottom: 0; + font-weight: 500; + color: var(--text); +} + +/* Spinner */ +.spinner { + width: 22px; + height: 22px; + border: 2.5px solid var(--border-light); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Build log terminal */ +.build-log { + margin-top: 0.75rem; + padding: 0.75rem 1rem; + background: #0f172a; + color: #94a3b8; + border-radius: 6px; + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 0.73rem; + white-space: pre-wrap; + height: calc(10 * 1.5em + 1.5rem); + overflow-y: auto; + line-height: 1.5; +} + +/* Collapsible log on install step */ +.log-details { + margin-top: 0.5rem; + margin-bottom: 0.25rem; +} + +.log-details summary { + font-size: 0.83rem; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem 0; +} + +.log-details summary:hover { + color: var(--text); +} + +/* Done screen log is shorter */ +.done-log { + height: calc(7 * 1.5em + 1.5rem); +} + +.step .hint { + margin-top: 1rem; + padding: 0.65rem 1rem; + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success-text); + border-radius: 8px; + font-size: 0.88rem; +} + +.error-log { + margin-top: 0.75rem; + padding: 0.75rem 1rem; + background: #0f172a; + color: #e2e8f0; + border-radius: 6px; + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 0.78rem; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +.step a { + color: var(--primary); + text-decoration: none; +} + +.step a:hover { + text-decoration: underline; +} + +.step .fallback-hint { + margin-top: 0.5rem; + margin-bottom: 1rem; + font-size: 0.78rem; + color: var(--text-secondary); + line-height: 1.5; +} + +select + .fallback-hint { + margin-top: -0.5rem; +} + +/* Install summary */ +.step .install-summary { + font-size: 0.93rem; + color: var(--text); + line-height: 1.6; + margin-bottom: 0.5rem; +} + +/* Install instructions */ +.install-instructions { + margin-top: 1rem; +} + +.install-instructions .warning { + margin-bottom: 0.75rem; +} + +.step .install-instructions .hint { + margin-top: 0; +} + +.install-steps { + margin: 0.5rem 0 0 1.25rem; + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.install-steps li { + padding: 0.1rem 0; +} + +.step .info-banner { + background: var(--primary-light); + border: 1px solid #bfdbfe; + color: #1e40af; + padding: 0.6rem 1rem; + border-radius: 8px; + font-size: 0.83rem; + margin-bottom: 1rem; +} + +/* Selected patches summary on build step */ +.selected-patches-list { + margin: 0 0 0.75rem 1.25rem; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.selected-patches-list li { + padding: 0.05rem 0; +} + +#firmware-download-url { + display: inline-block; + margin: 0.4rem 0; + padding: 0.3rem 0.6rem; + font-size: 0.7rem; + word-break: break-all; + color: #64748b; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 4px; +} + +#firmware-verify-notice { + font-size: 12px; +} + +[hidden] { + display: none !important; +} + +select { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.93rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card-bg); + color: var(--text); + margin-bottom: 1rem; + box-shadow: var(--shadow); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +select:focus, +button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Footer */ +.site-footer { + max-width: 640px; + margin: 0 auto; + padding: 1.5rem 1.5rem 2rem; + border-top: 1px solid var(--border-light); + text-align: center; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.site-footer a { + color: var(--text-secondary); + text-decoration: underline; +} + +.site-footer a:hover { + color: var(--text); +} + +/* Modal dialog */ +.modal { + border: none; + border-radius: 12px; + padding: 0; + max-width: 560px; + width: calc(100% - 2rem); + max-height: 80vh; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); +} + +.modal::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +.modal-content { + display: flex; + flex-direction: column; + max-height: 80vh; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.modal-header h2 { + margin-bottom: 0; + font-size: 1rem; +} + +.modal-close { + background: none; + border: none; + font-size: 1.4rem; + color: var(--text-secondary); + cursor: pointer; + padding: 0 0.25rem; + line-height: 1; + box-shadow: none; +} + +.modal-close:hover { + color: var(--text); +} + +.modal-body { + padding: 1.25rem; + overflow-y: auto; + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.modal-body h3 { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.modal-body p { + margin-bottom: 0.75rem; +} + +.modal-body ol { + margin: 0 0 0.75rem 1.25rem; +} + +.modal-body li { + margin-bottom: 0.5rem; +} + +.modal-body a { + color: var(--primary); + text-decoration: none; +} + +.modal-body a:hover { + text-decoration: underline; +} + +.modal-body code { + font-size: 0.8rem; + background: #f1f5f9; + padding: 0.1rem 0.35rem; + border-radius: 3px; +}