Open History to see the last commit
1import {
2 ServerSelfUpdateError,
3 type DesktopUpdateState,
4 type DesktopUpdateStatusReport,
5 type ServerSelfUpdateProgressStage,
6 type ServerSelfUpdateResult,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import * as SecureRandom from "effect/crypto/SecureRandom";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Ref from "effect/Ref";
15import * as Result from "effect/Result";
16import * as Stream from "effect/Stream";
17
18import * as ServerConfig from "../config.ts";
19import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
20
21const DESKTOP_UPDATE_TIMEOUT = Duration.minutes(20);
22const DESKTOP_INSTALL_TIMEOUT = Duration.minutes(2);
23
24function desktopUpdateProgressStage(
25 state: DesktopUpdateState,
26): ServerSelfUpdateProgressStage | null {
27 switch (state.status) {
28 case "checking":
29 case "available":
30 case "downloading":
31 return "downloading";
32 case "downloaded":
33 return "installing";
34 default:
35 return null;
36 }
37}
38
39export class DesktopAppUpdate extends Context.Service<
40 DesktopAppUpdate,
41 {
42 readonly available: boolean;
43 readonly run: (
44 reportProgress: (
45 stage: ServerSelfUpdateProgressStage,
46 ) => Effect.Effect<void, ServerSelfUpdateError>,
47 trackSwitch?: DesktopTelemetryReceiver.DesktopTrackSwitch,
48 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
49 /** Success stops the server; returns only on installation failure or timeout. */
50 readonly commit: (
51 requestId: string,
52 onHandoffAccepted?: () => Effect.Effect<void>,
53 ) => Effect.Effect<never, ServerSelfUpdateError>;
54 }
55>()("t3/desktopUpdate/DesktopAppUpdate") {}
56
57export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* () {
58 const config = yield* ServerConfig.ServerConfig;
59 const random = yield* SecureRandom.SecureRandom;
60 const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
61 const inFlight = yield* Ref.make(false);
62
63 const available = config.mode === "desktop" && config.desktopTelemetryControlFd !== undefined;
64 const failWith = (reason: string, cause?: unknown) =>
65 cause === undefined
66 ? new ServerSelfUpdateError({ reason })
67 : new ServerSelfUpdateError({ reason, cause });
68
69 const consumeReports = Effect.fn("DesktopAppUpdate.consumeReports")(function* (
70 requestId: string,
71 changes: Stream.Stream<DesktopUpdateStatusReport>,
72 reportProgress: (
73 stage: ServerSelfUpdateProgressStage,
74 ) => Effect.Effect<void, ServerSelfUpdateError>,
75 trackSwitch: boolean,
76 ) {
77 const lastStage = yield* Ref.make<ServerSelfUpdateProgressStage | null>(null);
78 const emitStage = (
79 stage: ServerSelfUpdateProgressStage | null,
80 ): Effect.Effect<void, ServerSelfUpdateError> =>
81 stage === null
82 ? Effect.void
83 : Ref.get(lastStage).pipe(
84 Effect.flatMap((previous) =>
85 previous === stage
86 ? Effect.void
87 : Ref.set(lastStage, stage).pipe(Effect.andThen(reportProgress(stage))),
88 ),
89 );
90
91 const terminal = yield* changes.pipe(
92 Stream.filter((report) => report.requestId === requestId),
93 Stream.mapEffect(
94 (report): Effect.Effect<Option.Option<DesktopUpdateStatusReport>, ServerSelfUpdateError> =>
95 report.outcome === undefined
96 ? emitStage(desktopUpdateProgressStage(report.state)).pipe(
97 Effect.as(Option.none<DesktopUpdateStatusReport>()),
98 )
99 : Effect.succeedSome(report),
100 ),
101 Stream.filterMap(
102 Option.match({
103 onNone: () => Result.failVoid,
104 onSome: Result.succeed,
105 }),
106 ),
107 Stream.runHead,
108 );
109 if (Option.isNone(terminal)) {
110 return yield* failWith("The desktop app stopped reporting its update.");
111 }
112
113 const report = terminal.value;
114 if (report.outcome === "ready-to-install") {
115 yield* emitStage("installing");
116 const targetVersion =
117 report.state.downloadedVersion ??
118 report.state.availableVersion ??
119 report.state.currentVersion;
120 yield* Effect.logInfo("Desktop app update prepared for install.", {
121 targetVersion,
122 });
123 yield* Ref.set(inFlight, false);
124 return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId };
125 }
126 if (report.outcome === "up-to-date") {
127 // A switch's host already on the newest build of its track only saved the track.
128 if (trackSwitch) {
129 yield* Ref.set(inFlight, false);
130 return { targetVersion: report.state.currentVersion, method: "desktop-app" as const };
131 }
132 return yield* failWith(
133 `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`,
134 );
135 }
136 return yield* failWith(
137 report.reason ?? report.state.message ?? "The desktop app update failed.",
138 );
139 });
140
141 const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")(
142 function* (reportProgress, trackSwitch) {
143 if (!available) {
144 return yield* failWith(
145 "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.",
146 );
147 }
148 if (yield* Ref.getAndSet(inFlight, true)) {
149 return yield* failWith("A desktop app update is already in progress.");
150 }
151
152 return yield* Effect.scoped(
153 Effect.gen(function* () {
154 const requestId = yield* random
155 .uuidV4()
156 .pipe(
157 Effect.mapError((error) =>
158 failWith("Could not generate a desktop update request id.", error),
159 ),
160 );
161 // Subscribe before requesting to catch fast reports.
162 const { changes } = yield* receiver.desktopUpdates;
163 yield* receiver
164 .requestDesktopUpdate(requestId, trackSwitch)
165 .pipe(
166 Effect.mapError((error) =>
167 failWith("Could not reach the T3 Code desktop app on this machine.", error),
168 ),
169 );
170 return yield* consumeReports(
171 requestId,
172 changes,
173 reportProgress,
174 trackSwitch !== undefined,
175 ).pipe(
176 Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)),
177 );
178 }),
179 ).pipe(
180 Effect.timeout(DESKTOP_UPDATE_TIMEOUT),
181 Effect.catchTags({
182 TimeoutError: () => failWith("The desktop app did not finish the update in time."),
183 }),
184 Effect.onError(() => Ref.set(inFlight, false)),
185 );
186 },
187 );
188
189 const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn(
190 "desktopUpdate.desktopAppUpdate.commit",
191 )(function* (requestId, onHandoffAccepted = () => Effect.void) {
192 if (!available) {
193 return yield* failWith("This server cannot commit a desktop app update.");
194 }
195 const terminal = yield* Effect.scoped(
196 Effect.gen(function* () {
197 const { latest, changes } = yield* receiver.desktopUpdates;
198 const reports = Option.match(latest, {
199 onNone: () => changes,
200 onSome: (report) => Stream.concat(Stream.make(report), changes),
201 });
202 yield* Effect.uninterruptible(
203 receiver.commitDesktopUpdate(requestId).pipe(
204 Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)),
205 Effect.tap(() => onHandoffAccepted()),
206 ),
207 );
208 return yield* reports.pipe(
209 Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"),
210 Stream.runHead,
211 Effect.timeout(DESKTOP_INSTALL_TIMEOUT),
212 Effect.catchTags({
213 TimeoutError: () =>
214 failWith("The desktop app did not report an install result in time."),
215 }),
216 );
217 }),
218 );
219 if (Option.isNone(terminal)) {
220 return yield* failWith("The desktop app stopped reporting the install.");
221 }
222 return yield* failWith(
223 terminal.value.reason ??
224 terminal.value.state.message ??
225 "The desktop app failed to install the update.",
226 );
227 });
228
229 return DesktopAppUpdate.of({ available, run, commit });
230});
231
232export const layer = Layer.effect(DesktopAppUpdate, make());