737 lines
30 KiB
JavaScript
737 lines
30 KiB
JavaScript
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 };
|
|
}
|