Files
bible-app/tools/build-full-installer.cjs
T
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

145 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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');
const sevenZip = path.join(ROOT, 'node_modules', '7zip-bin', 'win', 'x64', '7za.exe');
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.exe');
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, path.join(stage, '*')], { stdio: 'inherit' });
/*
* 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);
});