History

Fix desktop updater declined-state handling

Rhys Sullivan committed 4 months ago

Commit 9f93d691 parent a968cc56 files · +310 −24Browse files

Unified · Split

6 files
  1. apps/desktop/src/main/index.ts +78 −20
  2. apps/desktop/src/main/updater-state.test.ts +102 −0
  3. apps/desktop/src/main/updater-state.ts +108 −0
  4. apps/desktop/src/shared/update.ts +1 −0
  5. packages/react/src/components/update-card.tsx +20 −4
  6. packages/react/src/hooks/desktop-update.ts +1 −0
apps/desktop/src/main/index.ts+78−20
@@ -61,6 +61,14 @@ import { · 60 unmodified lines
6161 UPDATE_STATUS_CHANNEL,
6262 UPDATE_STATUS_GET_CHANNEL,
6363 } from "../shared/update";
64+import {
65+ planCompletedUpdateCheck,
66+ planDownloadedUpdate,
67+ planFatalAutoInstallOnQuit,
68+ planUpdateCheck,
69+ statusAfterUpdateError,
70+ type UpdateCheckTrigger,
71+} from "./updater-state";
6472
6573 // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every
6674 // Electron-side consumer (electron-store, electron-log, window-state) lands
@@ -741,7 +749,9 @@ const registerIpcHandlers = () => { · 674 unmodified lines
741749 // Crash-screen escape hatch: a recurring sidecar crash may already be
742750 // fixed upstream. Reuses the menu flow — staged updates prompt to install,
743751 // "no updates" / failures surface in their own dialogs.
744− ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true }));
752+ ipcMain.handle("executor:updates:check", () =>
753+ runUpdateCheck({ alertOnFail: true, trigger: "manual" }),
754+ );
745755 ipcMain.handle(UPDATE_STATUS_GET_CHANNEL, (): DesktopUpdateStatus => updateStatus);
746756 ipcMain.handle(UPDATE_INSTALL_CHANNEL, async (): Promise<void> => {
747757 const version = "version" in updateStatus ? updateStatus.version : "";
@@ -754,6 +764,7 @@ const registerIpcHandlers = () => { · 6 unmodified lines
754764 }
755765 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching
756766 // the native dialog's restart path.
767+ stopSupervisedMonitor();
757768 if (connection) {
758769 await stopConnection(connection);
759770 connection = null;
@@ -804,6 +815,7 @@ const registerIpcHandlers = () => { · 44 unmodified lines
804815 // ──────────────────────────────────────────────────────────────────────
805816
806817 let downloadedUpdateVersion: string | null = null;
818+let declinedUpdateVersion: string | null = null;
807819 let updateDialogOpen = false;
808820
809821 // Renderer-facing auto-update status. The web shell renders a desktop-native
@@ -845,11 +857,16 @@ const applyFakeUpdateFromEnv = () => { · 35 unmodified lines
845857 state === "available" ||
846858 state === "downloaded" ||
847859 state === "downloading" ||
860+ state === "error" ||
848861 state === "installing"
849862 ) {
850863 if (state === "downloaded") downloadedUpdateVersion = version;
851864 setUpdateStatus(
852− state === "downloading" ? { state, version, percent: 100 } : { state, version },
865+ state === "downloading"
866+ ? { state, version, percent: 100 }
867+ : state === "error"
868+ ? { state, version, message: "Update failed" }
869+ : { state, version },
853870 );
854871 }
855872 } catch (error) {
@@ -874,12 +891,15 @@ const promptInstallUpdate = async (version: string) => { · 18 unmodified lines
874891 if (response.response === 0) {
875892 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A
876893 // supervised daemon is left running — it's independent of this bundle.
894+ stopSupervisedMonitor();
877895 if (connection) {
878896 await stopConnection(connection);
879897 connection = null;
880898 }
881899 autoUpdater.quitAndInstall(false, true);
900+ return;
882901 }
902+ declinedUpdateVersion = version;
883903 } finally {
884904 updateDialogOpen = false;
885905 }
@@ -910,25 +930,41 @@ const setupAutoUpdater = () => { · 24 unmodified lines
910930 }
911931 });
912932 autoUpdater.on("update-downloaded", (info: UpdateInfo) => {
913− downloadedUpdateVersion = info.version;
914− setUpdateStatus({ state: "downloaded", version: info.version });
915− void promptInstallUpdate(info.version);
933+ const decision = planDownloadedUpdate({
934+ stagedVersion: downloadedUpdateVersion,
935+ declinedVersion: declinedUpdateVersion,
936+ incomingVersion: info.version,
937+ trigger: "boot",
938+ });
939+ downloadedUpdateVersion = decision.stagedVersion;
940+ declinedUpdateVersion = decision.declinedVersion;
941+ setUpdateStatus(decision.status);
942+ if (decision.promptVersion) void promptInstallUpdate(decision.promptVersion);
916943 });
917944 autoUpdater.on("error", (err: Error) => {
918945 log.warn("[updater] error", err);
946+ setUpdateStatus(
947+ statusAfterUpdateError(updateStatus, "Update failed. Check again from the menu."),
948+ );
949+ pendingUpdateVersion = null;
919950 });
920951
921952 setInterval(() => {
922− if (downloadedUpdateVersion) return; // already staged; waiting on the user
923− void runUpdateCheck({ alertOnFail: false });
953+ void runUpdateCheck({ alertOnFail: false, trigger: "interval" });
924954 }, UPDATE_POLL_INTERVAL_MS);
925955 };
926956
927957 interface UpdateCheckOptions {
928958 readonly alertOnFail: boolean;
959+ readonly trigger: UpdateCheckTrigger;
929960 }
930961
931−const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => {
962+type UpdateCheckResult = {
963+ readonly isUpdateAvailable?: boolean;
964+ readonly updateInfo?: { readonly version?: string };
965+};
966+
967+const runUpdateCheck = async ({ alertOnFail, trigger }: UpdateCheckOptions) => {
932968 if (!app.isPackaged) {
933969 if (alertOnFail) {
934970 await dialog.showMessageBox({
@@ -939,14 +975,23 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { · 4 unmodified lines
939975 }
940976 return;
941977 }
942− if (downloadedUpdateVersion) {
943− await promptInstallUpdate(downloadedUpdateVersion);
944− return;
945− }
978+ const checkPlan = planUpdateCheck({ stagedVersion: downloadedUpdateVersion, trigger });
979+ if (!checkPlan.check) return;
946980 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: surface network/update failures only when the user asked
947981 try {
948− const result = await autoUpdater.checkForUpdates();
982+ const result = (await autoUpdater.checkForUpdates()) as UpdateCheckResult | null;
949983 const newer = result?.isUpdateAvailable === true;
984+ const promptAfterCheck = planCompletedUpdateCheck({
985+ stagedVersion: checkPlan.promptVersionAfterCheck,
986+ trigger,
987+ updateAvailable: newer,
988+ availableVersion:
989+ typeof result?.updateInfo?.version === "string" ? result.updateInfo.version : null,
990+ }).promptVersion;
991+ if (promptAfterCheck) {
992+ await promptInstallUpdate(promptAfterCheck);
993+ return;
994+ }
950995 if (!alertOnFail) return;
951996 if (newer) return; // 'update-downloaded' handler will fire the install dialog
952997 await dialog.showMessageBox({
@@ -956,6 +1001,9 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { · 3 unmodified lines
9561001 });
9571002 } catch (error) {
9581003 log.warn("[updater] check failed", error);
1004+ setUpdateStatus(
1005+ statusAfterUpdateError(updateStatus, "Update failed. Check again from the menu."),
1006+ );
9591007 if (!alertOnFail) return;
9601008 await dialog.showMessageBox({
9611009 type: "error",
@@ -978,8 +1026,12 @@ const handleFatalSidecarFailure = async (error: unknown) => { · 16 unmodified lines
9781026 // Install whatever finishes downloading by the time the user quits the
9791027 // failure dialog; if it downloads while the dialog is open, the regular
9801028 // 'update-downloaded' prompt offers an immediate restart instead.
981− autoUpdater.autoInstallOnAppQuit = true;
982− void runUpdateCheck({ alertOnFail: false });
1029+ const autoInstallPlan = planFatalAutoInstallOnQuit({
1030+ packaged: true,
1031+ retryAfterReset: false,
1032+ });
1033+ if (autoInstallPlan.enableDuringFailure) autoUpdater.autoInstallOnAppQuit = true;
1034+ void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
9831035 }
9841036 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: sidecar startup failures arrive as plain Node errors and render in a native dialog
9851037 const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
@@ -995,7 +1047,13 @@ const handleFatalSidecarFailure = async (error: unknown) => { · 9 unmodified lines
9951047 // Damaged executor state (failed migration, corrupt SQLite) makes startup
9961048 // fail forever — updating can't fix it. Offer the move-aside reset and one
9971049 // immediate retry. Returns true when boot should be attempted again.
998− if (response === 1 && (await confirmResetState())) {
1050+ const retryAfterReset = response === 1 && (await confirmResetState());
1051+ const autoInstallPlan = planFatalAutoInstallOnQuit({
1052+ packaged: app.isPackaged,
1053+ retryAfterReset,
1054+ });
1055+ if (retryAfterReset) {
1056+ if (autoInstallPlan.restoreAfterRecovery) autoUpdater.autoInstallOnAppQuit = false;
9991057 const { backupDir } = resetExecutorState();
10001058 await announceBackup(backupDir);
10011059 return true;
@@ -1011,7 +1069,7 @@ const installApplicationMenu = () => { · 9 unmodified lines
10111069 { role: "about" },
10121070 {
10131071 label: "Check for Updates…",
1014− click: () => void runUpdateCheck({ alertOnFail: true }),
1072+ click: () => void runUpdateCheck({ alertOnFail: true, trigger: "manual" }),
10151073 },
10161074 {
10171075 label: "Export Diagnostics…",
@@ -1105,7 +1163,7 @@ const boot = async () => { · 87 unmodified lines
11051163 // A crashing sidecar may be a broken release — quietly stage any
11061164 // available update so the install prompt appears on its own (same
11071165 // self-heal as the fatal startup path).
1108− void runUpdateCheck({ alertOnFail: false });
1166+ void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
11091167 });
11101168 // Prefer an OS-supervised daemon: attach to one that's running, kick one
11111169 // that's installed, or offer to install on first run. Quitting the app then
@@ -1121,7 +1179,7 @@ const boot = async () => { · 9 unmodified lines
11211179 try {
11221180 await createWindow(supervised);
11231181 armSupervisedMonitor();
1124− void runUpdateCheck({ alertOnFail: false });
1182+ void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
11251183 return;
11261184 } catch (error) {
11271185 log.warn("Failed to load supervised daemon; falling back to managed sidecar", error);
@@ -1196,7 +1254,7 @@ const boot = async () => { · 68 unmodified lines
11961254 // Check at boot. If an update is available, autoDownload pulls it and
11971255 // the 'update-downloaded' handler fires the install dialog. Silent on
11981256 // no-update / failure so we don't bother users on every launch.
1199− void runUpdateCheck({ alertOnFail: false });
1257+ void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
12001258 };
12011259
12021260 if (ensureSingleInstance()) {
apps/desktop/src/main/updater-state.test.ts+102−0
@@ -0,0 +1,102 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ planCompletedUpdateCheck,
4+ planDownloadedUpdate,
5+ planFatalAutoInstallOnQuit,
6+ planUpdateCheck,
7+ statusAfterUpdateError,
8+} from "./updater-state";
9+
10+describe("updater state decisions", () => {
11+ it("auto-prompts when a newer version arrives after the staged version was declined", () => {
12+ const decision = planDownloadedUpdate({
13+ stagedVersion: "1.5.27",
14+ declinedVersion: "1.5.27",
15+ incomingVersion: "1.5.28",
16+ trigger: "interval",
17+ });
18+
19+ expect(decision).toEqual({
20+ stagedVersion: "1.5.28",
21+ declinedVersion: "1.5.27",
22+ status: { state: "downloaded", version: "1.5.28" },
23+ promptVersion: "1.5.28",
24+ });
25+ });
26+
27+ it("keeps interval checks running without re-prompting the same declined version", () => {
28+ const check = planUpdateCheck({
29+ stagedVersion: "1.5.27",
30+ trigger: "interval",
31+ });
32+ const downloaded = planDownloadedUpdate({
33+ stagedVersion: "1.5.27",
34+ declinedVersion: "1.5.27",
35+ incomingVersion: "1.5.27",
36+ trigger: "interval",
37+ });
38+
39+ expect(check).toEqual({ check: true, promptVersionAfterCheck: null });
40+ expect(downloaded.promptVersion).toBeNull();
41+ });
42+
43+ it("manual checks re-check first and then re-prompt the staged version if no newer version wins", () => {
44+ const check = planUpdateCheck({
45+ stagedVersion: "1.5.27",
46+ trigger: "manual",
47+ });
48+ const completedWithoutNewer = planCompletedUpdateCheck({
49+ stagedVersion: check.promptVersionAfterCheck,
50+ trigger: "manual",
51+ updateAvailable: false,
52+ availableVersion: null,
53+ });
54+ const completedWithNewer = planCompletedUpdateCheck({
55+ stagedVersion: check.promptVersionAfterCheck,
56+ trigger: "manual",
57+ updateAvailable: true,
58+ availableVersion: "1.5.28",
59+ });
60+
61+ expect(check.check).toBe(true);
62+ expect(completedWithoutNewer.promptVersion).toBe("1.5.27");
63+ expect(completedWithNewer.promptVersion).toBeNull();
64+ });
65+
66+ it("moves active update failures to error while idle failures stay idle", () => {
67+ expect(
68+ statusAfterUpdateError(
69+ { state: "downloading", version: "1.5.27", percent: 42 },
70+ "Download failed",
71+ ),
72+ ).toEqual({
73+ state: "error",
74+ version: "1.5.27",
75+ message: "Download failed",
76+ });
77+ expect(statusAfterUpdateError({ state: "idle" }, "Download failed")).toEqual({
78+ state: "idle",
79+ });
80+ });
81+
82+ it("restores autoInstallOnAppQuit only when the fatal path recovers", () => {
83+ expect(
84+ planFatalAutoInstallOnQuit({
85+ packaged: true,
86+ retryAfterReset: true,
87+ }),
88+ ).toEqual({
89+ enableDuringFailure: true,
90+ restoreAfterRecovery: true,
91+ });
92+ expect(
93+ planFatalAutoInstallOnQuit({
94+ packaged: true,
95+ retryAfterReset: false,
96+ }),
97+ ).toEqual({
98+ enableDuringFailure: true,
99+ restoreAfterRecovery: false,
100+ });
101+ });
102+});
apps/desktop/src/main/updater-state.ts+108−0
@@ -0,0 +1,108 @@
1+import type { DesktopUpdateStatus } from "../shared/update";
2+
3+export type UpdateCheckTrigger = "boot" | "interval" | "manual";
4+
5+export interface UpdateCheckPlan {
6+ readonly check: boolean;
7+ readonly promptVersionAfterCheck: string | null;
8+}
9+
10+export interface CompletedUpdateCheckInput {
11+ readonly stagedVersion: string | null;
12+ readonly trigger: UpdateCheckTrigger;
13+ readonly updateAvailable: boolean;
14+ readonly availableVersion: string | null;
15+}
16+
17+export interface DownloadedUpdateInput {
18+ readonly stagedVersion: string | null;
19+ readonly declinedVersion: string | null;
20+ readonly incomingVersion: string;
21+ readonly trigger: UpdateCheckTrigger;
22+}
23+
24+export interface DownloadedUpdatePlan {
25+ readonly stagedVersion: string;
26+ readonly declinedVersion: string | null;
27+ readonly status: DesktopUpdateStatus;
28+ readonly promptVersion: string | null;
29+}
30+
31+const parseVersionParts = (version: string): readonly number[] | null => {
32+ const core = version.trim().split(/[+-]/, 1)[0];
33+ if (!core) return null;
34+ const parts = core.split(".").map((part) => Number.parseInt(part, 10));
35+ return parts.every((part) => Number.isInteger(part) && part >= 0) ? parts : null;
36+};
37+
38+export const compareUpdateVersions = (left: string, right: string): number | null => {
39+ const leftParts = parseVersionParts(left);
40+ const rightParts = parseVersionParts(right);
41+ if (!leftParts || !rightParts) return null;
42+ const length = Math.max(leftParts.length, rightParts.length);
43+ for (let index = 0; index < length; index += 1) {
44+ const l = leftParts[index] ?? 0;
45+ const r = rightParts[index] ?? 0;
46+ if (l !== r) return l > r ? 1 : -1;
47+ }
48+ return 0;
49+};
50+
51+const isNewerUpdateVersion = (incomingVersion: string, declinedVersion: string): boolean => {
52+ const comparison = compareUpdateVersions(incomingVersion, declinedVersion);
53+ return comparison === null ? incomingVersion !== declinedVersion : comparison > 0;
54+};
55+
56+export const planUpdateCheck = (input: {
57+ readonly stagedVersion: string | null;
58+ readonly trigger: UpdateCheckTrigger;
59+}): UpdateCheckPlan => ({
60+ check: true,
61+ promptVersionAfterCheck: input.trigger === "manual" ? input.stagedVersion : null,
62+});
63+
64+export const planCompletedUpdateCheck = (
65+ input: CompletedUpdateCheckInput,
66+): { readonly promptVersion: string | null } => {
67+ if (input.trigger !== "manual" || !input.stagedVersion) return { promptVersion: null };
68+ if (!input.updateAvailable) return { promptVersion: input.stagedVersion };
69+ if (!input.availableVersion) return { promptVersion: null };
70+ const comparison = compareUpdateVersions(input.availableVersion, input.stagedVersion);
71+ if (comparison === null) {
72+ return {
73+ promptVersion: input.availableVersion === input.stagedVersion ? input.stagedVersion : null,
74+ };
75+ }
76+ return { promptVersion: comparison <= 0 ? input.stagedVersion : null };
77+};
78+
79+export const planDownloadedUpdate = (input: DownloadedUpdateInput): DownloadedUpdatePlan => {
80+ const prompt =
81+ input.trigger === "manual" ||
82+ !input.declinedVersion ||
83+ isNewerUpdateVersion(input.incomingVersion, input.declinedVersion);
84+ return {
85+ stagedVersion: input.incomingVersion,
86+ declinedVersion: input.declinedVersion,
87+ status: { state: "downloaded", version: input.incomingVersion },
88+ promptVersion: prompt ? input.incomingVersion : null,
89+ };
90+};
91+
92+export const statusAfterUpdateError = (
93+ status: DesktopUpdateStatus,
94+ message: string,
95+): DesktopUpdateStatus => {
96+ if (status.state === "available" || status.state === "downloading" || status.state === "error") {
97+ return { state: "error", version: status.version, message };
98+ }
99+ return status;
100+};
101+
102+export const planFatalAutoInstallOnQuit = (input: {
103+ readonly packaged: boolean;
104+ readonly retryAfterReset: boolean;
105+}): { readonly enableDuringFailure: boolean; readonly restoreAfterRecovery: boolean } => ({
106+ enableDuringFailure: input.packaged,
107+ restoreAfterRecovery: input.packaged && input.retryAfterReset,
108+});
apps/desktop/src/shared/update.ts+1−0
@@ -13,6 +13,7 @@ export type DesktopUpdateStatus = · 12 unmodified lines
1313 | { readonly state: "available"; readonly version: string }
1414 | { readonly state: "downloading"; readonly version: string; readonly percent: number }
1515 | { readonly state: "downloaded"; readonly version: string }
16+ | { readonly state: "error"; readonly version: string; readonly message: string }
1617 | { readonly state: "installing"; readonly version: string };
1718
1819 /** Push channel: main → renderer, whenever the update status changes. */
packages/react/src/components/update-card.tsx+20−4
@@ -83,9 +83,13 @@ function useLatestVersion(currentVersion: string | undefined) { · 82 unmodified lines
8383
8484 // ── Card chrome ──────────────────────────────────────────────────────────
8585
86−/** The bordered card with the download glyph + "Update available" + version.
86+/** The bordered card with the download glyph + title + version.
8787 * Each variant supplies its own action as children. */
88−function UpdateCardShell(props: { version: string | null; children?: React.ReactNode }) {
88+function UpdateCardShell(props: {
89+ title?: string;
90+ version: string | null;
91+ children?: React.ReactNode;
92+}) {
8993 return (
9094 <div className="mx-2 mb-2 rounded-xl border border-primary/25 bg-primary/[0.06] p-3">
9195 <div className="flex items-center gap-2">
@@ -102,7 +106,9 @@ function UpdateCardShell(props: { version: string | null; children?: React.React · 10 unmodified lines
102106 </svg>
103107 </div>
104108 <div className="min-w-0">
105− <p className="text-xs font-semibold text-foreground">Update available</p>
109+ <p className="text-xs font-semibold text-foreground">
110+ {props.title ?? "Update available"}
111+ </p>
106112 {props.version && <p className="text-sm text-muted-foreground">v{props.version}</p>}
107113 </div>
108114 </div>
@@ -231,10 +237,20 @@ function DesktopUpdateCard(props: { update: DesktopUpdate }) { · 122 unmodified lines
231237 if (status.state === "installing") {
232238 return <p className="mt-2.5 text-xs text-muted-foreground">Restarting…</p>;
233239 }
240+ if (status.state === "error") {
241+ return <p className="mt-2.5 text-xs text-muted-foreground">{status.message}</p>;
242+ }
234243 return null;
235244 })();
236245
237− return <UpdateCardShell version={version}>{action}</UpdateCardShell>;
246+ return (
247+ <UpdateCardShell
248+ title={status.state === "error" ? "Update failed" : undefined}
249+ version={version}
250+ >
251+ {action}
252+ </UpdateCardShell>
253+ );
238254 }
239255
240256 // ── SidebarUpdateCard (the only export the shells consume) ────────────────
packages/react/src/hooks/desktop-update.ts+1−0
@@ -12,6 +12,7 @@ export type DesktopUpdateStatus = · 11 unmodified lines
1212 | { readonly state: "available"; readonly version: string }
1313 | { readonly state: "downloading"; readonly version: string; readonly percent: number }
1414 | { readonly state: "downloaded"; readonly version: string }
15+ | { readonly state: "error"; readonly version: string; readonly message: string }
1516 | { readonly state: "installing"; readonly version: string };
1617
1718 type DesktopUpdateBridge = {