Files

287 lines
11 KiB
JavaScript

import { createHash } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
const RECOVERY_STATE_FILENAME = "app-recovery.v1.json";
const RECOVERY_CACHE_DIRECTORY = "app-recovery-cache";
const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
function stableVersion(value) {
if (typeof value !== "string") return null;
const normalized = value.trim().replace(/^v/i, "");
return STABLE_VERSION.test(normalized) ? normalized : null;
}
function compareStableVersions(left, right) {
const a = stableVersion(left);
const b = stableVersion(right);
if (!a || !b) return null;
const leftParts = a.split(".").map(Number);
const rightParts = b.split(".").map(Number);
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
function statePath(app) {
return path.join(app.getPath("userData"), RECOVERY_STATE_FILENAME);
}
export async function readRecoveryState(app, distribution) {
try {
const parsed = JSON.parse(await readFile(statePath(app), "utf8"));
if (parsed?.distribution !== distribution) return { currentVersion: null, previousVersion: null };
return {
currentVersion: stableVersion(parsed.currentVersion),
previousVersion: stableVersion(parsed.previousVersion),
};
} catch {
return { currentVersion: null, previousVersion: null };
}
}
export async function recordHealthyVersion(app, distribution, rawVersion) {
const version = stableVersion(rawVersion);
if (!version) throw new Error("Healthy app version must use the stable x.y.z format.");
const previous = await readRecoveryState(app, distribution);
const state = {
distribution,
currentVersion: version,
previousVersion: previous.currentVersion && previous.currentVersion !== version
? previous.currentVersion
: previous.previousVersion,
writtenAt: new Date().toISOString(),
};
const outputPath = statePath(app);
await mkdir(path.dirname(outputPath), { recursive: true });
const temporaryPath = `${outputPath}.${process.pid}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await rename(temporaryPath, outputPath);
return state;
}
export function recoveryManifestName(platform, arch, distribution) {
const channel = distribution === "public" ? "latest" : distribution;
if (platform === "darwin") return `${channel}-mac.yml`;
if (platform === "win32") return `${channel}.yml`;
return `${channel}-linux${arch === "arm64" ? "-arm64" : ""}.yml`;
}
export function parseRecoveryManifest(raw) {
const files = [];
let current = null;
for (const line of String(raw ?? "").split(/\r?\n/)) {
const start = line.match(/^\s*-\s+url:\s*(.+?)\s*$/);
if (start) {
current = { url: start[1].trim().replace(/^['"]|['"]$/g, "") };
files.push(current);
continue;
}
const property = line.match(/^\s{4}([A-Za-z][A-Za-z0-9_-]*):\s*(.+?)\s*$/);
if (property && current) current[property[1]] = property[2].trim().replace(/^['"]|['"]$/g, "");
}
return files;
}
function installerExtension(platform) {
if (platform === "darwin") return ".dmg";
if (platform === "win32") return ".exe";
return ".AppImage";
}
function validRecoveryArtifactIdentity(artifact) {
const version = stableVersion(artifact?.version);
if (!version || artifact.version !== version) return false;
if (!["darwin", "win32", "linux"].includes(artifact.platform)) return false;
if (!["arm64", "x64"].includes(artifact.arch)) return false;
if (!["public", "cloud", "enterprise"].includes(artifact.distribution)) return false;
if (typeof artifact.url !== "string" || typeof artifact.sha512 !== "string" || !artifact.sha512.trim()) return false;
let url;
try {
url = new URL(artifact.url);
} catch {
return false;
}
const prefix = `/different-ai/openwork/releases/download/v${version}/`;
if (url.protocol !== "https:" || url.hostname !== "github.com" || !url.pathname.startsWith(prefix)) return false;
const assetArch = artifact.platform === "linux" && artifact.arch === "x64" ? "x86_64" : artifact.arch;
const platformSlug = artifact.platform === "darwin" ? "mac" : artifact.platform === "win32" ? "win" : "linux";
const fileName = path.basename(url.pathname);
const distributionSlug = artifact.distribution === "public" ? "" : `${artifact.distribution}-`;
return fileName === `openwork-${distributionSlug}${platformSlug}-${assetArch}-${version}${installerExtension(artifact.platform)}`;
}
export function selectRecoveryArtifact(files, { version, platform, arch, distribution }) {
const normalizedVersion = stableVersion(version);
if (!normalizedVersion || !["public", "cloud", "enterprise"].includes(distribution)) return null;
const assetArch = platform === "linux" && arch === "x64" ? "x86_64" : arch;
const extension = installerExtension(platform);
const matching = files.filter((file) =>
typeof file?.url === "string"
&& file.url.includes(`-${assetArch}-`)
&& file.url.endsWith(extension)
&& typeof file.sha512 === "string"
&& file.sha512.trim(),
);
const baseUrl = `https://github.com/different-ai/openwork/releases/download/v${normalizedVersion}/`;
for (const selected of matching) {
const url = new URL(selected.url, baseUrl);
if (url.origin !== "https://github.com" || !url.pathname.startsWith(`/different-ai/openwork/releases/download/v${normalizedVersion}/`)) {
continue;
}
const artifact = {
version: normalizedVersion,
platform,
arch,
distribution,
url: url.toString(),
sha512: selected.sha512.trim(),
};
return validRecoveryArtifactIdentity(artifact) ? artifact : null;
}
return null;
}
export async function compatibleRecoveryReleases({
versions,
currentVersion,
previousVersion,
minimumVersion,
allowedVersions,
resolveArtifact,
limit = 5,
}) {
const current = stableVersion(currentVersion);
const previous = stableVersion(previousVersion);
const minimum = stableVersion(minimumVersion);
const allowed = Array.isArray(allowedVersions)
? new Set(allowedVersions.flatMap((value) => stableVersion(value) ?? []))
: null;
const filtered = [...new Set(Array.isArray(versions) ? versions.flatMap((value) => stableVersion(value) ?? []) : [])]
.filter((version) => !minimum || compareStableVersions(version, minimum) >= 0)
.filter((version) => !allowed || allowed.has(version))
.sort((left, right) => compareStableVersions(right, left) ?? 0)
.slice(0, limit);
const releases = [];
for (const version of filtered) {
const artifact = await resolveArtifact(version);
if (!artifact) continue;
releases.push({
id: version,
version,
marking: version === current ? "current" : version === previous ? "previous" : null,
artifact,
cachedFilePath: artifact.cachedFilePath ?? null,
});
}
return releases;
}
export function recoveryVersionMarkers(installedVersion, persistedState) {
const currentVersion = stableVersion(installedVersion);
const persistedCurrent = stableVersion(persistedState?.currentVersion);
const persistedPrevious = stableVersion(persistedState?.previousVersion);
return {
currentVersion,
previousVersion: persistedCurrent && persistedCurrent !== currentVersion
? persistedCurrent
: persistedPrevious,
};
}
export async function cacheVerifiedRecoveryArtifact({ app, artifact, fetchArtifact }) {
const response = await fetchArtifact(artifact.url);
if (!response.ok) throw new Error(`Recovery download failed with HTTP ${response.status}.`);
const bytes = Buffer.from(await response.arrayBuffer());
const checksum = createHash("sha512").update(bytes).digest("base64");
if (checksum !== artifact.sha512) throw new Error("Recovery installer checksum did not match its signed manifest.");
const cacheDirectory = path.join(app.getPath("userData"), RECOVERY_CACHE_DIRECTORY);
const stagingDirectory = `${cacheDirectory}.staging`;
const backupDirectory = `${cacheDirectory}.backup`;
const fileName = path.basename(new URL(artifact.url).pathname);
await rm(stagingDirectory, { recursive: true, force: true });
await mkdir(stagingDirectory, { recursive: true });
await writeFile(path.join(stagingDirectory, fileName), bytes);
await writeFile(
path.join(stagingDirectory, "metadata.json"),
`${JSON.stringify({ ...artifact, checksum, fileName }, null, 2)}\n`,
"utf8",
);
await rm(backupDirectory, { recursive: true, force: true });
let previousMoved = false;
try {
await rename(cacheDirectory, backupDirectory);
previousMoved = true;
} catch {
// No prior cache is the normal first-run case.
}
try {
await rename(stagingDirectory, cacheDirectory);
} catch (error) {
if (previousMoved) await rename(backupDirectory, cacheDirectory).catch(() => undefined);
throw error;
}
await rm(backupDirectory, { recursive: true, force: true });
return path.join(cacheDirectory, fileName);
}
export async function verifyCachedRecoveryArtifact(filePath, artifact) {
try {
if (!validRecoveryArtifactIdentity(artifact)) return false;
const bytes = await readFile(filePath);
const metadata = JSON.parse(await readFile(path.join(path.dirname(filePath), "metadata.json"), "utf8"));
const checksum = createHash("sha512").update(bytes).digest("base64");
return metadata.version === artifact.version
&& metadata.platform === artifact.platform
&& metadata.arch === artifact.arch
&& metadata.distribution === artifact.distribution
&& metadata.url === artifact.url
&& metadata.sha512 === artifact.sha512
&& metadata.fileName === path.basename(filePath)
&& metadata.fileName === path.basename(new URL(artifact.url).pathname)
&& checksum === artifact.sha512;
} catch {
return false;
}
}
export async function readCachedRecoveryArtifact(app, expected) {
const directories = [
path.join(app.getPath("userData"), RECOVERY_CACHE_DIRECTORY),
path.join(app.getPath("userData"), `${RECOVERY_CACHE_DIRECTORY}.backup`),
];
for (const directory of directories) {
try {
const metadata = JSON.parse(await readFile(path.join(directory, "metadata.json"), "utf8"));
const artifact = {
version: stableVersion(metadata.version),
platform: typeof metadata.platform === "string" ? metadata.platform : null,
arch: typeof metadata.arch === "string" ? metadata.arch : null,
distribution: typeof metadata.distribution === "string" ? metadata.distribution : null,
url: typeof metadata.url === "string" ? metadata.url : null,
sha512: typeof metadata.sha512 === "string" ? metadata.sha512 : null,
};
if (
!artifact.version
|| !artifact.url
|| !artifact.sha512
|| artifact.platform !== expected.platform
|| artifact.arch !== expected.arch
|| artifact.distribution !== expected.distribution
|| !validRecoveryArtifactIdentity(artifact)
) continue;
const fileName = typeof metadata.fileName === "string" ? metadata.fileName : "";
if (!fileName || fileName !== path.basename(fileName)) continue;
const filePath = path.join(directory, fileName);
if (!(await verifyCachedRecoveryArtifact(filePath, artifact))) continue;
return { artifact, filePath };
} catch {
// Missing, incomplete, or tampered caches are ignored.
}
}
return null;
}
export { compareStableVersions, stableVersion };