From 070c0dfb824cfa06d639fad7578b9848a9764ecf Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 24 Sep 2026 12:09:08 -0400 Subject: [PATCH] Pull updates from the church's own forge, from inside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-, 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 --- app/engine.css | 10 +++ app/engine.js | 85 +++++++++++++++++++++++ app/index.html | 24 +++++++ app/main.js | 185 +++++++++++++++++++++++++++++++++++++++++++++++++ app/preload.js | 4 ++ 5 files changed, 308 insertions(+) diff --git a/app/engine.css b/app/engine.css index 2000af6..ada5cd2 100644 --- a/app/engine.css +++ b/app/engine.css @@ -700,3 +700,13 @@ textarea:focus { border-color: var(--accent); } .langfly { display: none; } .langrow.touched { animation: none; } } + +/* Release notes are prose from the forge — wrap them, keep them short, and let + them scroll rather than push the rest of Settings off the pane. */ +.updnotes { + max-height: 16rem; overflow: auto; white-space: pre-wrap; word-break: break-word; + font-size: .75rem; line-height: 1.45; color: var(--muted); + background: var(--panel, rgba(255,255,255,.03)); + border-radius: 6px; padding: .625rem .75rem; margin: .375rem 0 0; +} +#updFound { margin-top: .5rem; } diff --git a/app/engine.js b/app/engine.js index 6423928..0e2f2cf 100644 --- a/app/engine.js +++ b/app/engine.js @@ -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 diff --git a/app/index.html b/app/index.html index 473d4ff..8266e8a 100644 --- a/app/index.html +++ b/app/index.html @@ -380,6 +380,30 @@
+

Updates

+

Checks this church’s own server, not the internet — so it works at a venue + with no route out. Nothing downloads on its own and nothing installs itself: this app is + often live in front of a congregation.

+
+ + — +
+ + + +

Stays on this Mac. It is not carried by Save settings to a file.

+ +
+

—

diff --git a/app/main.js b/app/main.js index 543aa85..e9dc8da 100644 --- a/app/main.js +++ b/app/main.js @@ -232,6 +232,22 @@ const DEFAULT_CONFIG = { browser sandbox — see ensureTranslator() below for why. */ mtDtype: 'fp32', autoConnect: true, + /* + * WHERE UPDATES COME FROM. + * + * The church's own Gitea, not the internet: a campus Mac may have no route + * out, and an installer that cannot be fetched at the venue is worse than no + * updater at all. Nothing here is checked automatically — this app is often + * mid-service, and a download starting on its own is the last thing anyone + * wants. The operator presses the button. + * + * `token` is only needed while the repository is private, and is deliberately + * NOT in PORTABLE_APP: it is this machine's credential and must not ride along + * in an exported settings file. + */ + updateForge: 'http://chronicles-ab.waterschurch.org:3000', + updateRepo: 'christian/church-translate', + updateToken: '', /* Setup-pane tuning that used to live only in the DOM. */ sensitivity: 3.5, /* @@ -2076,6 +2092,175 @@ app.on('before-quit', (e) => { if (ttsChild) { try { ttsChild.kill(); } catch (_) {} } }); +/* + * ─────────────────────────── updates from our own forge ─────────────────── + * + * Gitea's release API, read directly. Deliberately NOT electron-updater: that + * wants a static feed (latest-mac.yml) published beside the artifacts, and the + * build here is ad-hoc signed with no Team ID, so a silent in-place swap would + * trip Gatekeeper on a machine nobody is sitting at. Checking, downloading and + * telling the operator where the file is does the useful 90% and cannot leave + * a campus with a half-replaced app. + * + * Tags are `v-` — v3.23.2-112. The build number is what really + * orders two releases of the same version, which is why it is compared second + * and numerically. + */ +function parseTag(tag) { + const m = String(tag || '').match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-.](\d+))?/); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3], build: m[4] === undefined ? 0 : +m[4] }; +} + +/* Positive when a is newer than b. */ +function compareBuilds(a, b) { + if (!a) return -1; + if (!b) return 1; + for (const k of ['major', 'minor', 'patch', 'build']) { + if (a[k] !== b[k]) return a[k] > b[k] ? 1 : -1; + } + return 0; +} + +function forgeRequest(urlStr, token, cb) { + let u; + try { u = new URL(urlStr); } catch (e) { return cb(e); } + const mod = u.protocol === 'https:' ? require('https') : http; + const headers = { 'Accept': 'application/json', 'User-Agent': 'church-translate' }; + if (token) headers.Authorization = 'token ' + token; + const req = mod.get(urlStr, { headers, timeout: 15000 }, (res) => { + /* Gitea hands out asset downloads as a redirect to /attachments/. */ + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + return forgeRequest(new URL(res.headers.location, urlStr).toString(), token, cb); + } + let body = ''; + res.setEncoding('utf8'); + res.on('data', (d) => { body += d; }); + res.on('end', () => cb(null, res.statusCode, body)); + }); + req.on('timeout', () => { req.destroy(new Error('the forge did not answer within 15s')); }); + req.on('error', (e) => cb(e)); +} + +ipcMain.handle('update:check', async () => { + const cfg = loadConfig(); + const forge = String(cfg.updateForge || '').replace(/\/+$/, ''); + const repo = String(cfg.updateRepo || ''); + const here = appBuild(); + const current = parseTag('v' + here.version + '-' + here.build); + if (!forge || !repo) return { ok: false, error: 'No update server is configured.', current: here }; + + return await new Promise((resolve) => { + forgeRequest(forge + '/api/v1/repos/' + repo + '/releases/latest', cfg.updateToken, (err, status, body) => { + if (err) return resolve({ ok: false, error: err.message, current: here }); + /* + * A PRIVATE REPOSITORY ANSWERS 404, NOT 401 — Gitea hides its existence + * from a caller who cannot see it. Measured against this church's own + * forge: with no token the releases endpoint returns 404, so reporting + * "no releases published yet" told the operator the opposite of the + * truth and pointed them away from the one thing that would fix it. + * Without a token, 404 is ambiguous; say so and name the remedy. + */ + if (status === 404) { + return resolve(cfg.updateToken + ? { ok: true, current: here, latest: null, newer: false, note: 'No releases published yet.' } + : { ok: false, current: here, + error: 'Nothing found there. If the repository is private, set an access token below.' }); + } + if (status === 401 || status === 403) { + return resolve({ ok: false, current: here, + error: 'The update server refused the request. If the repository is private, set an access token below.' }); + } + if (status !== 200) return resolve({ ok: false, error: 'Update server returned ' + status, current: here }); + let rel; + try { rel = JSON.parse(body); } catch (_) { return resolve({ ok: false, error: 'Unreadable answer from the update server.', current: here }); } + const there = parseTag(rel.tag_name); + const asset = (rel.assets || []).find((a) => /\.dmg$/i.test(a.name || '')); + resolve({ + ok: true, + current: here, + latest: { tag: rel.tag_name, title: rel.title || rel.name || rel.tag_name, + notes: rel.body || '', published: rel.published_at || '', + asset: asset ? { name: asset.name, size: asset.size, url: asset.browser_download_url } : null }, + newer: compareBuilds(there, current) > 0 + }); + }); + }); +}); + +/* + * Download to the Downloads folder and reveal it. Not installed automatically: + * replacing a running app from inside itself needs a helper process, and doing + * that on a machine that might be live in front of a congregation is not a + * trade worth making. The operator drags it across when the room is empty. + */ +ipcMain.handle('update:download', async (_e, url, name) => { + const cfg = loadConfig(); + const dest = path.join(app.getPath('downloads'), String(name || 'church-translate.dmg').replace(/[^\w.\-]/g, '_')); + return await new Promise((resolve) => { + let u; + try { u = new URL(String(url)); } catch (e) { return resolve({ ok: false, error: 'Bad download address.' }); } + /* + * PIN THE DOWNLOAD TO THE CONFIGURED FORGE, don't merely check it. + * + * Gitea answers with whatever address IT was reached on, which is not + * necessarily the one configured here: this church's forge is reachable as + * chronicles-ab.waterschurch.org and as 10.3.8.90, and the release API + * hands back the IP form. A plain same-host check therefore REFUSED a + * perfectly good installer — measured, on the very first real release. + * + * Rebuilding the URL on the configured origin keeps the property that + * matters (this only ever fetches from the server the operator configured, + * so a tampered API answer cannot redirect it elsewhere) while surviving a + * forge that knows itself by more than one name. + */ + let base; + try { base = new URL(cfg.updateForge); } catch (_) { return resolve({ ok: false, error: 'The update server address is not valid.' }); } + u = new URL(u.pathname + u.search, base.origin); + + const go = (target, hops) => { + if (hops > 5) return resolve({ ok: false, error: 'Too many redirects.' }); + const mod = target.startsWith('https:') ? require('https') : http; + const headers = { 'User-Agent': 'church-translate' }; + if (cfg.updateToken) headers.Authorization = 'token ' + cfg.updateToken; + mod.get(target, { headers, timeout: 60000 }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + return go(new URL(res.headers.location, target).toString(), hops + 1); + } + if (res.statusCode !== 200) { res.resume(); return resolve({ ok: false, error: 'Download failed (' + res.statusCode + ').' }); } + const total = Number(res.headers['content-length'] || 0); + let got = 0, lastPush = 0; + const out = fs.createWriteStream(dest); + res.on('data', (d) => { + got += d.length; + const now = Date.now(); + if (now - lastPush > 400 && win && !win.isDestroyed()) { + lastPush = now; + try { win.webContents.send('update:progress', { got, total }); } catch (_) {} + } + }); + res.pipe(out); + out.on('finish', () => out.close(() => { + /* A truncated installer is worse than none: it looks present and + fails at the venue. Refuse it rather than leave it lying there. */ + if (total && got !== total) { + try { fs.unlinkSync(dest); } catch (_) {} + return resolve({ ok: false, error: 'The download ended early and was discarded.' }); + } + resolve({ ok: true, path: dest, bytes: got }); + })); + out.on('error', (e) => resolve({ ok: false, error: e.message })); + }).on('error', (e) => resolve({ ok: false, error: e.message })) + .on('timeout', function () { this.destroy(new Error('the download stalled')); }); + }; + go(u.toString(), 0); + }); +}); + +ipcMain.handle('update:reveal', (_e, p2) => { try { shell.showItemInFolder(String(p2)); return true; } catch (_) { return false; } }); + ipcMain.handle('config:get', () => loadConfig()); ipcMain.handle('config:set', (_e, patch) => { const merged = saveConfig(patch); diff --git a/app/preload.js b/app/preload.js index 371e6e6..e22dc06 100644 --- a/app/preload.js +++ b/app/preload.js @@ -21,6 +21,10 @@ contextBridge.exposeInMainWorld('engineHost', { /* Translation lives in the main process; these are its only two verbs. */ mtEnsure: () => ipcRenderer.invoke('mt:ensure'), + updateCheck: () => ipcRenderer.invoke('update:check'), + updateDownload: (u, n) => ipcRenderer.invoke('update:download', u, n), + updateReveal: (p) => ipcRenderer.invoke('update:reveal', p), + onUpdateProgress: (fn) => ipcRenderer.on('update:progress', (_e, p) => fn(p)), settingsExport: () => ipcRenderer.invoke('settings:export'), settingsImport: () => ipcRenderer.invoke('settings:import'), audioDevices: () => ipcRenderer.invoke('audio:devices'),