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.
+
+
+ —
+
+
+
+
+
+
+
+ What changed
+
+
+
+
+
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'),