1
0

Add removal options
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s

This commit is contained in:
2026-03-21 18:45:02 +01:00
parent c9354f6115
commit 7a7814a051
8 changed files with 240 additions and 2 deletions

View File

@@ -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>
</label>
<div id="nm-uninstall-options" hidden></div>
</div>
<div class="step-actions">
<button id="btn-nm-back" class="secondary">&#x2039; Back</button>

View File

@@ -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;
}

View File

@@ -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.
*/

View File

@@ -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');

View File

@@ -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');

View File

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