authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 19:08:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 19:08:38-07:00
logb2f25d390a8546e42eb6186115b673a53b8c38dc
tree19c6598b726eb3f4290472cdb84eab4efec673ac
parent80c708a1fa8986bce0b2d1ba4d35e79d9e4094ba
signature Signed by PGP key B5690EEEBB952194

feat(desktop): update the desktop app on remote Macs from the Update button (#6554)

Use a prepare and commit handoff for remote desktop updates. Retry lost commits, require the prepared version after reconnect, and recover backends and windows when installation fails. Built with GPT-5.6 Sol in Codex. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>

41 files changed, 3596 insertions(+), 344 deletions(-)

apps/desktop/src/app/DesktopApp.ts+2
......@@ -27,6 +27,7 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
2727import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2828import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
2929import * as DesktopState from "./DesktopState.ts";
30import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
3031import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
3132import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
3233
......@@ -292,6 +293,7 @@ const startup = Effect.gen(function* () {
292293 yield* appIdentity.configure;
293294 yield* applicationMenu.configure;
294295 yield* updates.configure;
296 yield* DesktopRemoteUpdates.listen;
295297 yield* linuxUrlHandler.register;
296298 yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
297299}).pipe(Effect.withSpan("desktop.startup"));
apps/desktop/src/app/DesktopLifecycle.test.ts+10-1
......@@ -102,6 +102,7 @@ describe("DesktopLifecycle", () => {
102102 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
103103 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
104104 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
105 let windowsDestroyed = false;
105106 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
106107 platform,
107108 isDevelopment: false,
......@@ -110,7 +111,13 @@ describe("DesktopLifecycle", () => {
110111 const layer = DesktopLifecycle.layer.pipe(
111112 Layer.provideMerge(makeElectronAppLayer(appListeners)),
112113 Layer.provideMerge(electronThemeLayer),
113 Layer.provideMerge(makeElectronWindowLayer()),
114 Layer.provideMerge(
115 makeElectronWindowLayer(
116 Effect.sync(() => {
117 windowsDestroyed = true;
118 }),
119 ),
120 ),
114121 Layer.provideMerge(makeDesktopWindowLayer()),
115122 Layer.provideMerge(environmentLayer),
116123 Layer.provideMerge(DesktopShutdown.layer),
......@@ -123,6 +130,7 @@ describe("DesktopLifecycle", () => {
123130 yield* lifecycle.register;
124131
125132 appListeners.get("before-quit-for-update")?.();
133 yield* Effect.yieldNow;
126134
127135 let prevented = false;
128136 const event = {
......@@ -136,6 +144,7 @@ describe("DesktopLifecycle", () => {
136144 prevented,
137145 "cancelling this event prevents the updater from completing its relaunch",
138146 );
147 assert.isTrue(windowsDestroyed);
139148
140149 const state = yield* DesktopState.DesktopState;
141150 assert.isTrue(yield* Ref.get(state.quitting));
apps/desktop/src/app/DesktopLifecycle.ts+11-2
......@@ -189,6 +189,7 @@ export const make = DesktopLifecycle.of({
189189 }),
190190 register: Effect.gen(function* () {
191191 const desktopWindow = yield* DesktopWindow.DesktopWindow;
192 const electronWindow = yield* ElectronWindow.ElectronWindow;
192193 const electronApp = yield* ElectronApp.ElectronApp;
193194 const electronTheme = yield* ElectronTheme.ElectronTheme;
194195 const environment = yield* DesktopEnvironment.DesktopEnvironment;
......@@ -206,8 +207,16 @@ export const make = DesktopLifecycle.of({
206207 // Cancelling the following app "before-quit" event breaks that sequence,
207208 // most visibly on macOS where the native updater performs the relaunch.
208209 updaterQuitAllowed = true;
209 void runEffect(
210 logLifecycleInfo("allowing updater-controlled quit").pipe(
210 // This event is synchronous and the updater's quit proceeds as soon as
211 // the listener returns, so a forked destroyAll would race the quit
212 // and windows could still be open when the process exits (visible on
213 // macOS). Destroy them inline.
214 Effect.runSyncWith(context)(
215 electronWindow.destroyAll.pipe(
216 Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")),
217 Effect.catchCause((cause) =>
218 logLifecycleError("failed to destroy windows before updater quit", { cause }),
219 ),
211220 Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
212221 ),
213222 );
apps/desktop/src/backend/DesktopBackendManager.test.ts+4
......@@ -167,6 +167,10 @@ function makeTestInstance(input: MakeInstanceInput) {
167167 handleControlForSource: (_sourceId, message) =>
168168 (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),
169169 removeControlSource: () => Effect.void,
170 publishUpdateReport: () => Effect.void,
171 updateRequests: Stream.empty,
172 updateCommits: Stream.empty,
173 updateCancellations: Stream.empty,
170174 ...input.desktopTelemetryPublisher,
171175 }),
172176 DesktopWslEnvironment.layerTest(
apps/desktop/src/backend/DesktopBackendPool.test.ts+4
......@@ -73,6 +73,10 @@ function makePoolLayer(
7373 handleControl: () => Effect.void,
7474 handleControlForSource: () => Effect.void,
7575 removeControlSource: () => Effect.void,
76 publishUpdateReport: () => Effect.void,
77 updateRequests: Stream.empty,
78 updateCommits: Stream.empty,
79 updateCancellations: Stream.empty,
7680 }),
7781 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
7882 resolvePrimary: Effect.die("unexpected primary config resolve"),
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+82-2
......@@ -157,8 +157,11 @@ describe("DesktopTelemetryPublisher", () => {
157157 decodeMessage(decoder.decode(bytes).trim()),
158158 );
159159
160 assert.equal(messages[0]?.type, "desktopTelemetryHello");
161 assert.equal(messages[0]?.electronPid, process.pid);
160 const hello = messages[0];
161 if (hello?.type !== "desktopTelemetryHello") {
162 return assert.fail("Expected the first telemetry message to be the hello.");
163 }
164 assert.equal(hello.electronPid, process.pid);
162165 const initialSnapshot = messages[1];
163166 if (initialSnapshot?.type !== "desktopTelemetry") {
164167 return assert.fail("Expected the second telemetry message to be a snapshot.");
......@@ -386,4 +389,81 @@ describe("DesktopTelemetryPublisher", () => {
386389 }).pipe(Effect.provide(layer));
387390 }),
388391 );
392
393 it.effect("routes requestDesktopUpdate control messages and replays update reports", () =>
394 Effect.gen(function* () {
395 const powerLayer = Layer.succeed(
396 ElectronPowerMonitor.ElectronPowerMonitor,
397 ElectronPowerMonitor.ElectronPowerMonitor.of({
398 isOnBatteryPower: Effect.succeed(false),
399 getSystemIdleTime: Effect.succeed(0),
400 getSystemIdleState: () => Effect.succeed("active"),
401 getCurrentThermalState: Effect.succeed("nominal"),
402 onSimpleEvent: () => Effect.void,
403 onThermalStateChange: () => Effect.void,
404 onSpeedLimitChange: () => Effect.void,
405 }),
406 );
407 const layer = DesktopTelemetryPublisher.layer.pipe(
408 Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)),
409 );
410
411 yield* Effect.gen(function* () {
412 const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
413
414 const requestFiber = yield* Stream.runHead(publisher.updateRequests).pipe(Effect.forkChild);
415 yield* Effect.yieldNow;
416 yield* publisher.handleControlForSource("test", {
417 version: 1,
418 type: "requestDesktopUpdate",
419 requestId: "req-9",
420 });
421 const received = yield* Fiber.join(requestFiber);
422 assert.equal(Option.getOrThrow(received).requestId, "req-9");
423
424 const report = {
425 version: 1,
426 type: "desktopUpdateStatus",
427 outcome: "up-to-date",
428 state: {
429 enabled: true,
430 status: "up-to-date",
431 channel: "latest",
432 currentVersion: "1.2.3",
433 hostArch: "arm64",
434 appArch: "arm64",
435 runningUnderArm64Translation: false,
436 availableVersion: null,
437 downloadedVersion: null,
438 releaseNotes: [],
439 downloadPercent: null,
440 checkedAt: null,
441 message: null,
442 errorContext: null,
443 canRetry: false,
444 omittedReleaseCount: 0,
445 },
446 } as const;
447 yield* publisher.publishUpdateReport(report);
448
449 // A subscriber that attaches after the publish (the backend spawned
450 // by a relaunch) still sees the latest report replayed.
451 const decoder = new TextDecoder();
452 const decodeMessage = Schema.decodeUnknownEffect(
453 Schema.fromJsonString(DesktopHostTelemetryMessage),
454 );
455 const replayed = yield* publisher.encoded.pipe(
456 Stream.mapEffect((bytes) => decodeMessage(decoder.decode(bytes).trim())),
457 Stream.filter((message) => message.type === "desktopUpdateStatus"),
458 Stream.runHead,
459 );
460 const replayedReport = Option.getOrThrow(replayed);
461 if (replayedReport.type !== "desktopUpdateStatus") {
462 return assert.fail("Expected a desktop update status report.");
463 }
464 assert.equal(replayedReport.outcome, "up-to-date");
465 assert.equal(replayedReport.state.currentVersion, "1.2.3");
466 }).pipe(Effect.provide(layer));
467 }),
468 );
389469});
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts+49-1
......@@ -2,6 +2,10 @@ import {
22 DesktopHostTelemetryMessage,
33 type DesktopHostTelemetrySnapshot,
44 type DesktopTelemetryControlMessage,
5 type DesktopTelemetryCancelDesktopUpdate,
6 type DesktopTelemetryCommitDesktopUpdate,
7 type DesktopTelemetryRequestDesktopUpdate,
8 type DesktopUpdateStatusReport,
59 type HostPowerSnapshot,
610} from "@t3tools/contracts";
711import * as Cause from "effect/Cause";
......@@ -65,6 +69,14 @@ export class DesktopTelemetryPublisher extends Context.Service<
6569 message: DesktopTelemetryControlMessage,
6670 ) => Effect.Effect<void>;
6771 readonly removeControlSource: (sourceId: string) => Effect.Effect<void>;
72 /** Sends the report to the attached backend and replays the latest one
73 to backends that attach later (including the one spawned after a
74 relaunch). */
75 readonly publishUpdateReport: (report: DesktopUpdateStatusReport) => Effect.Effect<void>;
76 /** Update requests received over the control channel. Single consumer. */
77 readonly updateRequests: Stream.Stream<DesktopTelemetryRequestDesktopUpdate>;
78 readonly updateCommits: Stream.Stream<DesktopTelemetryCommitDesktopUpdate>;
79 readonly updateCancellations: Stream.Stream<DesktopTelemetryCancelDesktopUpdate>;
6880 }
6981>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {}
7082
......@@ -160,6 +172,11 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
160172 const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());
161173 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
162174 const sequence = yield* Ref.make(0);
175 const latestUpdateReport = yield* Ref.make(Option.none<DesktopUpdateStatusReport>());
176 const updateReportChanges = yield* PubSub.sliding<DesktopUpdateStatusReport>(16);
177 const updateRequestQueue = yield* Queue.unbounded<DesktopTelemetryRequestDesktopUpdate>();
178 const updateCommitQueue = yield* Queue.unbounded<DesktopTelemetryCommitDesktopUpdate>();
179 const updateCancellationQueue = yield* Queue.unbounded<DesktopTelemetryCancelDesktopUpdate>();
163180
164181 const offer = (event: PowerEvent): void => {
165182 Queue.offerUnsafe(powerEvents, event);
......@@ -324,6 +341,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
324341 active: Duration.millis(message.activeIntervalMs),
325342 idle: Duration.millis(message.idleIntervalMs),
326343 }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid);
344 case "requestDesktopUpdate":
345 return Queue.offer(updateRequestQueue, message).pipe(Effect.asVoid);
346 case "commitDesktopUpdate":
347 return Queue.offer(updateCommitQueue, message).pipe(Effect.asVoid);
348 case "cancelDesktopUpdate":
349 return Queue.offer(updateCancellationQueue, message).pipe(Effect.asVoid);
327350 }
328351 };
329352 const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = (
......@@ -357,15 +380,36 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
357380 );
358381 }),
359382 );
383 const updateReports = Stream.unwrap(
384 Effect.gen(function* () {
385 const subscription = yield* PubSub.subscribe(updateReportChanges);
386 const initial = yield* Ref.get(latestUpdateReport);
387 return Stream.concat(
388 Option.match(initial, {
389 onNone: () => Stream.empty,
390 onSome: Stream.make,
391 }),
392 Stream.fromSubscription(subscription),
393 );
394 }),
395 );
360396 const encoded = Stream.concat(
361397 Stream.make({
362398 version: 1,
363399 type: "desktopTelemetryHello",
364400 electronPid: process.pid,
365401 } as const),
366 snapshots,
402 Stream.merge(snapshots, updateReports),
367403 ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`)));
368404
405 const publishUpdateReport: DesktopTelemetryPublisher["Service"]["publishUpdateReport"] = (
406 report,
407 ) =>
408 Ref.set(latestUpdateReport, Option.some(report)).pipe(
409 Effect.andThen(PubSub.publish(updateReportChanges, report)),
410 Effect.asVoid,
411 );
412
369413 return DesktopTelemetryPublisher.of({
370414 latest: Ref.get(latest),
371415 changes: Stream.fromPubSub(changes),
......@@ -373,6 +417,10 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
373417 handleControl,
374418 handleControlForSource,
375419 removeControlSource,
420 publishUpdateReport,
421 updateRequests: Stream.fromQueue(updateRequestQueue),
422 updateCommits: Stream.fromQueue(updateCommitQueue),
423 updateCancellations: Stream.fromQueue(updateCancellationQueue),
376424 });
377425});
378426
apps/desktop/src/updates/DesktopRemoteUpdates.test.ts created+842
......@@ -0,0 +1,842 @@
1import { assert, describe, it } from "@effect/vitest";
2import type {
3 DesktopTelemetryRequestDesktopUpdate,
4 DesktopTelemetryCommitDesktopUpdate,
5 DesktopTelemetryCancelDesktopUpdate,
6 DesktopUpdateStatusReport,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Queue from "effect/Queue";
14import * as Ref from "effect/Ref";
15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
17
18import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
19import * as DesktopState from "../app/DesktopState.ts";
20import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
21import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts";
22import * as DesktopUpdates from "./DesktopUpdates.ts";
23import { makeHarness } from "./updatesTestHarness.ts";
24
25// The remote flow hops between the test runtime's fibers and the updater's
26// runPromise-driven event handlers, so settling needs real microtask turns,
27// not just fiber yields.
28const settle = Effect.gen(function* () {
29 for (let i = 0; i < 20; i += 1) {
30 yield* Effect.yieldNow;
31 yield* Effect.promise(() => Promise.resolve());
32 }
33});
34
35const request = (requestId: string): DesktopTelemetryRequestDesktopUpdate => ({
36 version: 1,
37 type: "requestDesktopUpdate",
38 requestId,
39});
40
41function runRemoteUpdatesTest(
42 harness: ReturnType<typeof makeHarness>,
43 body: (context: {
44 readonly reports: DesktopUpdateStatusReport[];
45 readonly requests: Queue.Queue<DesktopTelemetryRequestDesktopUpdate>;
46 readonly commits: Queue.Queue<DesktopTelemetryCommitDesktopUpdate>;
47 readonly cancellations: Queue.Queue<DesktopTelemetryCancelDesktopUpdate>;
48 }) => Effect.Effect<void, never, DesktopUpdates.DesktopUpdates | DesktopState.DesktopState>,
49) {
50 return Effect.scoped(
51 Effect.gen(function* () {
52 const requests = yield* Queue.unbounded<DesktopTelemetryRequestDesktopUpdate>();
53 const commits = yield* Queue.unbounded<DesktopTelemetryCommitDesktopUpdate>();
54 const cancellations = yield* Queue.unbounded<DesktopTelemetryCancelDesktopUpdate>();
55 const reports: DesktopUpdateStatusReport[] = [];
56 const publisher = DesktopTelemetryPublisher.DesktopTelemetryPublisher.of({
57 latest: Effect.succeedNone,
58 changes: Stream.empty,
59 encoded: Stream.empty,
60 handleControl: () => Effect.void,
61 handleControlForSource: () => Effect.void,
62 removeControlSource: () => Effect.void,
63 publishUpdateReport: (report) =>
64 Effect.sync(() => {
65 reports.push(report);
66 }),
67 updateRequests: Stream.fromQueue(requests),
68 updateCommits: Stream.fromQueue(commits),
69 updateCancellations: Stream.fromQueue(cancellations),
70 });
71
72 const updates = yield* DesktopUpdates.DesktopUpdates;
73 yield* updates.configure;
74 yield* DesktopRemoteUpdates.listen.pipe(
75 Effect.provideService(DesktopTelemetryPublisher.DesktopTelemetryPublisher, publisher),
76 );
77 yield* settle;
78 yield* body({ reports, requests, commits, cancellations });
79 }),
80 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
81}
82
83function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateStatusReport[] {
84 return reports.filter((report) => report.outcome !== undefined);
85}
86
87describe("DesktopRemoteUpdates", () => {
88 it.effect("drives check, download, and install with no local confirmation", () => {
89 const harness = makeHarness();
90
91 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
92 Effect.gen(function* () {
93 yield* Queue.offer(requests, request("req-1"));
94 yield* settle;
95 assert.equal(harness.checkCount(), 1);
96
97 harness.emit("update-available", { version: "1.2.4" });
98 yield* settle;
99 harness.emit("update-downloaded", { version: "1.2.4" });
100 yield* settle;
101
102 const terminals = terminalReports(reports);
103 assert.equal(terminals.length, 1);
104 assert.equal(terminals[0]?.outcome, "ready-to-install");
105 assert.equal(terminals[0]?.requestId, "req-1");
106 assert.equal(harness.quitAndInstalls(), 0);
107
108 yield* Queue.offer(commits, {
109 version: 1,
110 type: "commitDesktopUpdate",
111 requestId: "req-1",
112 });
113 yield* settle;
114 assert.equal(harness.quitAndInstalls(), 1);
115
116 // The mirror stamped the in-run state changes with the request id.
117 const statuses = reports
118 .filter((report) => report.requestId === "req-1")
119 .map((report) => report.state.status);
120 assert.include(statuses, "available");
121 assert.include(statuses, "downloaded");
122 }),
123 );
124 });
125
126 it.effect("reports a failed outcome when quitAndInstall fails", () => {
127 const harness = makeHarness({
128 quitAndInstall: Effect.fail(
129 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
130 channel: "latest",
131 isSilent: true,
132 isForceRunAfter: true,
133 cause: new Error("spawn failed"),
134 }),
135 ),
136 });
137
138 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
139 Effect.gen(function* () {
140 yield* Queue.offer(requests, request("req-4"));
141 yield* settle;
142 harness.emit("update-available", { version: "1.2.4" });
143 yield* settle;
144 harness.emit("update-downloaded", { version: "1.2.4" });
145 yield* settle;
146
147 yield* Queue.offer(commits, {
148 version: 1,
149 type: "commitDesktopUpdate",
150 requestId: "req-4",
151 });
152 yield* settle;
153
154 // Install failures reduce to status "downloaded" + errorContext
155 // "install"; the prepared result is followed by the commit failure.
156 const terminals = terminalReports(reports);
157 assert.deepEqual(
158 terminals.map((report) => report.outcome),
159 ["ready-to-install", "failed"],
160 );
161 assert.equal(terminals[1]?.state.errorContext, "install");
162 }),
163 );
164 });
165
166 it.effect("joins an in-progress install on commit instead of failing the token", () => {
167 const installStarted = Deferred.makeUnsafe<void>();
168 const releaseInstall = Deferred.makeUnsafe<void>();
169 const harness = makeHarness({
170 stopBackend: Deferred.succeed(installStarted, undefined).pipe(
171 Effect.andThen(Deferred.await(releaseInstall)),
172 ),
173 });
174
175 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
176 Effect.gen(function* () {
177 const updates = yield* DesktopUpdates.DesktopUpdates;
178 yield* Queue.offer(requests, request("req-join-commit"));
179 yield* settle;
180 harness.emit("update-available", { version: "1.2.4" });
181 yield* settle;
182 harness.emit("update-downloaded", { version: "1.2.4" });
183 yield* settle;
184 assert.deepEqual(
185 terminalReports(reports).map((report) => report.outcome),
186 ["ready-to-install"],
187 );
188
189 // A local install takes the updater reservation and starts shutdown.
190 const localInstall = yield* updates.install.pipe(Effect.forkChild);
191 yield* Deferred.await(installStarted);
192 yield* Queue.offer(commits, {
193 version: 1,
194 type: "commitDesktopUpdate",
195 requestId: "req-join-commit",
196 });
197 yield* settle;
198
199 // No "failed" marker: that install relaunches the app and the
200 // client proves the handoff by reconnecting on the target version.
201 assert.deepEqual(
202 terminalReports(reports).map((report) => report.outcome),
203 ["ready-to-install"],
204 );
205 assert.equal(harness.quitAndInstalls(), 0);
206
207 yield* Deferred.succeed(releaseInstall, undefined);
208 yield* Fiber.join(localInstall);
209 }),
210 );
211 });
212
213 it.effect("does not join a normal app quit as an update install", () => {
214 const harness = makeHarness();
215
216 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
217 Effect.gen(function* () {
218 const desktopState = yield* DesktopState.DesktopState;
219 yield* Queue.offer(requests, request("req-normal-quit"));
220 yield* settle;
221 harness.emit("update-available", { version: "1.2.4" });
222 yield* settle;
223 harness.emit("update-downloaded", { version: "1.2.4" });
224 yield* settle;
225
226 yield* Ref.set(desktopState.quitting, true);
227 yield* Queue.offer(commits, {
228 version: 1,
229 type: "commitDesktopUpdate",
230 requestId: "req-normal-quit",
231 });
232 yield* settle;
233
234 assert.deepEqual(
235 terminalReports(reports).map((report) => report.outcome),
236 ["ready-to-install", "failed"],
237 );
238 assert.equal(harness.quitAndInstalls(), 0);
239 }),
240 );
241 });
242
243 it.effect("does not misread a lingering install error as a failed retry", () => {
244 let installAttempts = 0;
245 const harness = makeHarness({
246 quitAndInstall: Effect.suspend(() => {
247 installAttempts += 1;
248 return installAttempts === 1
249 ? Effect.fail(
250 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
251 channel: "latest",
252 isSilent: true,
253 isForceRunAfter: true,
254 cause: new Error("first attempt failed"),
255 }),
256 )
257 : Effect.void;
258 }),
259 });
260
261 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
262 Effect.gen(function* () {
263 yield* Queue.offer(requests, request("req-5"));
264 yield* settle;
265 harness.emit("update-available", { version: "1.2.4" });
266 yield* settle;
267 harness.emit("update-downloaded", { version: "1.2.4" });
268 yield* settle;
269 yield* Queue.offer(commits, {
270 version: 1,
271 type: "commitDesktopUpdate",
272 requestId: "req-5",
273 });
274 yield* settle;
275 // First run failed; state is "downloaded" with a lingering
276 // errorContext "install". The retry succeeds and must not report
277 // that leftover as a fresh failure.
278 yield* Queue.offer(requests, request("req-6"));
279 yield* settle;
280 yield* Queue.offer(commits, {
281 version: 1,
282 type: "commitDesktopUpdate",
283 requestId: "req-6",
284 });
285 yield* settle;
286
287 const retryTerminals = terminalReports(reports).filter(
288 (report) => report.requestId === "req-6",
289 );
290 assert.deepEqual(
291 retryTerminals.map((report) => report.outcome),
292 ["ready-to-install"],
293 );
294 assert.equal(harness.quitAndInstalls(), 2);
295 }),
296 );
297 });
298
299 it.effect("ignores an unrelated updater event while an install is starting", () => {
300 const stopStarted = Deferred.makeUnsafe<void>();
301 const releaseStop = Deferred.makeUnsafe<void>();
302 const harness = makeHarness({
303 stopBackend: Deferred.succeed(stopStarted, undefined).pipe(
304 Effect.andThen(Deferred.await(releaseStop)),
305 ),
306 });
307
308 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
309 Effect.gen(function* () {
310 yield* Queue.offer(requests, request("req-late-event"));
311 yield* settle;
312 harness.emit("update-available", { version: "1.2.4" });
313 yield* settle;
314 harness.emit("update-downloaded", { version: "1.2.4" });
315 yield* settle;
316
317 yield* Queue.offer(commits, {
318 version: 1,
319 type: "commitDesktopUpdate",
320 requestId: "req-late-event",
321 });
322 yield* Deferred.await(stopStarted);
323 harness.emit("download-progress", { percent: 90 });
324 yield* settle;
325 yield* Deferred.succeed(releaseStop, undefined);
326 yield* settle;
327
328 assert.deepEqual(
329 terminalReports(reports)
330 .filter((report) => report.requestId === "req-late-event")
331 .map((report) => report.outcome),
332 ["ready-to-install"],
333 );
334 }),
335 );
336 });
337
338 it.effect("retries a download refused while the check still holds the reservation", () => {
339 // electron-updater emits update-available from inside checkForUpdates,
340 // before the check action releases its reservation. The download the
341 // remote flow forks in response is refused and must be retried once the
342 // reservation frees up, without burning a download attempt.
343 const releaseCheck = Deferred.makeUnsafe<void>();
344 const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) });
345
346 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
347 Effect.gen(function* () {
348 yield* Queue.offer(requests, request("req-8"));
349 yield* settle;
350 assert.equal(harness.checkCount(), 1);
351
352 // Fire "available" while the check reservation is still held.
353 harness.emit("update-available", { version: "1.2.4" });
354 yield* settle;
355 assert.equal(harness.downloadCount(), 0);
356
357 yield* Deferred.succeed(releaseCheck, undefined);
358 yield* settle;
359 yield* TestClock.adjust(Duration.millis(300));
360 yield* settle;
361 assert.equal(harness.downloadCount(), 1);
362
363 harness.emit("update-downloaded", { version: "1.2.4" });
364 yield* settle;
365 assert.deepEqual(
366 terminalReports(reports).map((report) => report.outcome),
367 ["ready-to-install"],
368 );
369 assert.equal(harness.quitAndInstalls(), 0);
370 }),
371 );
372 });
373
374 it.effect("waits for the download reservation before reporting prepared", () => {
375 // update-downloaded fires from inside downloadUpdate. If the flow
376 // reported "installing" right then, install would be refused for the
377 // held reservation after the irrevocable terminal already went out.
378 const releaseDownload = Deferred.makeUnsafe<void>();
379 const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) });
380
381 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
382 Effect.gen(function* () {
383 yield* Queue.offer(requests, request("req-10"));
384 yield* settle;
385 harness.emit("update-available", { version: "1.2.4" });
386 yield* settle;
387 harness.emit("update-downloaded", { version: "1.2.4" });
388 yield* settle;
389 assert.deepEqual(terminalReports(reports), []);
390 assert.equal(harness.quitAndInstalls(), 0);
391
392 yield* Deferred.succeed(releaseDownload, undefined);
393 yield* settle;
394 yield* TestClock.adjust(Duration.millis(300));
395 yield* settle;
396 assert.deepEqual(
397 terminalReports(reports).map((report) => report.outcome),
398 ["ready-to-install"],
399 );
400 assert.equal(harness.quitAndInstalls(), 0);
401 }),
402 );
403 });
404
405 it.effect("joins an install that is already tearing the app down", () => {
406 const harness = makeHarness();
407
408 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
409 Effect.gen(function* () {
410 const desktopState = yield* DesktopState.DesktopState;
411 harness.emit("update-downloaded", { version: "1.2.4" });
412 yield* settle;
413 // Another install already owns the shutdown.
414 yield* Ref.set(desktopState.quitting, true);
415
416 yield* Queue.offer(requests, request("req-9"));
417 yield* settle;
418
419 // Report "installing" once, no "failed" after the refusal: that
420 // install will relaunch the app and this request rides along.
421 assert.deepEqual(
422 terminalReports(reports).map((report) => report.outcome),
423 ["ready-to-install"],
424 );
425 assert.equal(harness.quitAndInstalls(), 0);
426 }),
427 );
428 });
429
430 it.effect("does not run the installer twice for a repeated commit", () => {
431 const harness = makeHarness();
432
433 return runRemoteUpdatesTest(harness, ({ requests, commits }) =>
434 Effect.gen(function* () {
435 yield* Queue.offer(requests, request("req-repeat"));
436 yield* settle;
437 harness.emit("update-available", { version: "1.2.4" });
438 yield* settle;
439 harness.emit("update-downloaded", { version: "1.2.4" });
440 yield* settle;
441
442 const commit = {
443 version: 1,
444 type: "commitDesktopUpdate",
445 requestId: "req-repeat",
446 } as const;
447 yield* Queue.offer(commits, commit);
448 yield* settle;
449 yield* Queue.offer(commits, commit);
450 yield* settle;
451
452 assert.equal(harness.quitAndInstalls(), 1);
453 }),
454 );
455 });
456
457 it.effect("waits for a background check before installing a prepared update", () => {
458 const backgroundCheckStarted = Deferred.makeUnsafe<void>();
459 const releaseBackgroundCheck = Deferred.makeUnsafe<void>();
460 let checks = 0;
461 const harness = makeHarness({
462 checkForUpdates: Effect.suspend(() => {
463 checks += 1;
464 return checks === 2
465 ? Deferred.succeed(backgroundCheckStarted, undefined).pipe(
466 Effect.andThen(Deferred.await(releaseBackgroundCheck)),
467 )
468 : Effect.void;
469 }),
470 });
471
472 return runRemoteUpdatesTest(harness, ({ requests, commits }) =>
473 Effect.gen(function* () {
474 const updates = yield* DesktopUpdates.DesktopUpdates;
475 yield* Queue.offer(requests, request("req-background-check"));
476 yield* settle;
477 harness.emit("update-available", { version: "1.2.4" });
478 yield* settle;
479 harness.emit("update-downloaded", { version: "1.2.4" });
480 yield* settle;
481
482 const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild);
483 yield* Deferred.await(backgroundCheckStarted);
484 assert.equal((yield* updates.getState).status, "checking");
485
486 yield* Queue.offer(commits, {
487 version: 1,
488 type: "commitDesktopUpdate",
489 requestId: "req-background-check",
490 });
491 yield* settle;
492 assert.equal(harness.quitAndInstalls(), 0);
493
494 harness.emit("update-downloaded", { version: "1.2.4" });
495 yield* Deferred.succeed(releaseBackgroundCheck, undefined);
496 yield* Fiber.join(backgroundCheck);
497 yield* settle;
498 assert.equal(harness.quitAndInstalls(), 1);
499 }),
500 );
501 });
502
503 it.effect("fails a prepared install when its background check stays blocked", () => {
504 const backgroundCheckStarted = Deferred.makeUnsafe<void>();
505 const releaseBackgroundCheck = Deferred.makeUnsafe<void>();
506 let checks = 0;
507 const harness = makeHarness({
508 checkForUpdates: Effect.suspend(() => {
509 checks += 1;
510 return checks === 2
511 ? Deferred.succeed(backgroundCheckStarted, undefined).pipe(
512 Effect.andThen(Deferred.await(releaseBackgroundCheck)),
513 )
514 : Effect.void;
515 }),
516 });
517
518 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
519 Effect.gen(function* () {
520 const updates = yield* DesktopUpdates.DesktopUpdates;
521 yield* Queue.offer(requests, request("req-blocked-background-check"));
522 yield* settle;
523 harness.emit("update-available", { version: "1.2.4" });
524 yield* settle;
525 harness.emit("update-downloaded", { version: "1.2.4" });
526 yield* settle;
527
528 const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild);
529 yield* Deferred.await(backgroundCheckStarted);
530 const commit = {
531 version: 1,
532 type: "commitDesktopUpdate",
533 requestId: "req-blocked-background-check",
534 } as const;
535 yield* Queue.offer(commits, commit);
536 yield* settle;
537 yield* TestClock.adjust(Duration.seconds(91));
538 yield* settle;
539
540 assert.equal(harness.quitAndInstalls(), 0);
541 assert.deepEqual(
542 terminalReports(reports)
543 .filter((report) => report.requestId === "req-blocked-background-check")
544 .map((report) => report.outcome),
545 ["ready-to-install", "failed"],
546 );
547
548 harness.emit("update-downloaded", { version: "1.2.4" });
549 yield* Deferred.succeed(releaseBackgroundCheck, undefined);
550 yield* Fiber.join(backgroundCheck);
551 yield* settle;
552 assert.equal(harness.quitAndInstalls(), 0);
553
554 yield* Queue.offer(commits, commit);
555 yield* settle;
556 assert.deepEqual(
557 terminalReports(reports)
558 .filter((report) => report.requestId === "req-blocked-background-check")
559 .map((report) => report.outcome),
560 ["ready-to-install", "failed", "failed"],
561 );
562 }),
563 );
564 });
565
566 it.effect("rejects a new preparation while an install commit is active", () => {
567 const installStarted = Deferred.makeUnsafe<void>();
568 const releaseInstall = Deferred.makeUnsafe<void>();
569 const harness = makeHarness({
570 quitAndInstall: Deferred.succeed(installStarted, undefined).pipe(
571 Effect.andThen(Deferred.await(releaseInstall)),
572 Effect.andThen(
573 Effect.fail(
574 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
575 channel: "latest",
576 isSilent: true,
577 isForceRunAfter: true,
578 cause: new Error("installer refused"),
579 }),
580 ),
581 ),
582 ),
583 });
584
585 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
586 Effect.gen(function* () {
587 yield* Queue.offer(requests, request("req-active"));
588 yield* settle;
589 harness.emit("update-available", { version: "1.2.4" });
590 yield* settle;
591 harness.emit("update-downloaded", { version: "1.2.4" });
592 yield* settle;
593 yield* Queue.offer(commits, {
594 version: 1,
595 type: "commitDesktopUpdate",
596 requestId: "req-active",
597 });
598 yield* Deferred.await(installStarted);
599
600 yield* Queue.offer(requests, request("req-overlap"));
601 yield* settle;
602 const overlap = terminalReports(reports).find(
603 (report) => report.requestId === "req-overlap",
604 );
605 assert.equal(overlap?.outcome, "failed");
606 assert.equal(overlap?.reason, "A prepared desktop update is already in progress.");
607
608 yield* Deferred.succeed(releaseInstall, undefined);
609 yield* settle;
610 }),
611 );
612 });
613
614 it.effect("cancels an active preparation so the next request can run", () => {
615 const releaseCheck = Deferred.makeUnsafe<void>();
616 const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) });
617
618 return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
619 Effect.gen(function* () {
620 yield* Queue.offer(requests, request("req-cancel"));
621 yield* settle;
622 yield* Queue.offer(cancellations, {
623 version: 1,
624 type: "cancelDesktopUpdate",
625 requestId: "req-cancel",
626 });
627 yield* settle;
628 yield* Deferred.succeed(releaseCheck, undefined);
629 yield* Queue.offer(requests, request("req-next"));
630 yield* settle;
631
632 assert.equal(harness.checkCount(), 2);
633 }),
634 );
635 });
636
637 it.effect("remembers a cancellation that arrives before its request starts", () => {
638 const harness = makeHarness();
639
640 return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
641 Effect.gen(function* () {
642 yield* Queue.offer(cancellations, {
643 version: 1,
644 type: "cancelDesktopUpdate",
645 requestId: "req-early-cancel",
646 });
647 yield* Queue.offer(requests, request("req-early-cancel"));
648 yield* settle;
649
650 assert.equal(harness.checkCount(), 0);
651
652 yield* Queue.offer(requests, request("req-after-early-cancel"));
653 yield* settle;
654 assert.equal(harness.checkCount(), 1);
655 }),
656 );
657 });
658
659 it.effect("keeps every queued cancellation until its request starts", () => {
660 const harness = makeHarness();
661
662 return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
663 Effect.gen(function* () {
664 for (let index = 0; index < 40; index += 1) {
665 yield* Queue.offer(cancellations, {
666 version: 1,
667 type: "cancelDesktopUpdate",
668 requestId: `req-queued-cancel-${index}`,
669 });
670 }
671 yield* settle;
672 for (let index = 0; index < 40; index += 1) {
673 yield* Queue.offer(requests, request(`req-queued-cancel-${index}`));
674 }
675 yield* settle;
676 assert.equal(harness.checkCount(), 0);
677
678 yield* Queue.offer(requests, request("req-after-queued-cancels"));
679 yield* settle;
680 assert.equal(harness.checkCount(), 1);
681 }),
682 );
683 });
684
685 it.effect("does not install after cancellation wins the commit claim", () => {
686 const harness = makeHarness();
687
688 return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) =>
689 Effect.gen(function* () {
690 yield* Queue.offer(requests, request("req-cancel-before-commit"));
691 yield* settle;
692 harness.emit("update-available", { version: "1.2.4" });
693 yield* settle;
694 harness.emit("update-downloaded", { version: "1.2.4" });
695 yield* settle;
696
697 yield* Queue.offer(cancellations, {
698 version: 1,
699 type: "cancelDesktopUpdate",
700 requestId: "req-cancel-before-commit",
701 });
702 yield* settle;
703 yield* Queue.offer(commits, {
704 version: 1,
705 type: "commitDesktopUpdate",
706 requestId: "req-cancel-before-commit",
707 });
708 yield* settle;
709
710 assert.equal(harness.quitAndInstalls(), 0);
711 assert.deepEqual(
712 terminalReports(reports)
713 .filter((report) => report.requestId === "req-cancel-before-commit")
714 .map((report) => report.outcome),
715 ["ready-to-install", "failed"],
716 );
717 }),
718 );
719 });
720
721 it.effect("retains an install failure after cancellation loses the commit claim", () => {
722 const installStarted = Deferred.makeUnsafe<void>();
723 const releaseInstall = Deferred.makeUnsafe<void>();
724 const harness = makeHarness({
725 quitAndInstall: Deferred.succeed(installStarted, undefined).pipe(
726 Effect.andThen(Deferred.await(releaseInstall)),
727 Effect.andThen(
728 Effect.fail(
729 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
730 channel: "latest",
731 isSilent: true,
732 isForceRunAfter: true,
733 cause: new Error("installer refused"),
734 }),
735 ),
736 ),
737 ),
738 });
739
740 return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) =>
741 Effect.gen(function* () {
742 yield* Queue.offer(requests, request("req-cancel-after-commit"));
743 yield* settle;
744 harness.emit("update-available", { version: "1.2.4" });
745 yield* settle;
746 harness.emit("update-downloaded", { version: "1.2.4" });
747 yield* settle;
748 const commit = {
749 version: 1,
750 type: "commitDesktopUpdate",
751 requestId: "req-cancel-after-commit",
752 } as const;
753 yield* Queue.offer(commits, commit);
754 yield* Deferred.await(installStarted);
755 yield* Queue.offer(cancellations, {
756 version: 1,
757 type: "cancelDesktopUpdate",
758 requestId: "req-cancel-after-commit",
759 });
760 yield* settle;
761 yield* Deferred.succeed(releaseInstall, undefined);
762 yield* settle;
763 yield* Queue.offer(commits, commit);
764 yield* settle;
765
766 assert.deepEqual(
767 terminalReports(reports)
768 .filter((report) => report.requestId === "req-cancel-after-commit")
769 .map((report) => report.outcome),
770 ["ready-to-install", "failed", "failed"],
771 );
772 }),
773 );
774 });
775
776 it.effect("expires an uncommitted preparation", () => {
777 const harness = makeHarness();
778
779 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
780 Effect.gen(function* () {
781 yield* Queue.offer(requests, request("req-stale"));
782 yield* settle;
783 harness.emit("update-available", { version: "1.2.4" });
784 yield* settle;
785 harness.emit("update-downloaded", { version: "1.2.4" });
786 yield* settle;
787
788 yield* TestClock.setTime(Duration.toMillis(Duration.minutes(6)));
789 yield* Queue.offer(commits, {
790 version: 1,
791 type: "commitDesktopUpdate",
792 requestId: "req-stale",
793 });
794 yield* settle;
795
796 const outcomes = terminalReports(reports)
797 .filter((report) => report.requestId === "req-stale")
798 .map((report) => report.outcome);
799 assert.deepEqual(outcomes, ["ready-to-install", "failed"]);
800 }),
801 );
802 });
803
804 it.effect("reports up-to-date without installing when there is no update", () => {
805 const harness = makeHarness();
806
807 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
808 Effect.gen(function* () {
809 yield* Queue.offer(requests, request("req-2"));
810 yield* settle;
811 harness.emit("update-not-available");
812 yield* settle;
813
814 const terminals = terminalReports(reports);
815 assert.equal(terminals.length, 1);
816 assert.equal(terminals[0]?.outcome, "up-to-date");
817 assert.equal(terminals[0]?.requestId, "req-2");
818 assert.equal(harness.quitAndInstalls(), 0);
819 }),
820 );
821 });
822
823 it.effect("fails fast with the disabled reason when updates are off", () => {
824 const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } });
825
826 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
827 Effect.gen(function* () {
828 yield* Queue.offer(requests, request("req-3"));
829 yield* settle;
830
831 const terminals = terminalReports(reports);
832 assert.equal(terminals.length, 1);
833 assert.equal(terminals[0]?.outcome, "failed");
834 assert.equal(
835 terminals[0]?.reason,
836 "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.",
837 );
838 assert.equal(harness.quitAndInstalls(), 0);
839 }),
840 );
841 });
842});
apps/desktop/src/updates/DesktopRemoteUpdates.ts created+461
......@@ -0,0 +1,461 @@
1import type {
2 DesktopTelemetryRequestDesktopUpdate,
3 DesktopUpdateRemoteOutcome,
4 DesktopUpdateState,
5} from "@t3tools/contracts";
6import * as Duration from "effect/Duration";
7import * as Clock from "effect/Clock";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";
12import * as Ref from "effect/Ref";
13import type * as Scope from "effect/Scope";
14import * as Stream from "effect/Stream";
15
16import * as DesktopObservability from "../app/DesktopObservability.ts";
17import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
18import * as DesktopUpdates from "./DesktopUpdates.ts";
19import {
20 nextRemoteDesktopUpdateStep,
21 normalizeRemoteUpdateReason,
22 type RemoteDesktopUpdateAttempts,
23} from "./remoteUpdateFlow.ts";
24
25const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop-remote-updates");
26
27/** Pause before retrying an action the updater refused for a held reservation. */
28const ACTION_RETRY_DELAY = Duration.millis(250);
29const PREPARED_UPDATE_TTL = Duration.minutes(5);
30
31interface PreparedUpdate {
32 readonly requestId: string;
33 readonly downloadedVersion: string;
34 readonly status: "prepared" | "committing" | "failed";
35 readonly failureReason?: string;
36 readonly expiresAt: number;
37}
38
39type CommitClaim =
40 | { readonly _tag: "invalid" }
41 | { readonly _tag: "failed"; readonly prepared: PreparedUpdate }
42 | { readonly _tag: "committing" }
43 | { readonly _tag: "claimed"; readonly prepared: PreparedUpdate };
44
45/**
46 * Server-triggered desktop updates. Mirrors updater state, prepares downloads,
47 * cancels abandoned preparations, and commits installs after the remote client
48 * confirms that it received the preparation token.
49 */
50export const listen: Effect.Effect<
51 void,
52 never,
53 DesktopUpdates.DesktopUpdates | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope
54> = Effect.gen(function* () {
55 const updates = yield* DesktopUpdates.DesktopUpdates;
56 const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
57 const activeRequestIdRef = yield* Ref.make(Option.none<string>());
58 const requestControlRef = yield* Ref.make<{
59 readonly active: Option.Option<{
60 readonly requestId: string;
61 readonly signal: Deferred.Deferred<void>;
62 }>;
63 readonly cancelledBeforeStart: ReadonlyArray<string>;
64 }>({ active: Option.none(), cancelledBeforeStart: [] });
65 const preparedUpdateRef = yield* Ref.make(Option.none<PreparedUpdate>());
66
67 const publishReport = (
68 state: DesktopUpdateState,
69 terminal?: { readonly outcome: DesktopUpdateRemoteOutcome; readonly reason?: string },
70 explicitRequestId?: string,
71 ): Effect.Effect<void> => {
72 const reason = normalizeRemoteUpdateReason(terminal?.reason);
73 return Ref.get(activeRequestIdRef).pipe(
74 Effect.flatMap((requestId) =>
75 publisher.publishUpdateReport({
76 version: 1,
77 type: "desktopUpdateStatus",
78 ...(explicitRequestId !== undefined
79 ? { requestId: explicitRequestId }
80 : Option.isSome(requestId)
81 ? { requestId: requestId.value }
82 : {}),
83 ...(terminal === undefined ? {} : { outcome: terminal.outcome }),
84 ...(reason ? { reason } : {}),
85 state,
86 }),
87 ),
88 );
89 };
90
91 const recordPreparedFailure = (requestId: string, reason: string) =>
92 Ref.modify(preparedUpdateRef, (prepared) => {
93 if (
94 Option.isNone(prepared) ||
95 prepared.value.requestId !== requestId ||
96 prepared.value.status === "failed"
97 ) {
98 return [false, prepared] as const;
99 }
100 return [
101 true,
102 Option.some({ ...prepared.value, status: "failed" as const, failureReason: reason }),
103 ] as const;
104 });
105
106 const clearActiveRequest = (requestId: string) =>
107 Ref.update(activeRequestIdRef, (active) =>
108 Option.isSome(active) && active.value === requestId ? Option.none() : active,
109 );
110
111 yield* Effect.scoped(
112 Effect.gen(function* () {
113 const { latest, changes } = yield* updates.subscribe;
114 yield* publishReport(latest);
115 yield* Stream.runForEach(changes, (state) =>
116 Effect.gen(function* () {
117 yield* publishReport(state);
118 const prepared = yield* Ref.get(preparedUpdateRef);
119 if (
120 Option.isSome(prepared) &&
121 prepared.value.status === "committing" &&
122 state.errorContext === "install"
123 ) {
124 const reason = state.message ?? "The desktop app failed to install the update.";
125 if (yield* recordPreparedFailure(prepared.value.requestId, reason)) {
126 yield* publishReport(state, { outcome: "failed", reason }, prepared.value.requestId);
127 }
128 }
129 }),
130 );
131 }),
132 ).pipe(Effect.forkScoped);
133
134 const handleRequest = (request: DesktopTelemetryRequestDesktopUpdate): Effect.Effect<void> =>
135 Effect.scoped(
136 Effect.gen(function* () {
137 const cancellation = yield* Deferred.make<void>();
138 const shouldStart = yield* Ref.modify(requestControlRef, (control) => {
139 if (control.cancelledBeforeStart.includes(request.requestId)) {
140 return [
141 false,
142 {
143 ...control,
144 cancelledBeforeStart: control.cancelledBeforeStart.filter(
145 (requestId) => requestId !== request.requestId,
146 ),
147 },
148 ] as const;
149 }
150 return [
151 true,
152 {
153 ...control,
154 active: Option.some({ requestId: request.requestId, signal: cancellation }),
155 },
156 ] as const;
157 });
158 if (!shouldStart) return;
159
160 const now = yield* Clock.currentTimeMillis;
161 const prepared = yield* Ref.modify(preparedUpdateRef, (current) =>
162 Option.isSome(current) &&
163 current.value.status === "prepared" &&
164 current.value.expiresAt <= now
165 ? ([Option.none<typeof current.value>(), Option.none()] as const)
166 : ([current, current] as const),
167 );
168 if (Option.isSome(prepared) && prepared.value.status !== "failed") {
169 yield* publishReport(
170 yield* updates.getState,
171 {
172 outcome: "failed",
173 reason: "A prepared desktop update is already in progress.",
174 },
175 request.requestId,
176 );
177 return;
178 }
179 yield* Ref.set(activeRequestIdRef, Option.some(request.requestId));
180 yield* logInfo("remote update requested", { requestId: request.requestId });
181 const { latest, changes } = yield* updates.subscribe;
182 const disabledReason = Option.getOrNull(yield* updates.disabledReason);
183 let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 };
184 // The updater admits one action at a time. A state event can land
185 // while the action that produced it still holds the reservation
186 // (e.g. "available" before the check releases), so a forked action
187 // can be refused with no later state event to retry on. Rejected
188 // actions re-enqueue their state here after a short pause so the
189 // step runs again once the reservation is free.
190 const retries = yield* Queue.unbounded<DesktopUpdateState>();
191 const retryLater = (state: DesktopUpdateState) =>
192 Effect.sleep(ACTION_RETRY_DELAY).pipe(
193 Effect.andThen(Queue.offer(retries, state)),
194 Effect.asVoid,
195 Effect.forkScoped,
196 );
197
198 // Returns true when the run reached a terminal outcome.
199 const step = (state: DesktopUpdateState): Effect.Effect<boolean, never, Scope.Scope> =>
200 Effect.gen(function* () {
201 const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason);
202 switch (next.action) {
203 case "wait":
204 return false;
205 // Counters increment before the action so the state event the
206 // action produces already sees it; a refusal for a held
207 // reservation rolls the count back, since it was not a try.
208 case "check":
209 attempts = { ...attempts, checks: attempts.checks + 1 };
210 yield* updates.check("remote-update").pipe(
211 Effect.flatMap((result) => {
212 if (result.checked) return Effect.void;
213 attempts = { ...attempts, checks: attempts.checks - 1 };
214 return retryLater(state);
215 }),
216 Effect.forkScoped,
217 );
218 return false;
219 case "download":
220 attempts = { ...attempts, downloads: attempts.downloads + 1 };
221 yield* updates.download.pipe(
222 Effect.flatMap((result) => {
223 if (result.accepted) return Effect.void;
224 attempts = { ...attempts, downloads: attempts.downloads - 1 };
225 return retryLater(state);
226 }),
227 Effect.forkScoped,
228 );
229 return false;
230 case "install": {
231 // The download event fires before its action releases the
232 // updater reservation. Wait until the prepared install can
233 // be committed by the client in a separate RPC.
234 if (yield* updates.isActionActive) {
235 yield* retryLater(state);
236 return false;
237 }
238 if (state.downloadedVersion === null) {
239 yield* publishReport(
240 state,
241 {
242 outcome: "failed",
243 reason: "The desktop app lost the downloaded update.",
244 },
245 request.requestId,
246 );
247 return true;
248 }
249 yield* Ref.set(
250 preparedUpdateRef,
251 Option.some({
252 requestId: request.requestId,
253 downloadedVersion: state.downloadedVersion,
254 status: "prepared",
255 expiresAt:
256 (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL),
257 }),
258 );
259 yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId);
260 yield* logInfo("remote update prepared", { requestId: request.requestId });
261 return true;
262 }
263 case "done":
264 yield* publishReport(
265 state,
266 {
267 outcome: next.outcome,
268 ...(next.reason === undefined ? {} : { reason: next.reason }),
269 },
270 request.requestId,
271 );
272 yield* logInfo("remote update finished", {
273 requestId: request.requestId,
274 outcome: next.outcome,
275 reason: next.reason ?? null,
276 });
277 return true;
278 }
279 });
280
281 yield* Effect.raceFirst(
282 Effect.gen(function* () {
283 if (yield* step(latest)) return;
284 yield* Stream.merge(changes, Stream.fromQueue(retries)).pipe(
285 Stream.mapEffect(step),
286 Stream.takeUntil((done) => done),
287 Stream.runDrain,
288 );
289 }),
290 Deferred.await(cancellation),
291 );
292 }),
293 ).pipe(
294 Effect.ensuring(
295 Effect.all(
296 [
297 clearActiveRequest(request.requestId),
298 Ref.update(requestControlRef, (control) => ({
299 ...control,
300 active:
301 Option.isSome(control.active) &&
302 control.active.value.requestId === request.requestId
303 ? Option.none()
304 : control.active,
305 })),
306 ],
307 { discard: true },
308 ),
309 ),
310 Effect.catchCause((cause) =>
311 logError("remote update request failed unexpectedly", {
312 requestId: request.requestId,
313 cause: String(cause),
314 }),
315 ),
316 );
317
318 // Sequential by construction: a second remote request queued mid-run is
319 // handled after the current one, when the state machine resolves it fast.
320 yield* Stream.runForEach(publisher.updateRequests, handleRequest).pipe(Effect.forkScoped);
321
322 yield* Stream.runForEach(publisher.updateCancellations, (cancellation) =>
323 Effect.gen(function* () {
324 const activeSignal = yield* Ref.modify(requestControlRef, (control) => {
325 if (
326 Option.isSome(control.active) &&
327 control.active.value.requestId === cancellation.requestId
328 ) {
329 return [Option.some(control.active.value.signal), control] as const;
330 }
331 return [
332 Option.none<Deferred.Deferred<void>>(),
333 {
334 ...control,
335 cancelledBeforeStart: [
336 ...control.cancelledBeforeStart.filter(
337 (requestId) => requestId !== cancellation.requestId,
338 ),
339 cancellation.requestId,
340 ],
341 },
342 ] as const;
343 });
344 if (Option.isSome(activeSignal)) {
345 yield* Deferred.succeed(activeSignal.value, undefined);
346 }
347 const matchedPrepared = yield* Ref.modify(preparedUpdateRef, (prepared) => {
348 if (Option.isNone(prepared) || prepared.value.requestId !== cancellation.requestId) {
349 return [false, prepared] as const;
350 }
351 return [true, prepared.value.status === "prepared" ? Option.none() : prepared] as const;
352 });
353 if (matchedPrepared) {
354 yield* Ref.update(requestControlRef, (control) => {
355 return {
356 ...control,
357 cancelledBeforeStart: control.cancelledBeforeStart.filter(
358 (requestId) => requestId !== cancellation.requestId,
359 ),
360 };
361 });
362 }
363 }),
364 ).pipe(Effect.forkScoped);
365
366 yield* Stream.runForEach(publisher.updateCommits, (commit) =>
367 Effect.gen(function* () {
368 const current = yield* updates.getState;
369 const now = yield* Clock.currentTimeMillis;
370 const claim = yield* Ref.modify(
371 preparedUpdateRef,
372 (prepared): readonly [CommitClaim, Option.Option<PreparedUpdate>] => {
373 if (
374 Option.isNone(prepared) ||
375 prepared.value.requestId !== commit.requestId ||
376 (prepared.value.status === "prepared" && prepared.value.expiresAt <= now)
377 ) {
378 return [{ _tag: "invalid" as const }, prepared] as const;
379 }
380 if (prepared.value.status === "failed") {
381 return [{ _tag: "failed" as const, prepared: prepared.value }, prepared] as const;
382 }
383 if (prepared.value.status === "committing") {
384 return [{ _tag: "committing" as const }, prepared] as const;
385 }
386 return [
387 { _tag: "claimed" as const, prepared: prepared.value },
388 Option.some({ ...prepared.value, status: "committing" as const }),
389 ] as const;
390 },
391 );
392 if (claim._tag === "invalid") {
393 yield* publishReport(
394 yield* updates.getState,
395 {
396 outcome: "failed",
397 reason: "This desktop update is no longer prepared.",
398 },
399 commit.requestId,
400 );
401 return;
402 }
403 if (claim._tag === "failed") {
404 yield* publishReport(
405 current,
406 {
407 outcome: "failed",
408 reason: claim.prepared.failureReason ?? "The desktop app failed to install the update.",
409 },
410 commit.requestId,
411 );
412 return;
413 }
414 if (claim._tag === "committing") {
415 return;
416 }
417 yield* Ref.set(activeRequestIdRef, Option.some(commit.requestId));
418 if (current.downloadedVersion !== claim.prepared.downloadedVersion) {
419 const reason = "This desktop update is no longer prepared.";
420 if (yield* recordPreparedFailure(commit.requestId, reason)) {
421 yield* publishReport(current, { outcome: "failed", reason }, commit.requestId);
422 }
423 return;
424 }
425 const result = yield* updates.installPrepared(claim.prepared.downloadedVersion);
426 if (
427 !result.accepted &&
428 result.state.downloadedVersion === claim.prepared.downloadedVersion &&
429 (yield* updates.isInstallActive)
430 ) {
431 // Another install (local, or an earlier remote request) already owns
432 // the shutdown and will relaunch the app on the same downloaded
433 // version. This commit joins it: no failure marker, and the client
434 // proves the handoff the same way, by transport loss then the
435 // target version on reconnect.
436 yield* logInfo("remote update commit joining an in-progress install", {
437 requestId: commit.requestId,
438 });
439 return;
440 }
441 if (!result.accepted || result.failed) {
442 const reason = result.state.message ?? "The desktop app could not start the install.";
443 if (yield* recordPreparedFailure(commit.requestId, reason)) {
444 yield* publishReport(result.state, { outcome: "failed", reason }, commit.requestId);
445 }
446 return;
447 }
448 // A successful install tears down this backend. Do not send a success
449 // marker from the old process: transport loss followed by the target
450 // version is the only proof that the handoff succeeded.
451 }).pipe(
452 Effect.ensuring(clearActiveRequest(commit.requestId)),
453 Effect.catchCause((cause) =>
454 logError("remote update commit failed unexpectedly", {
455 requestId: commit.requestId,
456 cause: String(cause),
457 }),
458 ),
459 ),
460 ).pipe(Effect.forkScoped);
461});
apps/desktop/src/updates/DesktopUpdates.test.ts+139-211
......@@ -1,6 +1,4 @@
11import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { DesktopUpdateState } from "@t3tools/contracts";
42import * as Cause from "effect/Cause";
53import * as Deferred from "effect/Deferred";
64import * as Duration from "effect/Duration";
......@@ -11,221 +9,14 @@ import * as Logger from "effect/Logger";
119import * as Option from "effect/Option";
1210import * as References from "effect/References";
1311import * as Ref from "effect/Ref";
12import * as Stream from "effect/Stream";
1413import * as TestClock from "effect/testing/TestClock";
1514
16import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
17import * as DesktopConfig from "../app/DesktopConfig.ts";
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1915import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
20import * as ElectronWindow from "../electron/ElectronWindow.ts";
2116import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2217import * as DesktopState from "../app/DesktopState.ts";
2318import * as DesktopUpdates from "./DesktopUpdates.ts";
24
25interface UpdatesHarnessOptions {
26 readonly checkForUpdates?: Effect.Effect<
27 void,
28 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
29 >;
30 readonly beforeSetUpdateChannel?: Effect.Effect<void>;
31 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
32 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
33 readonly stopBackend?: Effect.Effect<void>;
34 readonly env?: Record<string, string | undefined>;
35}
36
37const flushCallbacks = Effect.yieldNow;
38
39function makeHarness(options: UpdatesHarnessOptions = {}) {
40 let checkCount = 0;
41 let allowDowngrade = false;
42 let fullChangelog = false;
43 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
44 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
45 const sentStates: DesktopUpdateState[] = [];
46
47 const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
48 const eventListeners = listeners.get(eventName) ?? new Set();
49 eventListeners.add(listener);
50 listeners.set(eventName, eventListeners);
51 };
52
53 const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
54 const eventListeners = listeners.get(eventName);
55 if (!eventListeners) {
56 return;
57 }
58 eventListeners.delete(listener);
59 if (eventListeners.size === 0) {
60 listeners.delete(eventName);
61 }
62 };
63
64 const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, {
65 setFeedURL: (options) =>
66 Effect.sync(() => {
67 feedUrls.push(options);
68 }),
69 setAutoDownload: () => Effect.void,
70 setAutoInstallOnAppQuit: () => Effect.void,
71 setChannel: () => Effect.void,
72 setAllowPrerelease: () => Effect.void,
73 allowDowngrade: Effect.sync(() => allowDowngrade),
74 setAllowDowngrade: (value) =>
75 Effect.sync(() => {
76 allowDowngrade = value;
77 }),
78 setFullChangelog: (value) =>
79 Effect.sync(() => {
80 fullChangelog = value;
81 }),
82 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
83 checkForUpdates: Effect.sync(() => {
84 checkCount += 1;
85 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
86 downloadUpdate: Effect.void,
87 quitAndInstall: () => Effect.void,
88 on: (eventName, listener) =>
89 Effect.acquireRelease(
90 Effect.sync(() => {
91 addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
92 }),
93 () =>
94 Effect.sync(() => {
95 removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
96 }),
97 ).pipe(Effect.asVoid),
98 } satisfies ElectronUpdater.ElectronUpdater["Service"]);
99
100 const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
101 create: () => Effect.die("unexpected BrowserWindow creation"),
102 main: Effect.succeed(Option.none()),
103 currentMainOrFirst: Effect.succeed(Option.none()),
104 focusedMainOrFirst: Effect.succeed(Option.none()),
105 setMain: () => Effect.void,
106 clearMain: () => Effect.void,
107 reveal: () => Effect.void,
108 sendAll: (_channel, state) =>
109 Effect.sync(() => {
110 sentStates.push(state as DesktopUpdateState);
111 }),
112 destroyAll: Effect.void,
113 syncAllAppearance: () => Effect.void,
114 } satisfies ElectronWindow.ElectronWindow["Service"]);
115
116 const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = {
117 id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
118 label: Effect.succeed("Windows"),
119 start: Effect.void,
120 stop: () => options.stopBackend ?? Effect.void,
121 currentConfig: Effect.succeed(Option.none()),
122 snapshot: Effect.succeed({
123 desiredRunning: false,
124 ready: false,
125 activePid: Option.none(),
126 restartAttempt: 0,
127 restartScheduled: false,
128 }),
129 waitForReady: () => Effect.succeed(true),
130 };
131 const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]);
132
133 const environmentLayer = DesktopEnvironment.layer({
134 dirname: "/repo/apps/desktop/src",
135 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
136 platform: "darwin",
137 processArch: "x64",
138 appVersion: "1.2.3",
139 appPath: "/repo",
140 isPackaged: true,
141 resourcesPath: "/missing/resources",
142 runningUnderArm64Translation: false,
143 }).pipe(
144 Layer.provide(
145 Layer.mergeAll(
146 NodeServices.layer,
147 DesktopConfig.layerTest({
148 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
149 T3CODE_DESKTOP_MOCK_UPDATES: "true",
150 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
151 ...options.env,
152 }),
153 ),
154 ),
155 );
156
157 let testSettings: DesktopAppSettings.DesktopSettings = {
158 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
159 };
160 const setUpdateChannelError = options.setUpdateChannelError;
161 const settingsLayer =
162 setUpdateChannelError || options.beforeSetUpdateChannel
163 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
164 get: Effect.sync(() => testSettings),
165 load: Effect.sync(() => testSettings),
166 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
167 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
168 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
169 setUpdateChannel: (channel) =>
170 setUpdateChannelError
171 ? Effect.fail(setUpdateChannelError)
172 : (options.beforeSetUpdateChannel ?? Effect.void).pipe(
173 Effect.andThen(
174 Effect.sync(() => {
175 const changed = testSettings.updateChannel !== channel;
176 testSettings = {
177 ...testSettings,
178 updateChannel: channel,
179 updateChannelConfiguredByUser: true,
180 };
181 return { settings: testSettings, changed };
182 }),
183 ),
184 ),
185 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
186 setWslDistro: () => Effect.die("unexpected WSL distro change"),
187 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
188 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
189 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
190 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
191 : DesktopAppSettings.layer;
192
193 const layer = DesktopUpdates.layer.pipe(
194 Layer.provideMerge(updaterLayer),
195 Layer.provideMerge(windowLayer),
196 Layer.provideMerge(backendLayer),
197 Layer.provideMerge(DesktopState.layer),
198 Layer.provideMerge(settingsLayer),
199 Layer.provideMerge(
200 DesktopConfig.layerTest({
201 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
202 T3CODE_DESKTOP_MOCK_UPDATES: "true",
203 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
204 ...options.env,
205 }),
206 ),
207 Layer.provideMerge(environmentLayer),
208 Layer.provideMerge(NodeServices.layer),
209 );
210
211 return {
212 layer,
213 checkCount: () => checkCount,
214 feedUrls: () => feedUrls,
215 fullChangelog: () => fullChangelog,
216 listenerCount: () =>
217 Array.from(listeners.values()).reduce(
218 (total, eventListeners) => total + eventListeners.size,
219 0,
220 ),
221 sentStates,
222 emit: (eventName: string, payload?: unknown) => {
223 for (const listener of listeners.get(eventName) ?? []) {
224 listener(payload);
225 }
226 },
227 };
228}
19import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";
22920
23021describe("DesktopUpdates", () => {
23122 it("preserves complete causes for update poller and event failures", () => {
......@@ -294,6 +85,29 @@ describe("DesktopUpdates", () => {
29485 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
29586 });
29687
88 it.effect("subscribe delivers the latest state plus subsequent changes", () => {
89 const harness = makeHarness();
90
91 return Effect.scoped(
92 Effect.gen(function* () {
93 const updates = yield* DesktopUpdates.DesktopUpdates;
94 yield* updates.configure;
95
96 const { latest, changes } = yield* updates.subscribe;
97 assert.equal(latest.status, "idle");
98
99 const nextState = yield* Stream.runHead(changes).pipe(Effect.forkChild);
100 yield* flushCallbacks;
101 harness.emit("update-available", { version: "1.2.4" });
102 yield* flushCallbacks;
103
104 const observed = yield* Fiber.join(nextState);
105 assert.equal(Option.getOrThrow(observed).status, "available");
106 assert.equal(Option.getOrThrow(observed).availableVersion, "1.2.4");
107 }),
108 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
109 });
110
297111 it.effect("updates and broadcasts state from updater events", () => {
298112 const harness = makeHarness();
299113
......@@ -745,6 +559,120 @@ describe("DesktopUpdates", () => {
745559 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
746560 });
747561
562 it.effect("keeps windows and restarts backends when quitAndInstall fails", () => {
563 const harness = makeHarness({
564 quitAndInstall: Effect.fail(
565 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
566 channel: "latest",
567 isSilent: true,
568 isForceRunAfter: true,
569 cause: new Error("installer refused"),
570 }),
571 ),
572 });
573
574 return Effect.scoped(
575 Effect.gen(function* () {
576 const desktopState = yield* DesktopState.DesktopState;
577 const updates = yield* DesktopUpdates.DesktopUpdates;
578 yield* updates.configure;
579 harness.emit("update-downloaded", { version: "1.2.4" });
580 yield* flushCallbacks;
581
582 const result = yield* updates.install;
583 assert.isTrue(result.accepted);
584 assert.isFalse(yield* Ref.get(desktopState.quitting));
585 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
586 }),
587 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
588 });
589
590 it.effect("holds the install reservation until failed-install recovery finishes", () => {
591 const recoveryStarted = Deferred.makeUnsafe<void>();
592 const releaseRecovery = Deferred.makeUnsafe<void>();
593 const harness = makeHarness({
594 quitAndInstall: Effect.fail(
595 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
596 channel: "latest",
597 isSilent: true,
598 isForceRunAfter: true,
599 cause: new Error("installer refused"),
600 }),
601 ),
602 startBackend: Deferred.succeed(recoveryStarted, undefined).pipe(
603 Effect.andThen(Deferred.await(releaseRecovery)),
604 ),
605 });
606
607 return Effect.scoped(
608 Effect.gen(function* () {
609 const updates = yield* DesktopUpdates.DesktopUpdates;
610 yield* updates.configure;
611 harness.emit("update-downloaded", { version: "1.2.4" });
612 yield* flushCallbacks;
613
614 const failedInstall = yield* updates.install.pipe(Effect.forkChild);
615 yield* Deferred.await(recoveryStarted);
616 assert.isFalse(yield* updates.isInstallActive);
617
618 const overlappingInstall = yield* updates.install;
619 assert.isFalse(overlappingInstall.accepted);
620 assert.equal(harness.quitAndInstalls(), 1);
621 harness.emit("error", new Error("duplicate native installer error"));
622 yield* flushCallbacks;
623 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
624
625 yield* Deferred.succeed(releaseRecovery, undefined);
626 const failedResult = yield* Fiber.join(failedInstall);
627 assert.equal(failedResult.state.errorContext, "install");
628
629 const retry = yield* updates.install;
630 assert.isTrue(retry.accepted);
631 assert.equal(harness.quitAndInstalls(), 2);
632 }),
633 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
634 });
635
636 it.effect("recovers when quitAndInstall reports failure through an updater event", () => {
637 const harness = makeHarness();
638
639 return Effect.scoped(
640 Effect.gen(function* () {
641 const desktopState = yield* DesktopState.DesktopState;
642 const updates = yield* DesktopUpdates.DesktopUpdates;
643 yield* updates.configure;
644 harness.emit("update-downloaded", { version: "1.2.4" });
645 yield* flushCallbacks;
646
647 yield* updates.install;
648 assert.deepEqual(harness.installSteps, ["quitAndInstall"]);
649 harness.emit("error", new Error("native installer refused"));
650 yield* flushCallbacks;
651
652 assert.isFalse(yield* Ref.get(desktopState.quitting));
653 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
654 assert.equal((yield* updates.getState).errorContext, "install");
655 }),
656 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
657 });
658
659 it.effect("rejects a prepared install when the downloaded version changed", () => {
660 const harness = makeHarness();
661
662 return Effect.scoped(
663 Effect.gen(function* () {
664 const updates = yield* DesktopUpdates.DesktopUpdates;
665 yield* updates.configure;
666 harness.emit("update-downloaded", { version: "1.2.5" });
667 yield* flushCallbacks;
668
669 const result = yield* updates.installPrepared("1.2.4");
670 assert.isFalse(result.accepted);
671 assert.equal(harness.quitAndInstalls(), 0);
672 }),
673 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
674 });
675
748676 it.effect("persists channel changes through the settings service", () => {
749677 const harness = makeHarness();
750678
apps/desktop/src/updates/DesktopUpdates.ts+213-99
......@@ -11,12 +11,16 @@ import * as Context from "effect/Context";
1111import * as DateTime from "effect/DateTime";
1212import * as Duration from "effect/Duration";
1313import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
1415import * as FileSystem from "effect/FileSystem";
1516import * as Layer from "effect/Layer";
1617import * as Option from "effect/Option";
18import * as PubSub from "effect/PubSub";
1719import * as Ref from "effect/Ref";
1820import * as Schema from "effect/Schema";
1921import * as Scope from "effect/Scope";
22import * as Semaphore from "effect/Semaphore";
23import * as Stream from "effect/Stream";
2024
2125import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
2226import * as DesktopConfig from "../app/DesktopConfig.ts";
......@@ -44,8 +48,13 @@ import {
4448
4549const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
4650const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
51const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90);
4752
48type UpdateAction = "check" | "download" | "install" | "channel";
53type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel";
54
55interface DesktopPreparedUpdateInstallResult extends DesktopUpdateActionResult {
56 readonly failed: boolean;
57}
4958
5059const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
5160type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
......@@ -152,6 +161,20 @@ export class DesktopUpdates extends Context.Service<
152161 DesktopUpdates,
153162 {
154163 readonly getState: Effect.Effect<DesktopUpdateState>;
164 /** True while a check, download, install, or channel change holds the
165 updater's single action reservation. */
166 readonly isActionActive: Effect.Effect<boolean>;
167 /** True only while an install owns the updater action reservation. */
168 readonly isInstallActive: Effect.Effect<boolean>;
169 /** Current state plus a stream of every later state change. */
170 readonly subscribe: Effect.Effect<
171 {
172 readonly latest: DesktopUpdateState;
173 readonly changes: Stream.Stream<DesktopUpdateState>;
174 },
175 never,
176 Scope.Scope
177 >;
155178 readonly emitState: Effect.Effect<void>;
156179 readonly disabledReason: Effect.Effect<Option.Option<string>>;
157180 readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;
......@@ -161,6 +184,9 @@ export class DesktopUpdates extends Context.Service<
161184 readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;
162185 readonly download: Effect.Effect<DesktopUpdateActionResult>;
163186 readonly install: Effect.Effect<DesktopUpdateActionResult>;
187 readonly installPrepared: (
188 expectedVersion: string,
189 ) => Effect.Effect<DesktopPreparedUpdateInstallResult>;
164190 }
165191>()("@t3tools/desktop/updates/DesktopUpdates") {}
166192
......@@ -258,6 +284,7 @@ export const make = Effect.gen(function* () {
258284
259285 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
260286 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
287 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();
261288 const updaterConfiguredRef = yield* Ref.make(false);
262289 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
263290 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
......@@ -268,12 +295,21 @@ export const make = Effect.gen(function* () {
268295 ),
269296 );
270297
298 const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16);
299 // Makes ref writes + publishes atomic against subscribe, so a snapshot
300 // never overlaps with the first change a subscriber receives.
301 const stateMutex = yield* Semaphore.make(1);
302
271303 const emitState = Ref.get(updateStateRef).pipe(
272304 Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),
273305 );
274306
275307 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
276 Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState));
308 stateMutex
309 .withPermits(1)(
310 Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))),
311 )
312 .pipe(Effect.andThen(emitState));
277313
278314 const updateState = (
279315 f: (state: DesktopUpdateState) => DesktopUpdateState,
......@@ -327,8 +363,13 @@ export const make = Effect.gen(function* () {
327363 );
328364
329365 const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>
330 Ref.update(activeUpdateActionRef, (activeAction) =>
331 Option.isSome(activeAction) && activeAction.value === action ? Option.none() : activeAction,
366 Ref.modify(activeUpdateActionRef, (activeAction) => {
367 const finished = Option.isSome(activeAction) && activeAction.value === action;
368 return [finished, finished ? Option.none() : activeAction] as const;
369 }).pipe(
370 Effect.flatMap((finished) =>
371 finished ? PubSub.publish(finishedUpdateActions, action).pipe(Effect.asVoid) : Effect.void,
372 ),
332373 );
333374
334375 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
......@@ -396,7 +437,10 @@ export const make = Effect.gen(function* () {
396437
397438 return yield* actionReservation === "held"
398439 ? check
399 : check.pipe(Effect.ensuring(finishUpdateAction("check")));
440 : check.pipe(
441 Effect.onInterrupt(() => setState(state)),
442 Effect.ensuring(finishUpdateAction("check")),
443 );
400444 });
401445
402446 const downloadAvailableUpdate = Effect.gen(function* () {
......@@ -462,85 +506,156 @@ export const make = Effect.gen(function* () {
462506 { discard: true },
463507 );
464508
465 const installDownloadedUpdate = Effect.gen(function* () {
466 const state = yield* Ref.get(updateStateRef);
467 const hasInstallableDownload =
468 state.downloadedVersion !== null &&
469 (state.status === "downloaded" ||
470 (state.status === "error" &&
471 (state.errorContext === null || state.errorContext === "install")));
472 if (
473 (yield* Ref.get(desktopState.quitting)) ||
474 !(yield* Ref.get(updaterConfiguredRef)) ||
475 !hasInstallableDownload
476 ) {
477 return { accepted: false, completed: false };
478 }
479
480 if (!(yield* tryStartUpdateAction("install"))) {
481 return { accepted: false, completed: false };
482 }
483
484 yield* Ref.set(desktopState.quitting, true);
509 const recoverFailedInstall = Effect.fn("desktop.updates.recoverFailedInstall")(function* (
510 message: string,
511 ) {
512 const ownsRecovery = yield* Ref.modify(activeUpdateActionRef, (activeAction) =>
513 Option.isSome(activeAction) && activeAction.value === "install"
514 ? ([true, Option.some<UpdateAction>("install-recovery")] as const)
515 : ([false, activeAction] as const),
516 );
517 if (!ownsRecovery) return;
485518
486 return yield* Effect.gen(function* () {
487 // Stop every backend in the pool, not just the primary. With
488 // parallel WSL + Windows backends, leaving the WSL instance up
489 // means quitAndInstall's app.quit() exits before the pool's
490 // scope cascade has a chance to run its stop finalizer, so the
491 // WSL child gets hard-killed by the OS instead of receiving
492 // SIGTERM + grace. Stops run concurrently with the same 5s
493 // budget the primary had on its own.
519 yield* Ref.set(desktopState.quitting, false);
520 yield* Effect.gen(function* () {
494521 const instances = yield* pool.list;
495 yield* Effect.forEach(
496 instances,
497 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
498 { concurrency: "unbounded" },
499 );
500 yield* electronWindow.destroyAll;
501 yield* electronUpdater.quitAndInstall({
502 isSilent: true,
503 isForceRunAfter: true,
504 });
505 return { accepted: true, completed: false };
522 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
523 concurrency: "unbounded",
524 discard: true,
525 }).pipe(Effect.exit);
526 yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
527 if (Exit.isFailure(restartExit)) {
528 yield* logUpdaterError("Desktop update install recovery could not restart every backend.");
529 }
506530 }).pipe(
507 Effect.catchTags({
508 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
509 function* (error) {
510 yield* resetInstallAction;
511 yield* updateState((current) =>
512 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
531 Effect.catchCause(() =>
532 logUpdaterError("Desktop update install recovery failed unexpectedly."),
533 ),
534 Effect.ensuring(finishUpdateAction("install-recovery")),
535 );
536 });
537
538 const installDownloadedUpdate = (expectedVersion?: string) =>
539 Effect.scoped(
540 Effect.gen(function* () {
541 const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions);
542 let admission: "admitted" | "refused" | "wait-for-check" = "wait-for-check";
543 while (admission === "wait-for-check") {
544 admission = yield* stateMutex.withPermits(1)(
545 Effect.gen(function* () {
546 const state = yield* Ref.get(updateStateRef);
547 const activeAction = yield* Ref.get(activeUpdateActionRef);
548 const hasExpectedDownload =
549 state.downloadedVersion !== null &&
550 (expectedVersion === undefined || state.downloadedVersion === expectedVersion);
551 if (
552 (yield* Ref.get(desktopState.quitting)) ||
553 !(yield* Ref.get(updaterConfiguredRef)) ||
554 !hasExpectedDownload
555 ) {
556 return "refused" as const;
557 }
558 if (Option.isSome(activeAction)) {
559 return activeAction.value === "check" && expectedVersion !== undefined
560 ? ("wait-for-check" as const)
561 : ("refused" as const);
562 }
563 const hasInstallableDownload =
564 state.status === "downloaded" ||
565 (state.status === "error" &&
566 (state.errorContext === null || state.errorContext === "install"));
567 if (!hasInstallableDownload) return "refused" as const;
568 return (yield* tryStartUpdateAction("install"))
569 ? ("admitted" as const)
570 : ("refused" as const);
571 }),
572 );
573 if (admission === "wait-for-check") {
574 const finishedAction = yield* PubSub.take(actionCompletions).pipe(
575 Effect.timeoutOption(PREPARED_INSTALL_CHECK_WAIT),
513576 );
514 yield* logUpdaterError(error.message, {
515 errorTag: error._tag,
516 channel: error.channel,
517 isSilent: error.isSilent,
518 isForceRunAfter: error.isForceRunAfter,
519 });
520 return { accepted: true, completed: false };
521 },
522 ),
523 }),
524 Effect.onInterrupt(() => resetInstallAction),
525 Effect.catchCause((cause) =>
526 Effect.gen(function* () {
527 if (Cause.hasInterruptsOnly(cause)) {
528 return yield* Effect.failCause(cause);
577 if (Option.isNone(finishedAction)) {
578 admission = "refused";
579 }
529580 }
530 yield* resetInstallAction;
531 const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause });
532 yield* updateState((current) =>
533 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
581 }
582 if (admission === "refused") {
583 return { accepted: false, completed: false, failed: false };
584 }
585
586 yield* Ref.set(desktopState.quitting, true);
587
588 return yield* Effect.gen(function* () {
589 // Stop every backend in the pool, not just the primary. With
590 // parallel WSL + Windows backends, leaving the WSL instance up
591 // means quitAndInstall's app.quit() exits before the pool's
592 // scope cascade has a chance to run its stop finalizer, so the
593 // WSL child gets hard-killed by the OS instead of receiving
594 // SIGTERM + grace. Stops run concurrently with the same 5s
595 // budget the primary had on its own.
596 const instances = yield* pool.list;
597 yield* Effect.forEach(
598 instances,
599 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
600 { concurrency: "unbounded" },
534601 );
535 yield* logUpdaterError(error.message, {
536 errorTag: error._tag,
537 action: error.action,
602 yield* electronUpdater.quitAndInstall({
603 isSilent: true,
604 isForceRunAfter: true,
538605 });
539 return { accepted: true, completed: false };
540 }),
541 ),
542 );
543 }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));
606 return { accepted: true, completed: false, failed: false };
607 }).pipe(
608 Effect.catchTags({
609 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
610 function* (error) {
611 yield* recoverFailedInstall(error.message);
612 yield* logUpdaterError(error.message, {
613 errorTag: error._tag,
614 channel: error.channel,
615 isSilent: error.isSilent,
616 isForceRunAfter: error.isForceRunAfter,
617 });
618 return { accepted: true, completed: false, failed: true };
619 },
620 ),
621 }),
622 Effect.onInterrupt(() => resetInstallAction),
623 Effect.catchCause((cause) =>
624 Effect.gen(function* () {
625 if (Cause.hasInterruptsOnly(cause)) {
626 return yield* Effect.failCause(cause);
627 }
628 const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause });
629 yield* recoverFailedInstall(error.message);
630 yield* logUpdaterError(error.message, {
631 errorTag: error._tag,
632 action: error.action,
633 });
634 return { accepted: true, completed: false, failed: true };
635 }),
636 ),
637 );
638 }),
639 ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));
640
641 const installWithExpectedVersion = (expectedVersion?: string) =>
642 Effect.gen(function* () {
643 if (yield* Ref.get(desktopState.quitting)) {
644 return {
645 accepted: false,
646 completed: false,
647 failed: false,
648 state: yield* Ref.get(updateStateRef),
649 };
650 }
651 const result = yield* installDownloadedUpdate(expectedVersion);
652 return {
653 accepted: result.accepted,
654 completed: result.completed,
655 failed: result.failed,
656 state: yield* Ref.get(updateStateRef),
657 };
658 }).pipe(Effect.withSpan("desktop.updates.install"));
544659
545660 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
546661 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
......@@ -640,15 +755,14 @@ export const make = Effect.gen(function* () {
640755 ) {
641756 const activeAction = yield* activeUpdateAction;
642757 const error = new DesktopUpdaterReportedError({
643 operation: Option.getOrElse(activeAction, () => "background" as const),
758 operation: Option.match(activeAction, {
759 onNone: () => "background" as const,
760 onSome: (action) => (action === "install-recovery" ? "install" : action),
761 }),
644762 cause,
645763 });
646764 if (Option.isSome(activeAction) && activeAction.value === "install") {
647 yield* finishUpdateAction("install");
648 yield* Ref.set(desktopState.quitting, false);
649 yield* updateState((current) =>
650 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
651 );
765 yield* recoverFailedInstall(error.message);
652766 yield* logUpdaterError(error.message, {
653767 errorTag: error._tag,
654768 operation: error.operation,
......@@ -733,6 +847,17 @@ export const make = Effect.gen(function* () {
733847
734848 return DesktopUpdates.of({
735849 getState: Ref.get(updateStateRef),
850 isActionActive: activeUpdateAction.pipe(Effect.map(Option.isSome)),
851 isInstallActive: activeUpdateAction.pipe(
852 Effect.map((action) => Option.isSome(action) && action.value === "install"),
853 ),
854 subscribe: stateMutex.withPermits(1)(
855 Effect.gen(function* () {
856 const subscription = yield* PubSub.subscribe(stateChanges);
857 const latest = yield* Ref.get(updateStateRef);
858 return { latest, changes: Stream.fromSubscription(subscription) };
859 }),
860 ),
736861 emitState,
737862 disabledReason: resolveDisabledReason,
738863 configure: Effect.gen(function* () {
......@@ -804,7 +929,7 @@ export const make = Effect.gen(function* () {
804929 const activeAction = yield* tryStartChannelChange;
805930 if (Option.isSome(activeAction)) {
806931 return yield* new DesktopUpdateActionInProgressError({
807 action: activeAction.value,
932 action: activeAction.value === "install-recovery" ? "install" : activeAction.value,
808933 requestedChannel: nextChannel,
809934 });
810935 }
......@@ -861,21 +986,10 @@ export const make = Effect.gen(function* () {
861986 state: yield* Ref.get(updateStateRef),
862987 };
863988 }).pipe(Effect.withSpan("desktop.updates.download")),
864 install: Effect.gen(function* () {
865 if (yield* Ref.get(desktopState.quitting)) {
866 return {
867 accepted: false,
868 completed: false,
869 state: yield* Ref.get(updateStateRef),
870 };
871 }
872 const result = yield* installDownloadedUpdate;
873 return {
874 accepted: result.accepted,
875 completed: result.completed,
876 state: yield* Ref.get(updateStateRef),
877 };
878 }).pipe(Effect.withSpan("desktop.updates.install")),
989 install: installWithExpectedVersion().pipe(
990 Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })),
991 ),
992 installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion),
879993 });
880994});
881995
apps/desktop/src/updates/remoteUpdateFlow.test.ts created+218
......@@ -0,0 +1,218 @@
1import { assert, describe, it } from "@effect/vitest";
2import type { DesktopUpdateState } from "@t3tools/contracts";
3
4import {
5 MAX_REMOTE_UPDATE_CHECKS,
6 MAX_REMOTE_UPDATE_DOWNLOADS,
7 nextRemoteDesktopUpdateStep,
8 normalizeRemoteUpdateReason,
9 type RemoteDesktopUpdateAttempts,
10} from "./remoteUpdateFlow.ts";
11
12const NO_ATTEMPTS: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 };
13
14function makeState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
15 return {
16 enabled: true,
17 status: "idle",
18 channel: "latest",
19 currentVersion: "1.2.3",
20 hostArch: "arm64",
21 appArch: "arm64",
22 runningUnderArm64Translation: false,
23 availableVersion: null,
24 downloadedVersion: null,
25 releaseNotes: [],
26 downloadPercent: null,
27 checkedAt: null,
28 message: null,
29 errorContext: null,
30 canRetry: false,
31 omittedReleaseCount: 0,
32 ...overrides,
33 };
34}
35
36describe("nextRemoteDesktopUpdateStep", () => {
37 it("fails immediately when updates are disabled, preferring the known reason", () => {
38 const state = makeState({ enabled: false, status: "disabled" });
39 assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, "dev build"), {
40 action: "done",
41 outcome: "failed",
42 reason: "dev build",
43 });
44 assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, null), {
45 action: "done",
46 outcome: "failed",
47 reason: "Automatic updates are disabled on this machine.",
48 });
49 });
50
51 it("installs only from the downloaded status the updater will accept", () => {
52 assert.deepEqual(
53 nextRemoteDesktopUpdateStep(
54 makeState({ status: "downloaded", downloadedVersion: "1.2.4" }),
55 NO_ATTEMPTS,
56 null,
57 ),
58 { action: "install" },
59 );
60 // A previous install failure keeps status "downloaded", so a remote run
61 // retries the install.
62 assert.deepEqual(
63 nextRemoteDesktopUpdateStep(
64 makeState({
65 status: "downloaded",
66 downloadedVersion: "1.2.4",
67 errorContext: "install",
68 message: "quitAndInstall failed",
69 }),
70 NO_ATTEMPTS,
71 null,
72 ),
73 { action: "install" },
74 );
75 // A download survives an unrelated background updater error, so a run
76 // installs it instead of replaying that error.
77 assert.deepEqual(
78 nextRemoteDesktopUpdateStep(
79 makeState({
80 status: "error",
81 downloadedVersion: "1.2.4",
82 errorContext: null,
83 message: "background updater error",
84 }),
85 NO_ATTEMPTS,
86 null,
87 ),
88 { action: "install" },
89 );
90 // A leftover download behind a check error is not installable: fresh
91 // runs re-check, and post-check the error is terminal.
92 const staleError = makeState({
93 status: "error",
94 downloadedVersion: "1.2.4",
95 errorContext: "check",
96 message: "feed unreachable",
97 });
98 assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, NO_ATTEMPTS, null), {
99 action: "check",
100 });
101 assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, { checks: 1, downloads: 0 }, null), {
102 action: "done",
103 outcome: "failed",
104 reason: "feed unreachable",
105 });
106 });
107
108 it("rides along while a check or download is already in flight", () => {
109 assert.deepEqual(
110 nextRemoteDesktopUpdateStep(makeState({ status: "checking" }), NO_ATTEMPTS, null),
111 {
112 action: "wait",
113 },
114 );
115 assert.deepEqual(
116 nextRemoteDesktopUpdateStep(
117 makeState({ status: "downloading", availableVersion: "1.2.4", downloadPercent: 40 }),
118 NO_ATTEMPTS,
119 null,
120 ),
121 { action: "wait" },
122 );
123 });
124
125 it("downloads an available update until the attempt cap, then fails", () => {
126 const available = makeState({ status: "available", availableVersion: "1.2.4" });
127 assert.deepEqual(nextRemoteDesktopUpdateStep(available, NO_ATTEMPTS, null), {
128 action: "download",
129 });
130 assert.deepEqual(
131 nextRemoteDesktopUpdateStep(
132 available,
133 { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS },
134 null,
135 ),
136 {
137 action: "done",
138 outcome: "failed",
139 reason: "The desktop app failed to download the update.",
140 },
141 );
142 assert.deepEqual(
143 nextRemoteDesktopUpdateStep(
144 makeState({
145 status: "available",
146 availableVersion: "1.2.4",
147 message: "network blipped",
148 }),
149 { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS },
150 null,
151 ),
152 { action: "done", outcome: "failed", reason: "network blipped" },
153 );
154 });
155
156 it("re-checks stale up-to-date and error states before trusting them", () => {
157 // These states are retained from earlier/background checks; a remote
158 // request must look again instead of replaying them.
159 assert.deepEqual(
160 nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), NO_ATTEMPTS, null),
161 { action: "check" },
162 );
163 assert.deepEqual(
164 nextRemoteDesktopUpdateStep(
165 makeState({ status: "error", message: "feed unreachable" }),
166 NO_ATTEMPTS,
167 null,
168 ),
169 { action: "check" },
170 );
171 });
172
173 it("reports up-to-date and error states as terminal after this run's check", () => {
174 const checked = { checks: 1, downloads: 0 };
175 assert.deepEqual(
176 nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), checked, null),
177 { action: "done", outcome: "up-to-date" },
178 );
179 assert.deepEqual(
180 nextRemoteDesktopUpdateStep(
181 makeState({ status: "error", message: "feed unreachable" }),
182 checked,
183 null,
184 ),
185 { action: "done", outcome: "failed", reason: "feed unreachable" },
186 );
187 assert.deepEqual(nextRemoteDesktopUpdateStep(makeState({ status: "error" }), checked, null), {
188 action: "done",
189 outcome: "failed",
190 reason: "The desktop app update failed.",
191 });
192 });
193
194 it("checks from idle until the attempt cap, then fails", () => {
195 assert.deepEqual(nextRemoteDesktopUpdateStep(makeState(), NO_ATTEMPTS, null), {
196 action: "check",
197 });
198 assert.deepEqual(
199 nextRemoteDesktopUpdateStep(
200 makeState(),
201 { checks: MAX_REMOTE_UPDATE_CHECKS, downloads: 0 },
202 null,
203 ),
204 {
205 action: "done",
206 outcome: "failed",
207 reason: "The desktop app did not report an update result.",
208 },
209 );
210 });
211
212 it("drops blank reasons so the wire report still encodes", () => {
213 assert.equal(normalizeRemoteUpdateReason(undefined), undefined);
214 assert.equal(normalizeRemoteUpdateReason(""), undefined);
215 assert.equal(normalizeRemoteUpdateReason(" "), undefined);
216 assert.equal(normalizeRemoteUpdateReason(" feed unreachable "), "feed unreachable");
217 });
218});
apps/desktop/src/updates/remoteUpdateFlow.ts created+115
......@@ -0,0 +1,115 @@
1import type { DesktopUpdateRemoteOutcome, DesktopUpdateState } from "@t3tools/contracts";
2
3/**
4 * What a server-triggered update run should do next, given the updater's
5 * current state. "wait" means an action (possibly started locally) is in
6 * flight and the run should ride along until the next state change.
7 */
8export type RemoteDesktopUpdateStep =
9 | { readonly action: "check" }
10 | { readonly action: "download" }
11 | { readonly action: "install" }
12 | { readonly action: "wait" }
13 | {
14 readonly action: "done";
15 readonly outcome: DesktopUpdateRemoteOutcome;
16 readonly reason?: string;
17 };
18
19/**
20 * How many times this run already issued each action. The caps are what stop
21 * a check -> up-to-date -> check loop and endless download retries; they are
22 * counts rather than booleans because a state event raced by the local
23 * 4-minute poller can re-show an already-handled status once.
24 */
25export interface RemoteDesktopUpdateAttempts {
26 readonly checks: number;
27 readonly downloads: number;
28}
29
30export const MAX_REMOTE_UPDATE_CHECKS = 2;
31export const MAX_REMOTE_UPDATE_DOWNLOADS = 3;
32
33/** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */
34export function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean {
35 return (
36 state.downloadedVersion !== null &&
37 (state.status === "downloaded" ||
38 (state.status === "error" &&
39 (state.errorContext === null || state.errorContext === "install")))
40 );
41}
42
43export function nextRemoteDesktopUpdateStep(
44 state: DesktopUpdateState,
45 attempts: RemoteDesktopUpdateAttempts,
46 disabledReason: string | null,
47): RemoteDesktopUpdateStep {
48 if (!state.enabled || state.status === "disabled") {
49 return {
50 action: "done",
51 outcome: "failed",
52 reason: disabledReason ?? "Automatic updates are disabled on this machine.",
53 };
54 }
55 // Mirror installDownloadedUpdate's own admission rule exactly, so the run
56 // never reports an install that the updater then refuses. A download
57 // survives an unrelated background updater error (errorContext null) and
58 // a previous failed install (errorContext "install"); it does not survive
59 // a check or download error, which fall through to the error branch.
60 if (isInstallableDesktopUpdateState(state)) {
61 return { action: "install" };
62 }
63 if (state.status === "downloading" || state.status === "checking") {
64 return { action: "wait" };
65 }
66 if (state.status === "available") {
67 if (attempts.downloads >= MAX_REMOTE_UPDATE_DOWNLOADS) {
68 return {
69 action: "done",
70 outcome: "failed",
71 reason: state.message ?? "The desktop app failed to download the update.",
72 };
73 }
74 return { action: "download" };
75 }
76 // "up-to-date" and "error" are retained from earlier/background checks,
77 // so before this run has issued its own check they are stale, not
78 // terminal: the whole point of a remote request is to look again.
79 if (state.status === "up-to-date") {
80 if (attempts.checks === 0) {
81 return { action: "check" };
82 }
83 return { action: "done", outcome: "up-to-date" };
84 }
85 if (state.status === "error") {
86 if (attempts.checks === 0) {
87 return { action: "check" };
88 }
89 return {
90 action: "done",
91 outcome: "failed",
92 reason: state.message ?? "The desktop app update failed.",
93 };
94 }
95 // status === "idle"
96 if (attempts.checks >= MAX_REMOTE_UPDATE_CHECKS) {
97 return {
98 action: "done",
99 outcome: "failed",
100 reason: "The desktop app did not report an update result.",
101 };
102 }
103 return { action: "check" };
104}
105
106/**
107 * Normalizes an updater message for the `reason` wire field, which is a
108 * TrimmedNonEmptyString. Updater messages are plain strings and may be blank;
109 * a blank reason would fail encoding and silently drop the terminal report,
110 * leaving the server to wait for its timeout.
111 */
112export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined {
113 const trimmed = reason?.trim();
114 return trimmed ? trimmed : undefined;
115}
apps/desktop/src/updates/updatesTestHarness.ts created+242
......@@ -0,0 +1,242 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import type { DesktopUpdateState } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
8import * as DesktopConfig from "../app/DesktopConfig.ts";
9import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
10import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
11import * as ElectronWindow from "../electron/ElectronWindow.ts";
12import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
13import * as DesktopState from "../app/DesktopState.ts";
14import * as DesktopUpdates from "./DesktopUpdates.ts";
15
16/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose
17 electron-updater events are driven by hand via `emit`. Used by
18 DesktopUpdates.test.ts and DesktopRemoteUpdates.test.ts. */
19
20export const flushCallbacks = Effect.yieldNow;
21
22export interface UpdatesHarnessOptions {
23 readonly checkForUpdates?: Effect.Effect<
24 void,
25 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
26 >;
27 readonly beforeSetUpdateChannel?: Effect.Effect<void>;
28 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
29 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
30 readonly downloadUpdate?: Effect.Effect<void>;
31 readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>;
32 readonly stopBackend?: Effect.Effect<void>;
33 readonly startBackend?: Effect.Effect<void>;
34 readonly env?: Record<string, string | undefined>;
35}
36
37export function makeHarness(options: UpdatesHarnessOptions = {}) {
38 let checkCount = 0;
39 let quitAndInstallCount = 0;
40 let downloadCount = 0;
41 let allowDowngrade = false;
42 let fullChangelog = false;
43 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
44 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
45 const sentStates: DesktopUpdateState[] = [];
46 const installSteps: string[] = [];
47
48 const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
49 const eventListeners = listeners.get(eventName) ?? new Set();
50 eventListeners.add(listener);
51 listeners.set(eventName, eventListeners);
52 };
53
54 const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
55 const eventListeners = listeners.get(eventName);
56 if (!eventListeners) {
57 return;
58 }
59 eventListeners.delete(listener);
60 if (eventListeners.size === 0) {
61 listeners.delete(eventName);
62 }
63 };
64
65 const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, {
66 setFeedURL: (options) =>
67 Effect.sync(() => {
68 feedUrls.push(options);
69 }),
70 setAutoDownload: () => Effect.void,
71 setAutoInstallOnAppQuit: () => Effect.void,
72 setChannel: () => Effect.void,
73 setAllowPrerelease: () => Effect.void,
74 allowDowngrade: Effect.sync(() => allowDowngrade),
75 setAllowDowngrade: (value) =>
76 Effect.sync(() => {
77 allowDowngrade = value;
78 }),
79 setFullChangelog: (value) =>
80 Effect.sync(() => {
81 fullChangelog = value;
82 }),
83 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
84 checkForUpdates: Effect.sync(() => {
85 checkCount += 1;
86 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
87 downloadUpdate: Effect.sync(() => {
88 downloadCount += 1;
89 }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)),
90 quitAndInstall: () =>
91 Effect.sync(() => {
92 quitAndInstallCount += 1;
93 installSteps.push("quitAndInstall");
94 }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)),
95 on: (eventName, listener) =>
96 Effect.acquireRelease(
97 Effect.sync(() => {
98 addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
99 }),
100 () =>
101 Effect.sync(() => {
102 removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
103 }),
104 ).pipe(Effect.asVoid),
105 } satisfies ElectronUpdater.ElectronUpdater["Service"]);
106
107 const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
108 create: () => Effect.die("unexpected BrowserWindow creation"),
109 main: Effect.succeed(Option.none()),
110 currentMainOrFirst: Effect.succeed(Option.none()),
111 focusedMainOrFirst: Effect.succeed(Option.none()),
112 setMain: () => Effect.void,
113 clearMain: () => Effect.void,
114 reveal: () => Effect.void,
115 sendAll: (_channel, state) =>
116 Effect.sync(() => {
117 sentStates.push(state as DesktopUpdateState);
118 }),
119 destroyAll: Effect.sync(() => {
120 installSteps.push("destroyAll");
121 }),
122 syncAllAppearance: () => Effect.void,
123 } satisfies ElectronWindow.ElectronWindow["Service"]);
124
125 const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = {
126 id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
127 label: Effect.succeed("Windows"),
128 start: Effect.sync(() => {
129 installSteps.push("startBackend");
130 }).pipe(Effect.andThen(options.startBackend ?? Effect.void)),
131 stop: () => options.stopBackend ?? Effect.void,
132 currentConfig: Effect.succeed(Option.none()),
133 snapshot: Effect.succeed({
134 desiredRunning: false,
135 ready: false,
136 activePid: Option.none(),
137 restartAttempt: 0,
138 restartScheduled: false,
139 }),
140 waitForReady: () => Effect.succeed(true),
141 };
142 const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]);
143
144 const environmentLayer = DesktopEnvironment.layer({
145 dirname: "/repo/apps/desktop/src",
146 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
147 platform: "darwin",
148 processArch: "x64",
149 appVersion: "1.2.3",
150 appPath: "/repo",
151 isPackaged: true,
152 resourcesPath: "/missing/resources",
153 runningUnderArm64Translation: false,
154 }).pipe(
155 Layer.provide(
156 Layer.mergeAll(
157 NodeServices.layer,
158 DesktopConfig.layerTest({
159 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
160 T3CODE_DESKTOP_MOCK_UPDATES: "true",
161 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
162 ...options.env,
163 }),
164 ),
165 ),
166 );
167
168 let testSettings: DesktopAppSettings.DesktopSettings = {
169 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
170 };
171 const setUpdateChannelError = options.setUpdateChannelError;
172 const settingsLayer =
173 setUpdateChannelError || options.beforeSetUpdateChannel
174 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
175 get: Effect.sync(() => testSettings),
176 load: Effect.sync(() => testSettings),
177 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
178 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
179 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
180 setUpdateChannel: (channel) =>
181 setUpdateChannelError
182 ? Effect.fail(setUpdateChannelError)
183 : (options.beforeSetUpdateChannel ?? Effect.void).pipe(
184 Effect.andThen(
185 Effect.sync(() => {
186 const changed = testSettings.updateChannel !== channel;
187 testSettings = {
188 ...testSettings,
189 updateChannel: channel,
190 updateChannelConfiguredByUser: true,
191 };
192 return { settings: testSettings, changed };
193 }),
194 ),
195 ),
196 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
197 setWslDistro: () => Effect.die("unexpected WSL distro change"),
198 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
199 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
200 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
201 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
202 : DesktopAppSettings.layer;
203
204 const layer = DesktopUpdates.layer.pipe(
205 Layer.provideMerge(updaterLayer),
206 Layer.provideMerge(windowLayer),
207 Layer.provideMerge(backendLayer),
208 Layer.provideMerge(DesktopState.layer),
209 Layer.provideMerge(settingsLayer),
210 Layer.provideMerge(
211 DesktopConfig.layerTest({
212 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
213 T3CODE_DESKTOP_MOCK_UPDATES: "true",
214 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
215 ...options.env,
216 }),
217 ),
218 Layer.provideMerge(environmentLayer),
219 Layer.provideMerge(NodeServices.layer),
220 );
221
222 return {
223 layer,
224 checkCount: () => checkCount,
225 quitAndInstalls: () => quitAndInstallCount,
226 installSteps,
227 downloadCount: () => downloadCount,
228 feedUrls: () => feedUrls,
229 fullChangelog: () => fullChangelog,
230 listenerCount: () =>
231 Array.from(listeners.values()).reduce(
232 (total, eventListeners) => total + eventListeners.size,
233 0,
234 ),
235 sentStates,
236 emit: (eventName: string, payload?: unknown) => {
237 for (const listener of listeners.get(eventName) ?? []) {
238 listener(payload);
239 }
240 },
241 };
242}
apps/desktop/src/window/DesktopApplicationMenu.test.ts+4
......@@ -60,6 +60,9 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
6060
6161const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
6262 getState: Effect.die("unexpected getState"),
63 isActionActive: Effect.succeed(false),
64 isInstallActive: Effect.succeed(false),
65 subscribe: Effect.die("unexpected subscribe"),
6366 emitState: Effect.void,
6467 disabledReason: Effect.succeed(Option.none()),
6568 configure: Effect.void,
......@@ -67,6 +70,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
6770 check: () => Effect.die("unexpected check"),
6871 download: Effect.die("unexpected download"),
6972 install: Effect.die("unexpected install"),
73 installPrepared: () => Effect.die("unexpected installPrepared"),
7074} satisfies DesktopUpdates.DesktopUpdates["Service"]);
7175
7276const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -35,6 +35,7 @@ export const RPC_REQUIRED_SCOPES = {
3535 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
3636 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
3737 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
38 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,
3839 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
3940 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
4041 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
apps/server/src/cloud/selfUpdate.test.ts+34
......@@ -9,6 +9,7 @@ import * as Path from "effect/Path";
99import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1010
1111import * as ServerConfig from "../config.ts";
12import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
1213import * as ProcessRunner from "../processRunner.ts";
1314import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
1415import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
......@@ -19,6 +20,7 @@ interface HarnessOptions {
1920 readonly managed?: boolean;
2021 readonly preflight?: "ready" | "blocked";
2122 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
23 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
2224}
2325
2426const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
......@@ -88,6 +90,13 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
8890 const selfUpdate = yield* ServerSelfUpdate.make().pipe(
8991 Effect.provideService(ProcessRunner.ProcessRunner, runner),
9092 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
93 Effect.provideService(
94 DesktopAppUpdate.DesktopAppUpdate,
95 options.desktopAppUpdate ?? {
96 available: false,
97 run: () => Effect.die("unexpected desktop app update run"),
98 },
99 ),
91100 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),
92101 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
93102 );
......@@ -121,6 +130,31 @@ it.layer(NodeServices.layer)("server self update", (it) => {
121130 }),
122131 );
123132
133 it.effect("delegates desktop-managed updates to the desktop app when available", () =>
134 Effect.gen(function* () {
135 const stages: string[] = [];
136 const { selfUpdate, order } = yield* makeHarness({
137 mode: "desktop",
138 desktopAppUpdate: {
139 available: true,
140 run: (reportProgress) =>
141 reportProgress("downloading").pipe(
142 Effect.andThen(reportProgress("installing")),
143 Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }),
144 ),
145 commit: () => Effect.never,
146 },
147 });
148 const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) =>
149 Effect.sync(() => void stages.push(stage)),
150 );
151 expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" });
152 expect(stages).toEqual(["downloading", "installing"]);
153 // The launcher staging path must not run on the desktop path.
154 expect(order).toEqual([]);
155 }),
156 );
157
124158 it.effect("preserves the preflight refusal reason", () =>
125159 Effect.gen(function* () {
126160 const { selfUpdate } = yield* makeHarness({ preflight: "blocked" });
apps/server/src/cloud/selfUpdate.ts+15-1
......@@ -15,6 +15,7 @@ import * as Path from "effect/Path";
1515import * as Ref from "effect/Ref";
1616
1717import * as ServerConfig from "../config.ts";
18import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
1819import * as ProcessRunner from "../processRunner.ts";
1920import {
2021 ensurePinnedRuntimeInstalled,
......@@ -42,11 +43,15 @@ export class ServerSelfUpdate extends Context.Service<
4243 input: ServerSelfUpdateInput,
4344 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
4445 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
46 readonly commitDesktopUpdate: (
47 requestId: string,
48 ) => Effect.Effect<never, ServerSelfUpdateError>;
4549 }
4650>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
4751
4852export const make = Effect.fn("cloud.server_self_update.make")(function* () {
4953 const serverConfig = yield* ServerConfig.ServerConfig;
54 const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;
5055 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
5156 const runner = yield* ProcessRunner.ProcessRunner;
5257 const fs = yield* FileSystem.FileSystem;
......@@ -65,6 +70,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
6570 "cloud.server_self_update.update",
6671 )(function* (input, reportProgress = () => Effect.void) {
6772 if (capability === "desktop-managed") {
73 // input.targetVersion is meaningless here: the desktop app's own
74 // update feed decides what it downloads, and the result carries what
75 // it actually got.
76 if (desktopAppUpdate.available) {
77 return yield* desktopAppUpdate.run(reportProgress);
78 }
6879 return yield* failWith(
6980 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
7081 );
......@@ -191,7 +202,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
191202 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));
192203 });
193204
194 return ServerSelfUpdate.of({ update });
205 return ServerSelfUpdate.of({
206 update,
207 commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId),
208 });
195209});
196210
197211export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts created+247
......@@ -0,0 +1,247 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import type { DesktopUpdateState, DesktopUpdateStatusReport } from "@t3tools/contracts";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Stream from "effect/Stream";
11
12import * as ServerConfig from "../config.ts";
13import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
14import * as DesktopAppUpdate from "./DesktopAppUpdate.ts";
15
16function makeState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
17 return {
18 enabled: true,
19 status: "idle",
20 channel: "latest",
21 currentVersion: "1.2.3",
22 hostArch: "arm64",
23 appArch: "arm64",
24 runningUnderArm64Translation: false,
25 availableVersion: null,
26 downloadedVersion: null,
27 releaseNotes: [],
28 downloadPercent: null,
29 checkedAt: null,
30 message: null,
31 errorContext: null,
32 canRetry: false,
33 omittedReleaseCount: 0,
34 ...overrides,
35 };
36}
37
38function report(
39 requestId: string,
40 state: DesktopUpdateState,
41 terminal?: {
42 readonly outcome: DesktopUpdateStatusReport["outcome"];
43 readonly reason?: string;
44 },
45): DesktopUpdateStatusReport {
46 return {
47 version: 1,
48 type: "desktopUpdateStatus",
49 requestId,
50 ...(terminal?.outcome === undefined ? {} : { outcome: terminal.outcome }),
51 ...(terminal?.reason === undefined ? {} : { reason: terminal.reason }),
52 state,
53 };
54}
55
56interface HarnessOptions {
57 readonly mode?: "web" | "desktop";
58 readonly controlFd?: number | undefined;
59 /** Reports emitted for the run, given the requestId the service generated.
60 The stream ends after the last one unless `keepOpen` is set. */
61 readonly reports?: (requestId: string) => readonly DesktopUpdateStatusReport[];
62 readonly keepOpen?: boolean;
63}
64
65const makeHarness = Effect.fn("test.make_desktop_app_update_harness")(function* (
66 options: HarnessOptions = {},
67) {
68 const fs = yield* FileSystem.FileSystem;
69 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-desktop-app-update-test-" });
70 const requestIdDeferred = yield* Deferred.make<string>();
71 const baseConfig = yield* ServerConfig.ServerConfig.pipe(
72 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
73 );
74 const config: ServerConfig.ServerConfig["Service"] = {
75 ...baseConfig,
76 mode: options.mode ?? "desktop",
77 ...("controlFd" in options
78 ? { desktopTelemetryControlFd: options.controlFd }
79 : { desktopTelemetryControlFd: 5 }),
80 };
81 const reportsForRun = options.reports ?? (() => []);
82 const changes = Stream.unwrap(
83 Deferred.await(requestIdDeferred).pipe(
84 Effect.map((requestId) => {
85 const emitted = Stream.fromIterable(reportsForRun(requestId));
86 return options.keepOpen ? Stream.concat(emitted, Stream.never) : emitted;
87 }),
88 ),
89 );
90
91 const service = yield* DesktopAppUpdate.make().pipe(
92 Effect.provide(
93 Layer.mergeAll(
94 DesktopTelemetryReceiver.layerTest({
95 requestDesktopUpdate: (requestId) =>
96 Deferred.succeed(requestIdDeferred, requestId).pipe(Effect.asVoid),
97 desktopUpdates: Effect.succeed({
98 latest: Option.none<DesktopUpdateStatusReport>(),
99 changes,
100 }),
101 }),
102 ServerConfig.layer(config),
103 ),
104 ),
105 );
106 return { service };
107});
108
109it.layer(NodeServices.layer)("desktop app update", (it) => {
110 it.effect("is unavailable without desktop mode or the control fd", () =>
111 Effect.gen(function* () {
112 const web = yield* makeHarness({ mode: "web" });
113 expect(web.service.available).toBe(false);
114 const noFd = yield* makeHarness({ controlFd: undefined });
115 expect(noFd.service.available).toBe(false);
116 expect((yield* noFd.service.run(() => Effect.void).pipe(Effect.flip)).reason).toContain(
117 "not started by the T3 Code desktop app",
118 );
119 const desktop = yield* makeHarness();
120 expect(desktop.service.available).toBe(true);
121 }),
122 );
123
124 it.effect("collapses state reports into progress stages and succeeds on installing", () =>
125 Effect.gen(function* () {
126 const { service } = yield* makeHarness({
127 reports: (requestId) => [
128 report(requestId, makeState({ status: "checking" })),
129 report(requestId, makeState({ status: "available", availableVersion: "1.2.4" })),
130 report(requestId, makeState({ status: "downloading", downloadPercent: 40 })),
131 // Reports from another run must be ignored.
132 report("other-run", makeState({ status: "error", message: "unrelated" })),
133 report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), {
134 outcome: "ready-to-install",
135 }),
136 ],
137 });
138 const stages: string[] = [];
139 const result = yield* service.run((stage) => Effect.sync(() => void stages.push(stage)));
140 expect(result).toEqual({
141 targetVersion: "1.2.4",
142 method: "desktop-app",
143 desktopUpdateToken: expect.any(String),
144 });
145 // "downloading" is not repeated for every download report.
146 expect(stages).toEqual(["downloading", "installing"]);
147
148 // Success releases the in-flight guard: if the desktop rejected the
149 // install after reporting, the server must accept a retry instead of
150 // refusing until restart. (The second run fails differently because
151 // the stub report stream is exhausted.)
152 const retry = yield* service.run(() => Effect.void).pipe(Effect.flip);
153 expect(retry.reason).not.toBe("A desktop app update is already in progress.");
154 }),
155 );
156
157 it.effect("maps up-to-date and failed outcomes to readable errors", () =>
158 Effect.gen(function* () {
159 const upToDate = yield* makeHarness({
160 reports: (requestId) => [
161 report(requestId, makeState({ status: "up-to-date" }), { outcome: "up-to-date" }),
162 ],
163 });
164 expect((yield* upToDate.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
165 "The T3 Code desktop app on this machine is already up to date on 1.2.3.",
166 );
167
168 const failed = yield* makeHarness({
169 reports: (requestId) => [
170 report(requestId, makeState({ status: "error", message: "feed unreachable" }), {
171 outcome: "failed",
172 reason: "feed unreachable",
173 }),
174 ],
175 });
176 expect((yield* failed.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
177 "feed unreachable",
178 );
179 }),
180 );
181
182 it.effect("replays a retained commit failure for the preparation token", () =>
183 Effect.gen(function* () {
184 const { service } = yield* makeHarness({
185 reports: (requestId) => [
186 report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), {
187 outcome: "ready-to-install",
188 }),
189 report(
190 requestId,
191 makeState({
192 status: "downloaded",
193 downloadedVersion: "1.2.4",
194 errorContext: "install",
195 message: "installer refused",
196 }),
197 { outcome: "failed", reason: "installer refused" },
198 ),
199 ],
200 });
201 const prepared = yield* service.run(() => Effect.void);
202
203 expect(
204 (yield* service.commit(prepared.desktopUpdateToken ?? "missing").pipe(Effect.flip)).reason,
205 ).toBe("installer refused");
206 }),
207 );
208
209 it.effect("fails when the desktop stops reporting before a terminal outcome", () =>
210 Effect.gen(function* () {
211 const { service } = yield* makeHarness({
212 reports: (requestId) => [report(requestId, makeState({ status: "checking" }))],
213 });
214 expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
215 "The desktop app stopped reporting its update.",
216 );
217 }),
218 );
219
220 it.effect("allows only one desktop update at a time", () =>
221 Effect.gen(function* () {
222 const { service } = yield* makeHarness({ reports: () => [], keepOpen: true });
223 const first = yield* Effect.forkChild(
224 service.run(() => Effect.void),
225 {
226 startImmediately: true,
227 },
228 );
229 expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
230 "A desktop app update is already in progress.",
231 );
232 yield* Fiber.interrupt(first);
233
234 const retry = yield* Effect.forkChild(
235 service.run(() => Effect.void),
236 {
237 startImmediately: true,
238 },
239 );
240 yield* Effect.yieldNow;
241 expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
242 "A desktop app update is already in progress.",
243 );
244 yield* Fiber.interrupt(retry);
245 }),
246 );
247});
apps/server/src/desktopUpdate/DesktopAppUpdate.ts created+219
......@@ -0,0 +1,219 @@
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 Crypto from "effect/Crypto";
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 { ServerConfig } from "../config.ts";
19import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
20
21/** Backstop for a desktop updater that hangs without ever reporting a
22 terminal outcome. Generous: it covers a slow download of a full build. */
23const DESKTOP_UPDATE_TIMEOUT = Duration.minutes(20);
24const DESKTOP_INSTALL_TIMEOUT = Duration.minutes(2);
25
26/** Progress stage a desktop update state maps to, or null when the state
27 carries no progress worth streaming. */
28export function desktopUpdateProgressStage(
29 state: DesktopUpdateState,
30): ServerSelfUpdateProgressStage | null {
31 switch (state.status) {
32 case "checking":
33 case "available":
34 case "downloading":
35 return "downloading";
36 case "downloaded":
37 return "installing";
38 default:
39 return null;
40 }
41}
42
43export class DesktopAppUpdate extends Context.Service<
44 DesktopAppUpdate,
45 {
46 /** True when this server was spawned by a desktop app that can be
47 driven over the telemetry control channel. */
48 readonly available: boolean;
49 /** Checks and downloads through the desktop app, then returns a token
50 while this server is still connected. `commit` starts installation. */
51 readonly run: (
52 reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
53 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
54 /** Starts the prepared install. Success stops this server, so this effect
55 returns only when installation fails or times out. */
56 readonly commit: (requestId: string) => Effect.Effect<never, ServerSelfUpdateError>;
57 }
58>()("t3/desktopUpdate/DesktopAppUpdate") {}
59
60export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* () {
61 const config = yield* ServerConfig;
62 const crypto = yield* Crypto.Crypto;
63 const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
64 const inFlight = yield* Ref.make(false);
65
66 const available = config.mode === "desktop" && config.desktopTelemetryControlFd !== undefined;
67 const failWith = (reason: string, cause?: unknown) =>
68 cause === undefined
69 ? new ServerSelfUpdateError({ reason })
70 : new ServerSelfUpdateError({ reason, cause });
71
72 const consumeReports = (
73 requestId: string,
74 changes: Stream.Stream<DesktopUpdateStatusReport>,
75 reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
76 ) =>
77 Effect.gen(function* () {
78 const lastStage = yield* Ref.make<ServerSelfUpdateProgressStage | null>(null);
79 const emitStage = (stage: ServerSelfUpdateProgressStage | null): Effect.Effect<void> =>
80 stage === null
81 ? Effect.void
82 : Ref.get(lastStage).pipe(
83 Effect.flatMap((previous) =>
84 previous === stage
85 ? Effect.void
86 : Ref.set(lastStage, stage).pipe(Effect.andThen(reportProgress(stage))),
87 ),
88 );
89
90 const terminal = yield* changes.pipe(
91 Stream.filter((report) => report.requestId === requestId),
92 Stream.mapEffect(
93 (report): Effect.Effect<Option.Option<DesktopUpdateStatusReport>> =>
94 report.outcome === undefined
95 ? emitStage(desktopUpdateProgressStage(report.state)).pipe(
96 Effect.as(Option.none<DesktopUpdateStatusReport>()),
97 )
98 : Effect.succeed(Option.some(report)),
99 ),
100 Stream.filterMap(
101 Option.match({
102 onNone: () => Result.failVoid,
103 onSome: Result.succeed,
104 }),
105 ),
106 Stream.runHead,
107 );
108 if (Option.isNone(terminal)) {
109 return yield* failWith("The desktop app stopped reporting its update.");
110 }
111
112 const report = terminal.value;
113 if (report.outcome === "ready-to-install") {
114 yield* emitStage("installing");
115 const targetVersion =
116 report.state.downloadedVersion ??
117 report.state.availableVersion ??
118 report.state.currentVersion;
119 yield* Effect.logInfo("Desktop app update prepared for install.", {
120 targetVersion,
121 });
122 yield* Ref.set(inFlight, false);
123 return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId };
124 }
125 if (report.outcome === "up-to-date") {
126 return yield* failWith(
127 `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`,
128 );
129 }
130 return yield* failWith(
131 report.reason ?? report.state.message ?? "The desktop app update failed.",
132 );
133 });
134
135 const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")(
136 function* (reportProgress) {
137 if (!available) {
138 return yield* failWith(
139 "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.",
140 );
141 }
142 if (yield* Ref.getAndSet(inFlight, true)) {
143 return yield* failWith("A desktop app update is already in progress.");
144 }
145
146 return yield* Effect.scoped(
147 Effect.gen(function* () {
148 const requestId = yield* crypto.randomUUIDv4.pipe(
149 Effect.mapError((error) =>
150 failWith("Could not generate a desktop update request id.", error),
151 ),
152 );
153 // Subscribe before sending the request so a fast first report
154 // cannot be missed.
155 const { changes } = yield* receiver.desktopUpdates;
156 yield* receiver
157 .requestDesktopUpdate(requestId)
158 .pipe(
159 Effect.mapError((error) =>
160 failWith("Could not reach the T3 Code desktop app on this machine.", error),
161 ),
162 );
163 return yield* consumeReports(requestId, changes, reportProgress).pipe(
164 Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)),
165 );
166 }),
167 ).pipe(
168 Effect.timeout(DESKTOP_UPDATE_TIMEOUT),
169 Effect.catchTags({
170 TimeoutError: () => failWith("The desktop app did not finish the update in time."),
171 }),
172 Effect.onError(() => Ref.set(inFlight, false)),
173 );
174 },
175 );
176
177 const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn(
178 "desktopUpdate.desktopAppUpdate.commit",
179 )(function* (requestId) {
180 if (!available) {
181 return yield* failWith("This server cannot commit a desktop app update.");
182 }
183 const terminal = yield* Effect.scoped(
184 Effect.gen(function* () {
185 const { latest, changes } = yield* receiver.desktopUpdates;
186 const reports = Option.match(latest, {
187 onNone: () => changes,
188 onSome: (report) => Stream.concat(Stream.make(report), changes),
189 });
190 yield* receiver
191 .commitDesktopUpdate(requestId)
192 .pipe(
193 Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)),
194 );
195 return yield* reports.pipe(
196 Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"),
197 Stream.runHead,
198 Effect.timeout(DESKTOP_INSTALL_TIMEOUT),
199 Effect.catchTags({
200 TimeoutError: () =>
201 failWith("The desktop app did not report an install result in time."),
202 }),
203 );
204 }),
205 );
206 if (Option.isNone(terminal)) {
207 return yield* failWith("The desktop app stopped reporting the install.");
208 }
209 return yield* failWith(
210 terminal.value.reason ??
211 terminal.value.state.message ??
212 "The desktop app failed to install the update.",
213 );
214 });
215
216 return DesktopAppUpdate.of({ available, run, commit });
217});
218
219export const layer = Layer.effect(DesktopAppUpdate, make());
apps/server/src/environment/ServerEnvironment.test.ts+37
......@@ -218,6 +218,43 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
218218 }),
219219 );
220220
221 it.effect("advertises desktopAppUpdate only with desktop mode and the control fd", () =>
222 Effect.gen(function* () {
223 const fileSystem = yield* FileSystem.FileSystem;
224 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
225 prefix: "t3-server-environment-desktop-update-test-",
226 });
227 const serverConfig = yield* makeServerConfig(baseDir);
228 yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true });
229
230 const describeWith = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) =>
231 Effect.gen(function* () {
232 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
233 return yield* serverEnvironment.getDescriptor;
234 }).pipe(
235 Effect.provide(
236 ServerEnvironment.layer.pipe(
237 Layer.provide(ServerSecretStore.layer),
238 Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })),
239 ),
240 ),
241 );
242
243 const withFd = yield* describeWith({ mode: "desktop", desktopTelemetryControlFd: 5 });
244 expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
245 expect(withFd.capabilities.desktopAppUpdate).toBe(true);
246 expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true);
247
248 const withoutFd = yield* describeWith({ mode: "desktop" });
249 expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
250 expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined();
251 expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined();
252
253 const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 });
254 expect(web.capabilities.desktopAppUpdate).toBeUndefined();
255 }),
256 );
257
221258 it.effect("structures persisted environment id filesystem failures", () =>
222259 Effect.gen(function* () {
223260 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/environment/ServerEnvironment.ts+10-1
......@@ -193,6 +193,12 @@ export const make = Effect.gen(function* () {
193193 desktopManaged: serverConfig.mode === "desktop",
194194 launcherManaged: launcher.managed,
195195 });
196 // Static is correct: the control fd is known at bootstrap, and the desktop
197 // app and its bundled server ship in one artifact, so a present fd means
198 // the app speaks the requestDesktopUpdate protocol. WSL backends never get
199 // the fd and correctly do not advertise.
200 const desktopAppUpdate =
201 serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined;
196202
197203 const descriptor: ExecutionEnvironmentDescriptor = {
198204 environmentId,
......@@ -217,7 +223,10 @@ export const make = Effect.gen(function* () {
217223 threadTitleRegeneration: true,
218224 threadPullRequestLinking: true,
219225 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
220 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
226 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
227 ? { serverSelfUpdateProgress: true }
228 : {}),
229 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
221230 },
222231 };
223232
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts+62
......@@ -7,6 +7,7 @@ import {
77 type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue,
88 type DesktopHostTelemetrySnapshot,
99 DesktopTelemetryControlMessage,
10 type DesktopUpdateStatusReport,
1011 type ResourceTelemetrySourceStatus,
1112} from "@t3tools/contracts";
1213import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
......@@ -171,6 +172,29 @@ export class DesktopTelemetryReceiver extends Context.Service<
171172 readonly setDiagnosticsDemand: (
172173 enabled: boolean,
173174 ) => Effect.Effect<void, DesktopTelemetryControlError>;
175 /** Asks the desktop app supervising this server to update itself. The
176 desktop answers with desktopUpdateStatus reports carrying the same
177 requestId. */
178 readonly requestDesktopUpdate: (
179 requestId: string,
180 ) => Effect.Effect<void, DesktopTelemetryControlError>;
181 readonly commitDesktopUpdate: (
182 requestId: string,
183 ) => Effect.Effect<void, DesktopTelemetryControlError>;
184 readonly cancelDesktopUpdate: (
185 requestId: string,
186 ) => Effect.Effect<void, DesktopTelemetryControlError>;
187 /** Latest desktop update state report plus subsequent reports. The
188 desktop replays its latest report when the backend attaches, so this
189 is populated shortly after startup on desktop-managed servers. */
190 readonly desktopUpdates: Effect.Effect<
191 {
192 readonly latest: Option.Option<DesktopUpdateStatusReport>;
193 readonly changes: Stream.Stream<DesktopUpdateStatusReport>;
194 },
195 never,
196 Scope.Scope
197 >;
174198 }
175199>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {}
176200
......@@ -322,6 +346,8 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")
322346 );
323347 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
324348 const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4);
349 const latestUpdateReport = yield* Ref.make(Option.none<DesktopUpdateStatusReport>());
350 const updateReportChanges = yield* PubSub.sliding<DesktopUpdateStatusReport>(16);
325351 const controlMutex = yield* Semaphore.make(1);
326352 const snapshotMutex = yield* Semaphore.make(1);
327353 const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({
......@@ -503,6 +529,15 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")
503529 );
504530 }
505531
532 // Not a resource sample: do not touch `latest` or sample health.
533 if (message.type === "desktopUpdateStatus") {
534 return recordContact.pipe(
535 Effect.andThen(Ref.set(latestUpdateReport, Option.some(message))),
536 Effect.andThen(PubSub.publish(updateReportChanges, message)),
537 Effect.asVoid,
538 );
539 }
540
506541 const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs);
507542 return snapshotMutex.withPermits(1)(
508543 recordContact.pipe(
......@@ -616,6 +651,24 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")
616651 health: Ref.get(health),
617652 subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)),
618653 setDiagnosticsDemand,
654 requestDesktopUpdate: (requestId) =>
655 sendControlMessage({
656 version: 1,
657 type: "requestDesktopUpdate",
658 requestId,
659 }),
660 commitDesktopUpdate: (requestId) =>
661 sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }),
662 cancelDesktopUpdate: (requestId) =>
663 sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }),
664 desktopUpdates: Effect.gen(function* () {
665 const subscription = yield* PubSub.subscribe(updateReportChanges);
666 const initial = yield* Ref.get(latestUpdateReport);
667 return {
668 latest: initial,
669 changes: Stream.fromSubscription(subscription),
670 };
671 }),
619672 });
620673});
621674
......@@ -656,6 +709,15 @@ export const layerTest = (
656709 })),
657710 ),
658711 setDiagnosticsDemand: () => Effect.void,
712 requestDesktopUpdate: () => Effect.void,
713 commitDesktopUpdate: () => Effect.void,
714 cancelDesktopUpdate: () => Effect.void,
715 desktopUpdates:
716 overrides.desktopUpdates ??
717 Effect.succeed({
718 latest: Option.none<DesktopUpdateStatusReport>(),
719 changes: Stream.empty,
720 }),
659721 ...overrides,
660722 }),
661723 );
apps/server/src/server.ts+8-1
......@@ -102,6 +102,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"
102102import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
103103import * as CloudCliState from "./cloud/CliState.ts";
104104import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
105import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts";
105106import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
106107import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
107108import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
......@@ -173,6 +174,12 @@ const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe(
173174 Layer.provide(DesktopTelemetryReceiverLayerLive),
174175);
175176
177// Reuses DesktopTelemetryReceiverLayerLive: a fresh receiver layer here
178// would open a second reader on the desktop telemetry fd.
179const DesktopAppUpdateLayerLive = DesktopAppUpdate.layer.pipe(
180 Layer.provide(DesktopTelemetryReceiverLayerLive),
181);
182
176183const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
177184 Layer.provide(HostPowerMonitorLayerLive),
178185 Layer.provideMerge(ServerSettingsLayerLive),
......@@ -491,7 +498,7 @@ export const makeRoutesLayer = Layer.mergeAll(
491498 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
492499 Layer.provide(PullRequestServiceLive),
493500 Layer.provide(PreviewAutomationBroker.layer),
494 Layer.provide(ServerSelfUpdate.layer),
501 Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(DesktopAppUpdateLayerLive))),
495502 Layer.provide(commandReadinessLayer),
496503 Layer.provide(browserApiCorsLayer),
497504 Layer.provide(httpCompressionLayer),
apps/server/src/ws.ts+6
......@@ -1721,6 +1721,12 @@ const makeWsRpcLayer = (
17211721 ),
17221722 { "rpc.aggregate": "server" },
17231723 ),
1724 [WS_METHODS.serverCommitDesktopUpdate]: (input) =>
1725 observeRpcEffect(
1726 WS_METHODS.serverCommitDesktopUpdate,
1727 serverSelfUpdate.commitDesktopUpdate(input.requestId),
1728 { "rpc.aggregate": "server" },
1729 ),
17241730 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
17251731 observeRpcEffect(
17261732 WS_METHODS.serverUpsertKeybinding,
apps/web/src/components/ChatView.tsx+12-3
......@@ -423,6 +423,7 @@ import {
423423 resolveServerConfigVersionMismatch,
424424 resolveServerSelfUpdateCapability,
425425 serverUpdateGuidance,
426 supportsDesktopAppUpdate,
426427} from "../versionSkew";
427428import { useAssetUrls } from "../assets/assetUrls";
428429
......@@ -2186,6 +2187,7 @@ function ChatViewContent(props: ChatViewProps) {
21862187 : "server";
21872188 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
21882189 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2190 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
21892191 const serverUpdateState = useAtomValue(
21902192 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
21912193 );
......@@ -2306,19 +2308,25 @@ function ChatViewContent(props: ChatViewProps) {
23062308 "Server update available"
23072309 ),
23082310 description:
2309 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"
2311 !updateInProgress &&
2312 !updateFailed &&
2313 versionMismatchSelfUpdate === "desktop-managed" &&
2314 !versionMismatchDesktopAppUpdate
23102315 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
23112316 : undefined,
23122317 // The desktop-managed guidance is already the description; the action
2313 // slot would only repeat it.
2318 // slot would only repeat it. When the desktop app accepts remote
2319 // update requests, the action button takes over instead.
23142320 actions:
23152321 updateInProgress ||
23162322 !versionMismatch ||
2317 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (
2323 (versionMismatchSelfUpdate === "desktop-managed" &&
2324 !versionMismatchDesktopAppUpdate) ? undefined : (
23182325 <ServerUpdateAction
23192326 environmentId={serverUpdateEnvironmentId}
23202327 serverLabel={versionMismatchServerLabel}
23212328 selfUpdate={versionMismatchSelfUpdate}
2329 desktopAppUpdate={versionMismatchDesktopAppUpdate}
23222330 targetVersion={versionMismatch.clientVersion}
23232331 label={updateFailed ? "Retry" : "Update"}
23242332 />
......@@ -2352,6 +2360,7 @@ function ChatViewContent(props: ChatViewProps) {
23522360 versionMismatchDismissKey,
23532361 serverUpdateEnvironmentId,
23542362 versionMismatchSelfUpdate,
2363 versionMismatchDesktopAppUpdate,
23552364 versionMismatchServerLabel,
23562365 ]);
23572366 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
apps/web/src/components/ServerUpdateAction.test.tsx+43
......@@ -98,6 +98,49 @@ describe("ServerUpdateAction", () => {
9898
9999 expect(testState.toast).not.toHaveBeenCalled();
100100 });
101
102 it("keeps the manual instruction for desktop servers without remote update support", () => {
103 const markup = renderToStaticMarkup(
104 <ServerUpdateAction
105 environmentId={"env-test" as EnvironmentId}
106 serverLabel="Test server"
107 selfUpdate="desktop-managed"
108 targetVersion="0.0.31"
109 />,
110 );
111
112 expect(markup).toContain("Update the desktop app on that machine to update this server.");
113 expect(markup).not.toContain("<button");
114 });
115
116 it("updates remote desktop apps through the shared update flow", async () => {
117 testState.updateServer.mockResolvedValue(
118 AsyncResult.success({ targetVersion: "0.0.34", method: "desktop-app" as const }),
119 );
120
121 const action = ServerUpdateAction({
122 environmentId: "env-test" as EnvironmentId,
123 serverLabel: "Test server",
124 selfUpdate: "desktop-managed",
125 desktopAppUpdate: true,
126 targetVersion: "0.0.31",
127 }) as ActionElement;
128
129 // No confirm-dialog host is mounted in this test, which the component
130 // treats as consent: the click itself was the request.
131 action.props.onClick?.();
132 await flushPromises();
133
134 expect(testState.updateServer).toHaveBeenCalledWith({
135 environmentId: "env-test",
136 input: { targetVersion: "0.0.31" },
137 });
138 expect(testState.toast).toHaveBeenCalledWith({
139 type: "success",
140 title: "Test server updated",
141 description: "Desktop app relaunched on 0.0.34.",
142 });
143 });
101144});
102145
103146describe("ServerUpdateProgress", () => {
apps/web/src/components/ServerUpdateAction.tsx+25-2
......@@ -5,6 +5,7 @@ import {
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
77
8import { requestConfirmDialog } from "~/confirmDialog";
89import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
910import { serverEnvironment } from "~/state/server";
1011import { useAtomCommand } from "~/state/use-atom-command";
......@@ -75,15 +76,20 @@ export function ServerUpdateAction({
7576 environmentId,
7677 serverLabel,
7778 selfUpdate,
79 desktopAppUpdate = false,
7880 targetVersion,
7981 label = "Update",
8082}: {
8183 readonly environmentId: EnvironmentId;
8284 readonly serverLabel: string;
8385 readonly selfUpdate: ServerSelfUpdateCapability | null;
86 /** The desktop app supervising this server accepts remote update
87 requests (capabilities.desktopAppUpdate). */
88 readonly desktopAppUpdate?: boolean;
8489 readonly targetVersion: string;
8590 readonly label?: string;
8691}) {
92 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
8793 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
8894 reportFailure: false,
8995 });
......@@ -106,6 +112,21 @@ export function ServerUpdateAction({
106112 });
107113
108114 const handleUpdate = async () => {
115 if (pendingUpdateEnvironmentIds.has(environmentId)) {
116 return;
117 }
118 if (isDesktopAppUpdate) {
119 // No themed host mounted (undefined) means proceed: the click itself
120 // was the request. This is the only confirmation in the flow; the
121 // remote machine installs without asking anyone there.
122 const confirmed =
123 (await requestConfirmDialog(
124 `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`,
125 )) ?? true;
126 if (!confirmed) {
127 return;
128 }
129 }
109130 if (pendingUpdateEnvironmentIds.has(environmentId)) {
110131 return;
111132 }
......@@ -129,14 +150,16 @@ export function ServerUpdateAction({
129150 toastManager.add({
130151 type: "success",
131152 title: `${serverLabel} updated`,
132 description: `Reconnected on t3@${result.value.targetVersion}.`,
153 description: isDesktopAppUpdate
154 ? `Desktop app relaunched on ${result.value.targetVersion}.`
155 : `Reconnected on t3@${result.value.targetVersion}.`,
133156 });
134157 } finally {
135158 pendingUpdateEnvironmentIds.delete(environmentId);
136159 }
137160 };
138161
139 if (selfUpdate === "desktop-managed") {
162 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
140163 return (
141164 <span className="text-muted-foreground text-xs">
142165 Update the desktop app on that machine to update this server.
apps/web/src/components/settings/ConnectionsSettings.tsx+6-1
......@@ -104,6 +104,7 @@ import { useUiStateStore } from "~/uiStateStore";
104104import {
105105 resolveServerConfigVersionMismatch,
106106 resolveServerSelfUpdateCapability,
107 supportsDesktopAppUpdate,
107108} from "~/versionSkew";
108109import { hasCloudPublicConfig } from "~/cloud/publicConfig";
109110import { useCloudLinkController } from "~/cloud/useCloudLinkController";
......@@ -1485,6 +1486,7 @@ function SavedBackendListRow({
14851486 environmentId={environmentId}
14861487 serverLabel={`${environment.label} server`}
14871488 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1489 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
14881490 targetVersion={versionMismatch.clientVersion}
14891491 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
14901492 />
......@@ -3057,8 +3059,11 @@ export function ConnectionsSettings() {
30573059 primaryServerUpdateState.status !== "running" ? (
30583060 <ServerUpdateAction
30593061 environmentId={primaryEnvironmentId}
3060 serverLabel={primaryEnvironment?.label ?? "this server"}
3062 serverLabel={
3063 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3064 }
30613065 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3066 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
30623067 targetVersion={primaryVersionMismatch.clientVersion}
30633068 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
30643069 />
apps/web/src/versionSkew.test.ts+22
......@@ -18,6 +18,7 @@ import {
1818 resolveServerSelfUpdateCapability,
1919 resolveVersionMismatch,
2020 serverUpdateGuidance,
21 supportsDesktopAppUpdate,
2122} from "./versionSkew";
2223
2324const MISMATCH_HINT =
......@@ -192,6 +193,27 @@ describe("versionSkew", () => {
192193 expect(resolveServerSelfUpdateCapability(null)).toBeNull();
193194 });
194195
196 it("detects remote desktop-app update support from config descriptors", () => {
197 const descriptor = (desktopAppUpdate?: boolean) => ({
198 environment: {
199 environmentId: EnvironmentId.make("environment-desktop"),
200 label: "Desktop",
201 platform: { os: "darwin", arch: "arm64" } as const,
202 serverVersion: "9.9.9",
203 capabilities: {
204 repositoryIdentity: true,
205 serverSelfUpdate: "desktop-managed" as const,
206 ...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }),
207 },
208 },
209 });
210
211 expect(supportsDesktopAppUpdate(descriptor(true))).toBe(true);
212 expect(supportsDesktopAppUpdate(descriptor(false))).toBe(false);
213 expect(supportsDesktopAppUpdate(descriptor())).toBe(false);
214 expect(supportsDesktopAppUpdate(null)).toBe(false);
215 });
216
195217 it("matches version-drift guidance to the advertised update path", () => {
196218 expect(serverUpdateGuidance("respawn", "Remote server")).toBe(
197219 "Update the Remote server so they stay in sync.",
apps/web/src/versionSkew.ts+8
......@@ -98,6 +98,14 @@ export function resolveServerSelfUpdateCapability(
9898 return serverConfig?.environment.capabilities.serverSelfUpdate ?? null;
9999}
100100
101/** True when the desktop app supervising this server can be told to update
102 itself over RPC. Older desktop servers only get the manual instruction. */
103export function supportsDesktopAppUpdate(
104 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
105): boolean {
106 return serverConfig?.environment.capabilities.desktopAppUpdate === true;
107}
108
101109/** The command to hand users whose server cannot update itself. */
102110export function manualServerUpdateCommand(targetVersion: string): string {
103111 return `npx t3@${targetVersion}`;
docs/internals/server-updates.md+10-2
......@@ -85,8 +85,16 @@ recorded reason. Older servers without an ID retain version-only reconnect behav
8585
8686The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers
8787advertise remote self-update only when they have valid launcher context and a live IPC channel.
88Desktop-managed servers direct the user to update the desktop app. Other process shapes provide a
89manual command; the old detached foreground respawn path no longer exists.
88Desktop-managed servers advertise `desktopAppUpdate` when the desktop telemetry control fd is
89attached. The progress RPC asks the desktop app to check and download, then returns a preparation
90token while the backend is still connected. Only after the client receives that result does it send
91`server.commitDesktopUpdate`. A successful commit closes the connection and must reconnect at the
92prepared version. The desktop app and bundled server versions stay equal because
93`scripts/update-release-package-versions.ts` bumps them together. If install fails, the desktop keeps its windows, restarts stopped backends, and
94replays the failure for the same token. This two-phase handoff prevents backend shutdown from
95dropping the only successful RPC result. Desktop servers without the capability direct the user to
96update the desktop app locally. Other process shapes provide a manual command; the old detached
97foreground respawn path no longer exists.
9098
9199## Source Map
92100
docs/user/updating.md+5-5
......@@ -22,11 +22,11 @@ The update does not remove saved threads, settings, or project files.
2222
2323## Choose the Action You See
2424
25| Action | What to do |
26| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27| **Update server** | Available for the T3 Code Linux background service. Select the button and leave T3 Code open while it prepares, tests, restarts, and reconnects. |
28| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. |
29| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. |
25| Action | What to do |
26| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27| **Update server** | Available for the T3 Code Linux background service and for servers run by a current T3 Code desktop app. Select the button and leave T3 Code open while it downloads, installs, restarts, and reconnects. For desktop-app servers this closes and relaunches the desktop app on that machine. If installation fails, the desktop app stays open and reconnects to its server. |
28| **Update the desktop app** | Shown for desktop apps that predate remote updates. Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. |
29| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. |
3030
3131The available action depends on how that server was started. T3 Code does not update connected
3232servers silently in the background.
packages/client-runtime/src/connection/registry.test.ts+28
......@@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts";
5050import * as RpcSession from "../rpc/session.ts";
5151import * as EnvironmentSupervisor from "./supervisor.ts";
5252import * as ConnectionWakeups from "./wakeups.ts";
53import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";
5354
5455const TARGET = new PrimaryConnectionTarget({
5556 environmentId: EnvironmentId.make("environment-1"),
......@@ -426,6 +427,33 @@ function awaitConnectionState(
426427}
427428
428429describe("EnvironmentRegistry", () => {
430 it.effect("replays connected state when arming a desktop commit observer", () =>
431 Effect.gen(function* () {
432 const harness = yield* makeHarness([TARGET]);
433
434 yield* Effect.gen(function* () {
435 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
436 yield* registry.start;
437 yield* awaitConnectionState(
438 registry,
439 TARGET.environmentId,
440 (state) => state.phase === "connected",
441 );
442
443 const commits = yield* Ref.make(0);
444 const result = yield* runDesktopCommitWithReconnectObserver(
445 registry.stateChanges(TARGET.environmentId),
446 Ref.update(commits, (count) => count + 1).pipe(
447 Effect.andThen(Effect.fail("commit refused")),
448 ),
449 ).pipe(Effect.flip, Effect.timeout("1 second"));
450
451 expect(result).toBe("commit refused");
452 expect(yield* Ref.get(commits)).toBe(1);
453 }).pipe(Effect.provide(harness.layer), Effect.scoped);
454 }),
455 );
456
429457 it.effect("does not acquire a session after the registry scope has already closed", () =>
430458 Effect.gen(function* () {
431459 const harness = yield* makeHarness([TARGET]);
packages/client-runtime/src/state/runtime.test.ts+51
......@@ -1,12 +1,14 @@
11import { describe, expect, it } from "@effect/vitest";
22import { EnvironmentId } from "@t3tools/contracts";
33import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
45import * as Effect from "effect/Effect";
56import * as Exit from "effect/Exit";
67import * as Fiber from "effect/Fiber";
78import * as Latch from "effect/Latch";
89import * as Layer from "effect/Layer";
910import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";
1012import * as Schema from "effect/Schema";
1113import * as Stream from "effect/Stream";
1214import * as SubscriptionRef from "effect/SubscriptionRef";
......@@ -833,6 +835,55 @@ describe("runtime command runner", () => {
833835 }),
834836 );
835837
838 it.effect("keeps an outer reconnect observer alive after a scheduled handoff returns", () =>
839 Effect.gen(function* () {
840 const commitStarting = yield* Deferred.make<void>();
841 const observerArmed = yield* Deferred.make<void>();
842 const reconnected = yield* Deferred.make<void>();
843 const states = yield* Queue.unbounded<{ readonly phase: string }>();
844 yield* Queue.offer(states, { phase: "connected" });
845 const runtime = Atom.runtime(Layer.empty);
846 const scheduler = createAtomCommandScheduler();
847 const concurrency = { mode: "serial" as const, key: () => "shared" };
848 const command = createRuntimeCommand(runtime, {
849 label: "test.desktop-update-handoff",
850 execute: (_input: void, registry) =>
851 Effect.gen(function* () {
852 yield* Deferred.await(commitStarting).pipe(
853 Effect.andThen(
854 Stream.fromQueue(states).pipe(
855 Stream.tap(() => Deferred.succeed(observerArmed, undefined)),
856 Stream.dropWhile((state) => state.phase === "connected"),
857 Stream.filter((state) => state.phase === "connected"),
858 Stream.runHead,
859 ),
860 ),
861 Effect.andThen(Deferred.succeed(reconnected, undefined)),
862 Effect.forkChild,
863 );
864 yield* scheduleAtomCommandEffect(
865 registry,
866 scheduler,
867 concurrency,
868 undefined,
869 Deferred.succeed(commitStarting, undefined).pipe(
870 Effect.andThen(Deferred.await(observerArmed)),
871 ),
872 );
873 yield* Queue.offer(states, { phase: "backoff" });
874 yield* Queue.offer(states, { phase: "connected" });
875 yield* Deferred.await(reconnected);
876 }),
877 });
878 const registry = AtomRegistry.make();
879
880 expect(yield* Effect.promise(() => command.run(registry, undefined))).toMatchObject({
881 _tag: "Success",
882 });
883 registry.dispose();
884 }),
885 );
886
836887 it("deduplicates single-flight commands by key", async () => {
837888 const latch = Latch.makeUnsafe();
838889 let executions = 0;
packages/client-runtime/src/state/server.test.ts+112
......@@ -8,6 +8,7 @@ import {
88import { describe, expect, it } from "@effect/vitest";
99import * as Cause from "effect/Cause";
1010import * as Duration from "effect/Duration";
11import * as Deferred from "effect/Deferred";
1112import * as Effect from "effect/Effect";
1213import * as Exit from "effect/Exit";
1314import * as Fiber from "effect/Fiber";
......@@ -33,6 +34,7 @@ import {
3334 makeEnvironmentServerConfigState,
3435 isLegacyUpdateHandoffLoss,
3536 matchesServerUpdateReadyEvent,
37 matchesServerUpdateResumeEvent,
3638 nudgeReconnectDuringUpdateRestart,
3739 projectServerWelcome,
3840 resolveServerConfigValue,
......@@ -40,6 +42,9 @@ import {
4042 serverUpdateStateForProgressEvent,
4143 serverUpdateStateForServerVersion,
4244 validateServerUpdateReadyEvent,
45 waitForNextEnvironmentReconnect,
46 waitForDesktopUpdateTarget,
47 runDesktopCommitWithReconnectObserver,
4348} from "./server.ts";
4449import { applyServerConfigProjection } from "./serverConfigProjection.ts";
4550
......@@ -81,6 +86,83 @@ function session(client: WsRpcProtocolClient): RpcSession {
8186}
8287
8388describe("update restart reconnect nudges", () => {
89 it.effect("retries a desktop commit that was lost before delivery", () =>
90 Effect.gen(function* () {
91 const readyEvents =
92 yield* Queue.unbounded<Parameters<typeof matchesServerUpdateReadyEvent>[1]>();
93 const ready = (serverVersion: string) =>
94 ({
95 version: 1 as const,
96 sequence: 1,
97 type: "ready" as const,
98 payload: {
99 at: "2026-09-01T00:00:00.000Z",
100 environment: { serverVersion },
101 },
102 }) as Parameters<typeof matchesServerUpdateReadyEvent>[1];
103 yield* Queue.offerAll(readyEvents, [ready("0.0.30"), ready("0.0.31")]);
104 const retries = yield* Ref.make(0);
105 const disconnect = new RpcClientError.RpcClientError({
106 reason: new Socket.SocketCloseError({ code: 1006 }),
107 });
108
109 const result = yield* waitForDesktopUpdateTarget(
110 "0.0.31",
111 Queue.take(readyEvents),
112 Ref.update(retries, (count) => count + 1).pipe(Effect.andThen(Effect.fail(disconnect))),
113 );
114
115 expect(result.payload.environment.serverVersion).toBe("0.0.31");
116 expect(yield* Ref.get(retries)).toBe(1);
117 }),
118 );
119 it.effect("observes a fast reconnect even when the caller awaits it later", () =>
120 Effect.gen(function* () {
121 const states = yield* Queue.unbounded<{ readonly phase: string }>();
122 const reconnected = yield* waitForNextEnvironmentReconnect(Stream.fromQueue(states)).pipe(
123 Effect.forkChild,
124 );
125 yield* Queue.offerAll(states, [
126 { phase: "connected" },
127 { phase: "backoff" },
128 { phase: "connected" },
129 ]);
130
131 yield* Fiber.join(reconnected);
132 }),
133 );
134 it.effect("arms the retry observer before a commit can disconnect", () =>
135 Effect.gen(function* () {
136 const allowSubscription = yield* Deferred.make<void>();
137 const subscriptionStarted = yield* Deferred.make<void>();
138 const states = yield* Queue.unbounded<{ readonly phase: string }>();
139 const commits = yield* Ref.make(0);
140 const disconnect = new RpcClientError.RpcClientError({
141 reason: new Socket.SocketCloseError({ code: 1006 }),
142 });
143 const stateChanges = Stream.unwrap(
144 Deferred.succeed(subscriptionStarted, undefined).pipe(
145 Effect.andThen(Deferred.await(allowSubscription)),
146 Effect.as(Stream.fromQueue(states)),
147 ),
148 );
149 const retry = yield* runDesktopCommitWithReconnectObserver(
150 stateChanges,
151 Ref.update(commits, (count) => count + 1).pipe(
152 Effect.andThen(Queue.offerAll(states, [{ phase: "backoff" }, { phase: "connected" }])),
153 Effect.andThen(Effect.fail(disconnect)),
154 ),
155 ).pipe(Effect.flip, Effect.forkChild);
156
157 yield* Deferred.await(subscriptionStarted);
158 expect(yield* Ref.get(commits)).toBe(0);
159 yield* Deferred.succeed(allowSubscription, undefined);
160 yield* Queue.offer(states, { phase: "connected" });
161
162 expect(yield* Fiber.join(retry)).toBe(disconnect);
163 expect(yield* Ref.get(commits)).toBe(1);
164 }),
165 );
84166 it.effect("retries once per backoff entry instead of only the first", () =>
85167 Effect.gen(function* () {
86168 const retries = yield* Ref.make(0);
......@@ -292,6 +374,36 @@ describe("server state projection", () => {
292374 }),
293375 );
294376
377 it("requires tokenless desktop updates to reach the target version", () => {
378 const ready = (serverVersion: string) =>
379 ({
380 version: 1 as const,
381 sequence: 1,
382 type: "ready" as const,
383 payload: {
384 at: "2026-09-01T00:00:00.000Z",
385 environment: { serverVersion },
386 },
387 }) as Parameters<typeof matchesServerUpdateResumeEvent>[1];
388
389 expect(
390 matchesServerUpdateResumeEvent(
391 { targetVersion: "0.0.31", method: "desktop-app" },
392 ready("0.0.30"),
393 ),
394 ).toBe(false);
395 expect(
396 matchesServerUpdateResumeEvent(
397 {
398 targetVersion: "0.0.31",
399 method: "desktop-app",
400 desktopUpdateToken: "update-1",
401 },
402 ready("0.0.30"),
403 ),
404 ).toBe(true);
405 });
406
295407 it("applies every config category to the projected snapshot", () => {
296408 const snapshot = applyServerConfigProjection(Option.none(), {
297409 version: 1,
packages/client-runtime/src/state/server.ts+156-10
......@@ -10,8 +10,10 @@ import {
1010} from "@t3tools/contracts";
1111import * as Cause from "effect/Cause";
1212import * as Duration from "effect/Duration";
13import * as Deferred from "effect/Deferred";
1314import * as Effect from "effect/Effect";
1415import * as Exit from "effect/Exit";
16import * as Fiber from "effect/Fiber";
1517import * as Option from "effect/Option";
1618import * as Queue from "effect/Queue";
1719import * as Ref from "effect/Ref";
......@@ -132,6 +134,16 @@ export function matchesServerUpdateReadyEvent(
132134 : event.payload.updateOutcome?.id === result.updateId;
133135}
134136
137export function matchesServerUpdateResumeEvent(
138 result: ServerSelfUpdateResult,
139 event: ServerLifecycleStreamReadyEvent,
140): boolean {
141 return (
142 (result.method === "desktop-app" && result.desktopUpdateToken !== undefined) ||
143 matchesServerUpdateReadyEvent(result, event)
144 );
145}
146
135147export function validateServerUpdateReadyEvent(
136148 result: ServerSelfUpdateResult,
137149 event: ServerLifecycleStreamReadyEvent,
......@@ -200,6 +212,71 @@ export function nudgeReconnectDuringUpdateRestart(input: {
200212 );
201213}
202214
215export function waitForNextEnvironmentReconnect<E>(
216 stateChanges: Stream.Stream<{ readonly phase: string }, E>,
217): Effect.Effect<void, E> {
218 return stateChanges.pipe(
219 Stream.dropWhile((state) => state.phase === "connected"),
220 Stream.filter((state) => state.phase === "connected"),
221 Stream.runHead,
222 Effect.asVoid,
223 );
224}
225
226export const runDesktopCommitWithReconnectObserver = Effect.fn(
227 "runDesktopCommitWithReconnectObserver",
228)(function* <EState, ECommit>(
229 stateChanges: Stream.Stream<{ readonly phase: string }, EState>,
230 commit: Effect.Effect<void, ECommit>,
231) {
232 const armed = yield* Deferred.make<void>();
233 const reconnected = yield* Deferred.make<void>();
234 const observer = yield* stateChanges.pipe(
235 Stream.tap(() => Deferred.succeed(armed, undefined)),
236 waitForNextEnvironmentReconnect,
237 Effect.andThen(Deferred.succeed(reconnected, undefined)),
238 Effect.forkChild,
239 );
240 yield* Deferred.await(armed);
241 const commitExit = yield* commit.pipe(Effect.exit);
242 if (Exit.isSuccess(commitExit)) {
243 yield* Fiber.interrupt(observer);
244 return;
245 }
246 if (!isLegacyUpdateHandoffLoss(commitExit.cause)) {
247 yield* Fiber.interrupt(observer);
248 return yield* Effect.failCause(commitExit.cause);
249 }
250 yield* Deferred.await(reconnected).pipe(Effect.timeout(SERVER_UPDATE_RESUME_TIMEOUT));
251 return yield* Effect.failCause(commitExit.cause);
252});
253
254export const waitForDesktopUpdateTarget = Effect.fn("waitForDesktopUpdateTarget")(function* <
255 EReady,
256 ECommit,
257>(
258 targetVersion: string,
259 nextReady: Effect.Effect<ServerLifecycleStreamReadyEvent, EReady>,
260 retryCommit: Effect.Effect<void, ECommit>,
261 maxCommitAttempts = 3,
262): Effect.fn.Return<ServerLifecycleStreamReadyEvent, EReady | ECommit | ServerUpdateTerminalError> {
263 for (let attempt = 1; attempt <= maxCommitAttempts; attempt += 1) {
264 const ready = yield* nextReady;
265 if (ready.payload.environment.serverVersion === targetVersion) return ready;
266 if (attempt === maxCommitAttempts) break;
267 const retryExit = yield* retryCommit.pipe(Effect.exit);
268 if (Exit.isSuccess(retryExit)) break;
269 if (!isLegacyUpdateHandoffLoss(retryExit.cause)) {
270 return yield* Effect.failCause(retryExit.cause);
271 }
272 }
273 return yield* new ServerUpdateTerminalError({
274 targetVersion,
275 status: "failed",
276 reason: "The desktop app resumed without installing the prepared update.",
277 });
278});
279
203280export function serverUpdateStateForProgressEvent(
204281 fromVersion: string,
205282 targetVersion: string,
......@@ -497,11 +574,12 @@ export function createServerEnvironmentAtoms<R, E>(
497574 concurrency: configConcurrency,
498575 execute: (target, atomRegistry) => {
499576 const stateAtom = serverUpdateStateAtom(target.environmentId);
500 const targetVersion = target.input.targetVersion;
577 let targetVersion = target.input.targetVersion;
501578 let fromVersion =
502579 atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ??
503580 targetVersion;
504581 let currentStage: ServerUpdateStage = "downloading";
582 let desktopCommitLostTransport = false;
505583 atomRegistry.set(stateAtom, {
506584 status: "running",
507585 stage: currentStage,
......@@ -511,6 +589,21 @@ export function createServerEnvironmentAtoms<R, E>(
511589
512590 return Effect.gen(function* () {
513591 const environmentRegistry = yield* EnvironmentRegistry;
592 const desktopCommitStarting = yield* Deferred.make<void>();
593 const desktopReconnectObserverArmed = yield* Deferred.make<void>();
594 const desktopReconnected = yield* Deferred.make<void>();
595 yield* Deferred.await(desktopCommitStarting).pipe(
596 Effect.andThen(
597 environmentRegistry.stateChanges(target.environmentId).pipe(
598 Stream.tap(() => Deferred.succeed(desktopReconnectObserverArmed, undefined)),
599 Stream.dropWhile((state) => state.phase === "connected"),
600 Stream.filter((state) => state.phase === "connected"),
601 Stream.runHead,
602 ),
603 ),
604 Effect.andThen(Deferred.succeed(desktopReconnected, undefined)),
605 Effect.forkChild,
606 );
514607 const result = yield* scheduleAtomCommandEffect(
515608 atomRegistry,
516609 configScheduler,
......@@ -582,6 +675,28 @@ export function createServerEnvironmentAtoms<R, E>(
582675 return yield* Effect.failCause(exit.cause);
583676 });
584677
678 if (
679 updateResult.method === "desktop-app" &&
680 updateResult.desktopUpdateToken !== undefined
681 ) {
682 yield* Deferred.succeed(desktopCommitStarting, undefined);
683 yield* Deferred.await(desktopReconnectObserverArmed);
684 const commitExit = yield* environmentRegistry
685 .run(
686 target.environmentId,
687 request(WS_METHODS.serverCommitDesktopUpdate, {
688 requestId: updateResult.desktopUpdateToken,
689 }),
690 )
691 .pipe(Effect.exit);
692 if (Exit.isFailure(commitExit) && !isLegacyUpdateHandoffLoss(commitExit.cause)) {
693 return yield* Effect.failCause(commitExit.cause);
694 }
695 desktopCommitLostTransport = Exit.isFailure(commitExit);
696 }
697
698 targetVersion = updateResult.targetVersion;
699
585700 currentStage = "resuming";
586701 atomRegistry.set(stateAtom, {
587702 status: "running",
......@@ -601,24 +716,55 @@ export function createServerEnvironmentAtoms<R, E>(
601716 retryNow: environmentRegistry.retryNow(target.environmentId),
602717 }).pipe(Effect.forkChild);
603718
604 const resumed = yield* environmentRegistry
719 if (result.method === "desktop-app" && desktopCommitLostTransport) {
720 yield* Deferred.await(desktopReconnected).pipe(
721 Effect.timeout(SERVER_UPDATE_RESUME_TIMEOUT),
722 );
723 }
724
725 const waitForReady = environmentRegistry
605726 .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {}))
606727 .pipe(
607728 Stream.filter(
608729 (event): event is ServerLifecycleStreamReadyEvent =>
609 event.type === "ready" && matchesServerUpdateReadyEvent(result, event),
730 event.type === "ready" && matchesServerUpdateResumeEvent(result, event),
610731 ),
611732 Stream.runHead,
612733 Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT),
613734 Effect.map(Option.flatten),
614735 );
615 if (Option.isNone(resumed)) {
616 return yield* new ServerUpdateResumeTimeoutError({
617 environmentId: target.environmentId,
618 targetVersion,
619 });
620 }
621 yield* validateServerUpdateReadyEvent(result, resumed.value);
736 const nextReady = waitForReady.pipe(
737 Effect.flatMap(
738 Option.match({
739 onNone: () =>
740 new ServerUpdateResumeTimeoutError({
741 environmentId: target.environmentId,
742 targetVersion,
743 }),
744 onSome: Effect.succeed,
745 }),
746 ),
747 );
748 const desktopUpdateToken = result.desktopUpdateToken;
749 const resumed =
750 result.method === "desktop-app" && desktopUpdateToken !== undefined
751 ? yield* waitForDesktopUpdateTarget(
752 result.targetVersion,
753 nextReady,
754 runDesktopCommitWithReconnectObserver(
755 environmentRegistry.stateChanges(target.environmentId),
756 environmentRegistry
757 .run(
758 target.environmentId,
759 request(WS_METHODS.serverCommitDesktopUpdate, {
760 requestId: desktopUpdateToken,
761 }),
762 )
763 .pipe(Effect.asVoid),
764 ),
765 )
766 : yield* nextReady;
767 yield* validateServerUpdateReadyEvent(result, resumed);
622768
623769 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
624770 return result;
packages/contracts/src/environment.ts+9-2
......@@ -30,8 +30,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
3030
3131/** How a server can replace itself with another version when asked over RPC.
3232 New servers only advertise the stable launcher-backed "boot-service" path;
33 "respawn" remains decodable for compatibility with older servers. */
34export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);
33 "respawn" remains decodable for compatibility with older servers.
34 "desktop-app" means the supervising desktop app updated and relaunched
35 itself, bringing the server back with it. */
36export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]);
3537export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
3638
3739/** What update path a client should offer for a server: one of the RPC
......@@ -97,6 +99,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
9799 this is false — no update would ever repaint it. Absent on older
98100 servers, which may still publish, so only an explicit false skips. */
99101 agentActivityPublishing: Schema.optionalKey(Schema.Boolean),
102 /** The desktop app supervising this server can be driven over RPC:
103 server.updateServer runs its check -> download -> relaunch. Absent on
104 desktop servers whose app predates the remote trigger, where clients
105 must keep telling the user to update the app on that machine. */
106 desktopAppUpdate: Schema.optionalKey(Schema.Boolean),
100107});
101108export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
102109
packages/contracts/src/resourceTelemetry.ts+58
......@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22
33import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
44import { HostPowerSnapshot } from "./background.ts";
5import { DesktopUpdateStateSchema } from "./ipc.ts";
56
67export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;
78
......@@ -244,9 +245,37 @@ export const DesktopHostTelemetryHello = Schema.Struct({
244245});
245246export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type;
246247
248/** Terminal marker for a server-triggered desktop update run. */
249export const DesktopUpdateRemoteOutcome = Schema.Literals([
250 "ready-to-install",
251 "up-to-date",
252 "failed",
253]);
254export type DesktopUpdateRemoteOutcome = typeof DesktopUpdateRemoteOutcome.Type;
255
256/**
257 * Desktop main -> server: the desktop app's update state. Sent once when the
258 * backend attaches and again on every state change, so the server always
259 * knows whether the app on its machine can be updated and how a
260 * server-triggered run is progressing.
261 */
262export const DesktopUpdateStatusReport = Schema.Struct({
263 version: Schema.Literal(1),
264 type: Schema.Literal("desktopUpdateStatus"),
265 // Set while a server-triggered run owns the flow; absent for the attach
266 // snapshot and for locally driven update activity.
267 requestId: Schema.optionalKey(TrimmedNonEmptyString),
268 // Terminal marker for a server-triggered run; absent while it is working.
269 outcome: Schema.optionalKey(DesktopUpdateRemoteOutcome),
270 reason: Schema.optionalKey(TrimmedNonEmptyString),
271 state: DesktopUpdateStateSchema,
272});
273export type DesktopUpdateStatusReport = typeof DesktopUpdateStatusReport.Type;
274
247275export const DesktopHostTelemetryMessage = Schema.Union([
248276 DesktopHostTelemetryHello,
249277 DesktopHostTelemetrySnapshot,
278 DesktopUpdateStatusReport,
250279]);
251280export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type;
252281
......@@ -266,9 +295,38 @@ export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({
266295export type DesktopTelemetrySetHostPowerIntervals =
267296 typeof DesktopTelemetrySetHostPowerIntervals.Type;
268297
298/**
299 * Server -> desktop main: run the app's own update flow now (check ->
300 * download -> quit-and-install) with no local confirmation. The remote click
301 * on the machine that sent the RPC is the consent.
302 */
303export const DesktopTelemetryRequestDesktopUpdate = Schema.Struct({
304 version: Schema.Literal(1),
305 type: Schema.Literal("requestDesktopUpdate"),
306 requestId: TrimmedNonEmptyString,
307});
308export type DesktopTelemetryRequestDesktopUpdate = typeof DesktopTelemetryRequestDesktopUpdate.Type;
309
310export const DesktopTelemetryCommitDesktopUpdate = Schema.Struct({
311 version: Schema.Literal(1),
312 type: Schema.Literal("commitDesktopUpdate"),
313 requestId: TrimmedNonEmptyString,
314});
315export type DesktopTelemetryCommitDesktopUpdate = typeof DesktopTelemetryCommitDesktopUpdate.Type;
316
317export const DesktopTelemetryCancelDesktopUpdate = Schema.Struct({
318 version: Schema.Literal(1),
319 type: Schema.Literal("cancelDesktopUpdate"),
320 requestId: TrimmedNonEmptyString,
321});
322export type DesktopTelemetryCancelDesktopUpdate = typeof DesktopTelemetryCancelDesktopUpdate.Type;
323
269324export const DesktopTelemetryControlMessage = Schema.Union([
270325 DesktopTelemetrySetDiagnosticsDemand,
271326 DesktopTelemetrySetHostPowerIntervals,
327 DesktopTelemetryRequestDesktopUpdate,
328 DesktopTelemetryCommitDesktopUpdate,
329 DesktopTelemetryCancelDesktopUpdate,
272330]);
273331export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type;
274332
packages/contracts/src/rpc.ts+9
......@@ -166,6 +166,7 @@ import {
166166} from "./previewAutomation.ts";
167167import {
168168 ServerConfigStreamEvent,
169 DesktopUpdateCommitInput,
169170 ServerConfig,
170171 ServerProviderUpdateError,
171172 ServerProviderUpdateInput,
......@@ -276,6 +277,7 @@ export const WS_METHODS = {
276277 serverUpdateProvider: "server.updateProvider",
277278 serverUpdateServer: "server.updateServer",
278279 serverUpdateServerWithProgress: "server.updateServerWithProgress",
280 serverCommitDesktopUpdate: "server.commitDesktopUpdate",
279281 serverUpsertKeybinding: "server.upsertKeybinding",
280282 serverRemoveKeybinding: "server.removeKeybinding",
281283 serverGetSettings: "server.getSettings",
......@@ -393,6 +395,12 @@ export const WsServerUpdateServerWithProgressRpc = Rpc.make(
393395 },
394396);
395397
398export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, {
399 payload: DesktopUpdateCommitInput,
400 success: ServerSelfUpdateResult,
401 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
402});
403
396404export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
397405 payload: Schema.Struct({}),
398406 success: ServerSettings,
......@@ -1033,6 +1041,7 @@ export const WsRpcGroup = RpcGroup.make(
10331041 WsServerUpdateProviderRpc,
10341042 WsServerUpdateServerRpc,
10351043 WsServerUpdateServerWithProgressRpc,
1044 WsServerCommitDesktopUpdateRpc,
10361045 WsServerUpsertKeybindingRpc,
10371046 WsServerRemoveKeybindingRpc,
10381047 WsServerGetSettingsRpc,
packages/contracts/src/server.ts+7
......@@ -755,9 +755,16 @@ export const ServerSelfUpdateResult = Schema.Struct({
755755 method: ServerSelfUpdateMethod,
756756 /** Launcher-generated correlation ID. Absent when talking to older servers. */
757757 updateId: Schema.optionalKey(TrimmedNonEmptyString),
758 /** Desktop preparation token. Present only for the desktop-app method. */
759 desktopUpdateToken: Schema.optionalKey(TrimmedNonEmptyString),
758760});
759761export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;
760762
763export const DesktopUpdateCommitInput = Schema.Struct({
764 requestId: TrimmedNonEmptyString,
765});
766export type DesktopUpdateCommitInput = typeof DesktopUpdateCommitInput.Type;
767
761768export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]);
762769export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type;
763770