Release ForgeFlow 0.6.0
This commit is contained in:
+165
-15
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const fsSync = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
@@ -12,14 +13,73 @@ function safeRepositoryPart(value, label) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath(environment = process.env) {
|
||||
const windowsRoot = environment.SystemRoot || environment.WINDIR;
|
||||
if (windowsRoot) {
|
||||
const absolute = path.join(windowsRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
if (fsSync.existsSync(absolute)) return absolute;
|
||||
}
|
||||
return 'powershell.exe';
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
try { return JSON.parse(await fs.readFile(filePath, 'utf8')); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
async function waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs = 12000,
|
||||
pollMs = 100,
|
||||
childState = null
|
||||
} = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await readJsonFile(statusPath);
|
||||
if (status && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) {
|
||||
return status;
|
||||
}
|
||||
if (childState?.error) throw childState.error;
|
||||
if (childState?.exited) {
|
||||
const error = new Error(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`);
|
||||
error.code = 'UPDATE_HELPER_EXITED_EARLY';
|
||||
throw error;
|
||||
}
|
||||
await delay(pollMs);
|
||||
}
|
||||
const error = new Error('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.');
|
||||
error.code = 'UPDATE_HELPER_START_TIMEOUT';
|
||||
throw error;
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
|
||||
constructor({
|
||||
store,
|
||||
gitea,
|
||||
diagnostics,
|
||||
appInfo,
|
||||
sourcePath,
|
||||
userDataPath,
|
||||
platform = process.platform,
|
||||
spawnProcess = spawn,
|
||||
powershellPath = null,
|
||||
handshakeTimeoutMs = 12000,
|
||||
handshakePollMs = 100
|
||||
}) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.appInfo = appInfo;
|
||||
this.sourcePath = sourcePath;
|
||||
this.updateDirectory = path.join(userDataPath, 'updates');
|
||||
this.platform = platform;
|
||||
this.spawnProcess = spawnProcess;
|
||||
this.powershellPath = powershellPath;
|
||||
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||
this.handshakePollMs = handshakePollMs;
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
@@ -99,37 +159,127 @@ class UpdateService {
|
||||
async apply(staged = null) {
|
||||
const update = staged?.archivePath ? staged : this.staged;
|
||||
if (!update?.archivePath) throw new Error('Download an update before applying it.');
|
||||
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
|
||||
if (this.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
|
||||
const stat = await fs.stat(update.archivePath).catch(() => null);
|
||||
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
|
||||
|
||||
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
|
||||
const scriptStat = await fs.stat(scriptPath).catch(() => null);
|
||||
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
|
||||
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
const logPath = path.join(this.updateDirectory, `apply-${updateId}.log`);
|
||||
const statusPath = path.join(this.updateDirectory, `apply-${updateId}.status.json`);
|
||||
const launching = {
|
||||
schemaVersion: 1,
|
||||
updateId,
|
||||
state: 'launching',
|
||||
expectedVersion: update.remoteVersion,
|
||||
sourcePath: this.sourcePath,
|
||||
logPath,
|
||||
statusPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { mode: 0o600 });
|
||||
|
||||
const executable = this.powershellPath || resolveWindowsPowerShellPath();
|
||||
const args = [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-SourcePath', this.sourcePath,
|
||||
'-ArchivePath', update.archivePath,
|
||||
'-ExpectedVersion', update.remoteVersion,
|
||||
'-ExpectedSha256', update.sha256,
|
||||
'-ParentPid', String(process.pid),
|
||||
'-LogPath', logPath
|
||||
'-LogPath', logPath,
|
||||
'-StatusPath', statusPath,
|
||||
'-UpdateId', updateId
|
||||
];
|
||||
const child = spawn('powershell.exe', args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false,
|
||||
cwd: this.sourcePath
|
||||
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
let child;
|
||||
try {
|
||||
child = this.spawnProcess(executable, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
cwd: this.sourcePath
|
||||
});
|
||||
} catch (error) {
|
||||
error.code ||= 'UPDATE_HELPER_SPAWN_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
child.once?.('error', (error) => { childState.error = error; });
|
||||
child.once?.('exit', (code) => { childState.exited = true; childState.code = code; });
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (handler, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
handler(value);
|
||||
};
|
||||
const timer = setTimeout(() => finish(reject, Object.assign(new Error('Windows did not start the update helper process.'), { code: 'UPDATE_HELPER_SPAWN_TIMEOUT' })), 5000);
|
||||
child.once?.('spawn', () => finish(resolve));
|
||||
child.once?.('error', (error) => finish(reject, error));
|
||||
if (!child.once) finish(resolve);
|
||||
});
|
||||
child.unref();
|
||||
await this.diagnostics?.info('updates.apply-launched', {
|
||||
|
||||
child.unref?.();
|
||||
const started = await waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: this.handshakeTimeoutMs,
|
||||
pollMs: this.handshakePollMs,
|
||||
childState
|
||||
});
|
||||
|
||||
await this.diagnostics?.info('updates.apply-started', {
|
||||
updateId,
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
logPath
|
||||
logPath,
|
||||
statusPath,
|
||||
helperPid: child.pid,
|
||||
helperState: started.state
|
||||
});
|
||||
return { launched: true, version: update.remoteVersion, logPath };
|
||||
return { launched: true, confirmed: true, updateId, version: update.remoteVersion, logPath, statusPath };
|
||||
}
|
||||
|
||||
async consumeLatestResult() {
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const entries = await fs.readdir(this.updateDirectory, { withFileTypes: true }).catch(() => []);
|
||||
const candidates = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^apply-.*\.status\.json$/i.test(entry.name)) continue;
|
||||
const filePath = path.join(this.updateDirectory, entry.name);
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (stat) candidates.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
for (const candidate of candidates) {
|
||||
const status = await readJsonFile(candidate.filePath);
|
||||
if (!status || status.acknowledgedAt || !['success', 'rolled-back', 'failed'].includes(status.state)) continue;
|
||||
status.acknowledgedAt = new Date().toISOString();
|
||||
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), { mode: 0o600 });
|
||||
return {
|
||||
state: status.state,
|
||||
expectedVersion: status.expectedVersion || null,
|
||||
installedVersion: status.installedVersion || null,
|
||||
message: status.message || '',
|
||||
logPath: status.logPath || null,
|
||||
restartLaunched: Boolean(status.restartLaunched),
|
||||
completedAt: status.completedAt || status.updatedAt || null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UpdateService, safeRepositoryPart };
|
||||
module.exports = {
|
||||
UpdateService,
|
||||
safeRepositoryPart,
|
||||
resolveWindowsPowerShellPath,
|
||||
waitForUpdaterStarted,
|
||||
readJsonFile
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user