286 lines
11 KiB
JavaScript
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
|
|
};
|