源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user