Files
bible-app/tools/build-full-installer.cjs
T

157 lines
7.4 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
#!/usr/bin/env node
/*
* build-full-installer.cjs — make ONE .exe that installs the app AND the offline AI model.
*
* Why this exists: NSIS installers are hard-capped at 2 GB, and the app (~300 MB) plus the AI model
* (~1.9 GB) is over that line — electron-builder dies with "failed creating mmap". So the normal
* installer stays small (and stays the thing the in-app updater downloads), and THIS script wraps
* that installer together with the model in a 7-Zip self-extracting archive. The result is a single
* file you can carry to a new machine on a USB stick: double-click, and both land in the right place.
*
* The model goes to %ProgramData%\Bible App\llama-cache\model.gguf. Not %APPDATA%: Windows treats an
* unmanifested "…Setup.exe" as an installer and runs it elevated, and an elevated process can carry a
* DIFFERENT user's %APPDATA% if someone types another admin's credentials at the UAC prompt.
* ProgramData is the same path for every account, readable by all of them, and no installer touches
* it — so the model lands where the app actually looks and survives every future update.
*
* Usage: node tools/build-full-installer.cjs [version]
* (defaults to the version in package.json; the installer must already be built)
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8').replace(/^/, ''));
const version = (process.argv[2] || pkg.version).trim();
const DIST = path.join(ROOT, 'dist-installer');
const installerName = `Bible App Setup ${version}.exe`;
const installerPath = path.join(DIST, installerName);
const modelPath = path.join(ROOT, 'vendor', 'llama', 'model.gguf');
const sfxModule = path.join(ROOT, 'tools', 'sfx', '7zSD.sfx');
/*
* 7za only packs the payload archive; the SFX stub and its config are plain byte concatenation, so
* the HOST's own 7za produces exactly the same Windows .exe. This used to be hard-coded to
* win/x64/7za.exe, which made the one-file installer buildable on Windows only — now the laptop can
* build it too.
*/
const sevenZip = (() => {
const base = path.join(ROOT, 'node_modules', '7zip-bin');
const arch = ['arm64', 'ia32', 'x64'].includes(process.arch) ? process.arch : 'x64';
if (process.platform === 'win32') return path.join(base, 'win', arch, '7za.exe');
if (process.platform === 'darwin') return path.join(base, 'mac', arch === 'arm64' ? 'arm64' : 'x64', '7za');
return path.join(base, 'linux', arch, '7za');
})();
2026-09-24 13:05:20 -04:00
const outName = `Bible App Full Setup ${version}.exe`;
const outPath = path.join(DIST, outName);
function need(p, what) {
if (!fs.existsSync(p)) {
console.error(`! Missing ${what}: ${p}`);
if (what === 'installer') console.error(' Build it first: npm run build:win');
process.exit(1);
}
}
need(installerPath, 'installer');
need(modelPath, 'AI model (vendor/llama/model.gguf)');
need(sfxModule, 'SFX module (tools/sfx/7zSD.sfx)');
need(sevenZip, `7za for ${process.platform}/${process.arch}`);
2026-09-24 13:05:20 -04:00
const mb = (n) => (n / 1048576).toFixed(1);
console.log(`Building one-file installer for ${version}`);
console.log(` installer : ${installerName} (${mb(fs.statSync(installerPath).size)} MB)`);
console.log(` AI model : model.gguf (${mb(fs.statSync(modelPath).size)} MB)`);
// Stage the payload. Hardlinks keep this instant instead of copying ~2 GB around; a copy is the
// fallback for the case where staging lands on a different volume.
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'bibleapp-full-'));
const place = (src, destName) => {
const dest = path.join(stage, destName);
try { fs.linkSync(src, dest); } catch (_) { fs.copyFileSync(src, dest); }
};
place(installerPath, installerName);
place(modelPath, 'model.gguf');
// Runs inside the SFX's temp folder once everything is extracted. Model first, so the app already
// has working AI if the installer launches it at the end. If ProgramData isn't writable (the SFX
// somehow ran unelevated), fall back to this user's own data folder — the launcher checks both.
fs.writeFileSync(path.join(stage, 'install.cmd'), [
'@echo off',
'title Bible App Setup',
'echo.',
'echo Installing Bible App and the offline AI model...',
'echo This takes a minute - please wait.',
'echo.',
'setlocal enableextensions',
'set "MODELDIR=%ProgramData%\\Bible App\\llama-cache"',
'if "%ProgramData%"=="" set "MODELDIR=C:\\ProgramData\\Bible App\\llama-cache"',
'if not exist "%MODELDIR%" mkdir "%MODELDIR%" 2>nul',
'if exist "model.gguf" move /y "model.gguf" "%MODELDIR%\\model.gguf" >nul 2>&1',
'if exist "model.gguf" (',
' if not exist "%APPDATA%\\Bible App\\llama-cache" mkdir "%APPDATA%\\Bible App\\llama-cache" 2>nul',
' move /y "model.gguf" "%APPDATA%\\Bible App\\llama-cache\\model.gguf" >nul 2>&1',
')',
`start /wait "" "${installerName}"`,
'exit /b 0',
''
].join('\r\n'), 'ascii');
// Store, don't compress: the model is quantised weights and the installer is already an archive, so
// LZMA would burn many minutes for ~0% gain.
const bundle = path.join(stage, 'payload.7z');
console.log(' packing : building archive (store mode)…');
execFileSync(sevenZip, ['a', '-t7z', '-mx0', '-bso0', '-bsp0', bundle, installerName, 'model.gguf', 'install.cmd'], { cwd: stage, stdio: 'inherit' });
2026-09-24 13:05:20 -04:00
/*
* ExecuteFile, NOT RunProgram. RunProgram launches via CreateProcess, which cannot start a .cmd at
* all (it needs a real executable) and prepends ".\" to whatever you give it — so "cmd /c ..." fails
* too. It also does not understand the "hidcon:" prefix, so that just became part of the filename
* and Windows reported "The system cannot find the file specified". ExecuteFile goes through
* ShellExecuteEx, which runs .cmd files properly, takes no directory prefix, and is still waited on.
*/
const config = Buffer.from([
';!@Install@!UTF-8!',
`Title="Bible App ${version}"`,
`BeginPrompt="Install Bible App ${version} with the offline AI model?"`,
'ExecuteFile="install.cmd"',
';!@InstallEnd@!',
''
].join('\r\n'), 'utf8');
console.log(' linking : sfx + config + payload…');
const out = fs.createWriteStream(outPath);
const append = (buf) => new Promise((res, rej) => out.write(buf, (e) => (e ? rej(e) : res())));
(async () => {
await append(fs.readFileSync(sfxModule));
await append(config);
// Stream the payload so a ~2 GB archive never has to sit in memory.
await new Promise((res, rej) => {
const rs = fs.createReadStream(bundle);
rs.on('error', rej);
rs.on('end', res);
rs.pipe(out, { end: false });
});
out.end();
await new Promise((res) => out.on('close', res));
fs.rmSync(stage, { recursive: true, force: true });
const size = fs.statSync(outPath).size;
const head = Buffer.alloc(2);
const fd = fs.openSync(outPath, 'r');
fs.readSync(fd, head, 0, 2, 0);
fs.closeSync(fd);
console.log('');
console.log(`✓ ${outName}`);
console.log(` ${mb(size)} MB header ${head.toString('ascii')} ${head.toString('ascii') === 'MZ' ? '(valid Windows exe)' : '(BAD — not an exe)'}`);
console.log(` ${outPath}`);
console.log('');
console.log('Carry this ONE file to a new machine. It installs the app and drops the AI model into');
console.log('%ProgramData%\\Bible App\\llama-cache, where app updates never touch it.');
})().catch((e) => {
console.error('! Build failed:', e.message);
try { fs.rmSync(stage, { recursive: true, force: true }); } catch (_) {}
process.exit(1);
});