196 lines
6.9 KiB
JavaScript
196 lines
6.9 KiB
JavaScript
/**
|
|
* NickelMenu installer module.
|
|
*
|
|
* Handles downloading bundled NickelMenu + kobo-config assets,
|
|
* and either writing them directly to a connected Kobo (auto mode)
|
|
* or building a zip for manual download.
|
|
*
|
|
* Options:
|
|
* 'nickelmenu-only' — just NickelMenu (KoboRoot.tgz)
|
|
* 'sample' — NickelMenu + config based on cfg flags
|
|
*
|
|
* Config flags (when option is 'sample'):
|
|
* fonts: bool — include Readerly fonts
|
|
* screensaver: bool — include custom screensaver
|
|
* simplifyTabs: bool — comment out experimental tab items in config
|
|
* simplifyHome: bool — append homescreen simplification lines
|
|
*/
|
|
class NickelMenuInstaller {
|
|
constructor() {
|
|
this.nickelMenuZip = null; // JSZip instance
|
|
this.koboConfigZip = null; // JSZip instance
|
|
}
|
|
|
|
/**
|
|
* Download and cache the bundled assets.
|
|
*/
|
|
async loadAssets(progressFn) {
|
|
if (this.nickelMenuZip && this.koboConfigZip) return;
|
|
|
|
progressFn('Downloading NickelMenu...');
|
|
const nmResp = await fetch('nickelmenu/NickelMenu.zip');
|
|
if (!nmResp.ok) throw new Error('Failed to download NickelMenu.zip: HTTP ' + nmResp.status);
|
|
this.nickelMenuZip = await JSZip.loadAsync(await nmResp.arrayBuffer());
|
|
|
|
progressFn('Downloading configuration files...');
|
|
const cfgResp = await fetch('nickelmenu/kobo-config.zip');
|
|
if (!cfgResp.ok) throw new Error('Failed to download kobo-config.zip: HTTP ' + cfgResp.status);
|
|
this.koboConfigZip = await JSZip.loadAsync(await cfgResp.arrayBuffer());
|
|
}
|
|
|
|
/**
|
|
* Get the KoboRoot.tgz from the NickelMenu zip.
|
|
*/
|
|
async getKoboRootTgz() {
|
|
const file = this.nickelMenuZip.file('KoboRoot.tgz');
|
|
if (!file) throw new Error('KoboRoot.tgz not found in NickelMenu.zip');
|
|
return new Uint8Array(await file.async('arraybuffer'));
|
|
}
|
|
|
|
/**
|
|
* Get config files from kobo-config.zip filtered by cfg flags.
|
|
* Returns { path: string[], data: Uint8Array } entries.
|
|
*/
|
|
async getConfigFiles(cfg) {
|
|
const files = [];
|
|
|
|
for (const [relativePath, zipEntry] of Object.entries(this.koboConfigZip.files)) {
|
|
if (zipEntry.dir) continue;
|
|
|
|
// Filter by cfg flags
|
|
if (relativePath.startsWith('fonts/') && !cfg.fonts) continue;
|
|
if (relativePath.startsWith('.kobo/screensaver/') && !cfg.screensaver) continue;
|
|
|
|
// Only include relevant directories
|
|
if (!relativePath.startsWith('.adds/') &&
|
|
!relativePath.startsWith('.kobo/screensaver/') &&
|
|
!relativePath.startsWith('fonts/')) {
|
|
continue;
|
|
}
|
|
|
|
let data = new Uint8Array(await zipEntry.async('arraybuffer'));
|
|
|
|
// Modify the NickelMenu items file based on config
|
|
if (relativePath === '.adds/nm/items') {
|
|
let text = new TextDecoder().decode(data);
|
|
|
|
// Comment out experimental lines at top if simplifyTabs is off
|
|
if (!cfg.simplifyTabs) {
|
|
text = text.split('\n').map(line => {
|
|
if (line.startsWith('experimental:') && !line.startsWith('experimental:hide_home')) {
|
|
return '#' + line;
|
|
}
|
|
return line;
|
|
}).join('\n');
|
|
}
|
|
|
|
// Append homescreen simplification lines
|
|
if (cfg.simplifyHome) {
|
|
text += '\nexperimental:hide_home_row1col2_enabled:1\nexperimental:hide_home_row3_enabled:1\n';
|
|
}
|
|
|
|
data = new TextEncoder().encode(text);
|
|
}
|
|
|
|
files.push({
|
|
path: relativePath.split('/'),
|
|
data,
|
|
});
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Install to a connected Kobo device via File System Access API.
|
|
* @param {KoboDevice} device
|
|
* @param {string} option - 'sample' or 'nickelmenu-only'
|
|
* @param {object|null} cfg - config flags (when option is 'sample')
|
|
* @param {function} progressFn
|
|
*/
|
|
async installToDevice(device, option, cfg, progressFn) {
|
|
await this.loadAssets(progressFn);
|
|
|
|
// Always install KoboRoot.tgz
|
|
progressFn('Writing KoboRoot.tgz...');
|
|
const tgz = await this.getKoboRootTgz();
|
|
await device.writeFile(['.kobo', 'KoboRoot.tgz'], tgz);
|
|
|
|
if (option === 'nickelmenu-only') {
|
|
progressFn('Done.');
|
|
return;
|
|
}
|
|
|
|
// Install config files
|
|
progressFn('Writing configuration files...');
|
|
const configFiles = await this.getConfigFiles(cfg);
|
|
for (const { path, data } of configFiles) {
|
|
await device.writeFile(path, data);
|
|
}
|
|
|
|
// Modify Kobo eReader.conf
|
|
progressFn('Updating Kobo eReader.conf...');
|
|
await this.updateEReaderConf(device);
|
|
|
|
progressFn('Done.');
|
|
}
|
|
|
|
/**
|
|
* Add ExcludeSyncFolders to Kobo eReader.conf if not already present.
|
|
*/
|
|
async updateEReaderConf(device) {
|
|
const confPath = ['.kobo', 'Kobo', 'Kobo eReader.conf'];
|
|
let content = await device.readFile(confPath) || '';
|
|
|
|
const settingLine = 'ExcludeSyncFolders=(calibre|\\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\\..+)';
|
|
|
|
if (content.includes('ExcludeSyncFolders')) {
|
|
// Already has the setting, don't duplicate
|
|
return;
|
|
}
|
|
|
|
// Add under [FeatureSettings], creating the section if needed
|
|
if (content.includes('[FeatureSettings]')) {
|
|
content = content.replace(
|
|
'[FeatureSettings]',
|
|
'[FeatureSettings]\n' + settingLine
|
|
);
|
|
} else {
|
|
content += '\n[FeatureSettings]\n' + settingLine + '\n';
|
|
}
|
|
|
|
await device.writeFile(confPath, new TextEncoder().encode(content));
|
|
}
|
|
|
|
/**
|
|
* Build a zip for manual download containing all files to copy to the Kobo.
|
|
* @param {string} option - 'sample' or 'nickelmenu-only'
|
|
* @param {object|null} cfg - config flags (when option is 'sample')
|
|
* @param {function} progressFn
|
|
* @returns {Uint8Array} zip contents
|
|
*/
|
|
async buildDownloadZip(option, cfg, progressFn) {
|
|
await this.loadAssets(progressFn);
|
|
|
|
progressFn('Building download package...');
|
|
const zip = new JSZip();
|
|
|
|
// Always include KoboRoot.tgz
|
|
const tgz = await this.getKoboRootTgz();
|
|
zip.file('.kobo/KoboRoot.tgz', tgz);
|
|
|
|
if (option !== 'nickelmenu-only') {
|
|
// Include config files
|
|
const configFiles = await this.getConfigFiles(cfg);
|
|
for (const { path, data } of configFiles) {
|
|
zip.file(path.join('/'), data);
|
|
}
|
|
}
|
|
|
|
progressFn('Compressing...');
|
|
const result = await zip.generateAsync({ type: 'uint8array' });
|
|
progressFn('Done.');
|
|
return result;
|
|
}
|
|
}
|