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') },