145 lines
6.7 KiB
JavaScript
145 lines
6.7 KiB
JavaScript
#!/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);
|
|||
|
|
});
|