Files
ForgeFlow/src/main/update-service.cjs
T
2026-07-25 05:59:07 +02:00

286 lines
11 KiB
JavaScript

'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');
const { isNewerVersion } = require('../shared/semver.cjs');
function safeRepositoryPart(value, label) {
const text = String(value || '').trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) throw new Error(`${label} contains unsupported characters.`);
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,
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;
}
async check() {
const settings = this.store.data.updates || {};
const owner = safeRepositoryPart(settings.owner || 'Jens', 'Update repository owner');
const repo = safeRepositoryPart(settings.repo || 'ForgeFlow', 'Update repository name');
const branchName = String(settings.branch || 'main').trim();
const branch = await this.gitea.getBranch(owner, repo, branchName);
const remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ''))) throw new Error('Gitea did not return a full commit SHA for the update branch.');
const file = await this.gitea.getRepositoryFile({ owner, repo, filePath: 'package.json', ref: remoteSha });
let manifest;
try { manifest = JSON.parse(file.decoded); }
catch { throw new Error('The remote ForgeFlow package.json is not valid JSON.'); }
if (manifest.name !== 'forgeflow') throw new Error('The configured update repository is not a ForgeFlow source repository.');
const remoteVersion = String(manifest.version || '').trim();
const currentVersion = String(this.appInfo.version || '').trim();
const available = isNewerVersion(remoteVersion, currentVersion);
const result = {
checkedAt: new Date().toISOString(),
owner,
repo,
branch: branchName,
currentVersion,
remoteVersion,
remoteSha,
shortSha: remoteSha.slice(0, 7),
available,
packaged: Boolean(this.appInfo.packaged),
mode: this.appInfo.packaged ? 'packaged' : 'source'
};
this.store.data.updates.lastCheckedAt = result.checkedAt;
await this.store.save();
await this.diagnostics?.info('updates.checked', {
repository: `${owner}/${repo}`,
branch: branchName,
currentVersion,
remoteVersion,
remoteSha,
available,
mode: result.mode
});
return result;
}
async download(expected = null) {
const update = expected?.remoteSha ? expected : await this.check();
if (!update.available) return { ...update, downloaded: false, reason: 'up-to-date' };
if (this.appInfo.packaged) {
const error = new Error('This developer release uses source updates. Install a signed packaged release before using binary auto-update.');
error.code = 'PACKAGED_UPDATE_NOT_CONFIGURED';
throw error;
}
await fs.mkdir(this.updateDirectory, { recursive: true });
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b) throw new Error('The downloaded update is not a valid ZIP archive.');
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
const archivePath = path.join(this.updateDirectory, `ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`);
const metadataPath = `${archivePath}.json`;
await fs.writeFile(archivePath, archive, { mode: 0o600 });
const metadata = { ...update, archivePath, sha256, downloadedAt: new Date().toISOString() };
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
this.staged = metadata;
await this.diagnostics?.info('updates.downloaded', {
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
bytes: archive.length,
sha256
});
return { ...metadata, downloaded: true };
}
async apply(staged = null) {
const update = staged?.archivePath ? staged : this.staged;
if (!update?.archivePath) throw new Error('Download an update before applying it.');
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.');
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 = [
'-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,
'-StatusPath', statusPath,
'-UpdateId', updateId
];
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?.();
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,
statusPath,
helperPid: child.pid,
helperState: started.state
});
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,
resolveWindowsPowerShellPath,
waitForUpdaterStarted,
readJsonFile
};