683 lines
20 KiB
JavaScript
683 lines
20 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 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.`,
|
|
);
|
|
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?.browser_download_url || !checksumAsset?.browser_download_url) {
|
|
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.downloadAuthenticated(asset.browser_download_url),
|
|
this.gitea.downloadAuthenticated(checksumAsset.browser_download_url),
|
|
]);
|
|
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
|
throw new Error(
|
|
"The downloaded Windows update is not a valid executable.",
|
|
);
|
|
}
|
|
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,
|
|
};
|