287 lines
11 KiB
JavaScript
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 };
|