#!/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); });