mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Let manual update checks bypass rollout
Automatic desktop update checks still respect rollout admission. Manual checks carry an explicit intent through the app and desktop updater service, and the up-to-date state now shows when the last check completed.
This commit is contained in:
@@ -89,7 +89,7 @@ Use the beta path when you need to:
|
|||||||
|
|
||||||
## Staged rollout (stable channel)
|
## Staged rollout (stable channel)
|
||||||
|
|
||||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||||
|
|
||||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
|||||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
|
||||||
|
|
||||||
## Mobile builds (EAS)
|
## Mobile builds (EAS)
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,22 @@ function createUpdater(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("desktop app updater — check", () => {
|
describe("desktop app updater — check", () => {
|
||||||
it("forwards the requested release channel to the port", async () => {
|
it("forwards manual check intent and the requested release channel to the port", async () => {
|
||||||
const { updater, port } = createUpdater();
|
const { updater, port } = createUpdater();
|
||||||
port.nextCheckResult(buildFakeCheckResult());
|
port.nextCheckResult(buildFakeCheckResult());
|
||||||
|
|
||||||
await updater.checkForUpdates({ releaseChannel: "beta" });
|
await updater.checkForUpdates({ releaseChannel: "beta" });
|
||||||
|
|
||||||
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]);
|
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta", intent: "manual" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards automatic check intent independently from silent UI state", async () => {
|
||||||
|
const { updater, port } = createUpdater();
|
||||||
|
port.nextCheckResult(buildFakeCheckResult());
|
||||||
|
|
||||||
|
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
|
||||||
|
|
||||||
|
expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "automatic" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves to 'checking' during a non-silent check", async () => {
|
it("moves to 'checking' during a non-silent check", async () => {
|
||||||
@@ -66,7 +75,11 @@ describe("desktop app updater — check", () => {
|
|||||||
expect(updater.getSnapshot().status).toBe("available");
|
expect(updater.getSnapshot().status).toBe("available");
|
||||||
|
|
||||||
const deferred = port.deferNextCheck();
|
const deferred = port.deferNextCheck();
|
||||||
const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true });
|
const pending = updater.checkForUpdates({
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
expect(updater.getSnapshot().status).toBe("available");
|
expect(updater.getSnapshot().status).toBe("available");
|
||||||
|
|
||||||
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||||
@@ -128,7 +141,7 @@ describe("desktop app updater — check", () => {
|
|||||||
const statusBeforeSilent = updater.getSnapshot().status;
|
const statusBeforeSilent = updater.getSnapshot().status;
|
||||||
|
|
||||||
port.failNextCheck(new Error("boom"));
|
port.failNextCheck(new Error("boom"));
|
||||||
await updater.checkForUpdates({ releaseChannel: "stable", silent: true });
|
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
|
||||||
|
|
||||||
expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
|
expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
|
||||||
});
|
});
|
||||||
@@ -226,6 +239,20 @@ describe("desktop app updater — subscribe", () => {
|
|||||||
describe("formatStatusText", () => {
|
describe("formatStatusText", () => {
|
||||||
const formatVersion = (version: string | null | undefined) =>
|
const formatVersion = (version: string | null | undefined) =>
|
||||||
version ? `v${version.replace(/^v/i, "")}` : "\u2014";
|
version ? `v${version.replace(/^v/i, "")}` : "\u2014";
|
||||||
|
const formatLastCheckedAt = (timestamp: number) => `time-${timestamp}`;
|
||||||
|
|
||||||
|
it("shows when an up-to-date check completed", () => {
|
||||||
|
expect(
|
||||||
|
formatStatusText({
|
||||||
|
status: "up-to-date",
|
||||||
|
availableUpdate: null,
|
||||||
|
installMessage: null,
|
||||||
|
lastCheckedAt: 42,
|
||||||
|
formatVersion,
|
||||||
|
formatLastCheckedAt,
|
||||||
|
}),
|
||||||
|
).toBe("Up to date. Last checked at time-42.");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the latest version in the 'available' message when present", () => {
|
it("uses the latest version in the 'available' message when present", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -233,7 +260,9 @@ describe("formatStatusText", () => {
|
|||||||
status: "available",
|
status: "available",
|
||||||
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
||||||
installMessage: null,
|
installMessage: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
formatVersion,
|
formatVersion,
|
||||||
|
formatLastCheckedAt,
|
||||||
}),
|
}),
|
||||||
).toBe("Update ready: v1.2.3");
|
).toBe("Update ready: v1.2.3");
|
||||||
});
|
});
|
||||||
@@ -244,7 +273,9 @@ describe("formatStatusText", () => {
|
|||||||
status: "available",
|
status: "available",
|
||||||
availableUpdate: null,
|
availableUpdate: null,
|
||||||
installMessage: null,
|
installMessage: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
formatVersion,
|
formatVersion,
|
||||||
|
formatLastCheckedAt,
|
||||||
}),
|
}),
|
||||||
).toBe("An app update is ready to install.");
|
).toBe("An app update is ready to install.");
|
||||||
});
|
});
|
||||||
@@ -255,7 +286,9 @@ describe("formatStatusText", () => {
|
|||||||
status: "installed",
|
status: "installed",
|
||||||
availableUpdate: null,
|
availableUpdate: null,
|
||||||
installMessage: "Restart now",
|
installMessage: "Restart now",
|
||||||
|
lastCheckedAt: null,
|
||||||
formatVersion,
|
formatVersion,
|
||||||
|
formatLastCheckedAt,
|
||||||
}),
|
}),
|
||||||
).toBe("Restart now");
|
).toBe("Restart now");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
DesktopAppUpdateCheckResult,
|
DesktopAppUpdateCheckResult,
|
||||||
|
DesktopAppUpdateCheckIntent,
|
||||||
DesktopAppUpdateInstallResult,
|
DesktopAppUpdateInstallResult,
|
||||||
DesktopReleaseChannel,
|
DesktopReleaseChannel,
|
||||||
} from "@/desktop/updates/desktop-updates";
|
} from "@/desktop/updates/desktop-updates";
|
||||||
@@ -29,6 +30,7 @@ export interface DesktopAppUpdaterSnapshot {
|
|||||||
export interface DesktopAppUpdaterPort {
|
export interface DesktopAppUpdaterPort {
|
||||||
checkDesktopAppUpdate(input: {
|
checkDesktopAppUpdate(input: {
|
||||||
releaseChannel: DesktopReleaseChannel;
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
intent: DesktopAppUpdateCheckIntent;
|
||||||
}): Promise<DesktopAppUpdateCheckResult>;
|
}): Promise<DesktopAppUpdateCheckResult>;
|
||||||
installDesktopAppUpdate(input: {
|
installDesktopAppUpdate(input: {
|
||||||
releaseChannel: DesktopReleaseChannel;
|
releaseChannel: DesktopReleaseChannel;
|
||||||
@@ -52,6 +54,7 @@ export interface DesktopAppUpdater {
|
|||||||
subscribe(listener: () => void): () => void;
|
subscribe(listener: () => void): () => void;
|
||||||
checkForUpdates(options?: {
|
checkForUpdates(options?: {
|
||||||
releaseChannel: DesktopReleaseChannel;
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
intent?: DesktopAppUpdateCheckIntent;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}): Promise<DesktopAppUpdateCheckResult | null>;
|
}): Promise<DesktopAppUpdateCheckResult | null>;
|
||||||
installUpdate(options: {
|
installUpdate(options: {
|
||||||
@@ -102,9 +105,18 @@ export function formatStatusText(input: {
|
|||||||
status: DesktopAppUpdateStatus;
|
status: DesktopAppUpdateStatus;
|
||||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||||
installMessage: string | null;
|
installMessage: string | null;
|
||||||
|
lastCheckedAt: number | null;
|
||||||
formatVersion: (version: string | null | undefined) => string;
|
formatVersion: (version: string | null | undefined) => string;
|
||||||
|
formatLastCheckedAt: (timestamp: number) => string;
|
||||||
}): string {
|
}): string {
|
||||||
const { status, availableUpdate, installMessage, formatVersion } = input;
|
const {
|
||||||
|
status,
|
||||||
|
availableUpdate,
|
||||||
|
installMessage,
|
||||||
|
lastCheckedAt,
|
||||||
|
formatVersion,
|
||||||
|
formatLastCheckedAt,
|
||||||
|
} = input;
|
||||||
|
|
||||||
if (status === "checking") {
|
if (status === "checking") {
|
||||||
return "Checking for app updates...";
|
return "Checking for app updates...";
|
||||||
@@ -115,7 +127,10 @@ export function formatStatusText(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "up-to-date") {
|
if (status === "up-to-date") {
|
||||||
return "App is up to date.";
|
if (lastCheckedAt != null) {
|
||||||
|
return `Up to date. Last checked at ${formatLastCheckedAt(lastCheckedAt)}.`;
|
||||||
|
}
|
||||||
|
return "Up to date.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
@@ -155,12 +170,13 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
|||||||
|
|
||||||
async function checkForUpdates(options?: {
|
async function checkForUpdates(options?: {
|
||||||
releaseChannel: DesktopReleaseChannel;
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
intent?: DesktopAppUpdateCheckIntent;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}): Promise<DesktopAppUpdateCheckResult | null> {
|
}): Promise<DesktopAppUpdateCheckResult | null> {
|
||||||
if (!options) {
|
if (!options) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { releaseChannel, silent = false } = options;
|
const { releaseChannel, intent = "manual", silent = false } = options;
|
||||||
const requestVersion = state.requestVersion + 1;
|
const requestVersion = state.requestVersion + 1;
|
||||||
|
|
||||||
commit({
|
commit({
|
||||||
@@ -171,7 +187,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel });
|
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel, intent });
|
||||||
if (requestVersion !== state.requestVersion) {
|
if (requestVersion !== state.requestVersion) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface DesktopRuntimeInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DesktopReleaseChannel = "stable" | "beta";
|
export type DesktopReleaseChannel = "stable" | "beta";
|
||||||
|
export type DesktopAppUpdateCheckIntent = "automatic" | "manual";
|
||||||
|
|
||||||
export interface LocalDaemonUpdateResult {
|
export interface LocalDaemonUpdateResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
@@ -99,10 +100,15 @@ export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
|
|||||||
|
|
||||||
export async function checkDesktopAppUpdate({
|
export async function checkDesktopAppUpdate({
|
||||||
releaseChannel,
|
releaseChannel,
|
||||||
|
intent,
|
||||||
}: {
|
}: {
|
||||||
releaseChannel: DesktopReleaseChannel;
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
intent: DesktopAppUpdateCheckIntent;
|
||||||
}): Promise<DesktopAppUpdateCheckResult> {
|
}): Promise<DesktopAppUpdateCheckResult> {
|
||||||
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
|
const result = await invokeDesktopCommand<unknown>("check_app_update", {
|
||||||
|
releaseChannel,
|
||||||
|
intent,
|
||||||
|
});
|
||||||
if (!isRecord(result)) {
|
if (!isRecord(result)) {
|
||||||
throw new Error("Unexpected response while checking desktop updates.");
|
throw new Error("Unexpected response while checking desktop updates.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
DesktopAppUpdateCheckResult,
|
DesktopAppUpdateCheckResult,
|
||||||
|
DesktopAppUpdateCheckIntent,
|
||||||
DesktopAppUpdateInstallResult,
|
DesktopAppUpdateInstallResult,
|
||||||
DesktopReleaseChannel,
|
DesktopReleaseChannel,
|
||||||
} from "@/desktop/updates/desktop-updates";
|
} from "@/desktop/updates/desktop-updates";
|
||||||
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
|
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
|
||||||
|
|
||||||
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
|
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
|
||||||
readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
readonly recordedChecks: Array<{
|
||||||
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
intent: DesktopAppUpdateCheckIntent;
|
||||||
|
}>;
|
||||||
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
||||||
nextCheckResult(result: DesktopAppUpdateCheckResult): void;
|
nextCheckResult(result: DesktopAppUpdateCheckResult): void;
|
||||||
deferNextCheck(): {
|
deferNextCheck(): {
|
||||||
@@ -53,7 +57,10 @@ function buildInstallResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
|
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
|
||||||
const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
const recordedChecks: Array<{
|
||||||
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
intent: DesktopAppUpdateCheckIntent;
|
||||||
|
}> = [];
|
||||||
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
||||||
const checkOutcomes: CheckOutcome[] = [];
|
const checkOutcomes: CheckOutcome[] = [];
|
||||||
const installOutcomes: InstallOutcome[] = [];
|
const installOutcomes: InstallOutcome[] = [];
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export function UpdateCalloutSource() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesktopApp) return;
|
if (!isDesktopApp) return;
|
||||||
|
|
||||||
void checkForUpdates({ silent: true });
|
void checkForUpdates({ intent: "automatic", silent: true });
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
void checkForUpdates({ silent: true });
|
void checkForUpdates({ intent: "automatic", silent: true });
|
||||||
}, CHECK_INTERVAL_MS);
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
installDesktopAppUpdate,
|
installDesktopAppUpdate,
|
||||||
shouldShowDesktopUpdateSection,
|
shouldShowDesktopUpdateSection,
|
||||||
type DesktopAppUpdateCheckResult,
|
type DesktopAppUpdateCheckResult,
|
||||||
|
type DesktopAppUpdateCheckIntent,
|
||||||
type DesktopAppUpdateInstallResult,
|
type DesktopAppUpdateInstallResult,
|
||||||
} from "@/desktop/updates/desktop-updates";
|
} from "@/desktop/updates/desktop-updates";
|
||||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
formatStatusText,
|
formatStatusText,
|
||||||
type DesktopAppUpdateStatus,
|
type DesktopAppUpdateStatus,
|
||||||
} from "@/desktop/updates/desktop-app-updater";
|
} from "@/desktop/updates/desktop-app-updater";
|
||||||
|
import { formatMessageTimestamp } from "@/utils/time";
|
||||||
|
|
||||||
export type { DesktopAppUpdateStatus };
|
export type { DesktopAppUpdateStatus };
|
||||||
|
|
||||||
@@ -27,7 +29,10 @@ export interface UseDesktopAppUpdaterReturn {
|
|||||||
lastCheckedAt: number | null;
|
lastCheckedAt: number | null;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>;
|
checkForUpdates: (options?: {
|
||||||
|
intent?: DesktopAppUpdateCheckIntent;
|
||||||
|
silent?: boolean;
|
||||||
|
}) => Promise<DesktopAppUpdateCheckResult | null>;
|
||||||
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
|
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +62,15 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const checkForUpdates = useCallback(
|
const checkForUpdates = useCallback(
|
||||||
async (options: { silent?: boolean } = {}) => {
|
async (options: { intent?: DesktopAppUpdateCheckIntent; silent?: boolean } = {}) => {
|
||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return updater.checkForUpdates({ releaseChannel, silent: options.silent });
|
return updater.checkForUpdates({
|
||||||
|
releaseChannel,
|
||||||
|
intent: options.intent ?? "manual",
|
||||||
|
silent: options.silent,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[isDesktopApp, releaseChannel, updater],
|
[isDesktopApp, releaseChannel, updater],
|
||||||
);
|
);
|
||||||
@@ -77,7 +86,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void checkForUpdates({ silent: true });
|
void checkForUpdates({ intent: "automatic", silent: true });
|
||||||
}, [checkForUpdates, isDesktopApp]);
|
}, [checkForUpdates, isDesktopApp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +95,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
void checkForUpdates({ silent: true });
|
void checkForUpdates({ intent: "automatic", silent: true });
|
||||||
}, PENDING_RECHECK_MS);
|
}, PENDING_RECHECK_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -101,7 +110,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
availableUpdate: snapshot.availableUpdate,
|
availableUpdate: snapshot.availableUpdate,
|
||||||
installMessage: snapshot.installMessage,
|
installMessage: snapshot.installMessage,
|
||||||
|
lastCheckedAt: snapshot.lastCheckedAt,
|
||||||
formatVersion: formatVersionWithPrefix,
|
formatVersion: formatVersionWithPrefix,
|
||||||
|
formatLastCheckedAt: (timestamp) => formatMessageTimestamp(new Date(timestamp)),
|
||||||
}),
|
}),
|
||||||
availableUpdate: snapshot.availableUpdate,
|
availableUpdate: snapshot.availableUpdate,
|
||||||
errorMessage: snapshot.errorMessage,
|
errorMessage: snapshot.errorMessage,
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ function DesktopAppUpdateRow() {
|
|||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
void checkForUpdates({ silent: true });
|
void checkForUpdates({ intent: "automatic", silent: true });
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [checkForUpdates, isDesktopApp]),
|
}, [checkForUpdates, isDesktopApp]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
checkForAppUpdate,
|
checkForAppUpdate,
|
||||||
downloadAndInstallUpdate,
|
downloadAndInstallUpdate,
|
||||||
|
type AppUpdateCheckIntent,
|
||||||
type AppReleaseChannel,
|
type AppReleaseChannel,
|
||||||
} from "../features/auto-updater.js";
|
} from "../features/auto-updater.js";
|
||||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
||||||
@@ -81,6 +82,12 @@ function parseReleaseChannel(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAppUpdateCheckIntent(
|
||||||
|
args: Record<string, unknown> | undefined,
|
||||||
|
): AppUpdateCheckIntent {
|
||||||
|
return args?.intent === "manual" ? "manual" : "automatic";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Utilities
|
// Utilities
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -517,6 +524,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
return checkForAppUpdate({
|
return checkForAppUpdate({
|
||||||
currentVersion,
|
currentVersion,
|
||||||
releaseChannel: await resolveRequestedReleaseChannel(args),
|
releaseChannel: await resolveRequestedReleaseChannel(args),
|
||||||
|
intent: parseAppUpdateCheckIntent(args),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
install_app_update: async (args) => {
|
install_app_update: async (args) => {
|
||||||
|
|||||||
156
packages/desktop/src/features/app-update-rollout.test.ts
Normal file
156
packages/desktop/src/features/app-update-rollout.test.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { bucketFromStagingUserId, shouldAdmitAppUpdate } from "./app-update-rollout";
|
||||||
|
|
||||||
|
describe("shouldAdmitAppUpdate", () => {
|
||||||
|
it("keeps automatic stable updates behind the rollout window", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
||||||
|
bucket: 0.51,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets manual stable checks bypass rollout admission", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "manual",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
||||||
|
bucket: 0.99,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "beta",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||||
|
bucket: 0.99,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: undefined,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||||
|
bucket: 0.99,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 0,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||||
|
bucket: 0.99,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: undefined,
|
||||||
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||||
|
bucket: 0.99,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks future automatic releases and admits the same release manually", () => {
|
||||||
|
const input = {
|
||||||
|
channel: "stable" as const,
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T02:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T01:00:00.000Z"),
|
||||||
|
bucket: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(shouldAdmitAppUpdate({ ...input, intent: "automatic" })).toBe(false);
|
||||||
|
expect(shouldAdmitAppUpdate({ ...input, intent: "manual" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T00:00:00.000Z"),
|
||||||
|
bucket: 0,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-28T00:00:00.001Z"),
|
||||||
|
bucket: 0,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits the highest-bucket automatic client at and past the rollout end", () => {
|
||||||
|
const maxBucket = (0x100000000 - 1) / 0x100000000;
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2026-04-29T00:00:00.000Z"),
|
||||||
|
bucket: maxBucket,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
now: Date.parse("2027-04-28T00:00:00.000Z"),
|
||||||
|
bucket: maxBucket,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits when releaseDate is unparseable", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdmitAppUpdate({
|
||||||
|
channel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
rolloutHours: 24,
|
||||||
|
releaseDate: "not a date",
|
||||||
|
now: Date.parse("2026-04-28T12:00:00.000Z"),
|
||||||
|
bucket: 0.99,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => {
|
||||||
|
const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
||||||
|
const allZeros = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1);
|
||||||
|
expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999);
|
||||||
|
expect(bucketFromStagingUserId(allZeros)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
packages/desktop/src/features/app-update-rollout.ts
Normal file
42
packages/desktop/src/features/app-update-rollout.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { UUID } from "builder-util-runtime";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type AppReleaseChannel = "stable" | "beta";
|
||||||
|
export type AppUpdateCheckIntent = "automatic" | "manual";
|
||||||
|
|
||||||
|
export const rolloutManifestSchema = z.object({
|
||||||
|
rolloutHours: z
|
||||||
|
.union([z.number(), z.string().transform(Number)])
|
||||||
|
.pipe(z.number().finite().nonnegative())
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
releaseDate: z.string().optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function shouldAdmitAppUpdate(args: {
|
||||||
|
channel: AppReleaseChannel;
|
||||||
|
intent: AppUpdateCheckIntent;
|
||||||
|
rolloutHours: number | undefined;
|
||||||
|
releaseDate: string | undefined;
|
||||||
|
now: number;
|
||||||
|
bucket: number;
|
||||||
|
}): boolean {
|
||||||
|
if (args.intent === "manual") return true;
|
||||||
|
if (args.channel !== "stable") return true;
|
||||||
|
if (args.rolloutHours == null) return true;
|
||||||
|
if (args.rolloutHours === 0) return true;
|
||||||
|
if (!args.releaseDate) return true;
|
||||||
|
|
||||||
|
const releaseTime = new Date(args.releaseDate).getTime();
|
||||||
|
if (Number.isNaN(releaseTime)) return true;
|
||||||
|
|
||||||
|
const ageHours = (args.now - releaseTime) / 3_600_000;
|
||||||
|
if (ageHours < 0) return false;
|
||||||
|
|
||||||
|
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
|
||||||
|
return args.bucket * 100 < pct;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bucketFromStagingUserId(stagingUserId: string): number {
|
||||||
|
return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
|
||||||
|
}
|
||||||
114
packages/desktop/src/features/app-update-service.test.ts
Normal file
114
packages/desktop/src/features/app-update-service.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAppUpdateService,
|
||||||
|
type AppUpdateRuntime,
|
||||||
|
type AppUpdateRuntimeConfiguration,
|
||||||
|
type RuntimeUpdateInfo,
|
||||||
|
} from "./app-update-service";
|
||||||
|
|
||||||
|
class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||||
|
private checks: Array<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null> = [];
|
||||||
|
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
|
||||||
|
|
||||||
|
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||||
|
this.gate = input.shouldAdmitUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCheck(result: { isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null): void {
|
||||||
|
this.checks.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkForUpdates(): Promise<{
|
||||||
|
isUpdateAvailable: boolean;
|
||||||
|
updateInfo: RuntimeUpdateInfo;
|
||||||
|
} | null> {
|
||||||
|
const result = this.checks.shift() ?? null;
|
||||||
|
if (!result || !this.gate) return result;
|
||||||
|
const admitted = await this.gate(result.updateInfo);
|
||||||
|
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadUpdate(): Promise<void> {}
|
||||||
|
|
||||||
|
quitAndInstall(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
|
||||||
|
const runtime = new FakeAppUpdateRuntime();
|
||||||
|
const service = createAppUpdateService({
|
||||||
|
runtime,
|
||||||
|
isPackaged: () => true,
|
||||||
|
now: input?.now ?? (() => Date.parse("2026-04-28T12:00:00.000Z")),
|
||||||
|
bucket: input?.bucket ?? (async () => 0.99),
|
||||||
|
});
|
||||||
|
return { runtime, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolledOutUpdate = {
|
||||||
|
version: "1.2.4",
|
||||||
|
releaseDate: "2026-04-28T00:00:00.000Z",
|
||||||
|
rolloutHours: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("app update service", () => {
|
||||||
|
it("does not expose automatic stable updates before the user is admitted to rollout", async () => {
|
||||||
|
const { runtime, service } = createService();
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
|
|
||||||
|
const result = await service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
latestVersion: "1.2.3",
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes manual stable updates even before the user is admitted to rollout", async () => {
|
||||||
|
const { runtime, service } = createService();
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
|
|
||||||
|
const result = await service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
hasUpdate: true,
|
||||||
|
readyToInstall: false,
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
latestVersion: "1.2.4",
|
||||||
|
body: null,
|
||||||
|
date: "2026-04-28T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts the runtime availability decision before comparing versions", async () => {
|
||||||
|
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||||
|
|
||||||
|
const result = await service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
latestVersion: "1.2.3",
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
303
packages/desktop/src/features/app-update-service.ts
Normal file
303
packages/desktop/src/features/app-update-service.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import {
|
||||||
|
rolloutManifestSchema,
|
||||||
|
shouldAdmitAppUpdate,
|
||||||
|
type AppReleaseChannel,
|
||||||
|
type AppUpdateCheckIntent,
|
||||||
|
} from "./app-update-rollout.js";
|
||||||
|
|
||||||
|
export interface AppUpdateCheckResult {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
readyToInstall: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
body: string | null;
|
||||||
|
date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateInstallResult {
|
||||||
|
installed: boolean;
|
||||||
|
version: string | null;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeUpdateInfo {
|
||||||
|
version: string;
|
||||||
|
releaseNotes?: unknown;
|
||||||
|
releaseDate?: unknown;
|
||||||
|
rolloutHours?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeUpdateCheckResult {
|
||||||
|
isUpdateAvailable: boolean;
|
||||||
|
updateInfo: RuntimeUpdateInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateRuntimeConfiguration {
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise<boolean>;
|
||||||
|
onUpdateAvailable(info: RuntimeUpdateInfo): void;
|
||||||
|
onUpdateDownloaded(info: RuntimeUpdateInfo): void;
|
||||||
|
onUpdateNotAvailable(): void;
|
||||||
|
onError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateRuntime {
|
||||||
|
configure(input: AppUpdateRuntimeConfiguration): void;
|
||||||
|
checkForUpdates(): Promise<RuntimeUpdateCheckResult | null>;
|
||||||
|
downloadUpdate(): Promise<unknown>;
|
||||||
|
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateService {
|
||||||
|
checkForAppUpdate(input: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
intent: AppUpdateCheckIntent;
|
||||||
|
}): Promise<AppUpdateCheckResult>;
|
||||||
|
downloadAndInstallUpdate(
|
||||||
|
input: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
},
|
||||||
|
onBeforeQuit?: () => Promise<void>,
|
||||||
|
): Promise<AppUpdateInstallResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateServiceDeps {
|
||||||
|
runtime: AppUpdateRuntime;
|
||||||
|
isPackaged(): boolean;
|
||||||
|
now(): number;
|
||||||
|
bucket(): Promise<number>;
|
||||||
|
reportCheckError?(error: unknown): void;
|
||||||
|
reportRuntimeError?(error: unknown): void;
|
||||||
|
reportInstallError?(message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCheckResult(input: {
|
||||||
|
currentVersion: string;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
readyToInstall: boolean;
|
||||||
|
info?: RuntimeUpdateInfo | null;
|
||||||
|
}): AppUpdateCheckResult {
|
||||||
|
const { currentVersion, hasUpdate, readyToInstall, info } = input;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUpdate,
|
||||||
|
readyToInstall,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: info?.version ?? currentVersion,
|
||||||
|
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
|
||||||
|
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performQuitAndInstall(
|
||||||
|
runtime: AppUpdateRuntime,
|
||||||
|
onBeforeQuit?: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (onBeforeQuit) await onBeforeQuit();
|
||||||
|
runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
|
||||||
|
let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
|
||||||
|
let downloadedUpdateVersion: string | null = null;
|
||||||
|
let downloading = false;
|
||||||
|
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
||||||
|
|
||||||
|
function isReadyToInstallVersion(version: string): boolean {
|
||||||
|
return downloadedUpdateVersion === version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUpdateState(): void {
|
||||||
|
cachedUpdateInfo = null;
|
||||||
|
downloadedUpdateVersion = null;
|
||||||
|
downloading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
|
||||||
|
if (configuredReleaseChannel !== releaseChannel) {
|
||||||
|
clearUpdateState();
|
||||||
|
configuredReleaseChannel = releaseChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.runtime.configure({
|
||||||
|
releaseChannel,
|
||||||
|
shouldAdmitUpdate: async (info) => {
|
||||||
|
const parsed = rolloutManifestSchema.parse(info);
|
||||||
|
return shouldAdmitAppUpdate({
|
||||||
|
channel: releaseChannel,
|
||||||
|
intent,
|
||||||
|
rolloutHours: parsed.rolloutHours,
|
||||||
|
releaseDate: parsed.releaseDate,
|
||||||
|
now: deps.now(),
|
||||||
|
bucket: await deps.bucket(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdateAvailable(info) {
|
||||||
|
cachedUpdateInfo = info;
|
||||||
|
downloadedUpdateVersion = null;
|
||||||
|
downloading = true;
|
||||||
|
},
|
||||||
|
onUpdateDownloaded(info) {
|
||||||
|
cachedUpdateInfo = info;
|
||||||
|
downloadedUpdateVersion = info.version;
|
||||||
|
downloading = false;
|
||||||
|
},
|
||||||
|
onUpdateNotAvailable() {
|
||||||
|
clearUpdateState();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
downloading = false;
|
||||||
|
deps.reportRuntimeError?.(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForAppUpdate({
|
||||||
|
currentVersion,
|
||||||
|
releaseChannel,
|
||||||
|
intent,
|
||||||
|
}: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
intent: AppUpdateCheckIntent;
|
||||||
|
}): Promise<AppUpdateCheckResult> {
|
||||||
|
if (!deps.isPackaged()) {
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
configureRuntime(releaseChannel, intent);
|
||||||
|
|
||||||
|
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||||
|
if (cachedVersion && cachedVersion !== currentVersion) {
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: true,
|
||||||
|
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
||||||
|
info: cachedUpdateInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deps.runtime.checkForUpdates();
|
||||||
|
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
|
||||||
|
clearUpdateState();
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = result.updateInfo;
|
||||||
|
const latestVersion = info.version;
|
||||||
|
const hasUpdate = latestVersion !== currentVersion;
|
||||||
|
|
||||||
|
if (hasUpdate) {
|
||||||
|
cachedUpdateInfo = info;
|
||||||
|
downloading = !isReadyToInstallVersion(latestVersion);
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: true,
|
||||||
|
readyToInstall: isReadyToInstallVersion(latestVersion),
|
||||||
|
info,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUpdateState();
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
deps.reportCheckError?.(error);
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndInstallUpdate(
|
||||||
|
{
|
||||||
|
currentVersion,
|
||||||
|
releaseChannel,
|
||||||
|
}: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
},
|
||||||
|
onBeforeQuit?: () => Promise<void>,
|
||||||
|
): Promise<AppUpdateInstallResult> {
|
||||||
|
if (!deps.isPackaged()) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: "Auto-update is not available in development mode.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cachedUpdateInfo) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: "No update available. Check for updates first.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
configureRuntime(releaseChannel, "manual");
|
||||||
|
|
||||||
|
const readyVersion = cachedUpdateInfo.version;
|
||||||
|
if (isReadyToInstallVersion(readyVersion)) {
|
||||||
|
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: readyVersion,
|
||||||
|
message: "Update downloaded. The app will restart shortly.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloading) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: "Update is still being prepared. Try again in a moment.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
downloading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.runtime.downloadUpdate();
|
||||||
|
downloadedUpdateVersion = readyVersion;
|
||||||
|
downloading = false;
|
||||||
|
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: readyVersion,
|
||||||
|
message: "Update downloaded. The app will restart shortly.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
downloading = false;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
deps.reportInstallError?.(message);
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: `Update failed: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkForAppUpdate,
|
||||||
|
downloadAndInstallUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,48 +3,34 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { UUID } from "builder-util-runtime";
|
import { UUID } from "builder-util-runtime";
|
||||||
import { autoUpdater, type UpdateInfo } from "electron-updater";
|
import { autoUpdater } from "electron-updater";
|
||||||
import { z } from "zod";
|
import {
|
||||||
|
createAppUpdateService,
|
||||||
|
type AppUpdateCheckResult,
|
||||||
|
type AppUpdateInstallResult,
|
||||||
|
type AppUpdateRuntime,
|
||||||
|
type AppUpdateRuntimeConfiguration,
|
||||||
|
type RuntimeUpdateCheckResult,
|
||||||
|
type RuntimeUpdateInfo,
|
||||||
|
} from "./app-update-service.js";
|
||||||
|
import {
|
||||||
|
bucketFromStagingUserId,
|
||||||
|
rolloutManifestSchema,
|
||||||
|
shouldAdmitAppUpdate,
|
||||||
|
type AppReleaseChannel,
|
||||||
|
type AppUpdateCheckIntent,
|
||||||
|
} from "./app-update-rollout.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export {
|
||||||
// Types
|
bucketFromStagingUserId,
|
||||||
// ---------------------------------------------------------------------------
|
rolloutManifestSchema,
|
||||||
|
shouldAdmitAppUpdate,
|
||||||
|
type AppReleaseChannel,
|
||||||
|
type AppUpdateCheckIntent,
|
||||||
|
type AppUpdateCheckResult,
|
||||||
|
type AppUpdateInstallResult,
|
||||||
|
};
|
||||||
|
|
||||||
export interface AppUpdateCheckResult {
|
|
||||||
hasUpdate: boolean;
|
|
||||||
readyToInstall: boolean;
|
|
||||||
currentVersion: string;
|
|
||||||
latestVersion: string;
|
|
||||||
body: string | null;
|
|
||||||
date: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppUpdateInstallResult {
|
|
||||||
installed: boolean;
|
|
||||||
version: string | null;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppReleaseChannel = "stable" | "beta";
|
|
||||||
|
|
||||||
export const rolloutManifestSchema = z.object({
|
|
||||||
rolloutHours: z
|
|
||||||
.union([z.number(), z.string().transform(Number)])
|
|
||||||
.pipe(z.number().finite().nonnegative())
|
|
||||||
.optional()
|
|
||||||
.catch(undefined),
|
|
||||||
releaseDate: z.string().optional().catch(undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// State
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let cachedUpdateInfo: UpdateInfo | null = null;
|
|
||||||
let downloadedUpdateVersion: string | null = null;
|
|
||||||
let downloading = false;
|
|
||||||
let autoUpdaterConfigured = false;
|
|
||||||
let configuredReleaseChannel: AppReleaseChannel | null = null;
|
|
||||||
let cachedStagingUserIdPromise: Promise<string> | null = null;
|
let cachedStagingUserIdPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
export function shouldAdmitToRollout(args: {
|
export function shouldAdmitToRollout(args: {
|
||||||
@@ -54,23 +40,7 @@ export function shouldAdmitToRollout(args: {
|
|||||||
now: number;
|
now: number;
|
||||||
bucket: number;
|
bucket: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (args.channel !== "stable") return true;
|
return shouldAdmitAppUpdate({ ...args, intent: "automatic" });
|
||||||
if (args.rolloutHours == null) return true;
|
|
||||||
if (args.rolloutHours === 0) return true;
|
|
||||||
if (!args.releaseDate) return true;
|
|
||||||
|
|
||||||
const releaseTime = new Date(args.releaseDate).getTime();
|
|
||||||
if (Number.isNaN(releaseTime)) return true;
|
|
||||||
|
|
||||||
const ageHours = (args.now - releaseTime) / 3_600_000;
|
|
||||||
if (ageHours < 0) return false;
|
|
||||||
|
|
||||||
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
|
|
||||||
return args.bucket * 100 < pct;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bucketFromStagingUserId(stagingUserId: string): number {
|
|
||||||
return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveStagingUserId(filePath: string): Promise<string> {
|
export async function resolveStagingUserId(filePath: string): Promise<string> {
|
||||||
@@ -106,100 +76,74 @@ export function getStagingUserId(): Promise<string> {
|
|||||||
return cachedStagingUserIdPromise;
|
return cachedStagingUserIdPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||||
// Configuration
|
private configured = false;
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
|
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||||
// Download updates in the background and only prompt once they are ready to install.
|
autoUpdater.autoDownload = true;
|
||||||
autoUpdater.autoDownload = true;
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
autoUpdater.autoRunAppAfterInstall = true;
|
||||||
|
autoUpdater.allowPrerelease = input.releaseChannel === "beta";
|
||||||
|
autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest";
|
||||||
|
autoUpdater.allowDowngrade = false;
|
||||||
|
autoUpdater.isUserWithinRollout = async (info) => {
|
||||||
|
try {
|
||||||
|
return await input.shouldAdmitUpdate(info as RuntimeUpdateInfo);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Suppress built-in dialogs; the renderer handles UI.
|
if (this.configured) return;
|
||||||
autoUpdater.autoRunAppAfterInstall = true;
|
this.configured = true;
|
||||||
autoUpdater.allowPrerelease = releaseChannel === "beta";
|
|
||||||
autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest";
|
|
||||||
autoUpdater.allowDowngrade = false;
|
|
||||||
autoUpdater.isUserWithinRollout = async (info) => {
|
|
||||||
try {
|
|
||||||
const parsed = rolloutManifestSchema.parse(info);
|
|
||||||
const stagingUserId = await getStagingUserId();
|
|
||||||
|
|
||||||
return shouldAdmitToRollout({
|
autoUpdater.on("update-available", (info) => {
|
||||||
channel: releaseChannel,
|
input.onUpdateAvailable(info as RuntimeUpdateInfo);
|
||||||
rolloutHours: parsed.rolloutHours,
|
});
|
||||||
releaseDate: parsed.releaseDate,
|
autoUpdater.on("update-downloaded", (info) => {
|
||||||
now: Date.now(),
|
input.onUpdateDownloaded(info as RuntimeUpdateInfo);
|
||||||
bucket: bucketFromStagingUserId(stagingUserId),
|
});
|
||||||
});
|
autoUpdater.on("update-not-available", () => {
|
||||||
} catch {
|
input.onUpdateNotAvailable();
|
||||||
return true;
|
});
|
||||||
}
|
autoUpdater.on("error", (error) => {
|
||||||
};
|
input.onError(error);
|
||||||
|
});
|
||||||
if (configuredReleaseChannel !== releaseChannel) {
|
|
||||||
cachedUpdateInfo = null;
|
|
||||||
downloadedUpdateVersion = null;
|
|
||||||
downloading = false;
|
|
||||||
configuredReleaseChannel = releaseChannel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoUpdaterConfigured) {
|
async checkForUpdates(): Promise<RuntimeUpdateCheckResult | null> {
|
||||||
return;
|
const result = await autoUpdater.checkForUpdates();
|
||||||
|
if (!result) return null;
|
||||||
|
return {
|
||||||
|
isUpdateAvailable: result.isUpdateAvailable,
|
||||||
|
updateInfo: result.updateInfo as RuntimeUpdateInfo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdaterConfigured = true;
|
downloadUpdate(): Promise<unknown> {
|
||||||
|
return autoUpdater.downloadUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
autoUpdater.on("update-available", (info) => {
|
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
||||||
cachedUpdateInfo = info;
|
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
|
||||||
downloadedUpdateVersion = null;
|
}
|
||||||
downloading = true;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
autoUpdater.on("update-downloaded", (info) => {
|
const appUpdateService = createAppUpdateService({
|
||||||
cachedUpdateInfo = info;
|
runtime: new ElectronAppUpdateRuntime(),
|
||||||
downloadedUpdateVersion = info.version;
|
isPackaged: () => app.isPackaged,
|
||||||
downloading = false;
|
now: () => Date.now(),
|
||||||
});
|
bucket: async () => bucketFromStagingUserId(await getStagingUserId()),
|
||||||
|
reportCheckError: (error) => {
|
||||||
autoUpdater.on("update-not-available", () => {
|
console.error("[auto-updater] Failed to check for updates:", error);
|
||||||
cachedUpdateInfo = null;
|
},
|
||||||
downloadedUpdateVersion = null;
|
reportRuntimeError: (error) => {
|
||||||
downloading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
autoUpdater.on("error", (error) => {
|
|
||||||
downloading = false;
|
|
||||||
console.error("[auto-updater] Updater event failed:", error);
|
console.error("[auto-updater] Updater event failed:", error);
|
||||||
});
|
},
|
||||||
}
|
reportInstallError: (message) => {
|
||||||
|
console.error("[auto-updater] Failed to download/install update:", message);
|
||||||
function isReadyToInstallVersion(version: string): boolean {
|
},
|
||||||
return downloadedUpdateVersion === version;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function buildCheckResult(input: {
|
|
||||||
currentVersion: string;
|
|
||||||
hasUpdate: boolean;
|
|
||||||
readyToInstall: boolean;
|
|
||||||
info?: UpdateInfo | null;
|
|
||||||
}): AppUpdateCheckResult {
|
|
||||||
const { currentVersion, hasUpdate, readyToInstall, info } = input;
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasUpdate,
|
|
||||||
readyToInstall,
|
|
||||||
currentVersion,
|
|
||||||
latestVersion: info?.version ?? currentVersion,
|
|
||||||
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
|
|
||||||
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
|
||||||
if (onBeforeQuit) await onBeforeQuit();
|
|
||||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
@@ -208,73 +152,13 @@ async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promis
|
|||||||
export async function checkForAppUpdate({
|
export async function checkForAppUpdate({
|
||||||
currentVersion,
|
currentVersion,
|
||||||
releaseChannel,
|
releaseChannel,
|
||||||
|
intent,
|
||||||
}: {
|
}: {
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
releaseChannel: AppReleaseChannel;
|
releaseChannel: AppReleaseChannel;
|
||||||
|
intent: AppUpdateCheckIntent;
|
||||||
}): Promise<AppUpdateCheckResult> {
|
}): Promise<AppUpdateCheckResult> {
|
||||||
if (!app.isPackaged) {
|
return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent });
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
configureAutoUpdater(releaseChannel);
|
|
||||||
|
|
||||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
|
||||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: true,
|
|
||||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
|
||||||
info: cachedUpdateInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await autoUpdater.checkForUpdates();
|
|
||||||
|
|
||||||
if (!result || !result.updateInfo) {
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = result.updateInfo;
|
|
||||||
const latestVersion = info.version;
|
|
||||||
const hasUpdate = latestVersion !== currentVersion;
|
|
||||||
|
|
||||||
if (hasUpdate) {
|
|
||||||
cachedUpdateInfo = info;
|
|
||||||
downloading = !isReadyToInstallVersion(latestVersion);
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: true,
|
|
||||||
readyToInstall: isReadyToInstallVersion(latestVersion),
|
|
||||||
info,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedUpdateInfo = null;
|
|
||||||
downloadedUpdateVersion = null;
|
|
||||||
downloading = false;
|
|
||||||
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[auto-updater] Failed to check for updates:", error);
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAndInstallUpdate(
|
export async function downloadAndInstallUpdate(
|
||||||
@@ -287,63 +171,8 @@ export async function downloadAndInstallUpdate(
|
|||||||
},
|
},
|
||||||
onBeforeQuit?: () => Promise<void>,
|
onBeforeQuit?: () => Promise<void>,
|
||||||
): Promise<AppUpdateInstallResult> {
|
): Promise<AppUpdateInstallResult> {
|
||||||
if (!app.isPackaged) {
|
return appUpdateService.downloadAndInstallUpdate(
|
||||||
return {
|
{ currentVersion, releaseChannel },
|
||||||
installed: false,
|
onBeforeQuit,
|
||||||
version: currentVersion,
|
);
|
||||||
message: "Auto-update is not available in development mode.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cachedUpdateInfo) {
|
|
||||||
return {
|
|
||||||
installed: false,
|
|
||||||
version: currentVersion,
|
|
||||||
message: "No update available. Check for updates first.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
configureAutoUpdater(releaseChannel);
|
|
||||||
|
|
||||||
const readyVersion = cachedUpdateInfo.version;
|
|
||||||
if (isReadyToInstallVersion(readyVersion)) {
|
|
||||||
await performQuitAndInstall(onBeforeQuit);
|
|
||||||
return {
|
|
||||||
installed: true,
|
|
||||||
version: readyVersion,
|
|
||||||
message: "Update downloaded. The app will restart shortly.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloading) {
|
|
||||||
return {
|
|
||||||
installed: false,
|
|
||||||
version: currentVersion,
|
|
||||||
message: "Update is still being prepared. Try again in a moment.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
downloading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await autoUpdater.downloadUpdate();
|
|
||||||
downloadedUpdateVersion = readyVersion;
|
|
||||||
downloading = false;
|
|
||||||
await performQuitAndInstall(onBeforeQuit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
installed: true,
|
|
||||||
version: readyVersion,
|
|
||||||
message: "Update downloaded. The app will restart shortly.",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
downloading = false;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error("[auto-updater] Failed to download/install update:", message);
|
|
||||||
return {
|
|
||||||
installed: false,
|
|
||||||
version: currentVersion,
|
|
||||||
message: `Update failed: ${message}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user