feat: implement auto-update functionality with update artifacts generation and launcher scripts

This commit is contained in:
2026-08-05 00:08:57 -05:00
parent c9c164277f
commit a5534af5b8
4 changed files with 533 additions and 46 deletions
+134 -22
View File
@@ -3,6 +3,7 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { execSync } = require('child_process');
const https = require('https');
const http = require('http');
@@ -266,6 +267,93 @@ exec "$DIR/${exeName}" "$@"
`;
}
function sha512File(file) {
return crypto.createHash('sha512').update(fs.readFileSync(file)).digest('hex');
}
// Launcher preamble shared by the macOS and Linux bash launchers. It exports
// the update metadata the autoUpdater reads, and applies any staged update
// (atomic rename of each payload item) before starting the engine.
function bashLauncher(exeName, engineName) {
return `#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"
export PATH="$DIR:$PATH"
export GELECTRON_NATIVE=1
export GELECTRON_PACKAGED=1
export GELECTRON_ENGINE=${engineName}
export GELECTRON_LAUNCHER="$DIR/${exeName}"
if [ -f "$DIR/.update/apply.sh" ]; then
if sh "$DIR/.update/apply.sh"; then
rm -f "$DIR/.update/apply.sh" "$DIR/.update/pending.json"
fi
fi
exec "$DIR/${engineName}" "$@"
`;
}
// Build the auto-update artifacts: a `payload/` staging dir (full bundle) tarballed
// next to a `latest.yml` manifest with version, path and sha512. Upload both to a
// GitHub release and point autoUpdater.setFeedURL at latest.yml.
function makeUpdateArtifacts(outDir, name, version, platform, arch) {
const exeName = name.replace(/[^a-zA-Z0-9]/g, '');
let appDir, compatDir, nodeFile, engineFile, libDir;
if (platform === 'darwin') {
const macos = path.join(outDir, `${name}.app`, 'Contents', 'MacOS');
appDir = path.join(outDir, `${name}.app`, 'Contents', 'Resources', 'app');
compatDir = path.join(macos, 'compat');
nodeFile = path.join(macos, 'node');
engineFile = path.join(macos, 'gelectron-bin');
libDir = path.join(macos, 'lib');
} else if (platform === 'win32') {
appDir = path.join(outDir, 'app');
compatDir = path.join(outDir, 'compat');
nodeFile = path.join(outDir, 'node.exe');
engineFile = path.join(outDir, `${exeName}.exe`);
libDir = null;
} else {
appDir = path.join(outDir, 'app');
compatDir = path.join(outDir, 'compat');
nodeFile = path.join(outDir, 'node');
engineFile = path.join(outDir, 'gelectron-bin');
libDir = null;
}
if (!fs.existsSync(engineFile)) die(`engine binary not found: ${engineFile}`);
const staging = path.join(outDir, '.update-stage');
const payloadDir = path.join(staging, 'payload');
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(payloadDir, { recursive: true });
const copyFile = (src, dest) => {
fs.copyFileSync(src, dest);
fs.chmodSync(dest, 0o755);
};
copyFile(engineFile, path.join(payloadDir, 'engine'));
if (fs.existsSync(nodeFile)) copyFile(nodeFile, path.join(payloadDir, 'node'));
if (fs.existsSync(compatDir)) copyDirSync(compatDir, path.join(payloadDir, 'compat'), []);
if (fs.existsSync(appDir)) copyDirSync(appDir, path.join(payloadDir, 'app'), []);
if (libDir && fs.existsSync(libDir)) copyDirSync(libDir, path.join(payloadDir, 'lib'), []);
const updateDir = path.join(outDir, 'update');
fs.mkdirSync(updateDir, { recursive: true });
const archiveName = `${exeName}-${version}-${platform}-${arch}.tar.gz`;
const archivePath = path.join(updateDir, archiveName);
log(' Building update archive...');
execSync(`tar -czf "${archivePath}" payload`, { cwd: staging, stdio: 'pipe' });
fs.rmSync(staging, { recursive: true, force: true });
const sha512 = sha512File(archivePath);
fs.writeFileSync(
path.join(updateDir, 'latest.yml'),
`version: ${version}\npath: ${archiveName}\nsha512: ${sha512}\n`,
);
log(` Update artifacts: update/${archiveName} (sha512 ${sha512.slice(0, 12)}…)`);
}
function generateLinuxDesktop(name, exeName) {
return `[Desktop Entry]
Name=${name}
@@ -322,6 +410,8 @@ async function packageApp(opts) {
await packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir, opts);
}
makeUpdateArtifacts(outDir, name, version, platform, arch);
log(`\n ✓ Packaged to ${outDir}\n`);
}
@@ -372,14 +462,9 @@ async function packageMac(appDir, outDir, name, version, gelectronBin, nodeDir,
const excludeDirs = ['node_modules', '.gelectron-cache', '.git', 'target'];
copyDirSync(appDir, appResources, excludeDirs);
// Generate bash launcher (CFBundleExecutable) — sets PATH so gelectron-bin finds node
const wrapper = `#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"
export PATH="$DIR:$PATH"
export GELECTRON_NATIVE=1
exec "$DIR/gelectron-bin" "$@"
`;
fs.writeFileSync(path.join(macosDir, exeName), wrapper, { mode: 0o755 });
// Generate bash launcher (CFBundleExecutable) — sets PATH so gelectron-bin
// finds node, exports update metadata, and applies any pending update.
fs.writeFileSync(path.join(macosDir, exeName), bashLauncher(exeName, 'gelectron-bin'), { mode: 0o755 });
// Generate Info.plist
const iconSource = path.join(appResources, 'icon.png');
@@ -439,11 +524,28 @@ async function packageWindows(appDir, outDir, name, version, gelectronBin, nodeD
log(' Copying app source...');
copyDirSync(appDir, path.join(outDir, 'app'), ['node_modules', '.gelectron-cache', '.git', 'target']);
// Generate VBScript launcher (no terminal window)
// Generate VBScript launcher (no terminal window). Applies any staged update
// before starting the engine and exports the update metadata.
const vbs = `Set WshShell = CreateObject("WScript.Shell")
WshShell.CurrentDirectory = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
WshShell.Environment("Process")("PATH") = WshShell.CurrentDirectory & ";" & WshShell.Environment("Process")("PATH")
WshShell.Run """" & WshShell.CurrentDirectory & "\\${exeName}.exe""", 1, False
Set objFSO = CreateObject("Scripting.FileSystemObject")
dir = objFSO.GetParentFolderName(WScript.ScriptFullName)
WshShell.CurrentDirectory = dir
applyCmd = dir & "\\.update\\apply.cmd"
If objFSO.FileExists(applyCmd) Then
rc = WshShell.Run("cmd /c " & Chr(34) & applyCmd & Chr(34), 0, True)
If rc = 0 Then
On Error Resume Next
objFSO.DeleteFile applyCmd
objFSO.DeleteFile dir & "\\.update\\pending.json"
On Error GoTo 0
End If
End If
WshShell.Environment("Process")("PATH") = dir & ";" & WshShell.Environment("Process")("PATH")
WshShell.Environment("Process")("GELECTRON_NATIVE") = "1"
WshShell.Environment("Process")("GELECTRON_PACKAGED") = "1"
WshShell.Environment("Process")("GELECTRON_ENGINE") = "${exeName}.exe"
WshShell.Environment("Process")("GELECTRON_LAUNCHER") = dir & "\\${exeName}.vbs"
WshShell.Run """" & dir & "\\${exeName}.exe""", 1, False
`;
fs.writeFileSync(path.join(outDir, `${exeName}.vbs`), vbs);
@@ -451,6 +553,17 @@ WshShell.Run """" & WshShell.CurrentDirectory & "\\${exeName}.exe""", 1, False
const bat = `@echo off
set DIR=%~dp0
set PATH=%DIR%;%PATH%
set GELECTRON_NATIVE=1
set GELECTRON_PACKAGED=1
set GELECTRON_ENGINE=${exeName}.exe
set GELECTRON_LAUNCHER=%DIR%${exeName}.vbs
if exist "%DIR%.update\\apply.cmd" (
call "%DIR%.update\\apply.cmd"
if not errorlevel 1 (
del /q "%DIR%.update\\apply.cmd"
del /q "%DIR%.update\\pending.json"
)
)
"%DIR%${exeName}.exe" %*
`;
fs.writeFileSync(path.join(outDir, `${exeName}.bat`), bat);
@@ -461,9 +574,10 @@ set PATH=%DIR%;%PATH%
async function packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir, opts) {
const exeName = name.replace(/[^a-zA-Z0-9]/g, '');
// Copy gelectron binary
fs.copyFileSync(gelectronBin, path.join(outDir, exeName));
fs.chmodSync(path.join(outDir, exeName), 0o755);
// Copy gelectron binary under a fixed name so the autoUpdater can find it.
// (Previously this overwrote the binary with the launcher script below.)
fs.copyFileSync(gelectronBin, path.join(outDir, 'gelectron-bin'));
fs.chmodSync(path.join(outDir, 'gelectron-bin'), 0o755);
// Copy Node.js
const nodeBin = path.join(nodeDir, 'bin', 'node');
@@ -488,13 +602,7 @@ async function packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir
copyDirSync(appDir, path.join(outDir, 'app'), ['node_modules', '.gelectron-cache', '.git', 'target']);
// Generate launcher script
const launcher = `#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"
export PATH="$DIR:$PATH"
export GELECTRON_NATIVE=1
exec "$DIR/${exeName}" "$@"
`;
fs.writeFileSync(path.join(outDir, exeName), launcher, { mode: 0o755 });
fs.writeFileSync(path.join(outDir, exeName), bashLauncher(exeName, 'gelectron-bin'), { mode: 0o755 });
// Desktop file
fs.writeFileSync(path.join(outDir, `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.desktop`),
@@ -538,6 +646,10 @@ for (let i = 0; i < args.length; i++) {
--arch, -a Target arch: x64, arm64 (default: current)
--binary, -b Path to gelectron binary (for cross-compiled builds)
--help, -h Show this help
Update artifacts (update/latest.yml + a full-bundle .tar.gz) are generated
for every package. Upload them to a GitHub release and point the app's
autoUpdater.setFeedURL at latest.yml.
`);
process.exit(0);
}
+15 -2
View File
@@ -131,13 +131,26 @@ class App extends EventEmitter {
get argv() { return this._argv; }
get isPackaged() {
return !process.argv[0].includes('node') && !process.argv[0].includes('gelectron');
return process.env.GELECTRON_PACKAGED === '1';
}
get name() { return this._name; }
set name(val) { this._name = val || 'Gelectron App'; }
get version() { return process.env.GELECTRON_VERSION || '0.1.0'; }
// The app's own version (from its package.json), used by autoUpdater for
// update checks. Falls back to the engine version in development.
get version() {
const appPath = process.env.GELECTRON_APP_PATH;
if (appPath) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf8'));
if (pkg && typeof pkg.version === 'string' && pkg.version) return pkg.version;
} catch (e) {
// No readable package.json — fall through to the engine version
}
}
return process.env.GELECTRON_VERSION || '0.1.0';
}
get locale() { return this.getLocale(); }
get userAgent() { return this.getUserAgent(); }
+379 -21
View File
@@ -2,24 +2,176 @@
/**
* Gelectron - autoUpdater module (Electron compatible)
* Stub implementation for electron-updater compatibility.
*
* electron-updater's MacUpdater / NsisUpdater / AppImageUpdater all
* access `require("electron").autoUpdater` and expect it to be an
* EventEmitter with the native Electron autoUpdater API surface:
* .on("error", …), .on("update-downloaded", …)
* .setFeedURL(), .getFeedURL()
* .checkForUpdates()
* .quitAndInstall()
* .removeListener()
* Real implementation for packaged apps. Checks a feed (GitHub release or any
* static host) for a `latest.yml` manifest, downloads the update archive, and
* stages it for atomic application on next launch.
*
* The update archive produced by the packager is a full bundle (`payload/`
* containing the app source, compat layer, bundled Node, and the engine
* binary), so a single release ships both app and engine updates.
*
* How the swap works:
* 1. `checkForUpdates()` fetches the manifest and compares versions.
* 2. `downloadUpdate()` downloads the archive, verifies its sha512, extracts
* it next to the engine (`<engineDir>/.update/`), and writes a generated
* `apply.sh`/`apply.cmd` plus a `pending.json` marker.
* 3. `quitAndInstall()` relaunches the app's launcher. The launcher runs the
* apply script (which atomically renames each payload item into place) and
* only then execs the new engine. Nothing is ever overwritten while a
* process is using it.
*
* API surface (Electron autoUpdater):
* events: error, checking-for-update, update-available, update-not-available,
* download-progress, update-downloaded, before-quit-for-update
* methods: setFeedURL(), getFeedURL(), checkForUpdates(), downloadUpdate(),
* quitAndInstall(), checkForAndNotifyIfAvailable()
*/
const { EventEmitter } = require('events');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { bridge, isNative } = require('./native-bridge');
const IS_WINDOWS = process.platform === 'win32';
// ─── Helpers ────────────────────────────────────────────────────────────────
function sha512File(file) {
return crypto.createHash('sha512').update(fs.readFileSync(file)).digest('hex');
}
// Minimal semver compare (ignores prerelease ordering nuances).
function compareVersions(a, b) {
const pa = String(a).split(/[.+-]/).map((n) => parseInt(n, 10) || 0);
const pb = String(b).split(/[.+-]/).map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] || 0;
const y = pb[i] || 0;
if (x !== y) return x > y ? 1 : -1;
}
return 0;
}
// Tolerantly parse the `latest.yml` manifest the packager writes.
function parseManifest(text) {
const m = {};
for (const raw of String(text).split(/\r?\n/)) {
const idx = raw.indexOf(':');
if (idx <= 0) continue;
const key = raw.slice(0, idx).trim();
const value = raw.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
if (key === 'version') m.version = value;
else if (key === 'path') m.path = value;
else if (key === 'sha512') m.sha512 = value.toLowerCase();
else if (key === 'url') m.url = value;
}
return m;
}
function manifestUrl(feedUrl) {
const url = String(feedUrl || '').trim();
if (!url) return '';
return /\.ya?ml$/i.test(url) ? url : url.replace(/\/?$/, '/') + 'latest.yml';
}
// Path to the engine binary (the file the launcher execs).
function engineFile() {
const fromEnv = process.env.GELECTRON_ENGINE;
if (fromEnv) return fromEnv;
if (!IS_WINDOWS) return 'gelectron-bin';
try {
const exe = fs
.readdirSync(path.dirname(process.execPath))
.find((f) => f.toLowerCase().endsWith('.exe') && f.toLowerCase() !== 'node.exe');
return exe || 'app.exe';
} catch (e) {
return 'app.exe';
}
}
function binDir() {
return path.dirname(process.execPath);
}
function updateDir() {
return path.join(binDir(), '.update');
}
function applyScriptName() {
return IS_WINDOWS ? 'apply.cmd' : 'apply.sh';
}
function hasPendingUpdate() {
return fs.existsSync(path.join(updateDir(), applyScriptName()));
}
// Current app version — same source of truth as app.getVersion().
function currentVersion() {
const appPath = process.env.GELECTRON_APP_PATH;
if (appPath) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf8'));
if (pkg && typeof pkg.version === 'string' && pkg.version) return pkg.version;
} catch (e) {
// Fall through to the engine version
}
}
return process.env.GELECTRON_VERSION || '0.1.0';
}
function shQuote(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
// Generate a bash script that atomically swaps each payload item into place,
// restoring the previous file on failure so a partial update never sticks.
function generateBashApply(items) {
const lines = ['#!/usr/bin/env bash', 'fail=0'];
for (const it of items) {
lines.push(`rm -rf ${shQuote(it.dest + '.bak')}`);
lines.push(`if [ -e ${shQuote(it.dest)} ]; then mv ${shQuote(it.dest)} ${shQuote(it.dest + '.bak')}; fi`);
lines.push(`if ! mv ${shQuote(it.src)} ${shQuote(it.dest)}; then`);
lines.push(` if [ -e ${shQuote(it.dest + '.bak')} ]; then mv ${shQuote(it.dest + '.bak')} ${shQuote(it.dest)}; fi`);
lines.push(' fail=1');
lines.push('else');
lines.push(` rm -rf ${shQuote(it.dest + '.bak')}`);
lines.push('fi');
if (it.type === 'file') lines.push(`chmod +x ${shQuote(it.dest)}`);
}
lines.push('if [ "$fail" -ne 0 ]; then exit 1; fi', 'exit 0');
return lines.join('\n') + '\n';
}
// Windows counterpart using cmd built-ins.
function generateCmdApply(items) {
const lines = ['@echo off', 'setlocal', 'set FAIL=0'];
for (const it of items) {
const bak = it.dest + '.bak';
const delBak = it.type === 'dir' ? `rmdir /s /q "${bak}"` : `del /q "${bak}"`;
lines.push(`if exist "${bak}" ${delBak}`);
lines.push(`if exist "${it.dest}" move /y "${it.dest}" "${bak}" >nul`);
lines.push(`if exist "${it.src}" move /y "${it.src}" "${it.dest}" >nul`);
lines.push(`if not exist "${it.dest}" (`);
lines.push(` if exist "${bak}" move /y "${bak}" "${it.dest}" >nul`);
lines.push(' set FAIL=1');
lines.push(') else (');
lines.push(` if exist "${bak}" ${delBak}`);
lines.push(')');
}
lines.push('if not "%FAIL%"=="0" exit /b 1');
lines.push('exit /b 0');
return lines.join('\r\n') + '\r\n';
}
// ─── AutoUpdater ────────────────────────────────────────────────────────────
class AutoUpdater extends EventEmitter {
constructor() {
super();
this._isUpdateAvailable = false;
this._updateInfo = null;
this._feedURL = null;
this.autoDownload = true;
@@ -33,33 +185,239 @@ class AutoUpdater extends EventEmitter {
}
setFeedURL(options) {
if (typeof options === 'string') {
this._feedURL = options;
} else if (options && typeof options === 'object') {
this._feedURL = options.url || this._feedURL;
}
}
_log(level, ...args) {
try {
const logger = this._logger || console;
if (logger && typeof logger[level] === 'function') logger[level]('[gelectron-auto-updater]', ...args);
} catch (e) {
// Never let logging break the updater
}
}
async checkForUpdates() {
return {
updateInfo: null,
isUpdateAvailable: false,
if (!isNative) {
return { updateInfo: null, isUpdateAvailable: false };
}
this.emit('checking-for-update');
const feed = manifestUrl(this.getFeedURL());
if (!feed) {
const err = new Error('autoUpdater: feed URL is not set (call setFeedURL first)');
this.emit('error', err);
return { updateInfo: null, isUpdateAvailable: false, error: err };
}
try {
const res = await fetch(feed, {
redirect: 'follow',
headers: { 'User-Agent': 'gelectron-auto-updater' },
});
if (!res.ok) throw new Error(`update manifest request failed: HTTP ${res.status}`);
const manifest = parseManifest(await res.text());
if (!manifest.version) throw new Error('update manifest is missing a version');
const current = currentVersion();
const updateInfo = {
version: manifest.version,
path: manifest.path,
sha512: manifest.sha512,
url: manifest.url || feed,
releaseDate: new Date().toISOString(),
};
this._log('info', 'current=' + current + ' remote=' + manifest.version);
if (compareVersions(manifest.version, current) > 0) {
this._updateInfo = updateInfo;
this.emit('update-available', updateInfo);
let downloadPromise = null;
if (this.autoDownload !== false) {
downloadPromise = this.downloadUpdate().catch((e) => {
this.emit('error', e);
});
}
return { updateInfo, isUpdateAvailable: true, downloadPromise };
}
this.emit('update-not-available', { version: current });
return { updateInfo: null, isUpdateAvailable: false };
} catch (e) {
this._log('error', e && e.message);
this.emit('error', e);
return { updateInfo: null, isUpdateAvailable: false, error: e };
}
}
async downloadUpdate() {
// Dedupe concurrent calls (autoDownload + an explicit app call) so the
// archive is only ever downloaded once per update.
if (this._downloadPromise) return this._downloadPromise;
this._downloadPromise = this._doDownload().finally(() => {
this._downloadPromise = null;
});
return this._downloadPromise;
}
async _doDownload() {
const manifest = this._updateInfo;
if (!manifest) throw new Error('no update available to download (run checkForUpdates first)');
const feed = manifestUrl(this.getFeedURL());
if (!feed) throw new Error('autoUpdater: feed URL is not set');
const dir = updateDir();
fs.mkdirSync(dir, { recursive: true });
fs.rmSync(path.join(dir, 'stage'), { recursive: true, force: true });
const archiveUrl = manifest.path ? new URL(manifest.path, feed).toString() : manifest.url;
const archivePath = path.join(dir, `${manifest.version}.tar.gz`);
this._log('info', 'downloading ' + archiveUrl);
this.emit('download-progress', { percent: 0, transferred: 0, total: 0 });
const res = await fetch(archiveUrl, {
redirect: 'follow',
headers: { 'User-Agent': 'gelectron-auto-updater' },
});
if (!res.ok) throw new Error(`update download failed: HTTP ${res.status}`);
const total = parseInt(res.headers.get('content-length') || '0', 10);
let received = 0;
const writer = fs.createWriteStream(archivePath);
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
writer.write(value);
if (total > 0) {
this.emit('download-progress', {
percent: Math.round((received / total) * 100),
transferred: received,
total,
});
}
}
writer.end();
await new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
if (manifest.sha512) {
const actual = sha512File(archivePath);
if (actual !== manifest.sha512) {
throw new Error(`update integrity check failed: sha512 mismatch`);
}
this._log('info', 'sha512 verified');
}
const stage = path.join(dir, 'stage');
fs.mkdirSync(stage, { recursive: true });
const extract = spawnSync('tar', ['-xzf', archivePath, '-C', stage], { encoding: 'utf8' });
if (extract.error || extract.status !== 0) {
throw new Error(`failed to extract update: ${(extract.error && extract.error.message) || (extract.stderr || '').trim()}`);
}
const payloadDir = path.join(stage, 'payload');
if (!fs.existsSync(payloadDir)) throw new Error('update archive is missing payload/');
this._writeApplyScript(payloadDir, manifest.version);
const result = { ...manifest, version: manifest.version, downloadedFile: archivePath };
this.emit('update-downloaded', result);
this._log('info', 'update staged; relaunch to apply');
return result;
}
_writeApplyScript(payloadDir, version) {
const appDir = process.env.GELECTRON_APP_PATH;
const bdir = binDir();
const items = [];
const maybe = (name, type, dest) => {
if (fs.existsSync(path.join(payloadDir, name))) {
items.push({ name, type, src: path.join(payloadDir, name), dest });
}
};
if (appDir) maybe('app', 'dir', appDir);
maybe('compat', 'dir', path.join(bdir, 'compat'));
maybe('node', 'file', path.join(bdir, path.basename(process.execPath)));
maybe('engine', 'file', path.join(bdir, engineFile()));
maybe('lib', 'dir', path.join(bdir, 'lib'));
if (items.length === 0) throw new Error('update archive contains nothing to install');
const script = IS_WINDOWS ? generateCmdApply(items) : generateBashApply(items);
const scriptPath = path.join(updateDir(), applyScriptName());
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
fs.writeFileSync(
path.join(updateDir(), 'pending.json'),
JSON.stringify({ version, applied: false }, null, 2),
);
}
quitAndInstall(isSilent = false, isForceRunAfter = false) {
this.emit('before-quit-for-update');
this._log('info', 'quitAndInstall called');
// Break the relaunch loop: the first call relaunches the launcher (so it
// applies the staged update before the app starts again). The relaunched
// process inherits GELECTRON_RELAUNCHED=1, so its own quitAndInstall call
// (or one from an app that auto-installs on startup) exits instead of
// relaunching a second time.
if (this._relaunched || process.env.GELECTRON_RELAUNCHED === '1') {
this._log('info', 'already relaunched; skipping relaunch');
setTimeout(() => process.exit(0), 300);
return;
}
const launcher = process.env.GELECTRON_LAUNCHER;
const pending = hasPendingUpdate();
if (pending && launcher) {
this._relaunched = true;
process.env.GELECTRON_RELAUNCHED = '1';
if (IS_WINDOWS) {
// The launcher is a .vbs file — spawn wscript directly so the swap
// runs before the engine starts, then let Rust quit.
const child = spawn('wscript.exe', [launcher], { detached: true, stdio: 'ignore' });
child.unref();
bridge.quit();
} else {
bridge.relaunch(launcher, []);
}
} else {
bridge.quit();
}
// Force the Node child process to exit so it never blocks the swap of the
// bundled node/engine (the Rust side exits its event loop around the same
// time; the timer guarantees node is gone before the relaunched launcher
// runs the apply script).
setTimeout(() => process.exit(0), 300);
}
async checkForAndNotifyIfAvailable() {
return this.checkForUpdates();
}
async downloadUpdate() {}
quitAndInstall(isSilent = false, isForceRunAfter = false) {
this.emit('before-quit-for-update');
process.exit(0);
}
quitAndInstallAgain(isSilent = false, isForceRunAfter = false) {
this.quitAndInstall(isSilent, isForceRunAfter);
}
disableDifferentialDownload() {}
async updateDownloaded() {
return false;
return hasPendingUpdate();
}
get isUpdateActive() {
+4
View File
@@ -153,6 +153,10 @@ class NativeBridge extends EventEmitter {
this._send({ type: 'quit' });
}
relaunch(execPath, args) {
this._send({ type: 'relaunch', exec_path: execPath, args: args || [] });
}
setAppIcon(base64Png) {
this._send({ type: 'set-app-icon', icon: base64Png });
}