源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
import { readFile, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
cacheVerifiedRecoveryArtifact,
|
||||
compatibleRecoveryReleases,
|
||||
compareStableVersions,
|
||||
parseRecoveryManifest,
|
||||
readCachedRecoveryArtifact,
|
||||
readRecoveryState,
|
||||
recordHealthyVersion,
|
||||
recoveryManifestName,
|
||||
recoveryVersionMarkers,
|
||||
selectRecoveryArtifact,
|
||||
stableVersion,
|
||||
verifyCachedRecoveryArtifact,
|
||||
} from "./recovery.mjs";
|
||||
|
||||
const ELECTRON_UPDATER_CHANNEL_FILENAME = "electron-updater-channel.v1.json";
|
||||
|
||||
// In dev mode, app.getVersion() returns the Electron framework version
|
||||
// (e.g. "35.7.5") instead of the OpenWork app version. Read from
|
||||
// package.json so the UI always shows the correct version.
|
||||
const __updater_dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
let _cachedAppVersion = null;
|
||||
function resolveAppVersion(app) {
|
||||
if (_cachedAppVersion) return _cachedAppVersion;
|
||||
const electronVersion = app.getVersion();
|
||||
// If packaged, app.getVersion() is correct (set by electron-builder).
|
||||
if (app.isPackaged) {
|
||||
_cachedAppVersion = electronVersion;
|
||||
return electronVersion;
|
||||
}
|
||||
// In dev, read from package.json.
|
||||
try {
|
||||
const pkgPath = path.resolve(__updater_dirname, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
_cachedAppVersion = pkg.version || electronVersion;
|
||||
} catch {
|
||||
_cachedAppVersion = electronVersion;
|
||||
}
|
||||
return _cachedAppVersion;
|
||||
}
|
||||
const ELECTRON_UPDATER_FEEDS = Object.freeze({
|
||||
stable: "https://github.com/different-ai/openwork/releases/latest/download",
|
||||
alpha: "https://github.com/different-ai/openwork/releases/download/alpha-macos-latest",
|
||||
});
|
||||
|
||||
function normalizeElectronUpdaterChannel(value, manifestChannel = "latest") {
|
||||
if (manifestChannel !== "latest") return "stable";
|
||||
if (value === "alpha" && process.platform === "darwin") return "alpha";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
function electronUpdaterChannelPath(app) {
|
||||
return path.join(app.getPath("userData"), ELECTRON_UPDATER_CHANNEL_FILENAME);
|
||||
}
|
||||
|
||||
async function readElectronUpdaterChannel(app, manifestChannel = "latest") {
|
||||
try {
|
||||
const raw = await readFile(electronUpdaterChannelPath(app), "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeElectronUpdaterChannel(parsed?.channel, manifestChannel);
|
||||
} catch {
|
||||
return "stable";
|
||||
}
|
||||
}
|
||||
|
||||
async function writeElectronUpdaterChannel(app, channel, manifestChannel = "latest") {
|
||||
const normalized = normalizeElectronUpdaterChannel(channel, manifestChannel);
|
||||
const outputPath = electronUpdaterChannelPath(app);
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(
|
||||
outputPath,
|
||||
`${JSON.stringify({ channel: normalized, writtenAt: new Date().toISOString() }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function electronUpdaterFeedUrl(channel, manifestChannel = "latest") {
|
||||
return ELECTRON_UPDATER_FEEDS[normalizeElectronUpdaterChannel(channel, manifestChannel)];
|
||||
}
|
||||
|
||||
function normalizeStableTargetVersion(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
return /^\d+\.\d+\.\d+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function parseComparableVersion(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
const [versionCore] = normalized.split("+", 1);
|
||||
if (!versionCore) return null;
|
||||
|
||||
const [releasePart, prereleasePart = ""] = versionCore.split("-", 2);
|
||||
const release = releasePart.split(".").map((segment) => Number(segment));
|
||||
if (!release.length || release.some((segment) => !Number.isInteger(segment) || segment < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prerelease = prereleasePart
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { release, prerelease };
|
||||
}
|
||||
|
||||
function comparePrereleaseIdentifiers(left, right) {
|
||||
if (!left.length && !right.length) return 0;
|
||||
if (!left.length) return 1;
|
||||
if (!right.length) return -1;
|
||||
|
||||
const count = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = left[index];
|
||||
const rightPart = right[index];
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
|
||||
const leftNumeric = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
|
||||
const rightNumeric = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
|
||||
|
||||
if (leftNumeric !== null && rightNumeric !== null) {
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric < rightNumeric ? -1 : 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftNumeric !== null) return -1;
|
||||
if (rightNumeric !== null) return 1;
|
||||
|
||||
const comparison = leftPart.localeCompare(rightPart);
|
||||
if (comparison !== 0) return comparison < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const parsedLeft = parseComparableVersion(left);
|
||||
const parsedRight = parseComparableVersion(right);
|
||||
if (!parsedLeft || !parsedRight) return null;
|
||||
|
||||
const count = Math.max(parsedLeft.release.length, parsedRight.release.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = parsedLeft.release[index] ?? 0;
|
||||
const rightPart = parsedRight.release[index] ?? 0;
|
||||
if (leftPart !== rightPart) return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
|
||||
return comparePrereleaseIdentifiers(parsedLeft.prerelease, parsedRight.prerelease);
|
||||
}
|
||||
|
||||
function isVersionNewer(candidate, current) {
|
||||
const comparison = compareVersions(candidate, current);
|
||||
return comparison === null ? candidate !== current : comparison > 0;
|
||||
}
|
||||
|
||||
export function targetedStableUpdaterFeed(currentVersion, targetVersion, allowOlder = false) {
|
||||
const normalizedTarget = normalizeStableTargetVersion(targetVersion);
|
||||
if (!normalizedTarget) {
|
||||
throw new Error("Target update version must use the stable x.y.z format.");
|
||||
}
|
||||
const comparison = compareVersions(normalizedTarget, currentVersion);
|
||||
if (comparison === null) {
|
||||
throw new Error("Installed version could not be validated for a targeted update.");
|
||||
}
|
||||
if (comparison === 0 || (!allowOlder && comparison < 0)) {
|
||||
throw new Error(allowOlder
|
||||
? "Recovery target version must differ from the installed version."
|
||||
: "Target update version must be newer than the installed version.");
|
||||
}
|
||||
return `https://github.com/different-ai/openwork/releases/download/v${normalizedTarget}`;
|
||||
}
|
||||
|
||||
function updaterChannelState(app, channel, targetVersion = null, manifestChannel = "latest") {
|
||||
const normalized = normalizeElectronUpdaterChannel(channel, manifestChannel);
|
||||
const currentVersion = resolveAppVersion(app);
|
||||
return {
|
||||
channel: normalized,
|
||||
feedUrl: targetVersion
|
||||
? targetedStableUpdaterFeed(currentVersion, targetVersion)
|
||||
: electronUpdaterFeedUrl(normalized, manifestChannel),
|
||||
currentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyElectronUpdaterFeed(
|
||||
app,
|
||||
updater,
|
||||
targetVersion = null,
|
||||
manifestChannel = "latest",
|
||||
allowOlder = false,
|
||||
channelOverride,
|
||||
) {
|
||||
const channel = channelOverride === undefined
|
||||
? await readElectronUpdaterChannel(app, manifestChannel)
|
||||
: normalizeElectronUpdaterChannel(channelOverride, manifestChannel);
|
||||
if (targetVersion && channel !== "stable") {
|
||||
throw new Error("Version-specific update feeds are supported only on the stable channel.");
|
||||
}
|
||||
const currentVersion = resolveAppVersion(app);
|
||||
const state = targetVersion
|
||||
? {
|
||||
channel,
|
||||
feedUrl: targetedStableUpdaterFeed(currentVersion, targetVersion, allowOlder),
|
||||
currentVersion,
|
||||
}
|
||||
: updaterChannelState(app, channel, null, manifestChannel);
|
||||
updater.allowPrerelease = state.channel === "alpha";
|
||||
// Moving from alpha back to stable can be a semver downgrade; still show
|
||||
// the latest stable so users can return to the stable channel deliberately.
|
||||
updater.allowDowngrade = state.channel === "stable" && (!targetVersion || allowOlder);
|
||||
// Select the manifest through the generic provider's own `channel` option
|
||||
// rather than AppUpdater#channel: that setter is a no-op unless the instance
|
||||
// was constructed with a channel, which would silently leave a custom
|
||||
// distribution reading latest*.yml and updating itself into the public app.
|
||||
// Public builds pass no channel and keep the provider's `latest` default.
|
||||
if (updater?.setFeedURL) {
|
||||
updater.setFeedURL({
|
||||
provider: "generic",
|
||||
url: state.feedUrl,
|
||||
...(manifestChannel !== "latest" ? { channel: manifestChannel } : {}),
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function runDefaults(args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile("/usr/bin/defaults", args, (error) => {
|
||||
// Best-effort: a failure here just means we fall back to Squirrel's
|
||||
// default move-based install. Never block the update on it.
|
||||
if (error) console.warn("[updater] defaults write failed", error?.message ?? error);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Squirrel.Mac's `ShipIt` helper (which swaps the .app on macOS) reads its
|
||||
// options from this NSUserDefaults domain.
|
||||
const SHIP_IT_DEFAULTS_DOMAIN = "com.differentai.openwork.ShipIt";
|
||||
|
||||
// Squirrel.Mac defaults to moving the *entire* app bundle through a temp
|
||||
// directory. On repeat installs that move can leave the staged bundle missing,
|
||||
// producing:
|
||||
// "Failed to copy bundle … no such file or directory"
|
||||
// "Too many attempts to install, aborting update"
|
||||
// and silently relaunching the OLD app (so the in-app version looks updated
|
||||
// while the on-disk renderer stays stale). Enabling DirectContentsWrite makes
|
||||
// ShipIt write file contents in place instead of moving whole bundles, which
|
||||
// avoids the ENOENT abort.
|
||||
async function enableSquirrelDirectContentsWrite(
|
||||
shipItDefaultsDomain = SHIP_IT_DEFAULTS_DOMAIN,
|
||||
) {
|
||||
if (process.platform !== "darwin") return;
|
||||
await runDefaults(["write", shipItDefaultsDomain, "SquirrelMacEnableDirectContentsWrite", "-bool", "YES"]);
|
||||
}
|
||||
|
||||
// Path of the ShipIt cache that, when stuck, keeps aborting future installs.
|
||||
// Exported for tests.
|
||||
export function staleUpdaterStatePaths(app, shipItDefaultsDomain = SHIP_IT_DEFAULTS_DOMAIN) {
|
||||
if (process.platform !== "darwin") return [];
|
||||
const home = app.getPath("home");
|
||||
return [path.join(home, "Library", "Caches", shipItDefaultsDomain)];
|
||||
}
|
||||
|
||||
// Remove a previously-failed, half-applied update so the next attempt starts
|
||||
// from a clean slate. A stuck `ShipIt` state (after "Too many attempts to
|
||||
// install, aborting update") can otherwise keep aborting future installs.
|
||||
async function cleanStaleUpdaterState(app, shipItDefaultsDomain) {
|
||||
for (const target of staleUpdaterStatePaths(app, shipItDefaultsDomain)) {
|
||||
try {
|
||||
await rm(target, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn("[updater] failed to clean stale state", target, error?.message ?? error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// electron-updater wiring. Packaged-only; dev builds skip this so the
|
||||
// updater doesn't try to probe a non-existent release channel.
|
||||
export function preventPendingUpdaterInstall(updater) {
|
||||
if (updater) updater.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
export function registerUpdaterIpc({
|
||||
app,
|
||||
ipcMain,
|
||||
getMainWindow,
|
||||
loadAutoUpdater = () => import("electron-updater"),
|
||||
manifestChannel = "latest",
|
||||
shipItDefaultsDomain = SHIP_IT_DEFAULTS_DOMAIN,
|
||||
electronNet = null,
|
||||
shell = null,
|
||||
distribution = "public",
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
env = process.env,
|
||||
}) {
|
||||
let autoUpdaterInstance = null;
|
||||
let autoUpdaterLoadPromise = null;
|
||||
let checkedUpdateVersion = null;
|
||||
let checkedUpdateTargetVersion = null;
|
||||
let checkedUpdateChannel = null;
|
||||
let updateDownloaded = false;
|
||||
let recoveryReleases = [];
|
||||
const recoveryWitness = { installRequests: [], openedArtifactUrls: [], quitRequested: false };
|
||||
let updaterOperationQueue = Promise.resolve();
|
||||
|
||||
function queueUpdaterOperation(operation) {
|
||||
const result = updaterOperationQueue.then(operation, operation);
|
||||
updaterOperationQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function sendToRenderer(channel, data) {
|
||||
try {
|
||||
const win = typeof getMainWindow === "function" ? getMainWindow() : null;
|
||||
if (win?.webContents && !win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
} catch {
|
||||
// Window may be closed; swallow send failures.
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAutoUpdater() {
|
||||
if (!app.isPackaged) return null;
|
||||
if (!autoUpdaterLoadPromise) {
|
||||
autoUpdaterLoadPromise = (async () => {
|
||||
try {
|
||||
const mod = await loadAutoUpdater();
|
||||
autoUpdaterInstance = mod.autoUpdater ?? mod.default?.autoUpdater ?? null;
|
||||
if (autoUpdaterInstance) {
|
||||
autoUpdaterInstance.autoDownload = false;
|
||||
autoUpdaterInstance.autoInstallOnAppQuit = true;
|
||||
// Differential (blockmap) downloads reconstruct the update zip from the
|
||||
// installed app + a diff. On macOS that reconstructed bundle is what
|
||||
// feeds Squirrel's fragile move-based install, and is a common trigger
|
||||
// for the "Failed to copy bundle … no such file" abort. Download the
|
||||
// full zip instead — alpha builds are swapped wholesale anyway.
|
||||
autoUpdaterInstance.disableDifferentialDownload = true;
|
||||
// Make Squirrel.Mac write contents in place rather than moving whole
|
||||
// bundles (see enableSquirrelDirectContentsWrite for why).
|
||||
await enableSquirrelDirectContentsWrite(shipItDefaultsDomain);
|
||||
autoUpdaterInstance.on("error", (err) => {
|
||||
// Do not invalidate a staged download on arbitrary updater errors.
|
||||
// A later transient check failure does not delete the downloaded
|
||||
// update; quitAndInstall reports a descriptive failure if it is gone.
|
||||
console.warn("[updater] error", err);
|
||||
});
|
||||
autoUpdaterInstance.on("update-downloaded", () => {
|
||||
updateDownloaded = true;
|
||||
});
|
||||
// Forward download progress to the renderer so the UI can show
|
||||
// incremental bytes instead of staying stuck at 0.
|
||||
autoUpdaterInstance.on("download-progress", (info) => {
|
||||
sendToRenderer("openwork:updater:download-progress", {
|
||||
bytesPerSecond: info.bytesPerSecond ?? 0,
|
||||
percent: info.percent ?? 0,
|
||||
transferred: info.transferred ?? 0,
|
||||
total: info.total ?? 0,
|
||||
delta: info.delta ?? 0,
|
||||
});
|
||||
});
|
||||
await applyElectronUpdaterFeed(app, autoUpdaterInstance, null, manifestChannel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[updater] electron-updater not available", error);
|
||||
autoUpdaterInstance = null;
|
||||
}
|
||||
return autoUpdaterInstance;
|
||||
})();
|
||||
}
|
||||
return autoUpdaterLoadPromise;
|
||||
}
|
||||
|
||||
async function resolveRecoveryArtifact(version) {
|
||||
if (!electronNet?.fetch) return null;
|
||||
try {
|
||||
const manifestUrl = `https://github.com/different-ai/openwork/releases/download/v${version}/${recoveryManifestName(platform, arch, distribution)}`;
|
||||
const response = await electronNet.fetch(manifestUrl, { headers: { Accept: "text/yaml, text/plain, */*" } });
|
||||
if (!response.ok) return null;
|
||||
return selectRecoveryArtifact(parseRecoveryManifest(await response.text()), {
|
||||
version,
|
||||
platform,
|
||||
arch,
|
||||
distribution,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheCurrentHealthyRelease() {
|
||||
if (!electronNet?.fetch) return null;
|
||||
const currentVersion = stableVersion(resolveAppVersion(app));
|
||||
if (!currentVersion) return null;
|
||||
const state = await readRecoveryState(app, distribution);
|
||||
if (state.currentVersion !== currentVersion) return null;
|
||||
const existing = await readCachedRecoveryArtifact(app, { platform, arch, distribution });
|
||||
if (existing?.artifact.version === currentVersion) return existing;
|
||||
const artifact = await resolveRecoveryArtifact(currentVersion);
|
||||
if (!artifact) return null;
|
||||
const filePath = await cacheVerifiedRecoveryArtifact({
|
||||
app,
|
||||
artifact,
|
||||
fetchArtifact: (url) => electronNet.fetch(url),
|
||||
});
|
||||
return { artifact, filePath };
|
||||
}
|
||||
|
||||
function evalRecoveryReleases() {
|
||||
if (typeof env.OPENWORK_EVAL_RECOVERY_RELEASES === "string") {
|
||||
try {
|
||||
const target = String(env.OPENWORK_EVAL_RECOVERY_TARGET ?? "").split("-");
|
||||
const targetPlatform = target[0];
|
||||
const targetArch = target[1];
|
||||
const targetDistribution = target.slice(2).join("-");
|
||||
const raw = JSON.parse(env.OPENWORK_EVAL_RECOVERY_RELEASES);
|
||||
const stable = Array.isArray(raw) ? raw.filter((release) =>
|
||||
stableVersion(release?.version)
|
||||
&& release?.channel === "stable"
|
||||
&& release?.artifact?.platform === targetPlatform
|
||||
&& release?.artifact?.arch === targetArch
|
||||
&& release?.artifact?.distribution === targetDistribution
|
||||
&& typeof release?.artifact?.url === "string",
|
||||
) : [];
|
||||
return stable.map((release, index) => ({
|
||||
id: release.version,
|
||||
version: release.version,
|
||||
marking: index === 0 ? "current" : index === 1 ? "previous" : null,
|
||||
artifact: release.artifact,
|
||||
cachedFilePath: null,
|
||||
eval: true,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (typeof env.OPENWORK_EVAL_RECOVERY_CANDIDATES === "string") {
|
||||
try {
|
||||
const raw = JSON.parse(env.OPENWORK_EVAL_RECOVERY_CANDIDATES);
|
||||
return Array.isArray(raw) ? raw.filter((candidate) =>
|
||||
candidate?.verified === true
|
||||
&& stableVersion(candidate?.version)
|
||||
&& typeof candidate?.artifactUrl === "string",
|
||||
).map((candidate) => ({
|
||||
id: candidate.version,
|
||||
version: candidate.version,
|
||||
marking: "previous",
|
||||
artifact: { platform, arch, distribution, url: candidate.artifactUrl },
|
||||
cachedFilePath: null,
|
||||
eval: true,
|
||||
})) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ipcMain.handle("openwork:recovery:recordHealthy", async () => {
|
||||
return recordHealthyVersion(app, distribution, resolveAppVersion(app));
|
||||
});
|
||||
|
||||
ipcMain.handle("openwork:recovery:list", async (_event, policy = {}) => {
|
||||
const evalReleases = evalRecoveryReleases();
|
||||
if (evalReleases) {
|
||||
recoveryReleases = evalReleases;
|
||||
return {
|
||||
ok: true,
|
||||
releases: recoveryReleases.map(({ id, version, marking }) => ({ id, version, marking })),
|
||||
};
|
||||
}
|
||||
const state = await readRecoveryState(app, distribution);
|
||||
const installedVersion = resolveAppVersion(app);
|
||||
const markers = recoveryVersionMarkers(installedVersion, state);
|
||||
const cached = await readCachedRecoveryArtifact(app, { platform, arch, distribution });
|
||||
const catalogVersions = Array.isArray(policy?.versions) ? policy.versions : [];
|
||||
const localOnly = catalogVersions.length === 0;
|
||||
recoveryReleases = await compatibleRecoveryReleases({
|
||||
versions: [
|
||||
...catalogVersions,
|
||||
installedVersion,
|
||||
...(state.currentVersion ? [state.currentVersion] : []),
|
||||
...(state.previousVersion ? [state.previousVersion] : []),
|
||||
...(cached ? [cached.artifact.version] : []),
|
||||
],
|
||||
currentVersion: markers.currentVersion,
|
||||
previousVersion: markers.previousVersion,
|
||||
minimumVersion: policy?.minimumVersion,
|
||||
allowedVersions: policy?.allowedVersions,
|
||||
resolveArtifact: async (version) =>
|
||||
cached?.artifact.version === version
|
||||
? { ...cached.artifact, cachedFilePath: cached.filePath }
|
||||
: localOnly ? null : resolveRecoveryArtifact(version),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
releases: recoveryReleases.map(({ id, version, marking }) => ({ id, version, marking })),
|
||||
};
|
||||
});
|
||||
|
||||
async function useRecoveryRelease(rawId) {
|
||||
const id = stableVersion(rawId);
|
||||
const release = id ? recoveryReleases.find((candidate) => candidate.id === id) : null;
|
||||
if (!release) return { ok: false, reason: "That recovery version is no longer available. Retry the release list." };
|
||||
if (release.eval) {
|
||||
if (env.OPENWORK_EVAL_RECOVERY_CANDIDATES) {
|
||||
recoveryWitness.installRequests.push({ version: release.version, artifactUrl: release.artifact.url });
|
||||
} else {
|
||||
recoveryWitness.openedArtifactUrls.push(release.artifact.url);
|
||||
}
|
||||
return { ok: true, action: "eval" };
|
||||
}
|
||||
if (compareStableVersions(release.version, resolveAppVersion(app)) === 0) {
|
||||
return { ok: false, reason: "That version is already installed." };
|
||||
}
|
||||
if (release.cachedFilePath) {
|
||||
if (!(await verifyCachedRecoveryArtifact(release.cachedFilePath, release.artifact))) {
|
||||
return { ok: false, reason: "The cached recovery installer could not be verified. Retry while online." };
|
||||
}
|
||||
if (!shell?.openPath) return { ok: false, reason: "This package cannot open the recovery installer." };
|
||||
const openError = await shell.openPath(release.cachedFilePath);
|
||||
if (openError) return { ok: false, reason: openError };
|
||||
return {
|
||||
ok: true,
|
||||
action: "installer",
|
||||
message: "The verified installer is open. Follow the operating system steps to finish.",
|
||||
};
|
||||
}
|
||||
const freshArtifact = await resolveRecoveryArtifact(release.version);
|
||||
if (!freshArtifact || freshArtifact.url !== release.artifact.url || freshArtifact.sha512 !== release.artifact.sha512) {
|
||||
return { ok: false, reason: "This installer could not be verified. Refresh the list and try again." };
|
||||
}
|
||||
const currentVersion = resolveAppVersion(app);
|
||||
const updater = await ensureAutoUpdater();
|
||||
if (updater && app.isPackaged) {
|
||||
try {
|
||||
await applyElectronUpdaterFeed(app, updater, release.version, manifestChannel, true);
|
||||
const result = await updater.checkForUpdates();
|
||||
if (compareVersions(result?.updateInfo?.version ?? "", release.version) !== 0) {
|
||||
throw new Error("Recovery manifest resolved to a different version.");
|
||||
}
|
||||
if (compareStableVersions(release.version, currentVersion) === null) {
|
||||
throw new Error("Installed version could not be validated.");
|
||||
}
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
await updater.downloadUpdate();
|
||||
updater.quitAndInstall(false, true);
|
||||
return { ok: true, action: "install" };
|
||||
} catch (error) {
|
||||
preventPendingUpdaterInstall(updater);
|
||||
return { ok: false, reason: String(error?.message ?? error) };
|
||||
}
|
||||
}
|
||||
if (!electronNet?.fetch || !shell?.openPath) return { ok: false, reason: "Automatic recovery is unavailable on this package." };
|
||||
try {
|
||||
const filePath = await cacheVerifiedRecoveryArtifact({
|
||||
app,
|
||||
artifact: freshArtifact,
|
||||
fetchArtifact: (url) => electronNet.fetch(url),
|
||||
});
|
||||
if (!(await verifyCachedRecoveryArtifact(filePath, freshArtifact))) {
|
||||
return { ok: false, reason: "The downloaded recovery installer could not be verified." };
|
||||
}
|
||||
const openError = await shell.openPath(filePath);
|
||||
if (openError) return { ok: false, reason: openError };
|
||||
return { ok: true, action: "installer", message: "The verified installer is open. Follow the operating system steps to finish." };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: String(error?.message ?? error) };
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("openwork:recovery:use", async (_event, id) =>
|
||||
queueUpdaterOperation(() => useRecoveryRelease(id)));
|
||||
ipcMain.handle("openwork:recovery:restorePrevious", async () => {
|
||||
return queueUpdaterOperation(() => {
|
||||
const previous = recoveryReleases.find((release) => release.marking === "previous");
|
||||
return previous ? useRecoveryRelease(previous.id) : { ok: false, reason: "No verified previous version is available." };
|
||||
});
|
||||
});
|
||||
ipcMain.handle("openwork:recovery:evalSnapshot", async () => ({
|
||||
candidates: recoveryReleases,
|
||||
releases: recoveryReleases,
|
||||
...recoveryWitness,
|
||||
}));
|
||||
|
||||
ipcMain.handle("openwork:updater:getChannel", async () => queueUpdaterOperation(async () => {
|
||||
const channel = await readElectronUpdaterChannel(app, manifestChannel);
|
||||
return updaterChannelState(app, channel, null, manifestChannel);
|
||||
}));
|
||||
|
||||
ipcMain.handle("openwork:updater:setChannel", async (_event, rawChannel) => queueUpdaterOperation(async () => {
|
||||
const channel = await writeElectronUpdaterChannel(app, rawChannel, manifestChannel);
|
||||
checkedUpdateVersion = null;
|
||||
checkedUpdateTargetVersion = null;
|
||||
checkedUpdateChannel = null;
|
||||
updateDownloaded = false;
|
||||
const updater = await ensureAutoUpdater();
|
||||
if (updater) {
|
||||
// A channel change invalidates any previously downloaded update. This
|
||||
// also prevents an Alpha build from installing automatically on quit
|
||||
// after an organization policy moves the desktop back to Stable.
|
||||
preventPendingUpdaterInstall(updater);
|
||||
return applyElectronUpdaterFeed(app, updater, null, manifestChannel, false, channel);
|
||||
}
|
||||
return updaterChannelState(app, channel, null, manifestChannel);
|
||||
}));
|
||||
|
||||
ipcMain.handle("openwork:updater:check", async (_event, rawChannel, rawTargetVersion) => queueUpdaterOperation(async () => {
|
||||
// A check selects a feed for this operation only. The persisted preference
|
||||
// belongs exclusively to setChannel so a stale check cannot undo a choice.
|
||||
const channel = rawChannel === undefined
|
||||
? await readElectronUpdaterChannel(app, manifestChannel)
|
||||
: normalizeElectronUpdaterChannel(rawChannel, manifestChannel);
|
||||
const updater = await ensureAutoUpdater();
|
||||
try {
|
||||
const targetVersion = rawTargetVersion === undefined
|
||||
? null
|
||||
: normalizeStableTargetVersion(rawTargetVersion);
|
||||
if (rawTargetVersion !== undefined && !targetVersion) {
|
||||
throw new Error("Target update version must use the stable x.y.z format.");
|
||||
}
|
||||
const channelState = updater
|
||||
? await applyElectronUpdaterFeed(app, updater, targetVersion, manifestChannel, false, channel)
|
||||
: updaterChannelState(app, channel, targetVersion, manifestChannel);
|
||||
if (!updater) return { available: false, reason: "unavailable", ...channelState };
|
||||
|
||||
const result = await updater.checkForUpdates();
|
||||
const info = result?.updateInfo ?? null;
|
||||
const currentVersion = resolveAppVersion(app);
|
||||
if (targetVersion && compareVersions(info?.version ?? "", targetVersion) !== 0) {
|
||||
throw new Error(`Target update manifest did not resolve to v${targetVersion}.`);
|
||||
}
|
||||
const available = Boolean(info?.version && isVersionNewer(info.version, currentVersion));
|
||||
checkedUpdateVersion = available ? info.version : null;
|
||||
checkedUpdateTargetVersion = available ? targetVersion : null;
|
||||
checkedUpdateChannel = available ? channelState.channel : null;
|
||||
if (!available) updateDownloaded = false;
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion: targetVersion ?? info?.version ?? null,
|
||||
releaseDate: info?.releaseDate ?? null,
|
||||
releaseNotes: info?.releaseNotes ?? null,
|
||||
...channelState,
|
||||
};
|
||||
} catch (error) {
|
||||
checkedUpdateVersion = null;
|
||||
checkedUpdateTargetVersion = null;
|
||||
checkedUpdateChannel = null;
|
||||
// A transient failed check must not invalidate an already-downloaded update.
|
||||
return {
|
||||
available: false,
|
||||
reason: String(error?.message ?? error),
|
||||
...updaterChannelState(
|
||||
app,
|
||||
channel,
|
||||
null,
|
||||
manifestChannel,
|
||||
),
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
ipcMain.handle("openwork:updater:download", async () => queueUpdaterOperation(async () => {
|
||||
const updater = await ensureAutoUpdater();
|
||||
if (!updater) return { ok: false, reason: "unavailable" };
|
||||
try {
|
||||
await applyElectronUpdaterFeed(
|
||||
app,
|
||||
updater,
|
||||
checkedUpdateTargetVersion,
|
||||
manifestChannel,
|
||||
false,
|
||||
checkedUpdateChannel ?? undefined,
|
||||
);
|
||||
const currentVersion = resolveAppVersion(app);
|
||||
if (!checkedUpdateVersion || !isVersionNewer(checkedUpdateVersion, currentVersion)) {
|
||||
const result = await updater.checkForUpdates();
|
||||
const info = result?.updateInfo ?? null;
|
||||
if (
|
||||
checkedUpdateTargetVersion &&
|
||||
compareVersions(info?.version ?? "", checkedUpdateTargetVersion) !== 0
|
||||
) {
|
||||
throw new Error(`Target update manifest did not resolve to v${checkedUpdateTargetVersion}.`);
|
||||
}
|
||||
checkedUpdateVersion = info?.version && isVersionNewer(info.version, currentVersion)
|
||||
? info.version
|
||||
: null;
|
||||
}
|
||||
if (!checkedUpdateVersion) {
|
||||
return { ok: false, reason: "No update available." };
|
||||
}
|
||||
await cacheCurrentHealthyRelease().catch((error) => {
|
||||
console.warn("[updater] could not cache the current healthy installer", error);
|
||||
});
|
||||
// Clear any stuck ShipIt state from a prior aborted install so this
|
||||
// download applies cleanly on quit.
|
||||
await cleanStaleUpdaterState(app, shipItDefaultsDomain);
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
await updater.downloadUpdate();
|
||||
updateDownloaded = true;
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
updateDownloaded = false;
|
||||
return { ok: false, reason: String(error?.message ?? error) };
|
||||
}
|
||||
}));
|
||||
|
||||
ipcMain.handle("openwork:updater:installAndRestart", async () => queueUpdaterOperation(async () => {
|
||||
if (!updateDownloaded) return { ok: false, reason: "update-not-downloaded" };
|
||||
const updater = await ensureAutoUpdater();
|
||||
if (!updater) return { ok: false, reason: "unavailable" };
|
||||
try {
|
||||
// Re-assert the in-place-write default right before the swap; the ShipIt
|
||||
// defaults domain may have been wiped when stale state was cleaned.
|
||||
await enableSquirrelDirectContentsWrite();
|
||||
updater.quitAndInstall(false, true);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: String(error?.message ?? error) };
|
||||
}
|
||||
}));
|
||||
|
||||
return { ensureAutoUpdater };
|
||||
}
|
||||
Reference in New Issue
Block a user