The dev machine is now a laptop, so the release path had to stop assuming Windows and a reachable network share. - tools/build-win.cjs: build the Windows x64 installer from macOS or Windows. electron-builder fetches its own mac wine build, so the only real trap is architecture: a plain `electron-builder --win` on Apple Silicon silently produces an arm64 installer the stage PC cannot run. This pins --x64 and strips the CSC_* signing vars (an EMPTY WIN_CSC_LINK aborts the build). - tools/publish-update.cjs: an http(s) destination now uploads to a Gitea generic package registry. Not a Gitea release: this server caps release attachments at 100 MB and disallows .exe, while the package registry takes the 750 MB installer and serves it anonymously — which the updater requires, since it sends no credentials. Installer first, manifest last, so a TV checking mid-publish never sees a manifest naming a file that is not there yet. - tools/release.cjs: bump, build and publish in one command. - server.js: honour "check for updates automatically when the app starts". The setting was stored but never acted on. It checks only — installing restarts the app, which must stay a deliberate tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
14 KiB
JavaScript
312 lines
14 KiB
JavaScript
/*
|
|
* publish-update.cjs — put the freshly built installer where the app's updater can find it, and
|
|
* write the latest.json manifest it reads. Run after a build:
|
|
*
|
|
* npm run release (build + publish)
|
|
* npm run publish-update (publish the newest existing build)
|
|
* npm run publish-update -- "<dest>" (publish to a specific destination)
|
|
* npm run publish-update -- "<dest>" "Notes about this version"
|
|
*
|
|
* A destination is either:
|
|
*
|
|
* a folder or network share \\SERVER\share\bible-app, D:\Builds -> the files are copied there
|
|
* an http(s) URL http://gitea:3000/api/packages/... -> uploaded to Gitea
|
|
*
|
|
* The URL form targets a Gitea *generic package registry*, not a release: Gitea caps release
|
|
* attachments (100 MB here, and .exe is not an allowed type) while the package registry takes the
|
|
* ~750 MB installer and serves it to anonymous readers, which is what the updater needs — it sends
|
|
* no credentials. Whatever destination you publish to is the exact string that goes in the app's
|
|
* Settings > Updates > "Update folder or address".
|
|
*
|
|
* Destination order: CLI arg -> BIBLEAPP_UPDATE_DIR env -> package.json bibleApp.updateTarget
|
|
* -> the folder configured in the app (Notes/update-config.json) -> dist-installer/publish
|
|
*
|
|
* Gitea auth: GITEA_TOKEN env, else the macOS keychain item the gitea tooling already uses
|
|
* (service "gitea-mcp"). The token is never written to disk.
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const crypto = require('crypto');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
// Fingerprint a file so a truncated or interrupted copy can never be mistaken for a good build.
|
|
function sha256(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const h = crypto.createHash('sha256');
|
|
const s = fs.createReadStream(file);
|
|
s.on('data', c => h.update(c));
|
|
s.on('error', reject);
|
|
s.on('end', () => resolve(h.digest('hex')));
|
|
});
|
|
}
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
|
const version = pkg.version;
|
|
|
|
function appNotesDir() {
|
|
// Mirrors the app's user-content location so we can reuse the configured update folder.
|
|
if (process.platform === 'darwin') {
|
|
return path.join(os.homedir(), 'Library', 'Application Support', 'Bible App', 'user-content', 'Notes');
|
|
}
|
|
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
return path.join(base, 'Bible App', 'user-content', 'Notes');
|
|
}
|
|
|
|
function configuredDest() {
|
|
try {
|
|
const p = path.join(appNotesDir(), 'update-config.json');
|
|
if (fs.existsSync(p)) {
|
|
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
const s = String(cfg.source || '').trim();
|
|
if (s) return s; // a URL is publishable now too (Gitea), not just a folder
|
|
}
|
|
} catch (_) {}
|
|
return '';
|
|
}
|
|
|
|
const argDest = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : '';
|
|
const notes = process.argv[3] || '';
|
|
// The church's canonical target, tracked in git so every clone publishes to the same place.
|
|
const repoTarget = (pkg.bibleApp && typeof pkg.bibleApp.updateTarget === 'string') ? pkg.bibleApp.updateTarget.trim() : '';
|
|
const dest = argDest || process.env.BIBLEAPP_UPDATE_DIR || repoTarget || configuredDest() || path.join(ROOT, 'dist-installer', 'publish');
|
|
const isUrlDest = /^https?:\/\//i.test(dest);
|
|
|
|
const distDir = path.join(ROOT, 'dist-installer');
|
|
if (!fs.existsSync(distDir)) {
|
|
console.error('No dist-installer folder — run the build first (npm run build:win).');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Prefer the installer matching package.json's version; else the newest .exe in dist-installer.
|
|
const exact = fs.readdirSync(distDir).find(f => f.endsWith('.exe') && f.includes(version));
|
|
let installer = exact;
|
|
if (!installer) {
|
|
const exes = fs.readdirSync(distDir)
|
|
.filter(f => f.toLowerCase().endsWith('.exe'))
|
|
.map(f => ({ f, t: fs.statSync(path.join(distDir, f)).mtimeMs }))
|
|
.sort((a, b) => b.t - a.t);
|
|
if (!exes.length) { console.error('No installer (.exe) found in dist-installer.'); process.exit(1); }
|
|
installer = exes[0].f;
|
|
console.warn(`! No installer named for v${version}; publishing newest: ${installer}`);
|
|
}
|
|
|
|
const srcPath = path.join(distDir, installer);
|
|
const sizeMB = (fs.statSync(srcPath).size / 1048576).toFixed(0);
|
|
|
|
/* ---- Gitea generic package registry -------------------------------------------------------- */
|
|
|
|
function giteaToken() {
|
|
const env = (process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || '').trim();
|
|
if (env) return env;
|
|
if (process.platform === 'darwin') {
|
|
// Same keychain item the gitea MCP launcher reads, so there is one token to rotate, not two.
|
|
try {
|
|
const account = process.env.GITEA_KEYCHAIN_ACCOUNT || os.userInfo().username;
|
|
return execFileSync('/usr/bin/security',
|
|
['find-generic-password', '-s', 'gitea-mcp', '-a', account, '-w'],
|
|
{ encoding: 'utf-8' }).trim();
|
|
} catch (_) {}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// One request helper for PUT (upload) and DELETE. Streams the body so a 750 MB installer never
|
|
// has to sit in memory, and reports progress because the upload is the slow part of a release.
|
|
function giteaRequest(method, url, token, filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
const u = new URL(url);
|
|
const mod = u.protocol === 'https:' ? https : http;
|
|
const headers = { Authorization: 'token ' + token };
|
|
let total = 0;
|
|
if (filePath) {
|
|
total = fs.statSync(filePath).size;
|
|
headers['Content-Length'] = total;
|
|
headers['Content-Type'] = 'application/octet-stream';
|
|
}
|
|
const req = mod.request(url, { method, headers }, (res) => {
|
|
let body = '';
|
|
res.on('data', c => { body += c.toString('utf-8'); });
|
|
res.on('end', () => resolve({ status: res.statusCode || 0, body }));
|
|
});
|
|
req.on('error', reject);
|
|
if (filePath) {
|
|
let sent = 0, lastPct = -1;
|
|
const rs = fs.createReadStream(filePath);
|
|
rs.on('data', (c) => {
|
|
sent += c.length;
|
|
const pct = total ? Math.floor((sent / total) * 100) : 0;
|
|
if (pct !== lastPct && pct % 5 === 0) {
|
|
lastPct = pct;
|
|
process.stdout.write(`\r uploading… ${pct}% `);
|
|
}
|
|
});
|
|
rs.on('error', reject);
|
|
rs.pipe(req);
|
|
} else {
|
|
req.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Gitea refuses a PUT onto a filename that already exists in the package version, so each file is
|
|
// removed first. A 404 here just means "nothing published yet", which is the normal first run.
|
|
async function giteaPut(baseUrl, token, filename, filePath, label) {
|
|
const target = baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(filename);
|
|
const del = await giteaRequest('DELETE', target, token, null);
|
|
if (del.status !== 204 && del.status !== 404) {
|
|
console.warn(`! could not clear the previous ${label} (HTTP ${del.status}) — continuing`);
|
|
}
|
|
const put = await giteaRequest('PUT', target, token, filePath);
|
|
if (put.status !== 201 && put.status !== 200) {
|
|
throw new Error(`upload of ${label} failed: HTTP ${put.status} ${put.body.slice(0, 200)}`);
|
|
}
|
|
process.stdout.write('\r' + ' '.repeat(28) + '\r');
|
|
return target;
|
|
}
|
|
|
|
// A new release uploads its installer under a NEW filename, so the superseded one is still sitting
|
|
// in the package version taking ~750 MB. Clear the stale ones out once the new manifest is live —
|
|
// never before, so a TV checking mid-publish always finds the installer its manifest names.
|
|
// One previous build is kept as a hand-rollback copy; anything older goes.
|
|
async function giteaPrune(baseUrl, token, keepFile, keepPrevious = 1) {
|
|
const u = new URL(baseUrl.replace(/\/+$/, ''));
|
|
// .../api/packages/<owner>/generic/<name>/<version> -> .../api/v1/packages/<owner>/generic/<name>/<version>/files
|
|
const parts = u.pathname.split('/').filter(Boolean); // api, packages, owner, generic, name, version
|
|
const i = parts.indexOf('packages');
|
|
if (i === -1 || parts.length < i + 5) return;
|
|
const [owner, type, name, ver] = parts.slice(i + 1, i + 5);
|
|
const listUrl = `${u.origin}/api/v1/packages/${owner}/${type}/${name}/${ver}/files`;
|
|
const res = await giteaRequest('GET', listUrl, token, null);
|
|
if (res.status !== 200) return; // listing is a nicety, not a gate
|
|
let files;
|
|
try { files = JSON.parse(res.body); } catch (_) { return; }
|
|
if (!Array.isArray(files)) return;
|
|
const stale = files
|
|
.filter(f => f && typeof f.name === 'string' && f.name.toLowerCase().endsWith('.exe') && f.name !== keepFile)
|
|
.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
|
|
.slice(keepPrevious);
|
|
for (const f of stale) {
|
|
const del = await giteaRequest('DELETE', baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(f.name), token, null);
|
|
if (del.status === 204) console.log(` removed old build: ${f.name}`);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------------------------- */
|
|
|
|
(async () => {
|
|
console.log(`Publishing v${version}`);
|
|
console.log(` installer : ${installer} (${sizeMB} MB)`);
|
|
console.log(` to : ${dest}`);
|
|
|
|
const installerSize = fs.statSync(srcPath).size;
|
|
console.log(' hashing installer (integrity check)…');
|
|
const installerHash = await sha256(srcPath);
|
|
|
|
const manifest = {
|
|
version,
|
|
file: installer,
|
|
size: installerSize,
|
|
sha256: installerHash,
|
|
notes: notes || `Bible App ${version}`,
|
|
date: new Date().toISOString().slice(0, 10)
|
|
};
|
|
|
|
if (isUrlDest) {
|
|
const token = giteaToken();
|
|
if (!token) {
|
|
console.error('No Gitea token. Set GITEA_TOKEN, or store one in the macOS keychain:');
|
|
console.error(' security add-generic-password -s gitea-mcp -a "$USER" -w <token>');
|
|
process.exit(1);
|
|
}
|
|
const manifestPath = path.join(os.tmpdir(), 'bibleapp-latest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
|
|
// The installer goes up first: until latest.json names it, no app can act on a half-published
|
|
// release. Publishing the manifest last is what makes the switch atomic from the TV's point of view.
|
|
console.log(` uploading installer (${sizeMB} MB)…`);
|
|
await giteaPut(dest, token, installer, srcPath, 'installer');
|
|
console.log(' uploading latest.json…');
|
|
await giteaPut(dest, token, 'latest.json', manifestPath, 'manifest');
|
|
try { fs.unlinkSync(manifestPath); } catch (_) {}
|
|
try { await giteaPrune(dest, token, installer); } catch (e) { console.warn('! could not prune old builds:', e.message); }
|
|
|
|
console.log(` sha256 : ${installerHash}`);
|
|
console.log('\nDone. The app will offer this version from Settings > Updates.');
|
|
console.log(`Update source to set on each machine:\n ${dest}`);
|
|
return;
|
|
}
|
|
|
|
/* ---- Folder / network-share destination (unchanged behaviour) ------------------------------- */
|
|
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const destPath = path.join(dest, installer);
|
|
fs.copyFileSync(srcPath, destPath);
|
|
|
|
/*
|
|
* The AI model does NOT need to go in the update folder any more: a new machine gets it from the
|
|
* one-file "Bible App Full Setup" bundle, and machines that already have it keep it (it lives in
|
|
* ProgramData/user-data, which no installer touches). So updates stay ~750 MB instead of ~2.7 GB.
|
|
*
|
|
* We still refresh a model.gguf that is ALREADY published, so an existing Settings > Local AI
|
|
* install path keeps working; pass --with-model to publish it for the first time.
|
|
*/
|
|
let modelPublished = false;
|
|
try {
|
|
const modelSrc = path.join(ROOT, 'vendor', 'llama', 'model.gguf');
|
|
const modelDest = path.join(dest, 'model.gguf');
|
|
const wanted = process.argv.includes('--with-model') || fs.existsSync(modelDest);
|
|
if (!wanted) {
|
|
console.log(' AI model : skipped (ships in the full one-file installer; use --with-model to publish it here)');
|
|
} else if (fs.existsSync(modelSrc)) {
|
|
const srcSize = fs.statSync(modelSrc).size;
|
|
const needCopy = !fs.existsSync(modelDest) || fs.statSync(modelDest).size !== srcSize;
|
|
if (needCopy) {
|
|
console.log(` AI model : copying model.gguf (${(srcSize / 1073741824).toFixed(2)} GB) — one time…`);
|
|
fs.copyFileSync(modelSrc, modelDest);
|
|
} else {
|
|
console.log(' AI model : already published (unchanged)');
|
|
}
|
|
modelPublished = true;
|
|
} else {
|
|
console.warn('! vendor/llama/model.gguf not found — publish it manually so machines can install the AI model.');
|
|
}
|
|
} catch (e) {
|
|
console.warn('! Could not publish the AI model:', e.message);
|
|
}
|
|
|
|
if (modelPublished) {
|
|
try {
|
|
const md = path.join(dest, 'model.gguf');
|
|
if (fs.existsSync(md)) {
|
|
manifest.model = 'model.gguf';
|
|
manifest.modelSize = fs.statSync(md).size;
|
|
console.log(' hashing model (integrity check)…');
|
|
manifest.modelSha256 = await sha256(md);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
fs.writeFileSync(path.join(dest, 'latest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
// Companion file so a person can verify a hand-copied installer:
|
|
// Get-FileHash "Bible App Setup x.y.z.exe" -Algorithm SHA256
|
|
fs.writeFileSync(path.join(dest, installer + '.sha256'), installerHash + ' ' + installer + '\n');
|
|
console.log(` sha256 : ${installerHash}`);
|
|
|
|
// Keep the folder tidy: retain the 3 newest installers alongside the current one.
|
|
try {
|
|
const olds = fs.readdirSync(dest)
|
|
.filter(f => f.toLowerCase().endsWith('.exe') && f !== installer)
|
|
.map(f => ({ f, t: fs.statSync(path.join(dest, f)).mtimeMs }))
|
|
.sort((a, b) => b.t - a.t)
|
|
.slice(3);
|
|
olds.forEach(o => { try { fs.unlinkSync(path.join(dest, o.f)); console.log(` removed old build: ${o.f}`); } catch (_) {} });
|
|
} catch (_) {}
|
|
|
|
console.log('\nDone. The app will offer this version from Settings > Updates.');
|
|
console.log(`Manifest: ${path.join(dest, 'latest.json')}`);
|
|
})().catch(e => { console.error('Publish failed:', e && e.message || e); process.exit(1); });
|