Publish updates to the Gitea package registry, and build the installer on macOS

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>
This commit is contained in:
2026-09-24 13:17:55 -04:00
co-authored by Claude Opus 5
parent 0db5e9b07d
commit 838a87dbf3
6 changed files with 422 additions and 100 deletions
+99 -64
View File
@@ -3,6 +3,16 @@
This folder is the complete source tree of the Bible App as it runs on the stage TV (version in 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. `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 <version>" && 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 ## 1. What is (and is not) in this folder
Included — everything the build and the app need: 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). `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`, - Vendored runtimes that ship inside the installer: `vendor/whisper`, `vendor/upscale`, `vendor/redist`,
`vendor/llama` (the llama-server binaries — NOT the models), `vendor/fontawesome`. `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`, - Build inputs: `package.json`, `package-lock.json`, `build/installer.nsh`, `tools/` (`build-win.cjs`,
`publish-update.cjs`, the SFX stub in `tools/sfx`, `mock-propresenter.cjs`), `build-installer.bat`. `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). - The `*.md` notes at the root (small; project history worth keeping).
Left out on purpose (would only take space): Left out on purpose (would only take space):
| Left out | Why | | Left out | Why |
|---|---| |---|---|
| `dist-installer/` (57 GB), `dist/` | every old installer ever built; the build recreates them | | `dist-installer/`, `dist/` | every 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) |
| `node_modules/` (640 MB) | `npm ci` recreates it exactly from `package-lock.json` | | `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) | | `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 | | `.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 | | `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. **The Windows installer builds on macOS as well as on Windows.** electron-builder downloads its own
- Node.js 22 LTS (this tree was built with Node 22.18 / npm 10.9). Install from nodejs.org. mac build of wine for the few Windows-only steps, so the laptop produces the exact same installer the
- Internet the first time: `npm ci` downloads Electron and electron-builder pulls NSIS and its stage PC installs. Nothing has to be done on the Windows machine any more.
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).
## 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 ## 3. Build the installer
cd bible-app-source
npm ci ```bash
set CSC_IDENTITY_AUTO_DISCOVERY=false npm ci # first time only
npx electron-builder --win npm run build:win
``` ```
Output: `dist-installer\Bible App Setup <version>.exe` (about 750 MB). Output: `dist-installer/Bible App Setup <version>.exe` (about 750 MB).
Notes: Notes:
- Bump `"version"` in `package.json` before every build you intend to ship. The TV's updater compares - `npm run build:win` always builds **x64**. Do not call `npx electron-builder --win` directly on an
versions numerically and only offers a HIGHER version. Apple Silicon Mac: electron-builder defaults to the host architecture and will quietly hand you an
- `build-installer.bat` does the same as the commands above and also bumps the patch version and clears arm64 build that the stage PC cannot run.
the electron-builder cache first. Either way works. - Bump `"version"` in `package.json` before every build you intend to ship — `npm run release` does it
- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment they must be for you. The TV's updater compares versions numerically and only offers a HIGHER version.
REMOVED, not set to empty — electron-builder aborts on an empty `WIN_CSC_LINK` - If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment,
("Env WIN_CSC_LINK is not correct"). In PowerShell: `Remove-Item Env:WIN_CSC_LINK`. `build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`).
- If the build fails while extracting `winCodeSign` (symlink / permission errors), run the terminal as - On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the
administrator or turn on Windows Developer Mode, delete `%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, terminal as administrator or turn on Developer Mode, delete
and build again. `%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again.
## 4. Update the TVs remotely (no cable, no remote desktop) ## 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`), Each TV has **Settings › Updates › "Update folder or address"** plus "Check for updates automatically
a folder, or an `http://` address — plus "Check for updates automatically when the app starts". The TV reads when the app starts". The TV reads `<source>/latest.json`, compares versions, then downloads and runs
`<source>/latest.json` there, compares versions, then downloads and runs the installer. The app closes, the installer. The app closes, installs, reopens; notes, graphics, annotations and settings are kept.
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, Paste that into Settings › Updates on every machine, once. It is also `bibleApp.updateTarget` in
sha256, notes). For an `http://` source, upload the `.exe` and `latest.json` to the web server yourself. `package.json`, which is where the publish script gets it from.
`npm run release` builds and publishes in one go.
The TV picks the update up at its next start (auto-check on) or when someone taps **Check for Updates** > Why the package registry and not a Gitea *release*? This server caps release attachments at 100 MB
in Settings. It never installs without that check, so time the publish for after the service. > 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) ## 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 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 "Bible App Full Setup", which drops the model into `%ProgramData%\Bible App\llama-cache` where updates
touch it. To build it, put the model at `vendor/llama/model.gguf` (exactly 2,019,377,696 bytes — copy it from never touch it. To build it, put the model at `vendor/llama/model.gguf` (exactly 2,019,377,696 bytes —
the stage PC: `C:\Users\media\Downloads\bible-app\vendor\llama\model.gguf` or copy it from the stage PC: `C:\ProgramData\Bible App\llama-cache\model.gguf`; it is gitignored, so it
`C:\ProgramData\Bible App\llama-cache\model.gguf`; it is gitignored, so it never enters the repo), build the never enters the repo), build the regular installer, then:
regular installer, then:
```bat ```bash
node tools/build-full-installer.cjs node tools/build-full-installer.cjs
``` ```
Machines that already have the model keep using the regular installer. Machines that already have the model keep using the regular installer.
## 6. Put it in git ## 6. Git
```bat The tree lives on the church Gitea server:
cd bible-app-source
git init ```
git add -A http://chronicles-ab.waterschurch.org:3000/christian/bible-app
git commit -m "Bible App <version> — full source"
git remote add origin <your local git server URL>
git push -u origin main
``` ```
`.gitignore` keeps the build outputs, `node_modules`, models and logs out; `.gitattributes` stores every `git push` and `git pull` work as usual; the credential is in the macOS keychain. `.gitignore` keeps
file byte-for-byte (the sources are CRLF, and several maintenance scripts match CRLF exactly). 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 Tag what you ship, so a rollback is a checkout rather than an archaeology exercise:
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). ```bash
git tag v<version> && 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 ## 7. Dev-run without building
```bat ```bash
npm ci 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 then open `http://localhost:8080/`. This runs the server on macOS and Windows alike. To run a test copy
`BIBLE_APP_DATA_DIR=<some scratch folder>` so it never touches the church's real content in beside a live app, set `BIBLE_APP_PORT=8095` and `BIBLE_APP_DATA_DIR=<some scratch folder>` so it never
`%APPDATA%\Bible App`. touches the church's real content.
+5 -2
View File
@@ -8,8 +8,8 @@
"start": "node server.js", "start": "node server.js",
"wordnet:fetch": "node tools/fetch-greek-wordnet.js", "wordnet:fetch": "node tools/fetch-greek-wordnet.js",
"build": "electron-builder", "build": "electron-builder",
"build:win": "electron-builder --win", "build:win": "node tools/build-win.cjs",
"release": "electron-builder --win && node tools/publish-update.cjs", "release": "node tools/release.cjs",
"publish-update": "node tools/publish-update.cjs", "publish-update": "node tools/publish-update.cjs",
"build:win:dir": "electron-builder --win --dir", "build:win:dir": "electron-builder --win --dir",
"build:installer": "build-installer.bat", "build:installer": "build-installer.bat",
@@ -27,6 +27,9 @@
], ],
"author": "Bible App Team", "author": "Bible App Team",
"license": "MIT", "license": "MIT",
"bibleApp": {
"updateTarget": "http://chronicles-ab.waterschurch.org:3000/api/packages/christian/generic/bible-app/latest"
},
"build": { "build": {
"appId": "com.bibleapp.desktop", "appId": "com.bibleapp.desktop",
"productName": "Bible App", "productName": "Bible App",
+28
View File
@@ -7823,6 +7823,34 @@ server.listen(PORT, () => {
console.error('[OSC] ❌ Failed to start OSC listener:', e && e.message || e); console.error('[OSC] ❌ Failed to start OSC listener:', e && e.message || e);
console.error('[OSC] Error stack:', e.stack); 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) => { server.on('error', (error) => {
+35
View File
@@ -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);
+184 -31
View File
@@ -1,19 +1,36 @@
/* /*
* publish-update.cjs — copy the freshly built installer to the church's update folder and write * publish-update.cjs — put the freshly built installer where the app's updater can find it, and
* the latest.json manifest the app reads. Run after a build: * write the latest.json manifest it reads. Run after a build:
* *
* npm run release (build + publish) * npm run release (build + publish)
* npm run publish-update (publish the newest existing build) * npm run publish-update (publish the newest existing build)
* npm run publish-update -- "D:\Builds" (publish to a specific folder) * npm run publish-update -- "<dest>" (publish to a specific destination)
* npm run publish-update -- "D:\Builds" "Notes about this version" * npm run publish-update -- "<dest>" "Notes about this version"
* *
* Destination order: CLI arg -> BIBLEAPP_UPDATE_DIR env -> the folder configured in the app * A destination is either:
* (Notes/update-config.json) -> dist-installer/publish *
* 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 fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const crypto = require('crypto'); 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. // Fingerprint a file so a truncated or interrupted copy can never be mistaken for a good build.
function sha256(file) { function sha256(file) {
@@ -32,6 +49,9 @@ const version = pkg.version;
function appNotesDir() { function appNotesDir() {
// Mirrors the app's user-content location so we can reuse the configured update folder. // 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'); const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return path.join(base, 'Bible App', 'user-content', 'Notes'); return path.join(base, 'Bible App', 'user-content', 'Notes');
} }
@@ -42,7 +62,7 @@ function configuredDest() {
if (fs.existsSync(p)) { if (fs.existsSync(p)) {
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8')); const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
const s = String(cfg.source || '').trim(); 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 (_) {} } catch (_) {}
return ''; return '';
@@ -50,7 +70,10 @@ function configuredDest() {
const argDest = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : ''; const argDest = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : '';
const notes = process.argv[3] || ''; 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'); const distDir = path.join(ROOT, 'dist-installer');
if (!fs.existsSync(distDir)) { if (!fs.existsSync(distDir)) {
@@ -71,20 +94,162 @@ if (!installer) {
console.warn(`! No installer named for v${version}; publishing newest: ${installer}`); console.warn(`! No installer named for v${version}; publishing newest: ${installer}`);
} }
fs.mkdirSync(dest, { recursive: true });
const srcPath = path.join(distDir, installer); const srcPath = path.join(distDir, installer);
const destPath = path.join(dest, installer);
const sizeMB = (fs.statSync(srcPath).size / 1048576).toFixed(0); 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(`Publishing v${version}`);
console.log(` installer : ${installer} (${sizeMB} MB)`); console.log(` installer : ${installer} (${sizeMB} MB)`);
console.log(` to : ${dest}`); 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); 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 * 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 * 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 * 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. * 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); console.warn('! Could not publish the AI model:', e.message);
} }
(async () => { if (modelPublished) {
const installerSize = fs.statSync(destPath).size; try {
const installerHash = await sha256(destPath);
let modelSize, modelHash;
try {
const md = path.join(dest, 'model.gguf'); const md = path.join(dest, 'model.gguf');
if (modelPublished && fs.existsSync(md)) { if (fs.existsSync(md)) {
modelSize = fs.statSync(md).size; manifest.model = 'model.gguf';
manifest.modelSize = fs.statSync(md).size;
console.log(' hashing model (integrity check)…'); console.log(' hashing model (integrity check)…');
modelHash = await sha256(md); manifest.modelSha256 = await sha256(md);
} }
} catch (_) {} } 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'); fs.writeFileSync(path.join(dest, 'latest.json'), JSON.stringify(manifest, null, 2) + '\n');
// Companion file so a person can verify a hand-copied installer: // Companion file so a person can verify a hand-copied installer:
// Get-FileHash "Bible App Setup x.y.z.exe" -Algorithm SHA256 // Get-FileHash "Bible App Setup x.y.z.exe" -Algorithm SHA256
+68
View File
@@ -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`);