"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 readLogTail(filePath, maxLines = 12) { if (!filePath) return ""; try { const text = await fs.readFile(filePath, "utf8"); return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n"); } catch { return ""; } } async function updaterStartupError( message, code, { statusPath, logPath, expectedUpdateId } = {}, ) { const status = statusPath ? await readJsonFile(statusPath) : null; const logTail = await readLogTail(logPath); const details = []; if ( status?.updateId && expectedUpdateId && status.updateId !== expectedUpdateId ) details.push("The helper wrote a status for a different update request."); if (status?.message) details.push(status.message); if (logTail) details.push(`Update helper log:\n${logTail}`); const error = new Error([message, ...details].filter(Boolean).join("\n\n")); error.code = code; error.status = status; error.logPath = logPath || null; return error; } async function waitForUpdaterStarted( statusPath, { timeoutMs = 15000, pollMs = 100, childState = null, expectedUpdateId = null, logPath = null, } = {}, ) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const status = await readJsonFile(statusPath); const belongsToRequest = !expectedUpdateId || status?.updateId === expectedUpdateId; if ( status && belongsToRequest && [ "started", "waiting-for-exit", "backing-up", "extracting", "applying", "validating", ].includes(status.state) ) { return status; } if ( status && belongsToRequest && ["failed", "rolled-back"].includes(status.state) ) { throw await updaterStartupError( "The update helper reported a failure before ForgeFlow could close.", "UPDATE_HELPER_START_FAILED", { statusPath, logPath, expectedUpdateId }, ); } if (childState?.error) throw childState.error; if (childState?.exited) { throw await updaterStartupError( `The update helper exited before it confirmed startup (exit code ${childState.code ?? "unknown"}).`, "UPDATE_HELPER_EXITED_EARLY", { statusPath, logPath, expectedUpdateId }, ); } await delay(pollMs); } throw await updaterStartupError( "The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.", "UPDATE_HELPER_START_TIMEOUT", { statusPath, logPath, expectedUpdateId }, ); } 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) { return this.downloadPackaged(update); } 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 downloadPackaged(update) { if (this.platform !== "win32") throw new Error("Packaged auto-update currently supports Windows only."); const release = (await this.gitea.getReleaseByTag( update.owner, update.repo, `v${update.remoteVersion}`, )) || (await this.gitea.getReleaseByTag( update.owner, update.repo, update.remoteVersion, )); if (!release || release.draft || release.prerelease) { const error = new Error( `ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching Windows installer/portable assets were not published. Run Publish-Missing-Binary-Release.ps1 from the release source or publish the four required assets in Gitea.`, ); error.code = "BINARY_RELEASE_NOT_FOUND"; throw error; } const portable = Boolean(this.appInfo.portableExecutablePath); const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`; const checksumName = `${assetName}.sha256`; const assets = Array.isArray(release.assets) ? release.assets : []; const asset = assets.find((item) => item.name === assetName); const checksumAsset = assets.find((item) => item.name === checksumName); if (!asset?.id || !checksumAsset?.id) { const error = new Error( `Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`, ); error.code = "BINARY_RELEASE_INCOMPLETE"; throw error; } const [binary, checksumBytes] = await Promise.all([ this.gitea.downloadReleaseAsset( update.owner, update.repo, release.id, asset.id, { downloadUrl: asset.browser_download_url }, ), this.gitea.downloadReleaseAsset( update.owner, update.repo, release.id, checksumAsset.id, { downloadUrl: checksumAsset.browser_download_url }, ), ]); if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) { const preview = binary.subarray(0, 200).toString("utf8").trim(); const looksLikeMetadata = /^\s*[{[]/.test(preview) || /browser_download_url/i.test(preview); const error = new Error( looksLikeMetadata ? "Gitea returned release-asset metadata instead of the Windows executable. Upgrade ForgeFlow with the 0.9.1 installer once; later in-app updates use the actual browser download URL." : "The downloaded Windows update is not a valid executable.", ); error.code = looksLikeMetadata ? "RELEASE_ASSET_METADATA_RECEIVED" : "INVALID_WINDOWS_UPDATE"; throw error; } const expectedSha256 = checksumBytes .toString("utf8") .trim() .split(/\s+/)[0] ?.toLowerCase(); if (!/^[a-f0-9]{64}$/.test(expectedSha256 || "")) throw new Error("The release SHA-256 file is invalid."); const sha256 = crypto.createHash("sha256").update(binary).digest("hex"); if (sha256 !== expectedSha256) throw new Error( "The downloaded Windows update failed SHA-256 verification.", ); await fs.mkdir(this.updateDirectory, { recursive: true }); const binaryPath = path.join(this.updateDirectory, assetName); await fs.writeFile(binaryPath, binary, { mode: 0o600 }); const metadata = { ...update, kind: "binary", binaryPath, assetName, sha256, portable, executablePath: portable ? this.appInfo.portableExecutablePath : this.appInfo.executablePath, releaseTag: release.tag_name, downloadedAt: new Date().toISOString(), downloaded: true, }; await fs.writeFile( `${binaryPath}.json`, JSON.stringify(metadata, null, 2), { mode: 0o600 }, ); this.staged = metadata; await this.diagnostics?.info("updates.binary-downloaded", { remoteVersion: update.remoteVersion, assetName, bytes: binary.length, sha256, portable, }); return metadata; } async apply(staged = null) { const update = staged?.archivePath || staged?.binaryPath ? staged : this.staged; if (!update?.archivePath && !update?.binaryPath) throw new Error("Download an update before applying it."); if (this.platform !== "win32") throw new Error( "The integrated updater currently supports Windows only.", ); if (update.kind === "binary") return this.applyPackaged(update); 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); }); const started = await waitForUpdaterStarted(statusPath, { timeoutMs: this.handshakeTimeoutMs, pollMs: this.handshakePollMs, childState, expectedUpdateId: updateId, logPath, }); child.unref?.(); 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 applyPackaged(update) { const stat = await fs.stat(update.binaryPath).catch(() => null); if (!stat?.isFile()) throw new Error("The staged Windows update is no longer available."); const actualSha256 = crypto .createHash("sha256") .update(await fs.readFile(update.binaryPath)) .digest("hex"); if (actualSha256 !== update.sha256) throw new Error( "The staged Windows update failed its final SHA-256 check.", ); const helperRoot = this.sourcePath.toLowerCase().endsWith("app.asar") ? `${this.sourcePath}.unpacked` : this.sourcePath; const scriptPath = path.join( helperRoot, "scripts", "apply-binary-update.ps1", ); if (!(await fs.stat(scriptPath).catch(() => null))?.isFile()) throw new Error("The binary update helper is missing."); await fs.mkdir(this.updateDirectory, { recursive: true }); const updateId = `${Date.now()}-${crypto.randomUUID()}`; const logPath = path.join(this.updateDirectory, `binary-${updateId}.log`); const statusPath = path.join( this.updateDirectory, `binary-${updateId}.status.json`, ); const launching = { schemaVersion: 1, updateId, state: "launching", expectedVersion: update.remoteVersion, 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, "-BinaryPath", update.binaryPath, "-ExpectedSha256", update.sha256, "-ExpectedVersion", update.remoteVersion, "-CurrentExecutable", update.executablePath || this.appInfo.executablePath, "-Portable", String(Boolean(update.portable)), "-ParentPid", String(process.pid), "-LogPath", logPath, "-StatusPath", statusPath, "-UpdateId", updateId, ]; const child = this.spawnProcess(executable, args, { detached: true, stdio: "ignore", windowsHide: true, cwd: this.updateDirectory, }); const childState = { exited: false, code: null, error: null }; child.once?.("error", (error) => { childState.error = error; }); child.once?.("exit", (code) => { childState.exited = true; childState.code = code; }); await new Promise((resolve, reject) => { const timer = setTimeout( () => reject( Object.assign( new Error("Windows did not start the binary update helper."), { code: "UPDATE_HELPER_SPAWN_TIMEOUT" }, ), ), 5000, ); child.once?.("spawn", () => { clearTimeout(timer); resolve(); }); child.once?.("error", (error) => { clearTimeout(timer); reject(error); }); if (!child.once) { clearTimeout(timer); resolve(); } }); const started = await waitForUpdaterStarted(statusPath, { timeoutMs: this.handshakeTimeoutMs, pollMs: this.handshakePollMs, childState, expectedUpdateId: updateId, logPath, }); child.unref?.(); await this.diagnostics?.info("updates.binary-apply-started", { updateId, remoteVersion: update.remoteVersion, assetName: update.assetName, 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|binary)-.*\.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, readLogTail, };