1import { useAtomValue } from "@effect/atom-react";
2import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
4import { useMemo } from "react";
5
6import { APP_VERSION } from "~/branding";
7import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
8import { isElectron } from "~/env";
9import { useEnvironments } from "~/state/environments";
10import { useNpmReleasePublished } from "~/state/npmRelease";
11import { serverEnvironment } from "~/state/server";
12import { environmentSession } from "~/state/session";
13import { getProviderUpdateRunOutcome } from "../ProviderUpdateLaunchNotification.logic";
14import { useProviderUpdates } from "../ProviderUpdatesAction";
15import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction";
16import {
17 collectRestartingMachines,
18 collectServerUpdateTargets,
19 collectUpdateEverythingResult,
20 describeUpdateEverything,
21 runUpdateEverything,
22 type SkippedMachine,
23 type UpdateRunMachine,
24} from "./updateEverything.logic";
25import {
26 clearSavedUpdateEverythingResult,
27 saveUpdateEverythingResult,
28 setUpdateEverythingRun,
29 settleUpdateRunMachine,
30 showUpdateEverythingResult,
31} from "./updateEverythingRun";
32
33/** Where this app's own download or install reports a failure. */
34export type ReportLocalUpdateError = (title: string, description: string) => void;
35
36/**
37 * The remote half of the app menu updater: outdated providers on every
38 * connected machine, and servers behind the version this client runs or is
39 * about to install (`localUpdateVersion`). Servers that install from npm appear
40 * only once npm confirms that version. `run` performs the whole pass, including
41 * this app's own download and install when given. It updates quietly, tracks
42 * each machine in the run state, and reports one summary toast at the end.
43 * When this app installs, the summary waits for the relaunch.
44 */
45export function useUpdateEverything(localUpdateVersion: string | null) {
46 const targetVersion = localUpdateVersion ?? APP_VERSION;
47 const { environments } = useEnvironments();
48 const machinesAtom = useMemo(
49 () =>
50 Atom.make((get) =>
51 environments.map((environment) => ({
52 environmentId: environment.environmentId,
53 label: environment.label,
54 serverConfig: environment.serverConfig,
55 connected: environment.connection.phase === "connected",
56 hostedByThisApp:
57 isElectron &&
58 (environment.entry.target._tag === "PrimaryConnectionTarget" ||
59 isDesktopLocalConnectionTarget(environment.entry.target)),
60 canMaintain: canUpdateServer(
61 get(environmentSession.sessionStateAtom(environment.environmentId)),
62 ),
63 updateState: get(serverEnvironment.updateStateAtom(environment.environmentId)),
64 })),
65 ),
66 [environments],
67 );
68 const machines = useAtomValue(machinesAtom);
69 const { targets: behindServers, skipped: unreachable } = collectServerUpdateTargets(
70 machines,
71 targetVersion,
72 );
73 // A desktop-managed server installs from its own app's update feed, so only
74 // servers that download t3 from npm wait for npm to publish the version.
75 const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed");
76 const published = useNpmReleasePublished(needsNpm ? targetVersion : null);
77 const servers = published
78 ? behindServers
79 : behindServers.filter((server) => server.selfUpdate === "desktop-managed");
80 const skipped: ReadonlyArray<SkippedMachine> = [
81 ...unreachable,
82 ...behindServers
83 .filter((server) => !servers.includes(server))
84 .map((server) => ({
85 environmentId: server.environmentId,
86 label: server.serverLabel,
87 reason: `t3@${targetVersion} is not on npm yet`,
88 })),
89 ];
90 const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null);
91 const providers = useProviderUpdates();
92 const updateServer = useServerUpdate();
93
94 const { summary, lines } = describeUpdateEverything({
95 localVersion: localUpdateVersion,
96 servers,
97 providerMachines: providers.machines.map((machine) => ({
98 environmentId: machine.environmentId,
99 label: machine.label,
100 providers: machine.candidates.map(
101 (candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver,
102 ),
103 })),
104 });
105
106 return {
107 servers,
108 hasUpdates: servers.length > 0 || providers.machines.length > 0,
109 /** Short label for the tooltip and the confirmation title. */
110 summary,
111 /** One line per machine, for the confirmation. */
112 lines,
113 /** The servers this run restarts, which stops their running threads. */
114 restarts,
115 /** Behind machines the run leaves alone, with the reason. */
116 skipped,
117 /** Runs the pass. `installLocal` resolves true when this app is relaunching.
118 Both report failures to the pass, which lists them in its summary. */
119 run: async ({
120 downloadLocal,
121 installLocal,
122 }: {
123 downloadLocal?: (reportError: ReportLocalUpdateError) => Promise<boolean>;
124 installLocal?: (reportError: ReportLocalUpdateError) => Promise<boolean>;
125 }) => {
126 const localFailures: Array<string> = [];
127 const reportLocalError: ReportLocalUpdateError = (title, description) => {
128 localFailures.push(`${title}: ${description}`);
129 };
130 const machinesById = new Map<UpdateRunMachine["environmentId"], UpdateRunMachine>();
131 for (const machine of providers.machines) {
132 machinesById.set(machine.environmentId, {
133 environmentId: machine.environmentId,
134 label: machine.label,
135 updatesProviders: true,
136 updatesServer: false,
137 result: null,
138 });
139 }
140 for (const server of servers) {
141 const existing = machinesById.get(server.environmentId);
142 machinesById.set(server.environmentId, {
143 environmentId: server.environmentId,
144 label: server.serverLabel,
145 updatesProviders: existing?.updatesProviders ?? false,
146 updatesServer: true,
147 result: null,
148 });
149 }
150 const localVersion = installLocal ? localUpdateVersion : null;
151 setUpdateEverythingRun(() => ({
152 localVersion,
153 phase: "providers",
154 machines: Array.from(machinesById.values()),
155 }));
156 const failedProviderMachines = new Set<UpdateRunMachine["environmentId"]>();
157 try {
158 const outcome = await runUpdateEverything({
159 ...(downloadLocal ? { downloadLocal: () => downloadLocal(reportLocalError) } : {}),
160 updateProviders: async () => {
161 const runs = await providers.updateAll({ quiet: true });
162 const outcomes = runs.map((run) => {
163 const runOutcome = getProviderUpdateRunOutcome(run);
164 // An interrupted update did not finish either, so its row is not "done".
165 if (runOutcome.status !== "updated") failedProviderMachines.add(run.environmentId);
166 return runOutcome;
167 });
168 for (const machine of machinesById.values()) {
169 if (!machine.updatesServer) {
170 settleUpdateRunMachine(
171 machine.environmentId,
172 failedProviderMachines.has(machine.environmentId),
173 );
174 }
175 }
176 setUpdateEverythingRun((run) => run && { ...run, phase: "servers" });
177 return outcomes;
178 },
179 updateServers: () =>
180 Promise.all(
181 servers.map(async (server) => {
182 const serverOutcome = await updateServer(server, { quiet: true });
183 settleUpdateRunMachine(
184 server.environmentId,
185 serverOutcome.status === "failed" ||
186 failedProviderMachines.has(server.environmentId),
187 );
188 return { label: server.serverLabel, outcome: serverOutcome };
189 }),
190 ),
191 });
192 const result = () =>
193 collectUpdateEverythingResult({
194 localVersion: null,
195 localFailures,
196 servers: outcome.servers,
197 providers: outcome.providers,
198 });
199 if (outcome.localReady && installLocal) {
200 setUpdateEverythingRun((run) => run && { ...run, phase: "installing" });
201 saveUpdateEverythingResult({ ...result(), localVersion });
202 if (await installLocal(reportLocalError)) return;
203 clearSavedUpdateEverythingResult();
204 }
205 showUpdateEverythingResult(result());
206 } finally {
207 setUpdateEverythingRun(() => null);
208 }
209 },
210 };
211}