diff --git a/tests/e2e/helpers/mock-device.js b/tests/e2e/helpers/mock-device.js index 95e7d38..28285a6 100644 --- a/tests/e2e/helpers/mock-device.js +++ b/tests/e2e/helpers/mock-device.js @@ -10,7 +10,7 @@ const { expect } = require('@playwright/test'); async function injectMockDevice(page, opts = {}) { const firmware = opts.firmware || '4.45.23646'; const serial = opts.serial || 'N4280A0000000'; - await page.evaluate(({ hasNickelMenu, firmware, serial }) => { + await page.evaluate(({ hasNickelMenu, hasKoreader, hasReaderlyFonts, hasScreensaver, firmware, serial }) => { const filesystem = { '.kobo': { _type: 'dir', @@ -38,6 +38,31 @@ async function injectMockDevice(page, opts = {}) { }; } + if (hasKoreader) { + if (!filesystem['.adds']) filesystem['.adds'] = { _type: 'dir' }; + filesystem['.adds']['koreader'] = { + _type: 'dir', + 'koreader.sh': { _type: 'file', content: '#!/bin/sh' }, + }; + } + + if (hasReaderlyFonts) { + filesystem['fonts'] = { + _type: 'dir', + 'KF_Readerly-Regular.ttf': { _type: 'file', content: '' }, + 'KF_Readerly-Italic.ttf': { _type: 'file', content: '' }, + 'KF_Readerly-Bold.ttf': { _type: 'file', content: '' }, + 'KF_Readerly-BoldItalic.ttf': { _type: 'file', content: '' }, + }; + } + + if (hasScreensaver) { + if (!filesystem['.kobo']['screensaver']) { + filesystem['.kobo']['screensaver'] = { _type: 'dir' }; + } + filesystem['.kobo']['screensaver']['moon.png'] = { _type: 'file', content: '' }; + } + window.__mockFS = filesystem; window.__mockWrittenFiles = {}; @@ -93,12 +118,26 @@ async function injectMockDevice(page, opts = {}) { } throw new DOMException('Not found: ' + childName, 'NotFoundError'); }, + removeEntry: async (childName) => { + if (node[childName]) { + delete node[childName]; + return; + } + throw new DOMException('Not found: ' + childName, 'NotFoundError'); + }, }; } const rootHandle = makeDirHandle(filesystem, 'KOBOeReader', ''); window.showDirectoryPicker = async () => rootHandle; - }, { hasNickelMenu: opts.hasNickelMenu || false, firmware, serial }); + }, { + hasNickelMenu: opts.hasNickelMenu || false, + hasKoreader: opts.hasKoreader || false, + hasReaderlyFonts: opts.hasReaderlyFonts || false, + hasScreensaver: opts.hasScreensaver || false, + firmware, + serial, + }); } /** diff --git a/tests/e2e/integration.spec.js b/tests/e2e/integration.spec.js index a7b3a53..02b1122 100644 --- a/tests/e2e/integration.spec.js +++ b/tests/e2e/integration.spec.js @@ -348,6 +348,10 @@ test.describe('NickelMenu', () => { // Select remove await page.click('input[name="nm-option"][value="remove"]'); + + // No extra features installed — uninstall options should be hidden + await expect(page.locator('#nm-uninstall-options')).toBeHidden(); + await page.click('#btn-nm-next'); // Review step @@ -374,6 +378,58 @@ test.describe('NickelMenu', () => { const uninstallExists = await mockPathExists(page, '.adds', 'nm', 'uninstall'); expect(uninstallExists, '.adds/nm/uninstall should exist').toBe(true); }); + + test('with device — remove NickelMenu with feature cleanup', async ({ page }) => { + test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot'); + + await connectMockDevice(page, { + hasNickelMenu: true, + hasKoreader: true, + hasReaderlyFonts: true, + hasScreensaver: true, + }); + + await page.click('#btn-device-next'); + await page.click('#btn-mode-next'); + + // Select remove + await page.click('input[name="nm-option"][value="remove"]'); + + // Uninstall checkboxes should appear for all 3 detected features + await expect(page.locator('#nm-uninstall-options')).not.toBeHidden(); + await expect(page.locator('input[name="nm-uninstall-koreader"]')).toBeChecked(); + await expect(page.locator('input[name="nm-uninstall-readerly-fonts"]')).toBeChecked(); + await expect(page.locator('input[name="nm-uninstall-screensaver"]')).toBeChecked(); + + // Uncheck screensaver (keep it) + await page.uncheck('input[name="nm-uninstall-screensaver"]'); + + await page.click('#btn-nm-next'); + + // Review should list KOReader and Readerly but not Screensaver + await expect(page.locator('#nm-review-summary')).toContainText('removal'); + await expect(page.locator('#nm-review-list')).toContainText('KOReader'); + await expect(page.locator('#nm-review-list')).toContainText('Readerly'); + await expect(page.locator('#nm-review-list')).not.toContainText('Screensaver'); + + // Execute removal + await page.click('#btn-nm-write'); + await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('#nm-done-status')).toContainText('removed'); + + // NickelMenu uninstall marker should exist + expect(await mockPathExists(page, '.adds', 'nm', 'uninstall')).toBe(true); + + // KOReader directory should be removed + expect(await mockPathExists(page, '.adds', 'koreader')).toBe(false); + + // Readerly fonts should be removed + expect(await mockPathExists(page, 'fonts', 'KF_Readerly-Regular.ttf')).toBe(false); + expect(await mockPathExists(page, 'fonts', 'KF_Readerly-Bold.ttf')).toBe(false); + + // Screensaver should NOT be removed (unchecked) + expect(await mockPathExists(page, '.kobo', 'screensaver', 'moon.png')).toBe(true); + }); }); // ============================================================ diff --git a/web/src/index.html b/web/src/index.html index 9e8707b..a568ba3 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -193,6 +193,7 @@
Removes NickelMenu from your device. (Only available when a Kobo with NickelMenu installed is connected.)
+
diff --git a/web/src/js/app.js b/web/src/js/app.js index d325f4b..8cd2550 100644 --- a/web/src/js/app.js +++ b/web/src/js/app.js @@ -474,6 +474,8 @@ import JSZip from 'jszip'; // --- Step 2b: NickelMenu configuration --- const nmConfigOptions = $('nm-config-options'); + const nmUninstallOptions = $('nm-uninstall-options'); + let detectedUninstallFeatures = []; // Render feature checkboxes dynamically from ALL_FEATURES function renderFeatureCheckboxes() { @@ -519,6 +521,7 @@ import JSZip from 'jszip'; for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) { radio.addEventListener('change', () => { nmConfigOptions.hidden = radio.value !== 'preset' || !radio.checked; + nmUninstallOptions.hidden = radio.value !== 'remove' || !radio.checked || detectedUninstallFeatures.length === 0; btnNmNext.disabled = false; }); } @@ -528,6 +531,9 @@ import JSZip from 'jszip'; const removeRadio = $q('input[value="remove"]', removeOption); const removeDesc = $('nm-remove-desc'); + detectedUninstallFeatures = []; + nmUninstallOptions.hidden = true; + if (!manualMode && device.directoryHandle) { try { const addsDir = await device.directoryHandle.getDirectoryHandle('.adds'); @@ -535,6 +541,18 @@ import JSZip from 'jszip'; removeRadio.disabled = false; removeOption.classList.remove('nm-option-disabled'); removeDesc.textContent = TL.STATUS.NM_REMOVAL_HINT; + + // Detect which removable features are installed on the device + for (const feature of ALL_FEATURES) { + if (!feature.uninstall) continue; + for (const detectPath of feature.uninstall.detect) { + if (await device.pathExists(detectPath)) { + detectedUninstallFeatures.push(feature); + break; + } + } + } + renderUninstallCheckboxes(); return; } catch { // .adds/nm not found @@ -551,6 +569,44 @@ import JSZip from 'jszip'; } } + function renderUninstallCheckboxes() { + nmUninstallOptions.innerHTML = ''; + if (detectedUninstallFeatures.length === 0) return; + + for (const feature of detectedUninstallFeatures) { + const label = document.createElement('label'); + label.className = 'nm-config-item'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = 'nm-uninstall-' + feature.id; + input.checked = true; + + const textDiv = document.createElement('div'); + textDiv.className = 'nm-config-text'; + + const titleSpan = document.createElement('span'); + titleSpan.textContent = 'Also remove ' + feature.uninstall.title; + + const descSpan = document.createElement('span'); + descSpan.className = 'nm-config-desc'; + descSpan.textContent = feature.uninstall.description; + + textDiv.appendChild(titleSpan); + textDiv.appendChild(descSpan); + label.appendChild(input); + label.appendChild(textDiv); + nmUninstallOptions.appendChild(label); + } + } + + function getSelectedUninstallFeatures() { + return detectedUninstallFeatures.filter(f => { + const cb = $q(`input[name="nm-uninstall-${f.id}"]`); + return cb && cb.checked; + }); + } + function getSelectedFeatures() { return ALL_FEATURES.filter(f => { if (f.available === false) return false; @@ -565,6 +621,7 @@ import JSZip from 'jszip'; renderFeatureCheckboxes(); const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu); nmConfigOptions.hidden = !currentOption || currentOption.value !== 'preset'; + nmUninstallOptions.hidden = !currentOption || currentOption.value !== 'remove' || detectedUninstallFeatures.length === 0; btnNmNext.disabled = !currentOption; setNavStep(3); showStep(stepNickelMenu); @@ -591,6 +648,12 @@ import JSZip from 'jszip'; if (nickelMenuOption === 'remove') { summary.textContent = TL.STATUS.NM_WILL_BE_REMOVED; + const featuresToRemove = getSelectedUninstallFeatures(); + for (const feature of featuresToRemove) { + const li = document.createElement('li'); + li.textContent = feature.uninstall.title + ' will also be removed'; + list.appendChild(li); + } btnNmWrite.hidden = manualMode; btnNmWrite.textContent = TL.BUTTON.REMOVE_FROM_KOBO; btnNmDownload.hidden = true; @@ -648,6 +711,19 @@ import JSZip from 'jszip'; await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz); nmProgress.textContent = 'Marking NickelMenu for removal...'; await device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0)); + + const featuresToRemove = getSelectedUninstallFeatures(); + for (const feature of featuresToRemove) { + nmProgress.textContent = 'Removing ' + feature.uninstall.title + '...'; + for (const entry of feature.uninstall.paths) { + try { + await device.removeEntry(entry.path, { recursive: !!entry.recursive }); + } catch { + // ignore — file may already be gone + } + } + } + showNmDone('remove'); return; } diff --git a/web/src/js/kobo-device.js b/web/src/js/kobo-device.js index e5f5c9f..8b0b971 100644 --- a/web/src/js/kobo-device.js +++ b/web/src/js/kobo-device.js @@ -167,6 +167,42 @@ class KoboDevice { } } + /** + * Check if a file or directory exists at the given path. + */ + async pathExists(pathParts) { + try { + let dir = this.directoryHandle; + const dirParts = pathParts.slice(0, -1); + const lastPart = pathParts[pathParts.length - 1]; + for (const part of dirParts) { + dir = await dir.getDirectoryHandle(part); + } + try { + await dir.getDirectoryHandle(lastPart); + return true; + } catch { + await dir.getFileHandle(lastPart); + return true; + } + } catch { + return false; + } + } + + /** + * Remove a file or directory at the given path. + */ + async removeEntry(pathParts, options = {}) { + let dir = this.directoryHandle; + const dirParts = pathParts.slice(0, -1); + const entryName = pathParts[pathParts.length - 1]; + for (const part of dirParts) { + dir = await dir.getDirectoryHandle(part); + } + await dir.removeEntry(entryName, options); + } + /** * Disconnect / release the directory handle. */ diff --git a/web/src/nickelmenu/features/koreader/index.js b/web/src/nickelmenu/features/koreader/index.js index aed4ec4..d92f57d 100644 --- a/web/src/nickelmenu/features/koreader/index.js +++ b/web/src/nickelmenu/features/koreader/index.js @@ -7,6 +7,15 @@ export default { default: false, available: false, // set to true at runtime if KOReader assets exist + uninstall: { + title: 'KOReader', + description: 'Removes the KOReader app directory (.adds/koreader/).', + detect: [['.adds', 'koreader']], + paths: [ + { path: ['.adds', 'koreader'], recursive: true }, + ], + }, + async install(ctx) { ctx.progress('Fetching KOReader release info...'); const metaResp = await fetch('/koreader/release.json'); diff --git a/web/src/nickelmenu/features/readerly-fonts/index.js b/web/src/nickelmenu/features/readerly-fonts/index.js index e8b652c..06cf926 100644 --- a/web/src/nickelmenu/features/readerly-fonts/index.js +++ b/web/src/nickelmenu/features/readerly-fonts/index.js @@ -6,6 +6,18 @@ export default { description: 'Adds the Readerly font family. These fonts are optically similar to Bookerly. When you are reading a book, you will be able to select this font from the dropdown as "KF Readerly".', default: true, + uninstall: { + title: 'Readerly fonts', + description: 'Removes Readerly font files from fonts/.', + detect: [['fonts', 'KF_Readerly-Regular.ttf']], + paths: [ + { path: ['fonts', 'KF_Readerly-Regular.ttf'] }, + { path: ['fonts', 'KF_Readerly-Italic.ttf'] }, + { path: ['fonts', 'KF_Readerly-Bold.ttf'] }, + { path: ['fonts', 'KF_Readerly-BoldItalic.ttf'] }, + ], + }, + async install(ctx) { ctx.progress('Downloading Readerly fonts...'); const resp = await fetch('/readerly/KF_Readerly.zip'); diff --git a/web/src/nickelmenu/features/screensaver/index.js b/web/src/nickelmenu/features/screensaver/index.js index c769405..9e284a7 100644 --- a/web/src/nickelmenu/features/screensaver/index.js +++ b/web/src/nickelmenu/features/screensaver/index.js @@ -4,6 +4,15 @@ export default { description: 'Copies a screensaver to .kobo/screensaver. Depending on your configuration, it will now be displayed instead of your current read. You can always add your own in the .kobo/screensaver folder, and choosing Tweak > Screensaver will let you toggle it off.', default: false, + uninstall: { + title: 'Screensaver', + description: 'Removes the custom screensaver image (moon.png).', + detect: [['.kobo', 'screensaver', 'moon.png']], + paths: [ + { path: ['.kobo', 'screensaver', 'moon.png'] }, + ], + }, + async install(ctx) { return [ { path: '.kobo/screensaver/moon.png', data: await ctx.asset('moon.png') },