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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
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) {
|
function generateLinuxDesktop(name, exeName) {
|
||||||
return `[Desktop Entry]
|
return `[Desktop Entry]
|
||||||
Name=${name}
|
Name=${name}
|
||||||
@@ -322,6 +410,8 @@ async function packageApp(opts) {
|
|||||||
await packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir, opts);
|
await packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeUpdateArtifacts(outDir, name, version, platform, arch);
|
||||||
|
|
||||||
log(`\n ✓ Packaged to ${outDir}\n`);
|
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'];
|
const excludeDirs = ['node_modules', '.gelectron-cache', '.git', 'target'];
|
||||||
copyDirSync(appDir, appResources, excludeDirs);
|
copyDirSync(appDir, appResources, excludeDirs);
|
||||||
|
|
||||||
// Generate bash launcher (CFBundleExecutable) — sets PATH so gelectron-bin finds node
|
// Generate bash launcher (CFBundleExecutable) — sets PATH so gelectron-bin
|
||||||
const wrapper = `#!/bin/bash
|
// finds node, exports update metadata, and applies any pending update.
|
||||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
fs.writeFileSync(path.join(macosDir, exeName), bashLauncher(exeName, 'gelectron-bin'), { mode: 0o755 });
|
||||||
export PATH="$DIR:$PATH"
|
|
||||||
export GELECTRON_NATIVE=1
|
|
||||||
exec "$DIR/gelectron-bin" "$@"
|
|
||||||
`;
|
|
||||||
fs.writeFileSync(path.join(macosDir, exeName), wrapper, { mode: 0o755 });
|
|
||||||
|
|
||||||
// Generate Info.plist
|
// Generate Info.plist
|
||||||
const iconSource = path.join(appResources, 'icon.png');
|
const iconSource = path.join(appResources, 'icon.png');
|
||||||
@@ -439,11 +524,28 @@ async function packageWindows(appDir, outDir, name, version, gelectronBin, nodeD
|
|||||||
log(' Copying app source...');
|
log(' Copying app source...');
|
||||||
copyDirSync(appDir, path.join(outDir, 'app'), ['node_modules', '.gelectron-cache', '.git', 'target']);
|
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")
|
const vbs = `Set WshShell = CreateObject("WScript.Shell")
|
||||||
WshShell.CurrentDirectory = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
|
Set objFSO = CreateObject("Scripting.FileSystemObject")
|
||||||
WshShell.Environment("Process")("PATH") = WshShell.CurrentDirectory & ";" & WshShell.Environment("Process")("PATH")
|
dir = objFSO.GetParentFolderName(WScript.ScriptFullName)
|
||||||
WshShell.Run """" & WshShell.CurrentDirectory & "\\${exeName}.exe""", 1, False
|
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);
|
fs.writeFileSync(path.join(outDir, `${exeName}.vbs`), vbs);
|
||||||
|
|
||||||
@@ -451,6 +553,17 @@ WshShell.Run """" & WshShell.CurrentDirectory & "\\${exeName}.exe""", 1, False
|
|||||||
const bat = `@echo off
|
const bat = `@echo off
|
||||||
set DIR=%~dp0
|
set DIR=%~dp0
|
||||||
set PATH=%DIR%;%PATH%
|
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" %*
|
"%DIR%${exeName}.exe" %*
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(path.join(outDir, `${exeName}.bat`), bat);
|
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) {
|
async function packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir, opts) {
|
||||||
const exeName = name.replace(/[^a-zA-Z0-9]/g, '');
|
const exeName = name.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
|
||||||
// Copy gelectron binary
|
// Copy gelectron binary under a fixed name so the autoUpdater can find it.
|
||||||
fs.copyFileSync(gelectronBin, path.join(outDir, exeName));
|
// (Previously this overwrote the binary with the launcher script below.)
|
||||||
fs.chmodSync(path.join(outDir, exeName), 0o755);
|
fs.copyFileSync(gelectronBin, path.join(outDir, 'gelectron-bin'));
|
||||||
|
fs.chmodSync(path.join(outDir, 'gelectron-bin'), 0o755);
|
||||||
|
|
||||||
// Copy Node.js
|
// Copy Node.js
|
||||||
const nodeBin = path.join(nodeDir, 'bin', 'node');
|
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']);
|
copyDirSync(appDir, path.join(outDir, 'app'), ['node_modules', '.gelectron-cache', '.git', 'target']);
|
||||||
|
|
||||||
// Generate launcher script
|
// Generate launcher script
|
||||||
const launcher = `#!/bin/bash
|
fs.writeFileSync(path.join(outDir, exeName), bashLauncher(exeName, 'gelectron-bin'), { mode: 0o755 });
|
||||||
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 });
|
|
||||||
|
|
||||||
// Desktop file
|
// Desktop file
|
||||||
fs.writeFileSync(path.join(outDir, `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.desktop`),
|
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)
|
--arch, -a Target arch: x64, arm64 (default: current)
|
||||||
--binary, -b Path to gelectron binary (for cross-compiled builds)
|
--binary, -b Path to gelectron binary (for cross-compiled builds)
|
||||||
--help, -h Show this help
|
--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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -131,13 +131,26 @@ class App extends EventEmitter {
|
|||||||
get argv() { return this._argv; }
|
get argv() { return this._argv; }
|
||||||
|
|
||||||
get isPackaged() {
|
get isPackaged() {
|
||||||
return !process.argv[0].includes('node') && !process.argv[0].includes('gelectron');
|
return process.env.GELECTRON_PACKAGED === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return this._name; }
|
get name() { return this._name; }
|
||||||
set name(val) { this._name = val || 'Gelectron App'; }
|
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 locale() { return this.getLocale(); }
|
||||||
get userAgent() { return this.getUserAgent(); }
|
get userAgent() { return this.getUserAgent(); }
|
||||||
|
|||||||
+379
-21
@@ -2,24 +2,176 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gelectron - autoUpdater module (Electron compatible)
|
* Gelectron - autoUpdater module (Electron compatible)
|
||||||
* Stub implementation for electron-updater compatibility.
|
|
||||||
*
|
*
|
||||||
* electron-updater's MacUpdater / NsisUpdater / AppImageUpdater all
|
* Real implementation for packaged apps. Checks a feed (GitHub release or any
|
||||||
* access `require("electron").autoUpdater` and expect it to be an
|
* static host) for a `latest.yml` manifest, downloads the update archive, and
|
||||||
* EventEmitter with the native Electron autoUpdater API surface:
|
* stages it for atomic application on next launch.
|
||||||
* .on("error", …), .on("update-downloaded", …)
|
*
|
||||||
* .setFeedURL(), .getFeedURL()
|
* The update archive produced by the packager is a full bundle (`payload/`
|
||||||
* .checkForUpdates()
|
* containing the app source, compat layer, bundled Node, and the engine
|
||||||
* .quitAndInstall()
|
* binary), so a single release ships both app and engine updates.
|
||||||
* .removeListener()
|
*
|
||||||
|
* 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 { 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 {
|
class AutoUpdater extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._isUpdateAvailable = false;
|
|
||||||
this._updateInfo = null;
|
this._updateInfo = null;
|
||||||
this._feedURL = null;
|
this._feedURL = null;
|
||||||
this.autoDownload = true;
|
this.autoDownload = true;
|
||||||
@@ -33,33 +185,239 @@ class AutoUpdater extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFeedURL(options) {
|
setFeedURL(options) {
|
||||||
|
if (typeof options === 'string') {
|
||||||
this._feedURL = options;
|
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() {
|
async checkForUpdates() {
|
||||||
return {
|
if (!isNative) {
|
||||||
updateInfo: null,
|
return { updateInfo: null, isUpdateAvailable: false };
|
||||||
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() {
|
async checkForAndNotifyIfAvailable() {
|
||||||
return this.checkForUpdates();
|
return this.checkForUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadUpdate() {}
|
|
||||||
|
|
||||||
quitAndInstall(isSilent = false, isForceRunAfter = false) {
|
|
||||||
this.emit('before-quit-for-update');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
quitAndInstallAgain(isSilent = false, isForceRunAfter = false) {
|
quitAndInstallAgain(isSilent = false, isForceRunAfter = false) {
|
||||||
this.quitAndInstall(isSilent, isForceRunAfter);
|
this.quitAndInstall(isSilent, isForceRunAfter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disableDifferentialDownload() {}
|
||||||
|
|
||||||
async updateDownloaded() {
|
async updateDownloaded() {
|
||||||
return false;
|
return hasPendingUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get isUpdateActive() {
|
get isUpdateActive() {
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ class NativeBridge extends EventEmitter {
|
|||||||
this._send({ type: 'quit' });
|
this._send({ type: 'quit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relaunch(execPath, args) {
|
||||||
|
this._send({ type: 'relaunch', exec_path: execPath, args: args || [] });
|
||||||
|
}
|
||||||
|
|
||||||
setAppIcon(base64Png) {
|
setAppIcon(base64Png) {
|
||||||
this._send({ type: 'set-app-icon', icon: base64Png });
|
this._send({ type: 'set-app-icon', icon: base64Png });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user