'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 };