mirror of
https://github.com/mileswolfallen2/gelectron.git
synced 2026-09-08 12:43:16 +00:00
feat: implement auto-update functionality with update artifacts generation and launcher scripts
This commit is contained in:
@@ -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
@@ -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(); }
|
||||
|
||||
+380
-22
@@ -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) {
|
||||
this._feedURL = 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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user