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'),