Add removal options
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s
This commit is contained in:
@@ -10,7 +10,7 @@ const { expect } = require('@playwright/test');
|
|||||||
async function injectMockDevice(page, opts = {}) {
|
async function injectMockDevice(page, opts = {}) {
|
||||||
const firmware = opts.firmware || '4.45.23646';
|
const firmware = opts.firmware || '4.45.23646';
|
||||||
const serial = opts.serial || 'N4280A0000000';
|
const serial = opts.serial || 'N4280A0000000';
|
||||||
await page.evaluate(({ hasNickelMenu, firmware, serial }) => {
|
await page.evaluate(({ hasNickelMenu, hasKoreader, hasReaderlyFonts, hasScreensaver, firmware, serial }) => {
|
||||||
const filesystem = {
|
const filesystem = {
|
||||||
'.kobo': {
|
'.kobo': {
|
||||||
_type: 'dir',
|
_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.__mockFS = filesystem;
|
||||||
window.__mockWrittenFiles = {};
|
window.__mockWrittenFiles = {};
|
||||||
|
|
||||||
@@ -93,12 +118,26 @@ async function injectMockDevice(page, opts = {}) {
|
|||||||
}
|
}
|
||||||
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
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', '');
|
const rootHandle = makeDirHandle(filesystem, 'KOBOeReader', '');
|
||||||
window.showDirectoryPicker = async () => rootHandle;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ test.describe('NickelMenu', () => {
|
|||||||
|
|
||||||
// Select remove
|
// Select remove
|
||||||
await page.click('input[name="nm-option"][value="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');
|
await page.click('#btn-nm-next');
|
||||||
|
|
||||||
// Review step
|
// Review step
|
||||||
@@ -374,6 +378,58 @@ test.describe('NickelMenu', () => {
|
|||||||
const uninstallExists = await mockPathExists(page, '.adds', 'nm', 'uninstall');
|
const uninstallExists = await mockPathExists(page, '.adds', 'nm', 'uninstall');
|
||||||
expect(uninstallExists, '.adds/nm/uninstall should exist').toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -193,6 +193,7 @@
|
|||||||
<div class="nm-option-desc" id="nm-remove-desc">Removes NickelMenu from your device. (Only available when a Kobo with NickelMenu installed is connected.)</div>
|
<div class="nm-option-desc" id="nm-remove-desc">Removes NickelMenu from your device. (Only available when a Kobo with NickelMenu installed is connected.)</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<div id="nm-uninstall-options" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-actions">
|
<div class="step-actions">
|
||||||
<button id="btn-nm-back" class="secondary">‹ Back</button>
|
<button id="btn-nm-back" class="secondary">‹ Back</button>
|
||||||
|
|||||||
@@ -474,6 +474,8 @@ import JSZip from 'jszip';
|
|||||||
|
|
||||||
// --- Step 2b: NickelMenu configuration ---
|
// --- Step 2b: NickelMenu configuration ---
|
||||||
const nmConfigOptions = $('nm-config-options');
|
const nmConfigOptions = $('nm-config-options');
|
||||||
|
const nmUninstallOptions = $('nm-uninstall-options');
|
||||||
|
let detectedUninstallFeatures = [];
|
||||||
|
|
||||||
// Render feature checkboxes dynamically from ALL_FEATURES
|
// Render feature checkboxes dynamically from ALL_FEATURES
|
||||||
function renderFeatureCheckboxes() {
|
function renderFeatureCheckboxes() {
|
||||||
@@ -519,6 +521,7 @@ import JSZip from 'jszip';
|
|||||||
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
|
for (const radio of $qa('input[name="nm-option"]', stepNickelMenu)) {
|
||||||
radio.addEventListener('change', () => {
|
radio.addEventListener('change', () => {
|
||||||
nmConfigOptions.hidden = radio.value !== 'preset' || !radio.checked;
|
nmConfigOptions.hidden = radio.value !== 'preset' || !radio.checked;
|
||||||
|
nmUninstallOptions.hidden = radio.value !== 'remove' || !radio.checked || detectedUninstallFeatures.length === 0;
|
||||||
btnNmNext.disabled = false;
|
btnNmNext.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -528,6 +531,9 @@ import JSZip from 'jszip';
|
|||||||
const removeRadio = $q('input[value="remove"]', removeOption);
|
const removeRadio = $q('input[value="remove"]', removeOption);
|
||||||
const removeDesc = $('nm-remove-desc');
|
const removeDesc = $('nm-remove-desc');
|
||||||
|
|
||||||
|
detectedUninstallFeatures = [];
|
||||||
|
nmUninstallOptions.hidden = true;
|
||||||
|
|
||||||
if (!manualMode && device.directoryHandle) {
|
if (!manualMode && device.directoryHandle) {
|
||||||
try {
|
try {
|
||||||
const addsDir = await device.directoryHandle.getDirectoryHandle('.adds');
|
const addsDir = await device.directoryHandle.getDirectoryHandle('.adds');
|
||||||
@@ -535,6 +541,18 @@ import JSZip from 'jszip';
|
|||||||
removeRadio.disabled = false;
|
removeRadio.disabled = false;
|
||||||
removeOption.classList.remove('nm-option-disabled');
|
removeOption.classList.remove('nm-option-disabled');
|
||||||
removeDesc.textContent = TL.STATUS.NM_REMOVAL_HINT;
|
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;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// .adds/nm not found
|
// .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() {
|
function getSelectedFeatures() {
|
||||||
return ALL_FEATURES.filter(f => {
|
return ALL_FEATURES.filter(f => {
|
||||||
if (f.available === false) return false;
|
if (f.available === false) return false;
|
||||||
@@ -565,6 +621,7 @@ import JSZip from 'jszip';
|
|||||||
renderFeatureCheckboxes();
|
renderFeatureCheckboxes();
|
||||||
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
|
const currentOption = $q('input[name="nm-option"]:checked', stepNickelMenu);
|
||||||
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'preset';
|
nmConfigOptions.hidden = !currentOption || currentOption.value !== 'preset';
|
||||||
|
nmUninstallOptions.hidden = !currentOption || currentOption.value !== 'remove' || detectedUninstallFeatures.length === 0;
|
||||||
btnNmNext.disabled = !currentOption;
|
btnNmNext.disabled = !currentOption;
|
||||||
setNavStep(3);
|
setNavStep(3);
|
||||||
showStep(stepNickelMenu);
|
showStep(stepNickelMenu);
|
||||||
@@ -591,6 +648,12 @@ import JSZip from 'jszip';
|
|||||||
|
|
||||||
if (nickelMenuOption === 'remove') {
|
if (nickelMenuOption === 'remove') {
|
||||||
summary.textContent = TL.STATUS.NM_WILL_BE_REMOVED;
|
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.hidden = manualMode;
|
||||||
btnNmWrite.textContent = TL.BUTTON.REMOVE_FROM_KOBO;
|
btnNmWrite.textContent = TL.BUTTON.REMOVE_FROM_KOBO;
|
||||||
btnNmDownload.hidden = true;
|
btnNmDownload.hidden = true;
|
||||||
@@ -648,6 +711,19 @@ import JSZip from 'jszip';
|
|||||||
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
||||||
nmProgress.textContent = 'Marking NickelMenu for removal...';
|
nmProgress.textContent = 'Marking NickelMenu for removal...';
|
||||||
await device.writeFile(['.adds', 'nm', 'uninstall'], new Uint8Array(0));
|
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');
|
showNmDone('remove');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Disconnect / release the directory handle.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ export default {
|
|||||||
default: false,
|
default: false,
|
||||||
available: false, // set to true at runtime if KOReader assets exist
|
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) {
|
async install(ctx) {
|
||||||
ctx.progress('Fetching KOReader release info...');
|
ctx.progress('Fetching KOReader release info...');
|
||||||
const metaResp = await fetch('/koreader/release.json');
|
const metaResp = await fetch('/koreader/release.json');
|
||||||
|
|||||||
@@ -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".',
|
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,
|
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) {
|
async install(ctx) {
|
||||||
ctx.progress('Downloading Readerly fonts...');
|
ctx.progress('Downloading Readerly fonts...');
|
||||||
const resp = await fetch('/readerly/KF_Readerly.zip');
|
const resp = await fetch('/readerly/KF_Readerly.zip');
|
||||||
|
|||||||
@@ -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.',
|
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,
|
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) {
|
async install(ctx) {
|
||||||
return [
|
return [
|
||||||
{ path: '.kobo/screensaver/moon.png', data: await ctx.asset('moon.png') },
|
{ path: '.kobo/screensaver/moon.png', data: await ctx.asset('moon.png') },
|
||||||
|
|||||||
Reference in New Issue
Block a user