Pull updates from the church's own forge, from inside the app

Settings gains an Updates block: it asks the church Gitea for the newest
release, compares the tag against the running build, and if there is a newer
one shows what changed and offers to download the installer.

Deliberately not electron-updater. That wants a static feed published beside
the artifacts, and this build is ad-hoc signed with no Team ID, so a silent
in-place swap would meet Gatekeeper on a machine nobody is sitting at. And
nothing here happens on its own: a background check that announces itself three
minutes into a sermon is a hazard, so the operator presses the button, and
presses a second one to download. The file lands in Downloads and is revealed;
installing is a drag across when the room is empty.

Tags are v<version>-<build>, compared numerically field by field so 3.9.0 does
not read as newer than 3.23.2 and build 113 beats build 112.

Two things only real testing found. Gitea answers with whatever address it was
reached on, so the release API returned asset URLs on 10.3.8.90 while the forge
is configured by hostname — the same-host guard refused a perfectly good
installer. The URL is now rebuilt on the configured origin, which keeps the
property that matters (this only ever fetches from the server the operator
configured) and survives a forge that knows itself by more than one name. And a
private repository answers 404 rather than 401, so 'no releases published yet'
told the operator the opposite of the truth; without a token that case now names
the remedy instead.

The token is this machine's credential: it stays out of PORTABLE_APP, so it does
not ride along in an exported settings file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 12:09:08 -04:00
co-authored by Claude Opus 5
parent 5d8d3e3527
commit 070c0dfb82
5 changed files with 308 additions and 0 deletions
+85
View File
@@ -2931,6 +2931,90 @@ $('optDynamics').addEventListener('change', async (e) => {
: 'voice dynamics off — every sentence at the voice\u2019s own level', 'ok');
});
/*
* ─────────────────────────── updates from our own forge ───────────────────
*
* Asked for, never volunteered. A background check that pops "an update is
* available" three minutes into a sermon is a hazard, not a feature, so this
* runs only when the operator presses the button and downloads only when they
* press the second one.
*/
function fmtMB(n) { return (Number(n || 0) / 1048576).toFixed(0) + ' MB'; }
let updFound = null;
async function checkForUpdate() {
const btn = $('updCheck');
btn.disabled = true;
$('updState').textContent = 'Checking…';
$('updFound').hidden = true;
try {
const r = await window.engineHost.updateCheck();
const here = r.current ? (r.current.version + ' \u00b7 ' + r.current.build) : 'this build';
if (!r.ok) { $('updState').textContent = r.error || 'Could not reach the update server.'; return; }
if (!r.latest) { $('updState').textContent = r.note || 'Nothing published yet. Running ' + here + '.'; return; }
const there = r.latest.tag;
if (!r.newer) { $('updState').textContent = 'Up to date \u2014 running ' + here + ', newest is ' + there + '.'; return; }
if (!r.latest.asset) {
$('updState').textContent = there + ' is newer, but it has no installer attached.';
return;
}
updFound = r.latest;
$('updState').textContent = 'Update available.';
$('updWhat').textContent = r.latest.title + ' \u2014 ' + fmtMB(r.latest.asset.size) +
'. You are on ' + here + '.';
$('updNotes').textContent = (r.latest.notes || '').trim() || 'No notes.';
$('updNotesBox').hidden = !r.latest.notes;
$('updFound').hidden = false;
$('updProgress').textContent = '';
$('updGet').disabled = false;
} catch (e) {
$('updState').textContent = 'Could not check: ' + e.message;
} finally {
btn.disabled = false;
}
}
async function downloadUpdate() {
if (!updFound || !updFound.asset) return;
const btn = $('updGet');
btn.disabled = true;
$('updProgress').textContent = 'Starting…';
try {
const r = await window.engineHost.updateDownload(updFound.asset.url, updFound.asset.name);
if (!r.ok) { $('updProgress').textContent = r.error || 'Download failed.'; btn.disabled = false; return; }
$('updProgress').textContent = 'Saved to Downloads.';
/* Say what is left to do. Nothing installs itself here on purpose — see
the note on the main-process side. */
$('updWhat').textContent = 'Downloaded ' + updFound.tag + ' (' + fmtMB(r.bytes) + '). ' +
'Quit Church Translate, open the disk image, and drag the app over the one in ' +
'Applications. Your settings, models and voices are untouched.';
log('update ' + updFound.tag + ' downloaded to ' + r.path, 'ok');
try { await window.engineHost.updateReveal(r.path); } catch (_) {}
} catch (e) {
$('updProgress').textContent = 'Download failed: ' + e.message;
btn.disabled = false;
}
}
$('updCheck').addEventListener('click', checkForUpdate);
$('updGet').addEventListener('click', downloadUpdate);
if (window.engineHost.onUpdateProgress) {
window.engineHost.onUpdateProgress((p) => {
if (!p || !p.total) { $('updProgress').textContent = fmtMB(p && p.got); return; }
$('updProgress').textContent = Math.round((p.got / p.total) * 100) + '% (' +
fmtMB(p.got) + ' of ' + fmtMB(p.total) + ')';
});
}
/* The token is a credential, so it is saved on blur rather than per keystroke
and never logged. */
$('updToken').addEventListener('change', async () => {
const v = $('updToken').value.trim();
state.cfg = await window.engineHost.setConfig({ updateToken: v });
$('updState').textContent = v ? 'Token saved.' : 'Token cleared.';
});
$('revealCache').addEventListener('click', () => window.engineHost.revealCache());
/* ---- carrying a setup to another campus ------------------------------- */
@@ -3583,6 +3667,7 @@ async function boot() {
const savedSens = Number(state.cfg && state.cfg.sensitivity);
state.sensitivity = Number.isFinite(savedSens) && savedSens > 0 ? savedSens : 3.5;
applyThreshFromConfig();
try { $('updToken').value = (state.cfg && state.cfg.updateToken) || ''; } catch (_) {}
refreshCacheInfo();
/* Start the download first and do not wait for it. It is the slow part, it