Files

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 };
}