Skip to content

Commit 9f93d69

Browse files
committed
Fix desktop updater declined-state handling
1 parent a968cc5 commit 9f93d69

6 files changed

Lines changed: 310 additions & 24 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ import {
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 = () => {
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 = () => {
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 = () => {
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 = () => {
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) => {
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 = () => {
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) => {
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) => {
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) => {
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) => {
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 = () => {
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 () => {
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 () => {
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 () => {
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()) {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)