Files
bible-app/tools/build-full-installer.cjs
christianandClaude Opus 5 4e6d24093c Release 1.4.35; build the one-file Full Setup on macOS too
- Version 1.4.35, published to the Gitea package registry and verified: the app
  downloads it, checks size and sha256, and lands a byte-identical installer.
- tools/build-full-installer.cjs picked node_modules/7zip-bin/win/x64/7za.exe
  unconditionally, so the one-file Full Setup could only be built on Windows.
  7za only packs the payload — the SFX stub and its config are concatenated
  byte-for-byte — so the host's own 7za yields the same Windows .exe. Also names
  the three payload files instead of passing a "*" that each 7za build expands
  its own way, and that would otherwise sweep the output archive into itself.
- BUILD-AND-DEPLOY.md / UPDATES.md rewritten for the laptop workflow and the
  Gitea update source.

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

157 lines
7.4 KiB
JavaScript
Raw Permalink 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');
/*
* 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');
})();
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}`);
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' });
/*
* 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);
});