diff --git a/BUILD-AND-DEPLOY.md b/BUILD-AND-DEPLOY.md index db91a03..3c7556b 100644 --- a/BUILD-AND-DEPLOY.md +++ b/BUILD-AND-DEPLOY.md @@ -3,6 +3,16 @@ This folder is the complete source tree of the Bible App as it runs on the stage TV (version in `package.json`). Everything the installer build needs is here. Nothing the build produces is. +The short version, once set up: + +```bash +npm run release -- "What changed in this version" +git commit -am "Release " && git push +``` + +That bumps the version, builds the Windows installer, uploads it to the church Gitea server, and the +TV picks it up from **Settings › Updates**. No .exe to hand-carry. + ## 1. What is (and is not) in this folder Included — everything the build and the app need: @@ -13,115 +23,140 @@ Included — everything the build and the app need: `dict/`, `fonts/`, `Notes/`, `Announcements/`, `Graphics/` (the defaults that ship inside the installer). - Vendored runtimes that ship inside the installer: `vendor/whisper`, `vendor/upscale`, `vendor/redist`, `vendor/llama` (the llama-server binaries — NOT the models), `vendor/fontawesome`. -- Build inputs: `package.json`, `package-lock.json`, `build/installer.nsh`, `tools/` (`build-full-installer.cjs`, - `publish-update.cjs`, the SFX stub in `tools/sfx`, `mock-propresenter.cjs`), `build-installer.bat`. +- Build inputs: `package.json`, `package-lock.json`, `build/installer.nsh`, `tools/` (`build-win.cjs`, + `release.cjs`, `publish-update.cjs`, `build-full-installer.cjs`, the SFX stub in `tools/sfx`, + `mock-propresenter.cjs`), `build-installer.bat`. - The `*.md` notes at the root (small; project history worth keeping). Left out on purpose (would only take space): | Left out | Why | |---|---| -| `dist-installer/` (57 GB), `dist/` | every old installer ever built; the build recreates them | -| `.git/` (32 GB) | the old history had installers and models committed into it — start a fresh repo (below) | +| `dist-installer/`, `dist/` | every installer ever built; the build recreates them | | `node_modules/` (640 MB) | `npm ci` recreates it exactly from `package-lock.json` | | `vendor/llama/*.gguf` (8 GB) | AI models; the regular installer never carries them (see §5) | | `.ebcache/`, `archive/`, `*.log`, `server-port.txt` | build cache, retired scratch files, runtime files | | `Dictionary/**/*.zip`, the Strong's XML folder | source archives; the app reads the converted JSON beside them | -Size of this folder: about 1.3 GB, of which ~700 MB is the vendored runtimes and ~400 MB is Bible data. +Size of this folder: about 1.2 GB, of which ~700 MB is the vendored runtimes and ~400 MB is Bible data. -## 2. Build machine prerequisites (the laptop) +## 2. Build machine prerequisites -- Windows 10/11 x64. -- Node.js 22 LTS (this tree was built with Node 22.18 / npm 10.9). Install from nodejs.org. -- Internet the first time: `npm ci` downloads Electron and electron-builder pulls NSIS and its - winCodeSign helper into `%LOCALAPPDATA%\electron-builder\Cache` (a few hundred MB, once). -- No code-signing certificate is used. The build must be told so (step 3). +**The Windows installer builds on macOS as well as on Windows.** electron-builder downloads its own +mac build of wine for the few Windows-only steps, so the laptop produces the exact same installer the +stage PC installs. Nothing has to be done on the Windows machine any more. -## 3. Build the regular installer +- macOS (Apple Silicon or Intel), or Windows 10/11 x64. +- Node.js 22 LTS. On macOS: `brew install node@22`. On Windows: nodejs.org. +- Internet the first time: `npm ci` downloads Electron, and electron-builder pulls NSIS, winCodeSign + and (on macOS) wine into its cache — a few hundred MB, once. +- No code-signing certificate is used, and `tools/build-win.cjs` sets the environment up accordingly. -```bat -cd bible-app-source -npm ci -set CSC_IDENTITY_AUTO_DISCOVERY=false -npx electron-builder --win +## 3. Build the installer + +```bash +npm ci # first time only +npm run build:win ``` -Output: `dist-installer\Bible App Setup .exe` (about 750 MB). +Output: `dist-installer/Bible App Setup .exe` (about 750 MB). Notes: -- Bump `"version"` in `package.json` before every build you intend to ship. The TV's updater compares - versions numerically and only offers a HIGHER version. -- `build-installer.bat` does the same as the commands above and also bumps the patch version and clears - the electron-builder cache first. Either way works. -- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment they must be - REMOVED, not set to empty — electron-builder aborts on an empty `WIN_CSC_LINK` - ("Env WIN_CSC_LINK is not correct"). In PowerShell: `Remove-Item Env:WIN_CSC_LINK`. -- If the build fails while extracting `winCodeSign` (symlink / permission errors), run the terminal as - administrator or turn on Windows Developer Mode, delete `%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, - and build again. +- `npm run build:win` always builds **x64**. Do not call `npx electron-builder --win` directly on an + Apple Silicon Mac: electron-builder defaults to the host architecture and will quietly hand you an + arm64 build that the stage PC cannot run. +- Bump `"version"` in `package.json` before every build you intend to ship — `npm run release` does it + for you. The TV's updater compares versions numerically and only offers a HIGHER version. +- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment, + `build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`). +- On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the + terminal as administrator or turn on Developer Mode, delete + `%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again. ## 4. Update the TVs remotely (no cable, no remote desktop) -Each TV has **Settings › Updates › "Update folder or address"** — a network share (`\\SERVER\share\bible-app`), -a folder, or an `http://` address — plus "Check for updates automatically when the app starts". The TV reads -`/latest.json` there, compares versions, then downloads and runs the installer. The app closes, -installs, reopens; notes, graphics, annotations and settings are kept. +Each TV has **Settings › Updates › "Update folder or address"** plus "Check for updates automatically +when the app starts". The TV reads `/latest.json`, compares versions, then downloads and runs +the installer. The app closes, installs, reopens; notes, graphics, annotations and settings are kept. -From the laptop, after a build: +The church's update source is the Gitea **package registry**: -```bat -npm run publish-update -- "\\SERVER\share\bible-app" "what changed in this version" +``` +http://chronicles-ab.waterschurch.org:3000/api/packages/christian/generic/bible-app/latest ``` -That copies the newest installer into the folder and writes `latest.json` (version, file name, size, -sha256, notes). For an `http://` source, upload the `.exe` and `latest.json` to the web server yourself. -`npm run release` builds and publishes in one go. +Paste that into Settings › Updates on every machine, once. It is also `bibleApp.updateTarget` in +`package.json`, which is where the publish script gets it from. -The TV picks the update up at its next start (auto-check on) or when someone taps **Check for Updates** -in Settings. It never installs without that check, so time the publish for after the service. +> Why the package registry and not a Gitea *release*? This server caps release attachments at 100 MB +> and does not allow `.exe`. The package registry takes the 750 MB installer and — importantly — +> serves it to **anonymous** readers, which is what the updater needs: it sends no credentials. The +> code repository stays private; only the published installer is readable without a login. + +Ship a version: + +```bash +npm run release -- "Faster laser, new popup cards" +``` + +That bumps the patch version, builds, uploads the installer and `latest.json`, and removes builds +older than the previous one. To publish somewhere else instead (a network share, a folder, a web +server), pass a destination — the same string you would put in Settings › Updates: + +```bash +npm run release -- --dest "\\SERVER\share\bible-app" "Notes" +npm run publish-update -- "\\SERVER\share\bible-app" "Notes" # publish an existing build +``` + +Publishing needs a Gitea token: `GITEA_TOKEN`, or the macOS keychain item `gitea-mcp` that the git +tooling already uses. It is never written to disk. + +The TV picks the update up at its next start (auto-check on) or when someone taps **Check for +Updates** in Settings. It never installs without that check, so time the publish for after the service. ## 5. Full Setup (only for a brand-new machine) A machine that has never had the app needs the AI model once (about 1.9 GB). That is the one-file -"Bible App Full Setup", which drops the model into `%ProgramData%\Bible App\llama-cache` where updates never -touch it. To build it, put the model at `vendor/llama/model.gguf` (exactly 2,019,377,696 bytes — copy it from -the stage PC: `C:\Users\media\Downloads\bible-app\vendor\llama\model.gguf` or -`C:\ProgramData\Bible App\llama-cache\model.gguf`; it is gitignored, so it never enters the repo), build the -regular installer, then: +"Bible App Full Setup", which drops the model into `%ProgramData%\Bible App\llama-cache` where updates +never touch it. To build it, put the model at `vendor/llama/model.gguf` (exactly 2,019,377,696 bytes — +copy it from the stage PC: `C:\ProgramData\Bible App\llama-cache\model.gguf`; it is gitignored, so it +never enters the repo), build the regular installer, then: -```bat +```bash node tools/build-full-installer.cjs ``` Machines that already have the model keep using the regular installer. -## 6. Put it in git +## 6. Git -```bat -cd bible-app-source -git init -git add -A -git commit -m "Bible App — full source" -git remote add origin -git push -u origin main +The tree lives on the church Gitea server: + +``` +http://chronicles-ab.waterschurch.org:3000/christian/bible-app ``` -`.gitignore` keeps the build outputs, `node_modules`, models and logs out; `.gitattributes` stores every -file byte-for-byte (the sources are CRLF, and several maintenance scripts match CRLF exactly). +`git push` and `git pull` work as usual; the credential is in the macOS keychain. `.gitignore` keeps +build outputs, `node_modules`, models and logs out; `.gitattributes` stores every file byte-for-byte +(several maintenance scripts match CRLF exactly). -The first push is about 1.3 GB, mostly the vendored runtimes and Bible JSON. A local Gitea / GitLab -handles that fine; if your server has Git LFS enabled, the commented lines in `.gitattributes` move the -`vendor/` binaries into LFS (only do that with LFS actually on, or clones get pointer files). +Tag what you ship, so a rollback is a checkout rather than an archaeology exercise: + +```bash +git tag v && git push --tags +``` + +If your server has Git LFS enabled, the commented lines in `.gitattributes` move the `vendor/` +binaries into LFS (only do that with LFS actually on, or clones get pointer files). ## 7. Dev-run without building -```bat +```bash npm ci -node server.js +npm start ``` -then open `http://localhost:8080/`. To run a test copy beside a live app, set `BIBLE_APP_PORT=8095` and -`BIBLE_APP_DATA_DIR=` so it never touches the church's real content in -`%APPDATA%\Bible App`. +then open `http://localhost:8080/`. This runs the server on macOS and Windows alike. To run a test copy +beside a live app, set `BIBLE_APP_PORT=8095` and `BIBLE_APP_DATA_DIR=` so it never +touches the church's real content. diff --git a/package.json b/package.json index 8d98681..4b7073c 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "start": "node server.js", "wordnet:fetch": "node tools/fetch-greek-wordnet.js", "build": "electron-builder", - "build:win": "electron-builder --win", - "release": "electron-builder --win && node tools/publish-update.cjs", + "build:win": "node tools/build-win.cjs", + "release": "node tools/release.cjs", "publish-update": "node tools/publish-update.cjs", "build:win:dir": "electron-builder --win --dir", "build:installer": "build-installer.bat", @@ -27,6 +27,9 @@ ], "author": "Bible App Team", "license": "MIT", + "bibleApp": { + "updateTarget": "http://chronicles-ab.waterschurch.org:3000/api/packages/christian/generic/bible-app/latest" + }, "build": { "appId": "com.bibleapp.desktop", "productName": "Bible App", diff --git a/server.js b/server.js index b063a64..23e6343 100644 --- a/server.js +++ b/server.js @@ -7823,6 +7823,34 @@ server.listen(PORT, () => { console.error('[OSC] ❌ Failed to start OSC listener:', e && e.message || e); console.error('[OSC] Error stack:', e.stack); } + + /* + * "Check for updates automatically when the app starts" (Settings > Updates). Until now that + * setting was stored but never acted on, so the TV only learned about a new build when someone + * opened Settings and tapped Check for Updates. + * + * This CHECKS only. The answer lands in lastResult for Settings to show; installing stays a + * deliberate tap, because installing closes and reopens the app — which must never happen by + * itself in the middle of a service. The delay keeps it clear of the llama/OSC startup rush. + */ + setTimeout(async () => { + try { + const cfg = readUpdateConfig(); + if (!cfg.autoCheck || !cfg.source) return; + const man = await readUpdateManifest(cfg.source); + const latest = String(man.version || '').trim(); + const current = appVersion(); + const available = latest && compareVersions(latest, current) > 0; + writeUpdateConfig({ lastCheck: Date.now(), lastResult: available ? ('Update ' + latest + ' available') : 'Up to date' }); + console.log(available + ? `[UPDATE] Version ${latest} is available (this machine has ${current}) — install it from Settings > Updates.` + : `[UPDATE] Up to date (${current}).`); + } catch (e) { + const msg = String(e && e.message || e); + try { writeUpdateConfig({ lastCheck: Date.now(), lastResult: 'Check failed: ' + msg }); } catch (_) {} + console.warn('[UPDATE] Startup check failed:', msg); + } + }, 5000); }); server.on('error', (error) => { diff --git a/tools/build-win.cjs b/tools/build-win.cjs new file mode 100644 index 0000000..54c2a13 --- /dev/null +++ b/tools/build-win.cjs @@ -0,0 +1,35 @@ +/* + * build-win.cjs — build the Windows x64 installer. Works on Windows AND on macOS: electron-builder + * fetches its own mac build of wine for the few Windows-only steps, so the laptop can produce the + * exact installer the stage PC installs. + * + * npm run build:win + * + * Two things this wrapper exists to guarantee: + * + * - --x64. electron-builder otherwise targets the HOST architecture, so on an Apple Silicon Mac a + * plain `electron-builder --win` quietly produces an arm64 build that the stage PC cannot run. + * - a clean signing environment. There is no code-signing certificate. electron-builder aborts on + * an EMPTY WIN_CSC_LINK ("Env WIN_CSC_LINK is not correct"), so those variables are removed + * rather than blanked, and identity auto-discovery is turned off. + */ +const { spawnSync } = require('child_process'); +const path = require('path'); + +const env = { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: 'false' }; +for (const key of ['CSC_LINK', 'WIN_CSC_LINK', 'CSC_KEY_PASSWORD', 'WIN_CSC_KEY_PASSWORD', 'CSC_KEYCHAIN']) { + delete env[key]; +} + +const extra = process.argv.slice(2); +const args = ['electron-builder', '--win', '--x64', ...extra]; + +console.log(`Building the Windows x64 installer on ${process.platform}…`); +const r = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, { + cwd: path.join(__dirname, '..'), + env, + stdio: 'inherit', + shell: process.platform === 'win32' +}); +if (r.error) { console.error('Build failed to start:', r.error.message); process.exit(1); } +process.exit(r.status === null ? 1 : r.status); diff --git a/tools/publish-update.cjs b/tools/publish-update.cjs index 513f27d..03e551c 100644 --- a/tools/publish-update.cjs +++ b/tools/publish-update.cjs @@ -1,19 +1,36 @@ /* - * publish-update.cjs — copy the freshly built installer to the church's update folder and write - * the latest.json manifest the app reads. Run after a build: + * 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 -- "D:\Builds" (publish to a specific folder) - * npm run publish-update -- "D:\Builds" "Notes about this version" + * npm run publish-update -- "" (publish to a specific destination) + * npm run publish-update -- "" "Notes about this version" * - * Destination order: CLI arg -> BIBLEAPP_UPDATE_DIR env -> the folder configured in the app - * (Notes/update-config.json) -> dist-installer/publish + * 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) { @@ -32,6 +49,9 @@ 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'); } @@ -42,7 +62,7 @@ function configuredDest() { if (fs.existsSync(p)) { const cfg = JSON.parse(fs.readFileSync(p, 'utf-8')); const s = String(cfg.source || '').trim(); - if (s && !/^https?:\/\//i.test(s)) return s; // http sources are uploaded by hand + if (s) return s; // a URL is publishable now too (Gitea), not just a folder } } catch (_) {} return ''; @@ -50,7 +70,10 @@ function configuredDest() { const argDest = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : ''; const notes = process.argv[3] || ''; -const dest = argDest || process.env.BIBLEAPP_UPDATE_DIR || configuredDest() || path.join(ROOT, 'dist-installer', 'publish'); +// 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)) { @@ -71,20 +94,162 @@ if (!installer) { console.warn(`! No installer named for v${version}; publishing newest: ${installer}`); } -fs.mkdirSync(dest, { recursive: true }); const srcPath = path.join(distDir, installer); -const destPath = path.join(dest, 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//generic// -> .../api/v1/packages//generic///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 '); + 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 ~300 MB instead of ~2.2 GB. + * 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. @@ -113,30 +278,18 @@ try { console.warn('! Could not publish the AI model:', e.message); } -(async () => { -const installerSize = fs.statSync(destPath).size; -const installerHash = await sha256(destPath); -let modelSize, modelHash; -try { - const md = path.join(dest, 'model.gguf'); - if (modelPublished && fs.existsSync(md)) { - modelSize = fs.statSync(md).size; - console.log(' hashing model (integrity check)…'); - modelHash = await sha256(md); - } -} catch (_) {} +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 (_) {} +} -const manifest = { - version, - file: installer, - size: installerSize, - sha256: installerHash, - notes: notes || `Bible App ${version}`, - date: new Date().toISOString().slice(0, 10), - model: modelPublished ? 'model.gguf' : undefined, - modelSize, - modelSha256: modelHash -}; 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 diff --git a/tools/release.cjs b/tools/release.cjs new file mode 100644 index 0000000..015cd07 --- /dev/null +++ b/tools/release.cjs @@ -0,0 +1,68 @@ +/* + * release.cjs — one command to ship a new version from the laptop: + * + * npm run release -- "Notes the operator will see in Settings > Updates" + * npm run release -- --no-bump "Re-publish the current version" + * npm run release -- --dest "\\SERVER\share\bible-app" "Notes" + * + * It bumps the patch version, builds the Windows x64 installer, and publishes it to the update + * target. The version bump matters: the TV's updater compares versions numerically and only offers + * a HIGHER one, so re-publishing the same version is invisible to it. + * + * Default destination is package.json > bibleApp.updateTarget (the church's Gitea package + * registry). Pass --dest, or set BIBLEAPP_UPDATE_DIR, to publish somewhere else. + */ +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const PKG = path.join(ROOT, 'package.json'); + +const argv = process.argv.slice(2); +const noBump = argv.includes('--no-bump'); +let dest = ''; +const destAt = argv.indexOf('--dest'); +if (destAt !== -1 && argv[destAt + 1]) dest = argv[destAt + 1]; +// Whatever is left over, ignoring the flags and their values, is the release note. +const notes = argv.filter((a, i) => + !a.startsWith('--') && !(destAt !== -1 && i === destAt + 1) +).join(' ').trim(); + +function run(label, cmd, args) { + console.log(`\n=== ${label} ===`); + const r = spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit' }); + if (r.error) { console.error(`${label} failed to start:`, r.error.message); process.exit(1); } + if (r.status !== 0) { console.error(`${label} failed (exit ${r.status}).`); process.exit(r.status || 1); } +} + +const pkg = JSON.parse(fs.readFileSync(PKG, 'utf-8')); + +if (!noBump) { + const parts = String(pkg.version).split('.').map(n => parseInt(n, 10) || 0); + while (parts.length < 3) parts.push(0); + parts[2] += 1; + const next = parts.join('.'); + // Rewrite only the version line, so the file keeps its own formatting and key order. + const raw = fs.readFileSync(PKG, 'utf-8'); + fs.writeFileSync(PKG, raw.replace( + /^(\s*"version"\s*:\s*")[^"]+(",?)$/m, + (_m, a, b) => a + next + b + )); + console.log(`Version ${pkg.version} -> ${next}`); + pkg.version = next; +} else { + console.log(`Version ${pkg.version} (not bumped)`); +} + +run('Build', process.execPath, [path.join(__dirname, 'build-win.cjs')]); + +const target = dest || process.env.BIBLEAPP_UPDATE_DIR || (pkg.bibleApp && pkg.bibleApp.updateTarget) || ''; +const publishArgs = [path.join(__dirname, 'publish-update.cjs')]; +if (target) publishArgs.push(target, notes || `Bible App ${pkg.version}`); +else if (notes) publishArgs.push('', notes); +run('Publish', process.execPath, publishArgs); + +console.log(`\nReleased ${pkg.version}.`); +console.log('Commit the version bump so git matches what the TVs are running:'); +console.log(` git commit -am "Release ${pkg.version}" && git push`);