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
61 UPDATE_STATUS_CHANNEL,61 UPDATE_STATUS_CHANNEL,
62 UPDATE_STATUS_GET_CHANNEL,62 UPDATE_STATUS_GET_CHANNEL,
63} from "../shared/update";63} from "../shared/update";
64import {
65 planCompletedUpdateCheck,
66 planDownloadedUpdate,
67 planFatalAutoInstallOnQuit,
68 planUpdateCheck,
69 statusAfterUpdateError,
70 type UpdateCheckTrigger,
71} from "./updater-state";
64 72
65// Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every73// Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every
66// Electron-side consumer (electron-store, electron-log, window-state) lands74// Electron-side consumer (electron-store, electron-log, window-state) lands
@@ -741,7 +749,9 @@ const registerIpcHandlers = () => { · 674 unmodified lines
741 // Crash-screen escape hatch: a recurring sidecar crash may already be749 // Crash-screen escape hatch: a recurring sidecar crash may already be
742 // fixed upstream. Reuses the menu flow — staged updates prompt to install,750 // fixed upstream. Reuses the menu flow — staged updates prompt to install,
743 // "no updates" / failures surface in their own dialogs.751 // "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 );
745 ipcMain.handle(UPDATE_STATUS_GET_CHANNEL, (): DesktopUpdateStatus => updateStatus);755 ipcMain.handle(UPDATE_STATUS_GET_CHANNEL, (): DesktopUpdateStatus => updateStatus);
746 ipcMain.handle(UPDATE_INSTALL_CHANNEL, async (): Promise<void> => {756 ipcMain.handle(UPDATE_INSTALL_CHANNEL, async (): Promise<void> => {
747 const version = "version" in updateStatus ? updateStatus.version : "";757 const version = "version" in updateStatus ? updateStatus.version : "";
@@ -754,6 +764,7 @@ const registerIpcHandlers = () => { · 6 unmodified lines
754 }764 }
755 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching765 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching
756 // the native dialog's restart path.766 // the native dialog's restart path.
767 stopSupervisedMonitor();
757 if (connection) {768 if (connection) {
758 await stopConnection(connection);769 await stopConnection(connection);
759 connection = null;770 connection = null;
@@ -804,6 +815,7 @@ const registerIpcHandlers = () => { · 44 unmodified lines
804// ──────────────────────────────────────────────────────────────────────815// ──────────────────────────────────────────────────────────────────────
805 816
806let downloadedUpdateVersion: string | null = null;817let downloadedUpdateVersion: string | null = null;
818let declinedUpdateVersion: string | null = null;
807let updateDialogOpen = false;819let updateDialogOpen = false;
808 820
809// Renderer-facing auto-update status. The web shell renders a desktop-native821// Renderer-facing auto-update status. The web shell renders a desktop-native
@@ -845,11 +857,16 @@ const applyFakeUpdateFromEnv = () => { · 35 unmodified lines
845 state === "available" ||857 state === "available" ||
846 state === "downloaded" ||858 state === "downloaded" ||
847 state === "downloading" ||859 state === "downloading" ||
860 state === "error" ||
848 state === "installing"861 state === "installing"
849 ) {862 ) {
850 if (state === "downloaded") downloadedUpdateVersion = version;863 if (state === "downloaded") downloadedUpdateVersion = version;
851 setUpdateStatus(864 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 },
853 );870 );
854 }871 }
855 } catch (error) {872 } catch (error) {
@@ -874,12 +891,15 @@ const promptInstallUpdate = async (version: string) => { · 18 unmodified lines
874 if (response.response === 0) {891 if (response.response === 0) {
875 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A892 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A
876 // supervised daemon is left running — it's independent of this bundle.893 // supervised daemon is left running — it's independent of this bundle.
894 stopSupervisedMonitor();
877 if (connection) {895 if (connection) {
878 await stopConnection(connection);896 await stopConnection(connection);
879 connection = null;897 connection = null;
880 }898 }
881 autoUpdater.quitAndInstall(false, true);899 autoUpdater.quitAndInstall(false, true);
900 return;
882 }901 }
902 declinedUpdateVersion = version;
883 } finally {903 } finally {
884 updateDialogOpen = false;904 updateDialogOpen = false;
885 }905 }
@@ -910,25 +930,41 @@ const setupAutoUpdater = () => { · 24 unmodified lines
910 }930 }
911 });931 });
912 autoUpdater.on("update-downloaded", (info: UpdateInfo) => {932 autoUpdater.on("update-downloaded", (info: UpdateInfo) => {
913 downloadedUpdateVersion = info.version;933 const decision = planDownloadedUpdate({
914 setUpdateStatus({ state: "downloaded", version: info.version });934 stagedVersion: downloadedUpdateVersion,
915 void promptInstallUpdate(info.version);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);
916 });943 });
917 autoUpdater.on("error", (err: Error) => {944 autoUpdater.on("error", (err: Error) => {
918 log.warn("[updater] error", err);945 log.warn("[updater] error", err);
946 setUpdateStatus(
947 statusAfterUpdateError(updateStatus, "Update failed. Check again from the menu."),
948 );
949 pendingUpdateVersion = null;
919 });950 });
920 951
921 setInterval(() => {952 setInterval(() => {
922 if (downloadedUpdateVersion) return; // already staged; waiting on the user953 void runUpdateCheck({ alertOnFail: false, trigger: "interval" });
923 void runUpdateCheck({ alertOnFail: false });
924 }, UPDATE_POLL_INTERVAL_MS);954 }, UPDATE_POLL_INTERVAL_MS);
925};955};
926 956
927interface UpdateCheckOptions {957interface UpdateCheckOptions {
928 readonly alertOnFail: boolean;958 readonly alertOnFail: boolean;
959 readonly trigger: UpdateCheckTrigger;
929}960}
930 961
931const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => {962type UpdateCheckResult = {
963 readonly isUpdateAvailable?: boolean;
964 readonly updateInfo?: { readonly version?: string };
965};
966
967const runUpdateCheck = async ({ alertOnFail, trigger }: UpdateCheckOptions) => {
932 if (!app.isPackaged) {968 if (!app.isPackaged) {
933 if (alertOnFail) {969 if (alertOnFail) {
934 await dialog.showMessageBox({970 await dialog.showMessageBox({
@@ -939,14 +975,23 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { · 4 unmodified lines
939 }975 }
940 return;976 return;
941 }977 }
942 if (downloadedUpdateVersion) {978 const checkPlan = planUpdateCheck({ stagedVersion: downloadedUpdateVersion, trigger });
943 await promptInstallUpdate(downloadedUpdateVersion);979 if (!checkPlan.check) return;
944 return;
945 }
946 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: surface network/update failures only when the user asked980 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: surface network/update failures only when the user asked
947 try {981 try {
948 const result = await autoUpdater.checkForUpdates();982 const result = (await autoUpdater.checkForUpdates()) as UpdateCheckResult | null;
949 const newer = result?.isUpdateAvailable === true;983 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 }
950 if (!alertOnFail) return;995 if (!alertOnFail) return;
951 if (newer) return; // 'update-downloaded' handler will fire the install dialog996 if (newer) return; // 'update-downloaded' handler will fire the install dialog
952 await dialog.showMessageBox({997 await dialog.showMessageBox({
@@ -956,6 +1001,9 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { · 3 unmodified lines
956 });1001 });
957 } catch (error) {1002 } catch (error) {
958 log.warn("[updater] check failed", error);1003 log.warn("[updater] check failed", error);
1004 setUpdateStatus(
1005 statusAfterUpdateError(updateStatus, "Update failed. Check again from the menu."),
1006 );
959 if (!alertOnFail) return;1007 if (!alertOnFail) return;
960 await dialog.showMessageBox({1008 await dialog.showMessageBox({
961 type: "error",1009 type: "error",
@@ -978,8 +1026,12 @@ const handleFatalSidecarFailure = async (error: unknown) => { · 16 unmodified lines
978 // Install whatever finishes downloading by the time the user quits the1026 // Install whatever finishes downloading by the time the user quits the
979 // failure dialog; if it downloads while the dialog is open, the regular1027 // failure dialog; if it downloads while the dialog is open, the regular
980 // 'update-downloaded' prompt offers an immediate restart instead.1028 // 'update-downloaded' prompt offers an immediate restart instead.
981 autoUpdater.autoInstallOnAppQuit = true;1029 const autoInstallPlan = planFatalAutoInstallOnQuit({
982 void runUpdateCheck({ alertOnFail: false });1030 packaged: true,
1031 retryAfterReset: false,
1032 });
1033 if (autoInstallPlan.enableDuringFailure) autoUpdater.autoInstallOnAppQuit = true;
1034 void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
983 }1035 }
984 // 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 dialog1036 // 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
985 const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);1037 const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
@@ -995,7 +1047,13 @@ const handleFatalSidecarFailure = async (error: unknown) => { · 9 unmodified lines
995 // Damaged executor state (failed migration, corrupt SQLite) makes startup1047 // Damaged executor state (failed migration, corrupt SQLite) makes startup
996 // fail forever — updating can't fix it. Offer the move-aside reset and one1048 // fail forever — updating can't fix it. Offer the move-aside reset and one
997 // immediate retry. Returns true when boot should be attempted again.1049 // 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;
999 const { backupDir } = resetExecutorState();1057 const { backupDir } = resetExecutorState();
1000 await announceBackup(backupDir);1058 await announceBackup(backupDir);
1001 return true;1059 return true;
@@ -1011,7 +1069,7 @@ const installApplicationMenu = () => { · 9 unmodified lines
1011 { role: "about" },1069 { role: "about" },
1012 {1070 {
1013 label: "Check for Updates…",1071 label: "Check for Updates…",
1014 click: () => void runUpdateCheck({ alertOnFail: true }),1072 click: () => void runUpdateCheck({ alertOnFail: true, trigger: "manual" }),
1015 },1073 },
1016 {1074 {
1017 label: "Export Diagnostics…",1075 label: "Export Diagnostics…",
@@ -1105,7 +1163,7 @@ const boot = async () => { · 87 unmodified lines
1105 // A crashing sidecar may be a broken release — quietly stage any1163 // A crashing sidecar may be a broken release — quietly stage any
1106 // available update so the install prompt appears on its own (same1164 // available update so the install prompt appears on its own (same
1107 // self-heal as the fatal startup path).1165 // self-heal as the fatal startup path).
1108 void runUpdateCheck({ alertOnFail: false });1166 void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
1109 });1167 });
1110 // Prefer an OS-supervised daemon: attach to one that's running, kick one1168 // Prefer an OS-supervised daemon: attach to one that's running, kick one
1111 // that's installed, or offer to install on first run. Quitting the app then1169 // that's installed, or offer to install on first run. Quitting the app then
@@ -1121,7 +1179,7 @@ const boot = async () => { · 9 unmodified lines
1121 try {1179 try {
1122 await createWindow(supervised);1180 await createWindow(supervised);
1123 armSupervisedMonitor();1181 armSupervisedMonitor();
1124 void runUpdateCheck({ alertOnFail: false });1182 void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
1125 return;1183 return;
1126 } catch (error) {1184 } catch (error) {
1127 log.warn("Failed to load supervised daemon; falling back to managed sidecar", error);1185 log.warn("Failed to load supervised daemon; falling back to managed sidecar", error);
@@ -1196,7 +1254,7 @@ const boot = async () => { · 68 unmodified lines
1196 // Check at boot. If an update is available, autoDownload pulls it and1254 // Check at boot. If an update is available, autoDownload pulls it and
1197 // the 'update-downloaded' handler fires the install dialog. Silent on1255 // the 'update-downloaded' handler fires the install dialog. Silent on
1198 // no-update / failure so we don't bother users on every launch.1256 // no-update / failure so we don't bother users on every launch.
1199 void runUpdateCheck({ alertOnFail: false });1257 void runUpdateCheck({ alertOnFail: false, trigger: "boot" });
1200};1258};
1201 1259
1202if (ensureSingleInstance()) {1260if (ensureSingleInstance()) {
apps/desktop/src/main/updater-state.test.ts+102−0
@@ -0,0 +1,102 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 planCompletedUpdateCheck,
4 planDownloadedUpdate,
5 planFatalAutoInstallOnQuit,
6 planUpdateCheck,
7 statusAfterUpdateError,
8} from "./updater-state";
9
10describe("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 @@
1import type { DesktopUpdateStatus } from "../shared/update";
2
3export type UpdateCheckTrigger = "boot" | "interval" | "manual";
4
5export interface UpdateCheckPlan {
6 readonly check: boolean;
7 readonly promptVersionAfterCheck: string | null;
8}
9
10export interface CompletedUpdateCheckInput {
11 readonly stagedVersion: string | null;
12 readonly trigger: UpdateCheckTrigger;
13 readonly updateAvailable: boolean;
14 readonly availableVersion: string | null;
15}
16
17export interface DownloadedUpdateInput {
18 readonly stagedVersion: string | null;
19 readonly declinedVersion: string | null;
20 readonly incomingVersion: string;
21 readonly trigger: UpdateCheckTrigger;
22}
23
24export interface DownloadedUpdatePlan {
25 readonly stagedVersion: string;
26 readonly declinedVersion: string | null;
27 readonly status: DesktopUpdateStatus;
28 readonly promptVersion: string | null;
29}
30
31const 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
38export 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
51const isNewerUpdateVersion = (incomingVersion: string, declinedVersion: string): boolean => {
52 const comparison = compareUpdateVersions(incomingVersion, declinedVersion);
53 return comparison === null ? incomingVersion !== declinedVersion : comparison > 0;
54};
55
56export 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
64export 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
79export 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
92export 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
102export 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
13 | { readonly state: "available"; readonly version: string }13 | { readonly state: "available"; readonly version: string }
14 | { readonly state: "downloading"; readonly version: string; readonly percent: number }14 | { readonly state: "downloading"; readonly version: string; readonly percent: number }
15 | { readonly state: "downloaded"; readonly version: string }15 | { readonly state: "downloaded"; readonly version: string }
16 | { readonly state: "error"; readonly version: string; readonly message: string }
16 | { readonly state: "installing"; readonly version: string };17 | { readonly state: "installing"; readonly version: string };
17 18
18/** Push channel: main → renderer, whenever the update status changes. */19/** 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
83 83
84// ── Card chrome ──────────────────────────────────────────────────────────84// ── Card chrome ──────────────────────────────────────────────────────────
85 85
86/** The bordered card with the download glyph + "Update available" + version.86/** The bordered card with the download glyph + title + version.
87 * Each variant supplies its own action as children. */87 * Each variant supplies its own action as children. */
88function UpdateCardShell(props: { version: string | null; children?: React.ReactNode }) {88function UpdateCardShell(props: {
89 title?: string;
90 version: string | null;
91 children?: React.ReactNode;
92}) {
89 return (93 return (
90 <div className="mx-2 mb-2 rounded-xl border border-primary/25 bg-primary/[0.06] p-3">94 <div className="mx-2 mb-2 rounded-xl border border-primary/25 bg-primary/[0.06] p-3">
91 <div className="flex items-center gap-2">95 <div className="flex items-center gap-2">
@@ -102,7 +106,9 @@ function UpdateCardShell(props: { version: string | null; children?: React.React · 10 unmodified lines
102 </svg>106 </svg>
103 </div>107 </div>
104 <div className="min-w-0">108 <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>
106 {props.version && <p className="text-sm text-muted-foreground">v{props.version}</p>}112 {props.version && <p className="text-sm text-muted-foreground">v{props.version}</p>}
107 </div>113 </div>
108 </div>114 </div>
@@ -231,10 +237,20 @@ function DesktopUpdateCard(props: { update: DesktopUpdate }) { · 122 unmodified lines
231 if (status.state === "installing") {237 if (status.state === "installing") {
232 return <p className="mt-2.5 text-xs text-muted-foreground">Restarting…</p>;238 return <p className="mt-2.5 text-xs text-muted-foreground">Restarting…</p>;
233 }239 }
240 if (status.state === "error") {
241 return <p className="mt-2.5 text-xs text-muted-foreground">{status.message}</p>;
242 }
234 return null;243 return null;
235 })();244 })();
236 245
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 );
238}254}
239 255
240// ── SidebarUpdateCard (the only export the shells consume) ────────────────256// ── 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
12 | { readonly state: "available"; readonly version: string }12 | { readonly state: "available"; readonly version: string }
13 | { readonly state: "downloading"; readonly version: string; readonly percent: number }13 | { readonly state: "downloading"; readonly version: string; readonly percent: number }
14 | { readonly state: "downloaded"; readonly version: string }14 | { readonly state: "downloaded"; readonly version: string }
15 | { readonly state: "error"; readonly version: string; readonly message: string }
15 | { readonly state: "installing"; readonly version: string };16 | { readonly state: "installing"; readonly version: string };
16 17
17type DesktopUpdateBridge = {18type DesktopUpdateBridge = {