1import { useAtomValue } from "@effect/atom-react";
2import type { DesktopUpdateState } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
4import { TriangleAlertIcon } from "lucide-react";
5import { useMemo, useRef, useState } from "react";
6
7import { isElectron } from "../../env";
8import { useMediaQuery } from "../../hooks/useMediaQuery";
9import { useDesktopUpdateState } from "../../state/desktopUpdate";
10import { serverEnvironment } from "../../state/server";
11import { Button } from "../ui/button";
12import {
13 canCheckForUpdate,
14 getArm64IntelBuildWarningDescription,
15 getDesktopUpdateButtonTooltip,
16 getDesktopUpdateDownloadedVersion,
17 isDesktopUpdateButtonDisabled,
18 resolveDesktopUpdateButtonAction,
19 shouldShowArm64IntelBuildWarning,
20} from "../desktopUpdate.logic";
21import {
22 downloadLocalUpdate,
23 installLocalUpdate,
24 performDesktopUpdate,
25 toastUpdateError,
26} from "../desktopUpdate.actions";
27import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon";
28import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
29import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
30import {
31 describeUpdateRun,
32 resolveUpdateRunMachineStatus,
33 type UpdateEverythingRun,
34} from "./updateEverything.logic";
35import { useUpdateEverythingRun } from "./updateEverythingRun";
36import { useUpdateEverything } from "./useUpdateEverything";
37
38function useUpdateRunStatus(
39 run: UpdateEverythingRun | null,
40 state: DesktopUpdateState | null,
41): string | null {
42 const statusesAtom = useMemo(
43 () =>
44 Atom.make((get) =>
45 run === null
46 ? []
47 : run.machines.map((machine) => ({
48 label: machine.label,
49 status: resolveUpdateRunMachineStatus(
50 machine,
51 run.phase,
52 get(serverEnvironment.updateStateAtom(machine.environmentId)),
53 ),
54 })),
55 ),
56 [run],
57 );
58 const statuses = useAtomValue(statusesAtom);
59 if (run === null) return null;
60 const local =
61 run.localVersion === null
62 ? null
63 : state?.status === "downloading"
64 ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}`
65 : run.phase === "installing"
66 ? "restarting"
67 : state && resolveDesktopUpdateButtonAction(state) === "install"
68 ? "restarts last"
69 : "waiting";
70 return describeUpdateRun(statuses, local).join("\n");
71}
72
73export function AppMenuUpdates() {
74 const state = useDesktopUpdateState();
75 const [isActionPending, setIsActionPending] = useState(false);
76 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
77 const pending = useRef(false);
78 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
79 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
80 const localUpdateVersion =
81 state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;
82 const restartsApp = state?.installKind !== "ui";
83 const everything = useUpdateEverything(restartsApp ? localUpdateVersion : null);
84 const run = useUpdateEverythingRun();
85 const runStatus = useUpdateRunStatus(run, state);
86 const isDownloading = state?.status === "downloading";
87 const isBusy = isActionPending || run !== null;
88 const hasUpdates = localUpdateVersion !== null || everything.hasUpdates;
89 const appActionLabel =
90 state?.status === "checking"
91 ? "Checking…"
92 : isDownloading
93 ? "Downloading…"
94 : action === "download"
95 ? state?.status === "error"
96 ? "Retry download"
97 : "Download"
98 : action === "install"
99 ? restartsApp
100 ? "Restart"
101 : "Reload"
102 : "Check";
103
104 const runEverything = async () => {
105 if (pending.current || run !== null) return;
106 pending.current = true;
107 setIsConfirmingEverything(false);
108 setIsActionPending(true);
109 const bridge = window.desktopBridge;
110 try {
111 await everything.run({
112 ...(bridge && action === "download"
113 ? {
114 downloadLocal: (reportError) =>
115 downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }),
116 }
117 : {}),
118 ...(bridge && restartsApp && action !== "none"
119 ? {
120 installLocal: (reportError) => installLocalUpdate(bridge, reportError),
121 }
122 : {}),
123 });
124 if (bridge && !restartsApp && action === "install") await installLocalUpdate(bridge);
125 } catch (error) {
126 toastUpdateError(
127 "Could not update everything",
128 error instanceof Error ? error.message : "Try again.",
129 );
130 } finally {
131 pending.current = false;
132 setIsActionPending(false);
133 }
134 };
135
136 const handleAppAction = async () => {
137 const bridge = window.desktopBridge;
138 if (!bridge || !state || pending.current || isBusy) return;
139 pending.current = true;
140 setIsActionPending(true);
141 try {
142 await performDesktopUpdate(bridge, state);
143 } finally {
144 pending.current = false;
145 setIsActionPending(false);
146 }
147 };
148
149 const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state);
150 const appStatus =
151 state?.status === "checking"
152 ? "Checking for updates…"
153 : state
154 ? getDesktopUpdateButtonTooltip(state)
155 : "Loading update status…";
156 const iconStatus =
157 state?.status === "checking"
158 ? "checking"
159 : isDownloading
160 ? "downloading"
161 : action === "install"
162 ? "downloaded"
163 : action === "download"
164 ? "available"
165 : "idle";
166
167 return (
168 <section aria-label="Updates" className="space-y-1">
169 <div className="flex min-h-7 items-center justify-between gap-2 px-1.5">
170 <h3 className="text-xs font-medium text-muted-foreground">Updates</h3>
171 {hasUpdates || run !== null ? (
172 <Button
173 size="xs"
174 variant="outline"
175 disabled={isBusy || isDownloading}
176 onClick={() => {
177 if (everything.restarts.length > 0 || (restartsApp && localUpdateVersion !== null)) {
178 setIsConfirmingEverything(true);
179 } else {
180 void runEverything();
181 }
182 }}
183 >
184 {run !== null ? "Updating…" : "Update all"}
185 </Button>
186 ) : null}
187 </div>
188 {isElectron ? (
189 <div className="flex min-h-9 items-center gap-2 px-1.5">
190 {/* Icon column width; the download ring overflows it evenly. */}
191 <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center">
192 <DesktopUpdateStatusIcon
193 status={iconStatus}
194 downloadPercent={state?.downloadPercent ?? null}
195 isCheckAnimating={!prefersReducedMotion}
196 />
197 </span>
198 <div className="min-w-0 flex-1">
199 <div className="text-xs font-medium">App</div>
200 <p role="status" className="text-2xs leading-4 text-muted-foreground">
201 {appStatus}
202 </p>
203 </div>
204 <Button
205 size="xs"
206 variant="outline"
207 disabled={
208 isBusy ||
209 (action === "none" ? !canCheckForUpdate(state) : isDesktopUpdateButtonDisabled(state))
210 }
211 aria-label={`${appActionLabel} app update`}
212 onClick={() => void handleAppAction()}
213 >
214 {appActionLabel}
215 </Button>
216 </div>
217 ) : null}
218 {showArchitectureWarning && state ? (
219 <p className="flex gap-2 px-1.5 text-xs text-warning-foreground">
220 <TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
221 {getArm64IntelBuildWarningDescription(state)}
222 </p>
223 ) : null}
224 {state?.channel === "nightly" && state.releaseNotes.length > 0 ? (
225 <details className="px-1.5 text-xs">
226 <summary className="cursor-pointer rounded-sm py-1 text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
227 Release notes
228 </summary>
229 <SidebarUpdateReleaseNotes
230 shell={window.desktopBridge}
231 state={state}
232 tooltip={appStatus}
233 />
234 </details>
235 ) : null}
236 {everything.servers.map((server) => (
237 <div
238 key={server.environmentId}
239 className="flex items-center justify-between gap-2 px-1.5 py-1 text-xs"
240 >
241 <span>{server.serverLabel}</span>
242 <span className="text-2xs text-muted-foreground">Server {server.targetVersion}</span>
243 </div>
244 ))}
245 {runStatus ? (
246 <p role="status" className="whitespace-pre-line px-1.5 text-xs text-muted-foreground">
247 {runStatus}
248 </p>
249 ) : null}
250 {everything.skipped.length > 0 ? (
251 <details className="px-1.5 text-xs text-muted-foreground">
252 <summary className="cursor-pointer rounded-sm py-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
253 Unavailable machines ({everything.skipped.length})
254 </summary>
255 <ul className="space-y-1 py-1">
256 {everything.skipped.map((machine) => (
257 <li key={machine.environmentId}>
258 {machine.label}: {machine.reason}
259 </li>
260 ))}
261 </ul>
262 </details>
263 ) : null}
264 <UpdateEverythingDialog
265 open={isConfirmingEverything}
266 onOpenChange={setIsConfirmingEverything}
267 summary={everything.summary}
268 lines={everything.lines}
269 restarts={everything.restarts}
270 skipped={everything.skipped}
271 onConfirm={() => void runEverything()}
272 />
273 </section>
274 );
275}