Install the update itself, instead of asking for a drag
Build 115. Downloading and then telling the operator to quit, mount and drag was the friction the updater was supposed to remove. An app cannot replace itself while running, so the swap is done by a small script that outlives the process: it waits for us to exit, moves the old bundle aside, copies the new one in, clears the quarantine flag and relaunches. Moved aside, not deleted. If the copy fails half way — a full disk, a truncated download, the volume going away — the old app goes straight back and is reopened. The failure 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. Tested in a sandbox across all three paths: a clean swap, a copy that fails mid-way (old app restored, no leftovers), and a disk image with no app in it (aborts before touching the installed one). It refuses while translation is live: quitting stops every listener's captions and no update is worth that. Stop translating first. No sudo — /Applications is admin-writable and the bundle belongs to the operator. If that is ever untrue the copy fails and the rollback runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6753a94e1c
commit
4aa30699c9
+20
-8
@@ -2983,14 +2983,26 @@ async function downloadUpdate() {
|
|||||||
try {
|
try {
|
||||||
const r = await window.engineHost.updateDownload(updFound.asset.url, updFound.asset.name);
|
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; }
|
if (!r.ok) { $('updProgress').textContent = r.error || 'Download failed.'; btn.disabled = false; return; }
|
||||||
$('updProgress').textContent = 'Saved to Downloads.';
|
$('updProgress').textContent = 'Downloaded. Installing\u2026';
|
||||||
/* Say what is left to do. Nothing installs itself here on purpose — see
|
log('update ' + updFound.tag + ' downloaded to ' + r.path, 'ok');
|
||||||
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 ' +
|
* Straight into the install. The app quits, a helper swaps the bundle and
|
||||||
'Applications. Your settings, models and voices are untouched.';
|
* reopens it — so from here the window closing IS the update working, and
|
||||||
log('update ' + updFound.tag + ' downloaded to ' + r.path, 'ok');
|
* saying so before it happens stops that looking like a crash.
|
||||||
try { await window.engineHost.updateReveal(r.path); } catch (_) {}
|
*/
|
||||||
|
$('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) {
|
} catch (e) {
|
||||||
$('updProgress').textContent = 'Download failed: ' + e.message;
|
$('updProgress').textContent = 'Download failed: ' + e.message;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
+1
-1
@@ -391,7 +391,7 @@
|
|||||||
<div id="updFound" hidden>
|
<div id="updFound" hidden>
|
||||||
<p class="hint" id="updWhat"></p>
|
<p class="hint" id="updWhat"></p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="btn" id="updGet">Download it</button>
|
<button class="btn" id="updGet">Download and install</button>
|
||||||
<span class="hint" id="updProgress"></span>
|
<span class="hint" id="updProgress"></span>
|
||||||
</div>
|
</div>
|
||||||
<details id="updNotesBox"><summary class="hint">What changed</summary>
|
<details id="updNotesBox"><summary class="hint">What changed</summary>
|
||||||
|
|||||||
+91
@@ -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('update:reveal', (_e, p2) => { try { shell.showItemInFolder(String(p2)); return true; } catch (_) { return false; } });
|
||||||
|
|
||||||
ipcMain.handle('config:get', () => loadConfig());
|
ipcMain.handle('config:get', () => loadConfig());
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "church-translate-engine",
|
"name": "church-translate-engine",
|
||||||
"productName": "Church Translate",
|
"productName": "Church Translate",
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"buildNumber": 114,
|
"buildNumber": 115,
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself",
|
"description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"asar": false,
|
"asar": false,
|
||||||
"buildVersion": "114",
|
"buildVersion": "115",
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ contextBridge.exposeInMainWorld('engineHost', {
|
|||||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||||
updateDownload: (u, n) => ipcRenderer.invoke('update:download', u, n),
|
updateDownload: (u, n) => ipcRenderer.invoke('update:download', u, n),
|
||||||
updateReveal: (p) => ipcRenderer.invoke('update:reveal', p),
|
updateReveal: (p) => ipcRenderer.invoke('update:reveal', p),
|
||||||
|
updateInstall: (p) => ipcRenderer.invoke('update:install', p),
|
||||||
onUpdateProgress: (fn) => ipcRenderer.on('update:progress', (_e, p) => fn(p)),
|
onUpdateProgress: (fn) => ipcRenderer.on('update:progress', (_e, p) => fn(p)),
|
||||||
settingsExport: () => ipcRenderer.invoke('settings:export'),
|
settingsExport: () => ipcRenderer.invoke('settings:export'),
|
||||||
settingsImport: () => ipcRenderer.invoke('settings:import'),
|
settingsImport: () => ipcRenderer.invoke('settings:import'),
|
||||||
|
|||||||
Reference in New Issue
Block a user