Open History to see the last commit
1import { useAtomValue } from "@effect/atom-react";
2import {
3 AuthEnvironmentMaintainScope,
4 type AuthSessionState,
5 managedUpdatesDenial,
6 sessionGrantsScope,
7} from "@t3tools/contracts";
8import type { AsyncResult } from "effect/reactivity";
9import { environmentSession } from "~/state/session";
10import type {
11 DesktopUpdateChannel,
12 EnvironmentId,
13 ServerInstallation,
14 ServerSelfUpdateCapability,
15} from "@t3tools/contracts";
16import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
17import {
18 isAtomCommandInterrupted,
19 settlePromise,
20 squashAtomCommandFailure,
21} from "@t3tools/client-runtime/state/runtime";
22import { CircleArrowUpIcon } from "lucide-react";
23import { type ComponentProps, useRef, useState } from "react";
24
25import { requestConfirmDialog } from "~/confirmDialog";
26import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
27import { serverEnvironment, updateOutdatedServer } from "~/state/server";
28import { appAtomRegistry } from "~/rpc/atomRegistry";
29import { useAtomCommand } from "~/state/use-atom-command";
30import { useServerConfigs } from "~/state/entities";
31import { useManagedPolicy } from "~/state/managedPolicy";
32import { environmentPresentations } from "~/state/presentation";
33import { ManagedControl } from "./ManagedPolicy";
34import { manualServerUpdateCommand } from "~/versionSkew";
35import { crossesReleaseTrack } from "@t3tools/shared/cliRelease";
36import { Button } from "./ui/button";
37import { toastManager } from "./ui/toast";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
39
40const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = {
41 downloading: "Downloading…",
42 installing: "Downloading…",
43 resuming: "Restarting…",
44};
45const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
46
47export function serverUpdateStageLabel(stage: ServerUpdateStage): string {
48 return UPDATE_STAGE_LABELS[stage];
49}
50
51/** Why the environment's policy refuses server updates, read outside render. */
52function readUpdatesDenial(environmentId: EnvironmentId): string | undefined {
53 return managedUpdatesDenial(
54 appAtomRegistry.get(environmentPresentations.serverConfigsAtom).get(environmentId)
55 ?.managedPolicy,
56 );
57}
58
59const CROSS_TRACK_NOTE = "Switch tracks in About → Update track";
60
61/** Hosts refuse a Stable/Nightly crossing outside a track switch, which checks the database first. */
62function refusedCrossing(
63 target: {
64 readonly selfUpdate: ServerSelfUpdateCapability | null;
65 readonly targetVersion: string;
66 readonly channel?: DesktopUpdateChannel | undefined;
67 },
68 serverVersion: string | undefined,
69): boolean {
70 return (
71 target.channel === undefined &&
72 target.selfUpdate !== null &&
73 serverVersion !== undefined &&
74 crossesReleaseTrack(serverVersion, target.targetVersion)
75 );
76}
77
78function CrossTrackNote() {
79 return <span className="text-muted-foreground text-xs">{CROSS_TRACK_NOTE}</span>;
80}
81
82function updateFailureMessage(error: unknown): string {
83 return error instanceof Error ? error.message : "Server update failed.";
84}
85
86export interface ServerUpdateTarget {
87 readonly environmentId: EnvironmentId;
88 readonly serverLabel: string;
89 readonly selfUpdate: ServerSelfUpdateCapability | null;
90 readonly installation?: ServerInstallation | undefined;
91 readonly desktopAppUpdate?: boolean;
92 readonly targetVersion: string;
93 /** Set only by a track switch; the host's desktop app moves to this track. */
94 readonly channel?: DesktopUpdateChannel;
95 /** A track switch's checked release migration; the host re-checks its own database. */
96 readonly targetDatabaseMigration?: number;
97 /** The host already runs its track's newest build and only saves the track. */
98 readonly trackOnly?: boolean;
99}
100
101type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | "className"> & {
102 readonly label?: string;
103 readonly appearance?: "button" | "icon";
104};
105
106export type ServerUpdateOutcome =
107 | { readonly status: "updated" | "skipped" }
108 | { readonly status: "failed"; readonly message: string };
109
110/** Toasts its success or failure unless `quiet`; never rejects. */
111export function useServerUpdate() {
112 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
113 return async (
114 target: ServerUpdateTarget,
115 { quiet = false }: { readonly quiet?: boolean } = {},
116 ): Promise<ServerUpdateOutcome> => {
117 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
118 const denial = readUpdatesDenial(environmentId);
119 if (denial !== undefined) {
120 if (!quiet) {
121 toastManager.add({
122 type: "error",
123 title: `${serverLabel} not updated`,
124 description: denial,
125 });
126 }
127 return { status: "failed", message: denial };
128 }
129 if (
130 !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) ||
131 pendingUpdateEnvironmentIds.has(environmentId)
132 )
133 return { status: "skipped" };
134 pendingUpdateEnvironmentIds.add(environmentId);
135 const result = await updateServer({
136 environmentId,
137 input: {
138 targetVersion,
139 ...(target.channel === undefined ? {} : { channel: target.channel }),
140 ...(target.targetDatabaseMigration === undefined
141 ? {}
142 : { targetDatabaseMigration: target.targetDatabaseMigration }),
143 },
144 });
145 pendingUpdateEnvironmentIds.delete(environmentId);
146 if (result._tag === "Failure") {
147 if (isAtomCommandInterrupted(result)) return { status: "skipped" };
148 const message = updateFailureMessage(squashAtomCommandFailure(result));
149 if (!quiet) {
150 toastManager.add({
151 type: "error",
152 title: `${serverLabel} update failed`,
153 description: message,
154 });
155 }
156 return { status: "failed", message };
157 }
158 if (!quiet) {
159 toastManager.add({
160 type: "success",
161 title: `${serverLabel} updated`,
162 description:
163 selfUpdate === "desktop-managed"
164 ? `Desktop app relaunched on ${result.value.targetVersion}.`
165 : `Reconnected on t3@${result.value.targetVersion}.`,
166 });
167 }
168 return { status: "updated" };
169 };
170}
171
172export function ServerUpdatesAction({
173 targets,
174 label = "Update all",
175 variant = "outline",
176 size = "xs",
177 className,
178}: UpdateButtonProps & {
179 readonly targets: ReadonlyArray<ServerUpdateTarget>;
180}) {
181 const update = useServerUpdate();
182 const serverConfigs = useServerConfigs();
183 const pending = useRef(false);
184 const [isPending, setIsPending] = useState(false);
185 const eligible = targets.filter(
186 (target) =>
187 target.selfUpdate !== null &&
188 (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate) &&
189 managedUpdatesDenial(serverConfigs.get(target.environmentId)?.managedPolicy) === undefined &&
190 !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion),
191 );
192 const handleUpdate = async () => {
193 if (pending.current) return;
194 pending.current = true;
195 setIsPending(true);
196 await settlePromise(async () => {
197 const available = eligible.filter(
198 (target) => !pendingUpdateEnvironmentIds.has(target.environmentId),
199 );
200 const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed");
201 if (desktopTargets.length > 0) {
202 const confirmed =
203 (await requestConfirmDialog(
204 `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`,
205 { initialFocus: "confirm" },
206 )) ?? true;
207 if (!confirmed) return;
208 }
209 await Promise.all(available.map((target) => update(target)));
210 });
211 pending.current = false;
212 setIsPending(false);
213 };
214 return (
215 <Button
216 size={size}
217 variant={variant}
218 className={className}
219 disabled={isPending || eligible.length === 0}
220 onClick={() => void handleUpdate()}
221 >
222 {label}
223 </Button>
224 );
225}
226
227export function canUpdateServer(
228 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
229): boolean {
230 if (result._tag !== "Success" || !result.value.authenticated) return false;
231 return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
232}
233
234export function ServerUpdateProgress({
235 state,
236}: {
237 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
238}) {
239 if (state.status === "failed") {
240 return (
241 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">
242 <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />
243 <Tooltip>
244 <TooltipTrigger render={<span className="min-w-0 truncate">{state.message}</span>} />
245 <TooltipPopup side="top">{state.message}</TooltipPopup>
246 </Tooltip>
247 </div>
248 );
249 }
250 return (
251 <div className="mt-1 flex items-center gap-2 text-xs font-medium text-foreground">
252 <span className="size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden="true" />
253 <span>{serverUpdateStageLabel(state.stage)}</span>
254 </div>
255 );
256}
257
258/** client-runtime owns self-updates so unmounting during reconnect preserves operation state. */
259export function ServerUpdateAction({
260 environmentId,
261 serverLabel,
262 selfUpdate,
263 installation,
264 desktopAppUpdate = false,
265 targetVersion,
266 channel,
267 label = "Update",
268 variant = "outline",
269 size = "xs",
270 className,
271 appearance = "button",
272}: ServerUpdateTarget & UpdateButtonProps) {
273 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
274 const serverVersion = useServerConfigs().get(environmentId)?.environment.serverVersion;
275 const sessionStateAtom = environmentSession.sessionStateAtom(environmentId);
276 const canUpdate = useAtomValue(sessionStateAtom, canUpdateServer);
277 const updatesDenial = managedUpdatesDenial(useManagedPolicy(environmentId));
278 const update = useServerUpdate();
279 const updatesInstall =
280 installation?.kind === "npm-global" || installation?.kind === "t3-launcher";
281 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
282 target: updatesInstall ? "update command" : "relaunch command",
283 onCopy: ({ command }) => {
284 toastManager.add({
285 type: "success",
286 title: updatesInstall ? "Update command copied" : "Relaunch command copied",
287 description: updatesInstall
288 ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.`
289 : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`,
290 });
291 },
292 onError: (error) => {
293 toastManager.add({
294 type: "error",
295 title: "Could not copy update command",
296 description: error.message,
297 });
298 },
299 });
300
301 const handleUpdate = async () => {
302 if (
303 !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) ||
304 pendingUpdateEnvironmentIds.has(environmentId)
305 ) {
306 return;
307 }
308 if (isDesktopAppUpdate) {
309 const confirmed =
310 (await requestConfirmDialog(
311 `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`,
312 { initialFocus: "confirm" },
313 )) ?? true;
314 if (!confirmed) {
315 return;
316 }
317 }
318 if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return;
319 await update({
320 environmentId,
321 serverLabel,
322 selfUpdate,
323 desktopAppUpdate,
324 targetVersion,
325 });
326 };
327
328 if (updatesDenial !== undefined) {
329 return appearance === "icon" ? (
330 <ManagedControl reason={updatesDenial}>
331 <Button size="icon-xs" variant="ghost-muted" aria-label={updatesDenial} disabled>
332 <CircleArrowUpIcon className="size-3.5" />
333 </Button>
334 </ManagedControl>
335 ) : (
336 <span className="text-muted-foreground text-xs">{updatesDenial}</span>
337 );
338 }
339
340 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
341 return (
342 <span className="text-muted-foreground text-xs">
343 Update the desktop app on that machine to update this server.
344 </span>
345 );
346 }
347
348 if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) {
349 return <CrossTrackNote />;
350 }
351
352 const manualCommand =
353 selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null;
354 const actionLabel =
355 manualCommand !== null
356 ? updatesInstall
357 ? "Copy update command"
358 : "Copy relaunch command"
359 : label;
360 const onClick =
361 manualCommand !== null
362 ? () => copyToClipboard(manualCommand, { command: manualCommand })
363 : () => void handleUpdate();
364
365 if (appearance === "icon") {
366 return (
367 <Tooltip>
368 <TooltipTrigger
369 render={
370 <Button
371 size="icon-xs"
372 variant="ghost-muted"
373 className={className}
374 aria-label={`${actionLabel} for ${serverLabel}`}
375 disabled={manualCommand === null && !canUpdate}
376 onClick={onClick}
377 />
378 }
379 >
380 <CircleArrowUpIcon className="size-3.5" />
381 </TooltipTrigger>
382 <TooltipPopup side="top">{actionLabel}</TooltipPopup>
383 </Tooltip>
384 );
385 }
386
387 return (
388 <Button
389 size={size}
390 variant={variant}
391 className={className}
392 disabled={manualCommand === null && !canUpdate}
393 onClick={onClick}
394 >
395 {actionLabel}
396 </Button>
397 );
398}
399
400/** Read the host descriptor's version because the host never delivers server config. */
401export function OutdatedServerUpdateAction({
402 environmentId,
403 serverLabel,
404 fromVersion,
405 targetVersion,
406 label = "Update",
407}: {
408 readonly environmentId: EnvironmentId;
409 readonly serverLabel: string;
410 readonly fromVersion: string | undefined;
411 readonly targetVersion: string;
412 readonly label?: string;
413}) {
414 const update = useAtomCommand(updateOutdatedServer, { reportFailure: false });
415 const handleUpdate = async () => {
416 if (pendingUpdateEnvironmentIds.has(environmentId)) return;
417 pendingUpdateEnvironmentIds.add(environmentId);
418 const result = await update({
419 environmentId,
420 input: { targetVersion },
421 ...(fromVersion === undefined ? {} : { fromVersion }),
422 });
423 pendingUpdateEnvironmentIds.delete(environmentId);
424 if (result._tag === "Failure") {
425 if (isAtomCommandInterrupted(result)) return;
426 toastManager.add({
427 type: "error",
428 title: `${serverLabel} update failed`,
429 description: updateFailureMessage(squashAtomCommandFailure(result)),
430 });
431 return;
432 }
433 toastManager.add({
434 type: "success",
435 title: `${serverLabel} updated`,
436 description: `Reconnected on t3@${result.value.targetVersion}.`,
437 });
438 };
439 if (fromVersion !== undefined && crossesReleaseTrack(fromVersion, targetVersion)) {
440 return <CrossTrackNote />;
441 }
442 return (
443 <Button size="xs" variant="outline" onClick={() => void handleUpdate()}>
444 {label}
445 </Button>
446 );
447}