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:
Mohamed Boudra
2026-06-09 15:47:30 +07:00
parent e72b0773e6
commit eca0a5bf67
14 changed files with 809 additions and 284 deletions
@@ -38,13 +38,22 @@ function createUpdater(
}
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();
port.nextCheckResult(buildFakeCheckResult());
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 () => {
@@ -66,7 +75,11 @@ describe("desktop app updater — check", () => {
expect(updater.getSnapshot().status).toBe("available");
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");
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
@@ -128,7 +141,7 @@ describe("desktop app updater — check", () => {
const statusBeforeSilent = updater.getSnapshot().status;
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);
});
@@ -226,6 +239,20 @@ describe("desktop app updater — subscribe", () => {
describe("formatStatusText", () => {
const formatVersion = (version: string | null | undefined) =>
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", () => {
expect(
@@ -233,7 +260,9 @@ describe("formatStatusText", () => {
status: "available",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Update ready: v1.2.3");
});
@@ -244,7 +273,9 @@ describe("formatStatusText", () => {
status: "available",
availableUpdate: null,
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("An app update is ready to install.");
});
@@ -255,7 +286,9 @@ describe("formatStatusText", () => {
status: "installed",
availableUpdate: null,
installMessage: "Restart now",
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Restart now");
});
@@ -1,5 +1,6 @@
import type {
DesktopAppUpdateCheckResult,
DesktopAppUpdateCheckIntent,
DesktopAppUpdateInstallResult,
DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates";
@@ -29,6 +30,7 @@ export interface DesktopAppUpdaterSnapshot {
export interface DesktopAppUpdaterPort {
checkDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}): Promise<DesktopAppUpdateCheckResult>;
installDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel;
@@ -52,6 +54,7 @@ export interface DesktopAppUpdater {
subscribe(listener: () => void): () => void;
checkForUpdates(options?: {
releaseChannel: DesktopReleaseChannel;
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}): Promise<DesktopAppUpdateCheckResult | null>;
installUpdate(options: {
@@ -102,9 +105,18 @@ export function formatStatusText(input: {
status: DesktopAppUpdateStatus;
availableUpdate: DesktopAppUpdateCheckResult | null;
installMessage: string | null;
lastCheckedAt: number | null;
formatVersion: (version: string | null | undefined) => string;
formatLastCheckedAt: (timestamp: number) => string;
}): string {
const { status, availableUpdate, installMessage, formatVersion } = input;
const {
status,
availableUpdate,
installMessage,
lastCheckedAt,
formatVersion,
formatLastCheckedAt,
} = input;
if (status === "checking") {
return "Checking for app updates...";
@@ -115,7 +127,10 @@ export function formatStatusText(input: {
}
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") {
@@ -155,12 +170,13 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
async function checkForUpdates(options?: {
releaseChannel: DesktopReleaseChannel;
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}): Promise<DesktopAppUpdateCheckResult | null> {
if (!options) {
return null;
}
const { releaseChannel, silent = false } = options;
const { releaseChannel, intent = "manual", silent = false } = options;
const requestVersion = state.requestVersion + 1;
commit({
@@ -171,7 +187,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
});
try {
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel });
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel, intent });
if (requestVersion !== state.requestVersion) {
return result;
}
@@ -23,6 +23,7 @@ export interface DesktopRuntimeInfo {
}
export type DesktopReleaseChannel = "stable" | "beta";
export type DesktopAppUpdateCheckIntent = "automatic" | "manual";
export interface LocalDaemonUpdateResult {
exitCode: number;
@@ -99,10 +100,15 @@ export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
export async function checkDesktopAppUpdate({
releaseChannel,
intent,
}: {
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
const result = await invokeDesktopCommand<unknown>("check_app_update", {
releaseChannel,
intent,
});
if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates.");
}
@@ -1,12 +1,16 @@
import type {
DesktopAppUpdateCheckResult,
DesktopAppUpdateCheckIntent,
DesktopAppUpdateInstallResult,
DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates";
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>;
readonly recordedChecks: Array<{
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}>;
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
nextCheckResult(result: DesktopAppUpdateCheckResult): void;
deferNextCheck(): {
@@ -53,7 +57,10 @@ function buildInstallResult(
}
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
const recordedChecks: Array<{
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}> = [];
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
const checkOutcomes: CheckOutcome[] = [];
const installOutcomes: InstallOutcome[] = [];
@@ -62,10 +62,10 @@ export function UpdateCalloutSource() {
useEffect(() => {
if (!isDesktopApp) return;
void checkForUpdates({ silent: true });
void checkForUpdates({ intent: "automatic", silent: true });
intervalRef.current = setInterval(() => {
void checkForUpdates({ silent: true });
void checkForUpdates({ intent: "automatic", silent: true });
}, CHECK_INTERVAL_MS);
return () => {
@@ -5,6 +5,7 @@ import {
installDesktopAppUpdate,
shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateCheckIntent,
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
@@ -15,6 +16,7 @@ import {
formatStatusText,
type DesktopAppUpdateStatus,
} from "@/desktop/updates/desktop-app-updater";
import { formatMessageTimestamp } from "@/utils/time";
export type { DesktopAppUpdateStatus };
@@ -27,7 +29,10 @@ export interface UseDesktopAppUpdaterReturn {
lastCheckedAt: number | null;
isChecking: boolean;
isInstalling: boolean;
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>;
checkForUpdates: (options?: {
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}) => Promise<DesktopAppUpdateCheckResult | null>;
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
}
@@ -57,11 +62,15 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
);
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
async (options: { intent?: DesktopAppUpdateCheckIntent; silent?: boolean } = {}) => {
if (!isDesktopApp) {
return null;
}
return updater.checkForUpdates({ releaseChannel, silent: options.silent });
return updater.checkForUpdates({
releaseChannel,
intent: options.intent ?? "manual",
silent: options.silent,
});
},
[isDesktopApp, releaseChannel, updater],
);
@@ -77,7 +86,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
void checkForUpdates({ intent: "automatic", silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => {
@@ -86,7 +95,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
}
const intervalId = setInterval(() => {
void checkForUpdates({ silent: true });
void checkForUpdates({ intent: "automatic", silent: true });
}, PENDING_RECHECK_MS);
return () => {
@@ -101,7 +110,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
status: snapshot.status,
availableUpdate: snapshot.availableUpdate,
installMessage: snapshot.installMessage,
lastCheckedAt: snapshot.lastCheckedAt,
formatVersion: formatVersionWithPrefix,
formatLastCheckedAt: (timestamp) => formatMessageTimestamp(new Date(timestamp)),
}),
availableUpdate: snapshot.availableUpdate,
errorMessage: snapshot.errorMessage,
+1 -1
View File
@@ -523,7 +523,7 @@ function DesktopAppUpdateRow() {
if (!isDesktopApp) {
return undefined;
}
void checkForUpdates({ silent: true });
void checkForUpdates({ intent: "automatic", silent: true });
return undefined;
}, [checkForUpdates, isDesktopApp]),
);