diff --git a/app/engine.js b/app/engine.js
index 0e2f2cf..b23fa2b 100644
--- a/app/engine.js
+++ b/app/engine.js
@@ -2983,14 +2983,26 @@ async function downloadUpdate() {
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 (_) {}
+ $('updProgress').textContent = 'Downloaded. Installing\u2026';
+ log('update ' + updFound.tag + ' downloaded to ' + r.path, 'ok');
+
+ /*
+ * Straight into the install. The app quits, a helper swaps the bundle and
+ * reopens it — so from here the window closing IS the update working, and
+ * saying so before it happens stops that looking like a crash.
+ */
+ $('updWhat').textContent = 'Installing ' + updFound.tag + '. Church Translate will close and ' +
+ 'reopen by itself in a few seconds. Your settings, models and voices are untouched.';
+ const ins = await window.engineHost.updateInstall(r.path);
+ if (!ins || !ins.ok) {
+ /* Still useful: the file is downloaded, so fall back to the manual route
+ rather than leaving them with nothing. */
+ $('updProgress').textContent = (ins && ins.error) || 'Could not install.';
+ $('updWhat').textContent = 'Downloaded, but it could not install itself. Quit Church ' +
+ 'Translate, open the disk image in Downloads, and drag the app over the one in Applications.';
+ try { await window.engineHost.updateReveal(r.path); } catch (_) {}
+ btn.disabled = false;
+ }
} catch (e) {
$('updProgress').textContent = 'Download failed: ' + e.message;
btn.disabled = false;
diff --git a/app/index.html b/app/index.html
index 8266e8a..50625c0 100644
--- a/app/index.html
+++ b/app/index.html
@@ -391,7 +391,7 @@
-
+
What changed
diff --git a/app/main.js b/app/main.js
index e9dc8da..701849b 100644
--- a/app/main.js
+++ b/app/main.js
@@ -2259,6 +2259,97 @@ ipcMain.handle('update:download', async (_e, url, name) => {
});
});
+/*
+ * ─────────────────────────── installing it, in place ──────────────────────
+ *
+ * An app cannot replace itself while it is running, so the swap is done by a
+ * small script that outlives us: it waits for this process to exit, moves the
+ * old bundle aside, copies the new one in, and relaunches.
+ *
+ * MOVED ASIDE, NOT DELETED. If the copy fails half way — a full disk, a bad
+ * download, the volume going away — the old app is put straight back and
+ * relaunched. The failure mode this is written against is a campus Mac with no
+ * working app and a service in an hour, which is far worse than not updating.
+ *
+ * No sudo: /Applications is admin-writable on macOS and the bundle belongs to
+ * the operator, so nothing here needs a password. If that is ever not true the
+ * copy fails, the old app comes back, and the log says so.
+ */
+ipcMain.handle('update:install', async (_e, dmgPath) => {
+ const dmg = String(dmgPath || '');
+ if (!dmg || !fs.existsSync(dmg)) return { ok: false, error: 'That installer is no longer on disk.' };
+
+ /* Never mid-service. Quitting the app stops every listener's captions, and an
+ update is never worth that — the operator stops translation first. */
+ if (engineIsLive) {
+ return { ok: false, error: 'Translation is live. Press Stop translating first, then install.' };
+ }
+
+ const appPath = app.getPath('exe').replace(/\/Contents\/MacOS\/[^/]+$/, '');
+ if (!/\.app$/.test(appPath)) {
+ return { ok: false, error: 'This copy is not running from an .app bundle, so it cannot replace itself.' };
+ }
+
+ const tmp = path.join(require('os').tmpdir(), 'ct-install-' + process.pid + '.sh');
+ const mount = '/Volumes/ChurchTranslateUpdate';
+ const aside = appPath + '.replacing-' + process.pid;
+ const script = [
+ '#!/bin/bash',
+ '# Written by Church Translate to swap itself. Safe to delete.',
+ 'set -u',
+ 'DMG=' + JSON.stringify(dmg),
+ 'APP=' + JSON.stringify(appPath),
+ 'ASIDE=' + JSON.stringify(aside),
+ 'MNT=' + JSON.stringify(mount),
+ 'PID=' + process.pid,
+ 'LOG="$HOME/Library/Application Support/Church Translate/data/logs/install.log"',
+ 'mkdir -p "$(dirname "$LOG")"',
+ 'say(){ echo "$(date "+%Y-%m-%d %H:%M:%S") $*" >> "$LOG"; }',
+ 'say "installing $DMG into $APP"',
+ '# wait for the app to actually be gone (up to 30s)',
+ 'for i in $(seq 1 60); do kill -0 "$PID" 2>/dev/null || break; sleep 0.5; done',
+ 'if kill -0 "$PID" 2>/dev/null; then say "app did not exit; aborting"; exit 1; fi',
+ 'hdiutil detach "$MNT" -quiet 2>/dev/null',
+ 'if ! hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MNT"; then say "could not mount"; open -a "$APP"; exit 1; fi',
+ 'NEW="$MNT/Church Translate.app"',
+ '# refuse to touch the installed app unless the replacement is really there',
+ 'if [ ! -f "$NEW/Contents/Info.plist" ]; then say "disk image has no app"; hdiutil detach "$MNT" -quiet; open -a "$APP"; exit 1; fi',
+ 'rm -rf "$ASIDE"',
+ 'if ! mv "$APP" "$ASIDE"; then say "could not move the old app aside"; hdiutil detach "$MNT" -quiet; open -a "$APP"; exit 1; fi',
+ 'if cp -R "$NEW" "$APP"; then',
+ ' xattr -dr com.apple.quarantine "$APP" 2>/dev/null',
+ ' say "installed $( /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP/Contents/Info.plist" 2>/dev/null )"',
+ ' rm -rf "$ASIDE"',
+ 'else',
+ ' say "copy failed — putting the old app back"',
+ ' rm -rf "$APP"; mv "$ASIDE" "$APP"',
+ 'fi',
+ 'hdiutil detach "$MNT" -quiet 2>/dev/null',
+ 'open -a "$APP"',
+ 'say "relaunched"',
+ 'rm -f "$0"'
+ ].join('\n');
+
+ try {
+ fs.writeFileSync(tmp, script, { mode: 0o755 });
+ } catch (e) {
+ return { ok: false, error: 'Could not prepare the installer: ' + e.message };
+ }
+
+ /* Detached and unref'd, or it dies with us the moment we quit. */
+ try {
+ const child = spawn('/bin/bash', [tmp], { detached: true, stdio: 'ignore' });
+ child.unref();
+ } catch (e) {
+ return { ok: false, error: 'Could not start the installer: ' + e.message };
+ }
+
+ /* Give the renderer a moment to show the message before we go. */
+ forceQuit = true;
+ setTimeout(() => { try { app.quit(); } catch (_) {} }, 600);
+ return { ok: true };
+});
+
ipcMain.handle('update:reveal', (_e, p2) => { try { shell.showItemInFolder(String(p2)); return true; } catch (_) { return false; } });
ipcMain.handle('config:get', () => loadConfig());
diff --git a/app/package.json b/app/package.json
index 762aca5..7e58b43 100644
--- a/app/package.json
+++ b/app/package.json
@@ -2,7 +2,7 @@
"name": "church-translate-engine",
"productName": "Church Translate",
"version": "3.23.2",
- "buildNumber": 114,
+ "buildNumber": 115,
"private": true,
"description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself",
"main": "main.js",
@@ -106,7 +106,7 @@
]
},
"asar": false,
- "buildVersion": "114",
+ "buildVersion": "115",
"win": {
"target": [
{
diff --git a/app/preload.js b/app/preload.js
index e22dc06..b5a0314 100644
--- a/app/preload.js
+++ b/app/preload.js
@@ -24,6 +24,7 @@ contextBridge.exposeInMainWorld('engineHost', {
updateCheck: () => ipcRenderer.invoke('update:check'),
updateDownload: (u, n) => ipcRenderer.invoke('update:download', u, n),
updateReveal: (p) => ipcRenderer.invoke('update:reveal', p),
+ updateInstall: (p) => ipcRenderer.invoke('update:install', p),
onUpdateProgress: (fn) => ipcRenderer.on('update:progress', (_e, p) => fn(p)),
settingsExport: () => ipcRenderer.invoke('settings:export'),
settingsImport: () => ipcRenderer.invoke('settings:import'),