Files
ForgeFlow/src/main/update-service.cjs
T
NuklearRabbit 958d5b84d3
ForgeFlow quality gate / quality (push) Canceled after 0s
fix: make Windows updater helper launch reliable
2026-08-01 17:59:55 +02:00

718 lines
22 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";
}
function windowsUpdaterSpawnOptions(cwd) {
return {
// A detached hidden PowerShell child can exit successfully on Windows
// without ever executing its -File script. Normal Windows children survive
// their parent; unref() below releases the event-loop reference instead.
detached: false,
stdio: "ignore",
windowsHide: true,
cwd,
};
}
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,
windowsUpdaterSpawnOptions(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,
windowsUpdaterSpawnOptions(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,
windowsUpdaterSpawnOptions,
waitForUpdaterStarted,
readJsonFile,
readLogTail,
};