diff --git a/packager/bin/gelectron-packager.js b/packager/bin/gelectron-packager.js index 643365b..02d32d1 100755 --- a/packager/bin/gelectron-packager.js +++ b/packager/bin/gelectron-packager.js @@ -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); } diff --git a/src/electron/app.js b/src/electron/app.js index 3d10b75..fb49ee0 100644 --- a/src/electron/app.js +++ b/src/electron/app.js @@ -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(); } diff --git a/src/electron/auto-updater.js b/src/electron/auto-updater.js index 3c98fe6..9a66a38 100644 --- a/src/electron/auto-updater.js +++ b/src/electron/auto-updater.js @@ -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 (`/.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() { diff --git a/src/electron/native-bridge.js b/src/electron/native-bridge.js index 254dd5a..005aa23 100644 --- a/src/electron/native-bridge.js +++ b/src/electron/native-bridge.js @@ -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 }); }