Update
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
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;
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.appInfo = appInfo;
|
||||
this.sourcePath = sourcePath;
|
||||
this.updateDirectory = path.join(userDataPath, 'updates');
|
||||
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 (process.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`);
|
||||
const args = [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-SourcePath', this.sourcePath,
|
||||
'-ArchivePath', update.archivePath,
|
||||
'-ExpectedVersion', update.remoteVersion,
|
||||
'-ExpectedSha256', update.sha256,
|
||||
'-ParentPid', String(process.pid),
|
||||
'-LogPath', logPath
|
||||
];
|
||||
const child = spawn('powershell.exe', args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false,
|
||||
cwd: this.sourcePath
|
||||
});
|
||||
child.unref();
|
||||
await this.diagnostics?.info('updates.apply-launched', {
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
logPath
|
||||
});
|
||||
return { launched: true, version: update.remoteVersion, logPath };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UpdateService, safeRepositoryPart };
|
||||
Reference in New Issue
Block a user