From 7bb5c255f54c79e17d3ee3d8ec58e2ee00911ef6 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Thu, 19 Mar 2026 12:19:18 +0100 Subject: [PATCH] Improve device and version detection --- tests/e2e/integration.spec.js | 63 ++++++++++++++++++++++++++++++-- web/public/css/style.css | 16 +++++++++ web/public/index.html | 21 +++++++---- web/public/js/app.js | 68 ++++++++++++++++++++++++++--------- web/public/js/kobo-device.js | 2 ++ web/public/js/patch-worker.js | 2 +- 6 files changed, 146 insertions(+), 26 deletions(-) diff --git a/tests/e2e/integration.spec.js b/tests/e2e/integration.spec.js index b86e38a..507bcf9 100644 --- a/tests/e2e/integration.spec.js +++ b/tests/e2e/integration.spec.js @@ -110,16 +110,20 @@ function setupFirmwareSymlink() { * @param {import('@playwright/test').Page} page * @param {object} opts * @param {boolean} [opts.hasNickelMenu=false] - Whether .adds/nm/ exists on device + * @param {string} [opts.firmware='4.45.23646'] - Firmware version to report + * @param {string} [opts.serial='N4280A0000000'] - Serial number to report */ async function injectMockDevice(page, opts = {}) { - await page.evaluate(({ hasNickelMenu }) => { + const firmware = opts.firmware || '4.45.23646'; + const serial = opts.serial || 'N4280A0000000'; + await page.evaluate(({ hasNickelMenu, firmware, serial }) => { // In-memory filesystem for the mock device const filesystem = { '.kobo': { _type: 'dir', 'version': { _type: 'file', - content: 'N4280A0000000,4.9.77,4.45.23646,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390', + content: serial + ',4.9.77,' + firmware + ',4.9.77,4.9.77,00000000-0000-0000-0000-000000000390', }, 'Kobo': { _type: 'dir', @@ -205,7 +209,7 @@ async function injectMockDevice(page, opts = {}) { // Override showDirectoryPicker window.showDirectoryPicker = async () => rootHandle; - }, { hasNickelMenu: opts.hasNickelMenu || false }); + }, { hasNickelMenu: opts.hasNickelMenu || false, firmware: firmware, serial: serial }); } /** @@ -686,6 +690,59 @@ test.describe('Custom patches', () => { expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1); }); + test('with device — incompatible version 5.x shows error', async ({ page }) => { + await page.goto('/'); + await injectMockDevice(page, { firmware: '5.0.0' }); + await page.click('#btn-connect'); + + // Device info should be displayed + await expect(page.locator('#step-device')).not.toBeHidden(); + await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour'); + await expect(page.locator('#device-firmware')).toHaveText('5.0.0'); + + // Status message should show incompatibility warning + await expect(page.locator('#device-status')).toContainText('incompatible'); + await expect(page.locator('#device-status')).toContainText('NickelMenu does not support it'); + await expect(page.locator('#device-status')).toHaveClass(/error/); + + // Continue and restore buttons should be hidden + await expect(page.locator('#btn-device-next')).toBeHidden(); + await expect(page.locator('#btn-device-restore')).toBeHidden(); + }); + + test('with device — unknown model shows warning and requires checkbox', async ({ page }) => { + await page.goto('/'); + await injectMockDevice(page, { serial: 'X9990A0000000' }); + await page.click('#btn-connect'); + + // Device info should be displayed with unknown model + await expect(page.locator('#step-device')).not.toBeHidden(); + await expect(page.locator('#device-model')).toContainText('Unknown'); + await expect(page.locator('#device-firmware')).toHaveText('4.45.23646'); + + // Warning should be visible with GitHub link + await expect(page.locator('#device-unknown-warning')).not.toBeHidden(); + await expect(page.locator('#device-unknown-warning')).toContainText('file an issue on GitHub'); + await expect(page.locator('#device-unknown-warning a')).toHaveAttribute('href', 'https://github.com/nicoverbruggen/kobopatch-webui/issues/new'); + + // Checkbox should be visible, Continue should be disabled + await expect(page.locator('#device-unknown-ack')).not.toBeHidden(); + await expect(page.locator('#btn-device-next')).toBeVisible(); + await expect(page.locator('#btn-device-next')).toBeDisabled(); + + // Restore Software should be hidden (no firmware URL for unknown model) + await expect(page.locator('#btn-device-restore')).toBeHidden(); + + // Checking the checkbox enables Continue + await page.check('#device-unknown-checkbox'); + await expect(page.locator('#btn-device-next')).toBeEnabled(); + + // Custom patches should be disabled in mode selection (no firmware URL) + await page.click('#btn-device-next'); + await expect(page.locator('#step-mode')).not.toBeHidden(); + await expect(page.locator('input[name="mode"][value="patches"]')).toBeDisabled(); + }); + test('no device — both modes available in manual mode', async ({ page }) => { await page.goto('/'); diff --git a/web/public/css/style.css b/web/public/css/style.css index db18cc2..0a3c771 100644 --- a/web/public/css/style.css +++ b/web/public/css/style.css @@ -522,6 +522,22 @@ button.btn-success:hover { font-size: 0.88rem; } +.device-unknown-ack { + display: flex; + align-items: flex-start; + gap: 0.6rem; + padding: 0.5rem 0; + font-size: 0.83rem; + color: var(--text); + cursor: pointer; +} + +.device-unknown-ack input[type="checkbox"] { + flex-shrink: 0; + margin-top: 0.15rem; + accent-color: var(--primary); +} + /* Status banners */ .warning { background: var(--warning-bg); diff --git a/web/public/index.html b/web/public/index.html index 7a54c71..b69f289 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -28,7 +28,7 @@ @keyframes spin { to { transform: rotate(360deg); } } [hidden] { display: none !important; } - + @@ -122,6 +122,15 @@

+ +
@@ -434,10 +443,10 @@ - - - - - + + + + + diff --git a/web/public/js/app.js b/web/public/js/app.js index bdec227..c809f3d 100644 --- a/web/public/js/app.js +++ b/web/public/js/app.js @@ -97,6 +97,9 @@ const errorMessage = $('error-message'); const errorLog = $('error-log'); const deviceStatus = $('device-status'); + const deviceUnknownWarning = $('device-unknown-warning'); + const deviceUnknownAck = $('device-unknown-ack'); + const deviceUnknownCheckbox = $('device-unknown-checkbox'); const patchContainer = $('patch-container'); const buildStatus = $('build-status'); const existingTgzWarning = $('existing-tgz-warning'); @@ -264,37 +267,66 @@ }); // Auto connect -> show device info + function displayDeviceInfo(info) { + $('device-model').textContent = info.model; + const serialEl = $('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))); + $('device-firmware').textContent = info.firmware; + } + btnConnect.addEventListener('click', async () => { try { const info = await device.connect(); - $('device-model').textContent = info.model; - const serialEl = $('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))); - $('device-firmware').textContent = info.firmware; + displayDeviceInfo(info); + + if (info.isIncompatible) { + deviceStatus.textContent = + 'You seem to have an incompatible Kobo software version installed. ' + + 'NickelMenu does not support it, and the custom patches are incompatible with this version.'; + deviceStatus.classList.add('error'); + btnDeviceNext.hidden = true; + btnDeviceRestore.hidden = true; + showStep(stepDevice); + return; + } selectedPrefix = info.serialPrefix; await availablePatchesReady; const match = availablePatches.find(p => p.version === info.firmware); + configureFirmwareStep(info.firmware, info.serialPrefix); + if (match) { await patchUI.loadFromURL('patches/' + match.filename); patchUI.render(patchContainer); updatePatchCount(); patchesLoaded = true; - configureFirmwareStep(info.firmware, info.serialPrefix); - btnDeviceRestore.hidden = false; - } else { - btnDeviceRestore.hidden = true; } - deviceStatus.textContent = 'Your device has been recognized. You can continue to the next step!'; + btnDeviceRestore.hidden = !patchesLoaded || !firmwareURL; + + deviceStatus.classList.remove('error'); + const isUnknownModel = info.model.startsWith('Unknown'); + if (isUnknownModel) { + deviceStatus.textContent = ''; + deviceUnknownWarning.hidden = false; + deviceUnknownAck.hidden = false; + deviceUnknownCheckbox.checked = false; + btnDeviceNext.disabled = true; + } else { + deviceStatus.textContent = 'Your device has been recognized. You can continue to the next step!'; + deviceUnknownWarning.hidden = true; + deviceUnknownAck.hidden = true; + deviceUnknownCheckbox.checked = false; + btnDeviceNext.disabled = false; + } btnDeviceNext.hidden = false; showStep(stepDevice); } catch (err) { @@ -308,6 +340,10 @@ goToModeSelection(); }); + deviceUnknownCheckbox.addEventListener('change', () => { + btnDeviceNext.disabled = !deviceUnknownCheckbox.checked; + }); + btnDeviceRestore.addEventListener('click', () => { if (!patchesLoaded) return; selectedMode = 'patches'; @@ -329,10 +365,10 @@ // --- Step 2: Mode selection --- function goToModeSelection() { - // In auto mode, disable custom patches if firmware isn't supported + // In auto mode, disable custom patches if firmware or download URL isn't available const patchesRadio = $q('input[value="patches"]', stepMode); const patchesCard = patchesRadio.closest('.mode-card'); - const autoModeNoPatchesAvailable = !manualMode && !patchesLoaded; + const autoModeNoPatchesAvailable = !manualMode && (!patchesLoaded || !firmwareURL); const patchesHint = $('mode-patches-hint'); if (autoModeNoPatchesAvailable) { diff --git a/web/public/js/kobo-device.js b/web/public/js/kobo-device.js index 6401294..fce0763 100644 --- a/web/public/js/kobo-device.js +++ b/web/public/js/kobo-device.js @@ -154,6 +154,7 @@ class KoboDevice { : serial.substring(0, 3); const model = KOBO_MODELS[serialPrefix] || 'Unknown Kobo (' + serial.substring(0, 4) + ')'; const isSupported = SUPPORTED_FIRMWARE.includes(firmware); + const isIncompatible = firmware.startsWith('5.'); return { serial, @@ -162,6 +163,7 @@ class KoboDevice { hardwareId, model, isSupported, + isIncompatible, }; } diff --git a/web/public/js/patch-worker.js b/web/public/js/patch-worker.js index 06d0c96..f472a77 100644 --- a/web/public/js/patch-worker.js +++ b/web/public/js/patch-worker.js @@ -10,7 +10,7 @@ async function loadWasm() { const go = new Go(); const result = await WebAssembly.instantiateStreaming( - fetch('../wasm/kobopatch.wasm?ts=1773771588'), + fetch('../wasm/kobopatch.wasm?ts=1773916731'), go.importObject ); go.run(result.instance);