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";...@@ -27,6 +27,7 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
27import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";27import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
28import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";28import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
29import * as DesktopState from "./DesktopState.ts";29import * as DesktopState from "./DesktopState.ts";
30import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
30import * as DesktopUpdates from "../updates/DesktopUpdates.ts";31import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
31import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";32import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
3233
...@@ -292,6 +293,7 @@ const startup = Effect.gen(function* () {...@@ -292,6 +293,7 @@ const startup = Effect.gen(function* () {
292 yield* appIdentity.configure;293 yield* appIdentity.configure;
293 yield* applicationMenu.configure;294 yield* applicationMenu.configure;
294 yield* updates.configure;295 yield* updates.configure;
296 yield* DesktopRemoteUpdates.listen;
295 yield* linuxUrlHandler.register;297 yield* linuxUrlHandler.register;
296 yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));298 yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
297}).pipe(Effect.withSpan("desktop.startup"));299}).pipe(Effect.withSpan("desktop.startup"));
apps/desktop/src/app/DesktopLifecycle.test.ts+10-1
...@@ -102,6 +102,7 @@ describe("DesktopLifecycle", () => {...@@ -102,6 +102,7 @@ describe("DesktopLifecycle", () => {
102 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {102 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
103 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {103 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
104 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();104 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
105 let windowsDestroyed = false;
105 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {106 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
106 platform,107 platform,
107 isDevelopment: false,108 isDevelopment: false,
...@@ -110,7 +111,13 @@ describe("DesktopLifecycle", () => {...@@ -110,7 +111,13 @@ describe("DesktopLifecycle", () => {
110 const layer = DesktopLifecycle.layer.pipe(111 const layer = DesktopLifecycle.layer.pipe(
111 Layer.provideMerge(makeElectronAppLayer(appListeners)),112 Layer.provideMerge(makeElectronAppLayer(appListeners)),
112 Layer.provideMerge(electronThemeLayer),113 Layer.provideMerge(electronThemeLayer),
113 Layer.provideMerge(makeElectronWindowLayer()),114 Layer.provideMerge(
115 makeElectronWindowLayer(
116 Effect.sync(() => {
117 windowsDestroyed = true;
118 }),
119 ),
120 ),
114 Layer.provideMerge(makeDesktopWindowLayer()),121 Layer.provideMerge(makeDesktopWindowLayer()),
115 Layer.provideMerge(environmentLayer),122 Layer.provideMerge(environmentLayer),
116 Layer.provideMerge(DesktopShutdown.layer),123 Layer.provideMerge(DesktopShutdown.layer),
...@@ -123,6 +130,7 @@ describe("DesktopLifecycle", () => {...@@ -123,6 +130,7 @@ describe("DesktopLifecycle", () => {
123 yield* lifecycle.register;130 yield* lifecycle.register;
124131
125 appListeners.get("before-quit-for-update")?.();132 appListeners.get("before-quit-for-update")?.();
133 yield* Effect.yieldNow;
126134
127 let prevented = false;135 let prevented = false;
128 const event = {136 const event = {
...@@ -136,6 +144,7 @@ describe("DesktopLifecycle", () => {...@@ -136,6 +144,7 @@ describe("DesktopLifecycle", () => {
136 prevented,144 prevented,
137 "cancelling this event prevents the updater from completing its relaunch",145 "cancelling this event prevents the updater from completing its relaunch",
138 );146 );
147 assert.isTrue(windowsDestroyed);
139148
140 const state = yield* DesktopState.DesktopState;149 const state = yield* DesktopState.DesktopState;
141 assert.isTrue(yield* Ref.get(state.quitting));150 assert.isTrue(yield* Ref.get(state.quitting));
apps/desktop/src/app/DesktopLifecycle.ts+11-2
...@@ -189,6 +189,7 @@ export const make = DesktopLifecycle.of({...@@ -189,6 +189,7 @@ export const make = DesktopLifecycle.of({
189 }),189 }),
190 register: Effect.gen(function* () {190 register: Effect.gen(function* () {
191 const desktopWindow = yield* DesktopWindow.DesktopWindow;191 const desktopWindow = yield* DesktopWindow.DesktopWindow;
192 const electronWindow = yield* ElectronWindow.ElectronWindow;
192 const electronApp = yield* ElectronApp.ElectronApp;193 const electronApp = yield* ElectronApp.ElectronApp;
193 const electronTheme = yield* ElectronTheme.ElectronTheme;194 const electronTheme = yield* ElectronTheme.ElectronTheme;
194 const environment = yield* DesktopEnvironment.DesktopEnvironment;195 const environment = yield* DesktopEnvironment.DesktopEnvironment;
...@@ -206,8 +207,16 @@ export const make = DesktopLifecycle.of({...@@ -206,8 +207,16 @@ export const make = DesktopLifecycle.of({
206 // Cancelling the following app "before-quit" event breaks that sequence,207 // Cancelling the following app "before-quit" event breaks that sequence,
207 // most visibly on macOS where the native updater performs the relaunch.208 // most visibly on macOS where the native updater performs the relaunch.
208 updaterQuitAllowed = true;209 updaterQuitAllowed = true;
209 void runEffect(210 // This event is synchronous and the updater's quit proceeds as soon as
210 logLifecycleInfo("allowing updater-controlled quit").pipe(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 ),
211 Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),220 Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
212 ),221 ),
213 );222 );
apps/desktop/src/backend/DesktopBackendManager.test.ts+4
...@@ -167,6 +167,10 @@ function makeTestInstance(input: MakeInstanceInput) {...@@ -167,6 +167,10 @@ function makeTestInstance(input: MakeInstanceInput) {
167 handleControlForSource: (_sourceId, message) =>167 handleControlForSource: (_sourceId, message) =>
168 (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),168 (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),
169 removeControlSource: () => Effect.void,169 removeControlSource: () => Effect.void,
170 publishUpdateReport: () => Effect.void,
171 updateRequests: Stream.empty,
172 updateCommits: Stream.empty,
173 updateCancellations: Stream.empty,
170 ...input.desktopTelemetryPublisher,174 ...input.desktopTelemetryPublisher,
171 }),175 }),
172 DesktopWslEnvironment.layerTest(176 DesktopWslEnvironment.layerTest(
apps/desktop/src/backend/DesktopBackendPool.test.ts+4
...@@ -73,6 +73,10 @@ function makePoolLayer(...@@ -73,6 +73,10 @@ function makePoolLayer(
73 handleControl: () => Effect.void,73 handleControl: () => Effect.void,
74 handleControlForSource: () => Effect.void,74 handleControlForSource: () => Effect.void,
75 removeControlSource: () => Effect.void,75 removeControlSource: () => Effect.void,
76 publishUpdateReport: () => Effect.void,
77 updateRequests: Stream.empty,
78 updateCommits: Stream.empty,
79 updateCancellations: Stream.empty,
76 }),80 }),
77 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {81 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
78 resolvePrimary: Effect.die("unexpected primary config resolve"),82 resolvePrimary: Effect.die("unexpected primary config resolve"),
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+82-2
...@@ -157,8 +157,11 @@ describe("DesktopTelemetryPublisher", () => {...@@ -157,8 +157,11 @@ describe("DesktopTelemetryPublisher", () => {
157 decodeMessage(decoder.decode(bytes).trim()),157 decodeMessage(decoder.decode(bytes).trim()),
158 );158 );
159159
160 assert.equal(messages[0]?.type, "desktopTelemetryHello");160 const hello = messages[0];
161 assert.equal(messages[0]?.electronPid, process.pid);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);
162 const initialSnapshot = messages[1];165 const initialSnapshot = messages[1];
163 if (initialSnapshot?.type !== "desktopTelemetry") {166 if (initialSnapshot?.type !== "desktopTelemetry") {
164 return assert.fail("Expected the second telemetry message to be a snapshot.");167 return assert.fail("Expected the second telemetry message to be a snapshot.");
...@@ -386,4 +389,81 @@ describe("DesktopTelemetryPublisher", () => {...@@ -386,4 +389,81 @@ describe("DesktopTelemetryPublisher", () => {
386 }).pipe(Effect.provide(layer));389 }).pipe(Effect.provide(layer));
387 }),390 }),
388 );391 );
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 );
389});469});
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts+49-1
...@@ -2,6 +2,10 @@ import {...@@ -2,6 +2,10 @@ import {
2 DesktopHostTelemetryMessage,2 DesktopHostTelemetryMessage,
3 type DesktopHostTelemetrySnapshot,3 type DesktopHostTelemetrySnapshot,
4 type DesktopTelemetryControlMessage,4 type DesktopTelemetryControlMessage,
5 type DesktopTelemetryCancelDesktopUpdate,
6 type DesktopTelemetryCommitDesktopUpdate,
7 type DesktopTelemetryRequestDesktopUpdate,
8 type DesktopUpdateStatusReport,
5 type HostPowerSnapshot,9 type HostPowerSnapshot,
6} from "@t3tools/contracts";10} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
...@@ -65,6 +69,14 @@ export class DesktopTelemetryPublisher extends Context.Service<...@@ -65,6 +69,14 @@ export class DesktopTelemetryPublisher extends Context.Service<
65 message: DesktopTelemetryControlMessage,69 message: DesktopTelemetryControlMessage,
66 ) => Effect.Effect<void>;70 ) => Effect.Effect<void>;
67 readonly removeControlSource: (sourceId: string) => Effect.Effect<void>;71 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>;
68 }80 }
69>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {}81>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {}
7082
...@@ -160,6 +172,11 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -160,6 +172,11 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
160 const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());172 const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());
161 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);173 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
162 const sequence = yield* Ref.make(0);174 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
164 const offer = (event: PowerEvent): void => {181 const offer = (event: PowerEvent): void => {
165 Queue.offerUnsafe(powerEvents, event);182 Queue.offerUnsafe(powerEvents, event);
...@@ -324,6 +341,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -324,6 +341,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
324 active: Duration.millis(message.activeIntervalMs),341 active: Duration.millis(message.activeIntervalMs),
325 idle: Duration.millis(message.idleIntervalMs),342 idle: Duration.millis(message.idleIntervalMs),
326 }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid);343 }).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);
327 }350 }
328 };351 };
329 const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = (352 const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = (
...@@ -357,15 +380,36 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -357,15 +380,36 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
357 );380 );
358 }),381 }),
359 );382 );
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 );
360 const encoded = Stream.concat(396 const encoded = Stream.concat(
361 Stream.make({397 Stream.make({
362 version: 1,398 version: 1,
363 type: "desktopTelemetryHello",399 type: "desktopTelemetryHello",
364 electronPid: process.pid,400 electronPid: process.pid,
365 } as const),401 } as const),
366 snapshots,402 Stream.merge(snapshots, updateReports),
367 ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`)));403 ).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
369 return DesktopTelemetryPublisher.of({413 return DesktopTelemetryPublisher.of({
370 latest: Ref.get(latest),414 latest: Ref.get(latest),
371 changes: Stream.fromPubSub(changes),415 changes: Stream.fromPubSub(changes),
...@@ -373,6 +417,10 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -373,6 +417,10 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
373 handleControl,417 handleControl,
374 handleControlForSource,418 handleControlForSource,
375 removeControlSource,419 removeControlSource,
420 publishUpdateReport,
421 updateRequests: Stream.fromQueue(updateRequestQueue),
422 updateCommits: Stream.fromQueue(updateCommitQueue),
423 updateCancellations: Stream.fromQueue(updateCancellationQueue),
376 });424 });
377});425});
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 @@...@@ -1,6 +1,4 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
5import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
6import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
...@@ -11,221 +9,14 @@ import * as Logger from "effect/Logger";...@@ -11,221 +9,14 @@ import * as Logger from "effect/Logger";
11import * as Option from "effect/Option";9import * as Option from "effect/Option";
12import * as References from "effect/References";10import * as References from "effect/References";
13import * as Ref from "effect/Ref";11import * as Ref from "effect/Ref";
12import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";13import * 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";
19import * as ElectronUpdater from "../electron/ElectronUpdater.ts";15import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
20import * as ElectronWindow from "../electron/ElectronWindow.ts";
21import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";16import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
22import * as DesktopState from "../app/DesktopState.ts";17import * as DesktopState from "../app/DesktopState.ts";
23import * as DesktopUpdates from "./DesktopUpdates.ts";18import * as DesktopUpdates from "./DesktopUpdates.ts";
2419import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";
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}
22920
230describe("DesktopUpdates", () => {21describe("DesktopUpdates", () => {
231 it("preserves complete causes for update poller and event failures", () => {22 it("preserves complete causes for update poller and event failures", () => {
...@@ -294,6 +85,29 @@ describe("DesktopUpdates", () => {...@@ -294,6 +85,29 @@ describe("DesktopUpdates", () => {
294 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));85 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
295 });86 });
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
297 it.effect("updates and broadcasts state from updater events", () => {111 it.effect("updates and broadcasts state from updater events", () => {
298 const harness = makeHarness();112 const harness = makeHarness();
299113
...@@ -745,6 +559,120 @@ describe("DesktopUpdates", () => {...@@ -745,6 +559,120 @@ describe("DesktopUpdates", () => {
745 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));559 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
746 });560 });
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
748 it.effect("persists channel changes through the settings service", () => {676 it.effect("persists channel changes through the settings service", () => {
749 const harness = makeHarness();677 const harness = makeHarness();
750678
apps/desktop/src/updates/DesktopUpdates.ts+213-99
...@@ -11,12 +11,16 @@ import * as Context from "effect/Context";...@@ -11,12 +11,16 @@ import * as Context from "effect/Context";
11import * as DateTime from "effect/DateTime";11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
14import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";17import * as Option from "effect/Option";
18import * as PubSub from "effect/PubSub";
17import * as Ref from "effect/Ref";19import * as Ref from "effect/Ref";
18import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";21import * as Scope from "effect/Scope";
22import * as Semaphore from "effect/Semaphore";
23import * as Stream from "effect/Stream";
2024
21import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";25import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
22import * as DesktopConfig from "../app/DesktopConfig.ts";26import * as DesktopConfig from "../app/DesktopConfig.ts";
...@@ -44,8 +48,13 @@ import {...@@ -44,8 +48,13 @@ import {
4448
45const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";49const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
46const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";50const 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
50const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);59const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
51type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;60type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
...@@ -152,6 +161,20 @@ export class DesktopUpdates extends Context.Service<...@@ -152,6 +161,20 @@ export class DesktopUpdates extends Context.Service<
152 DesktopUpdates,161 DesktopUpdates,
153 {162 {
154 readonly getState: Effect.Effect<DesktopUpdateState>;163 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 >;
155 readonly emitState: Effect.Effect<void>;178 readonly emitState: Effect.Effect<void>;
156 readonly disabledReason: Effect.Effect<Option.Option<string>>;179 readonly disabledReason: Effect.Effect<Option.Option<string>>;
157 readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;180 readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;
...@@ -161,6 +184,9 @@ export class DesktopUpdates extends Context.Service<...@@ -161,6 +184,9 @@ export class DesktopUpdates extends Context.Service<
161 readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;184 readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;
162 readonly download: Effect.Effect<DesktopUpdateActionResult>;185 readonly download: Effect.Effect<DesktopUpdateActionResult>;
163 readonly install: Effect.Effect<DesktopUpdateActionResult>;186 readonly install: Effect.Effect<DesktopUpdateActionResult>;
187 readonly installPrepared: (
188 expectedVersion: string,
189 ) => Effect.Effect<DesktopPreparedUpdateInstallResult>;
164 }190 }
165>()("@t3tools/desktop/updates/DesktopUpdates") {}191>()("@t3tools/desktop/updates/DesktopUpdates") {}
166192
...@@ -258,6 +284,7 @@ export const make = Effect.gen(function* () {...@@ -258,6 +284,7 @@ export const make = Effect.gen(function* () {
258284
259 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());285 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
260 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());286 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
287 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();
261 const updaterConfiguredRef = yield* Ref.make(false);288 const updaterConfiguredRef = yield* Ref.make(false);
262 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);289 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
263 const updateStateRef = yield* Ref.make<DesktopUpdateState>(290 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
...@@ -268,12 +295,21 @@ export const make = Effect.gen(function* () {...@@ -268,12 +295,21 @@ export const make = Effect.gen(function* () {
268 ),295 ),
269 );296 );
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
271 const emitState = Ref.get(updateStateRef).pipe(303 const emitState = Ref.get(updateStateRef).pipe(
272 Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),304 Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),
273 );305 );
274306
275 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>307 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
278 const updateState = (314 const updateState = (
279 f: (state: DesktopUpdateState) => DesktopUpdateState,315 f: (state: DesktopUpdateState) => DesktopUpdateState,
...@@ -327,8 +363,13 @@ export const make = Effect.gen(function* () {...@@ -327,8 +363,13 @@ export const make = Effect.gen(function* () {
327 );363 );
328364
329 const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>365 const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>
330 Ref.update(activeUpdateActionRef, (activeAction) =>366 Ref.modify(activeUpdateActionRef, (activeAction) => {
331 Option.isSome(activeAction) && activeAction.value === action ? Option.none() : 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 ),
332 );373 );
333374
334 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (375 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
...@@ -396,7 +437,10 @@ export const make = Effect.gen(function* () {...@@ -396,7 +437,10 @@ export const make = Effect.gen(function* () {
396437
397 return yield* actionReservation === "held"438 return yield* actionReservation === "held"
398 ? check439 ? check
399 : check.pipe(Effect.ensuring(finishUpdateAction("check")));440 : check.pipe(
441 Effect.onInterrupt(() => setState(state)),
442 Effect.ensuring(finishUpdateAction("check")),
443 );
400 });444 });
401445
402 const downloadAvailableUpdate = Effect.gen(function* () {446 const downloadAvailableUpdate = Effect.gen(function* () {
...@@ -462,85 +506,156 @@ export const make = Effect.gen(function* () {...@@ -462,85 +506,156 @@ export const make = Effect.gen(function* () {
462 { discard: true },506 { discard: true },
463 );507 );
464508
465 const installDownloadedUpdate = Effect.gen(function* () {509 const recoverFailedInstall = Effect.fn("desktop.updates.recoverFailedInstall")(function* (
466 const state = yield* Ref.get(updateStateRef);510 message: string,
467 const hasInstallableDownload =511 ) {
468 state.downloadedVersion !== null &&512 const ownsRecovery = yield* Ref.modify(activeUpdateActionRef, (activeAction) =>
469 (state.status === "downloaded" ||513 Option.isSome(activeAction) && activeAction.value === "install"
470 (state.status === "error" &&514 ? ([true, Option.some<UpdateAction>("install-recovery")] as const)
471 (state.errorContext === null || state.errorContext === "install")));515 : ([false, activeAction] as const),
472 if (516 );
473 (yield* Ref.get(desktopState.quitting)) ||517 if (!ownsRecovery) return;
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);
485518
486 return yield* Effect.gen(function* () {519 yield* Ref.set(desktopState.quitting, false);
487 // Stop every backend in the pool, not just the primary. With520 yield* Effect.gen(function* () {
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.
494 const instances = yield* pool.list;521 const instances = yield* pool.list;
495 yield* Effect.forEach(522 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
496 instances,523 concurrency: "unbounded",
497 (instance) => instance.stop({ timeout: Duration.seconds(5) }),524 discard: true,
498 { concurrency: "unbounded" },525 }).pipe(Effect.exit);
499 );526 yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
500 yield* electronWindow.destroyAll;527 if (Exit.isFailure(restartExit)) {
501 yield* electronUpdater.quitAndInstall({528 yield* logUpdaterError("Desktop update install recovery could not restart every backend.");
502 isSilent: true,529 }
503 isForceRunAfter: true,
504 });
505 return { accepted: true, completed: false };
506 }).pipe(530 }).pipe(
507 Effect.catchTags({531 Effect.catchCause(() =>
508 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(532 logUpdaterError("Desktop update install recovery failed unexpectedly."),
509 function* (error) {533 ),
510 yield* resetInstallAction;534 Effect.ensuring(finishUpdateAction("install-recovery")),
511 yield* updateState((current) =>535 );
512 reduceDesktopUpdateStateOnInstallFailure(current, error.message),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),
513 );576 );
514 yield* logUpdaterError(error.message, {577 if (Option.isNone(finishedAction)) {
515 errorTag: error._tag,578 admission = "refused";
516 channel: error.channel,579 }
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);
529 }580 }
530 yield* resetInstallAction;581 }
531 const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause });582 if (admission === "refused") {
532 yield* updateState((current) =>583 return { accepted: false, completed: false, failed: false };
533 reduceDesktopUpdateStateOnInstallFailure(current, error.message),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" },
534 );601 );
535 yield* logUpdaterError(error.message, {602 yield* electronUpdater.quitAndInstall({
536 errorTag: error._tag,603 isSilent: true,
537 action: error.action,604 isForceRunAfter: true,
538 });605 });
539 return { accepted: true, completed: false };606 return { accepted: true, completed: false, failed: false };
540 }),607 }).pipe(
541 ),608 Effect.catchTags({
542 );609 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
543 }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));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
545 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {660 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
546 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(661 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
...@@ -640,15 +755,14 @@ export const make = Effect.gen(function* () {...@@ -640,15 +755,14 @@ export const make = Effect.gen(function* () {
640 ) {755 ) {
641 const activeAction = yield* activeUpdateAction;756 const activeAction = yield* activeUpdateAction;
642 const error = new DesktopUpdaterReportedError({757 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 }),
644 cause,762 cause,
645 });763 });
646 if (Option.isSome(activeAction) && activeAction.value === "install") {764 if (Option.isSome(activeAction) && activeAction.value === "install") {
647 yield* finishUpdateAction("install");765 yield* recoverFailedInstall(error.message);
648 yield* Ref.set(desktopState.quitting, false);
649 yield* updateState((current) =>
650 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
651 );
652 yield* logUpdaterError(error.message, {766 yield* logUpdaterError(error.message, {
653 errorTag: error._tag,767 errorTag: error._tag,
654 operation: error.operation,768 operation: error.operation,
...@@ -733,6 +847,17 @@ export const make = Effect.gen(function* () {...@@ -733,6 +847,17 @@ export const make = Effect.gen(function* () {
733847
734 return DesktopUpdates.of({848 return DesktopUpdates.of({
735 getState: Ref.get(updateStateRef),849 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 ),
736 emitState,861 emitState,
737 disabledReason: resolveDisabledReason,862 disabledReason: resolveDisabledReason,
738 configure: Effect.gen(function* () {863 configure: Effect.gen(function* () {
...@@ -804,7 +929,7 @@ export const make = Effect.gen(function* () {...@@ -804,7 +929,7 @@ export const make = Effect.gen(function* () {
804 const activeAction = yield* tryStartChannelChange;929 const activeAction = yield* tryStartChannelChange;
805 if (Option.isSome(activeAction)) {930 if (Option.isSome(activeAction)) {
806 return yield* new DesktopUpdateActionInProgressError({931 return yield* new DesktopUpdateActionInProgressError({
807 action: activeAction.value,932 action: activeAction.value === "install-recovery" ? "install" : activeAction.value,
808 requestedChannel: nextChannel,933 requestedChannel: nextChannel,
809 });934 });
810 }935 }
...@@ -861,21 +986,10 @@ export const make = Effect.gen(function* () {...@@ -861,21 +986,10 @@ export const make = Effect.gen(function* () {
861 state: yield* Ref.get(updateStateRef),986 state: yield* Ref.get(updateStateRef),
862 };987 };
863 }).pipe(Effect.withSpan("desktop.updates.download")),988 }).pipe(Effect.withSpan("desktop.updates.download")),
864 install: Effect.gen(function* () {989 install: installWithExpectedVersion().pipe(
865 if (yield* Ref.get(desktopState.quitting)) {990 Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })),
866 return {991 ),
867 accepted: false,992 installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion),
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")),
879 });993 });
880});994});
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, {...@@ -60,6 +60,9 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
6060
61const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {61const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
62 getState: Effect.die("unexpected getState"),62 getState: Effect.die("unexpected getState"),
63 isActionActive: Effect.succeed(false),
64 isInstallActive: Effect.succeed(false),
65 subscribe: Effect.die("unexpected subscribe"),
63 emitState: Effect.void,66 emitState: Effect.void,
64 disabledReason: Effect.succeed(Option.none()),67 disabledReason: Effect.succeed(Option.none()),
65 configure: Effect.void,68 configure: Effect.void,
...@@ -67,6 +70,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {...@@ -67,6 +70,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
67 check: () => Effect.die("unexpected check"),70 check: () => Effect.die("unexpected check"),
68 download: Effect.die("unexpected download"),71 download: Effect.die("unexpected download"),
69 install: Effect.die("unexpected install"),72 install: Effect.die("unexpected install"),
73 installPrepared: () => Effect.die("unexpected installPrepared"),
70} satisfies DesktopUpdates.DesktopUpdates["Service"]);74} satisfies DesktopUpdates.DesktopUpdates["Service"]);
7175
72const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>76const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -35,6 +35,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -35,6 +35,7 @@ export const RPC_REQUIRED_SCOPES = {
35 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,35 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
36 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,36 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
37 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,37 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
38 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,
38 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,39 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
39 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,40 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
40 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,41 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
apps/server/src/cloud/selfUpdate.test.ts+34
...@@ -9,6 +9,7 @@ import * as Path from "effect/Path";...@@ -9,6 +9,7 @@ import * as Path from "effect/Path";
9import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";9import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1010
11import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
12import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
12import * as ProcessRunner from "../processRunner.ts";13import * as ProcessRunner from "../processRunner.ts";
13import * as ServiceLauncherClient from "./serviceLauncherClient.ts";14import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
14import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";15import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
...@@ -19,6 +20,7 @@ interface HarnessOptions {...@@ -19,6 +20,7 @@ interface HarnessOptions {
19 readonly managed?: boolean;20 readonly managed?: boolean;
20 readonly preflight?: "ready" | "blocked";21 readonly preflight?: "ready" | "blocked";
21 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];22 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
23 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
22}24}
2325
24const makeHarness = Effect.fn("test.make_self_update_harness")(function* (26const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
...@@ -88,6 +90,13 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (...@@ -88,6 +90,13 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
88 const selfUpdate = yield* ServerSelfUpdate.make().pipe(90 const selfUpdate = yield* ServerSelfUpdate.make().pipe(
89 Effect.provideService(ProcessRunner.ProcessRunner, runner),91 Effect.provideService(ProcessRunner.ProcessRunner, runner),
90 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),92 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 ),
91 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),100 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),
92 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),101 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
93 );102 );
...@@ -121,6 +130,31 @@ it.layer(NodeServices.layer)("server self update", (it) => {...@@ -121,6 +130,31 @@ it.layer(NodeServices.layer)("server self update", (it) => {
121 }),130 }),
122 );131 );
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
124 it.effect("preserves the preflight refusal reason", () =>158 it.effect("preserves the preflight refusal reason", () =>
125 Effect.gen(function* () {159 Effect.gen(function* () {
126 const { selfUpdate } = yield* makeHarness({ preflight: "blocked" });160 const { selfUpdate } = yield* makeHarness({ preflight: "blocked" });
apps/server/src/cloud/selfUpdate.ts+15-1
...@@ -15,6 +15,7 @@ import * as Path from "effect/Path";...@@ -15,6 +15,7 @@ import * as Path from "effect/Path";
15import * as Ref from "effect/Ref";15import * as Ref from "effect/Ref";
1616
17import * as ServerConfig from "../config.ts";17import * as ServerConfig from "../config.ts";
18import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
18import * as ProcessRunner from "../processRunner.ts";19import * as ProcessRunner from "../processRunner.ts";
19import {20import {
20 ensurePinnedRuntimeInstalled,21 ensurePinnedRuntimeInstalled,
...@@ -42,11 +43,15 @@ export class ServerSelfUpdate extends Context.Service<...@@ -42,11 +43,15 @@ export class ServerSelfUpdate extends Context.Service<
42 input: ServerSelfUpdateInput,43 input: ServerSelfUpdateInput,
43 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,44 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
44 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;45 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
46 readonly commitDesktopUpdate: (
47 requestId: string,
48 ) => Effect.Effect<never, ServerSelfUpdateError>;
45 }49 }
46>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}50>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
4751
48export const make = Effect.fn("cloud.server_self_update.make")(function* () {52export const make = Effect.fn("cloud.server_self_update.make")(function* () {
49 const serverConfig = yield* ServerConfig.ServerConfig;53 const serverConfig = yield* ServerConfig.ServerConfig;
54 const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;
50 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;55 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
51 const runner = yield* ProcessRunner.ProcessRunner;56 const runner = yield* ProcessRunner.ProcessRunner;
52 const fs = yield* FileSystem.FileSystem;57 const fs = yield* FileSystem.FileSystem;
...@@ -65,6 +70,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {...@@ -65,6 +70,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
65 "cloud.server_self_update.update",70 "cloud.server_self_update.update",
66 )(function* (input, reportProgress = () => Effect.void) {71 )(function* (input, reportProgress = () => Effect.void) {
67 if (capability === "desktop-managed") {72 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 }
68 return yield* failWith(79 return yield* failWith(
69 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",80 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
70 );81 );
...@@ -191,7 +202,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {...@@ -191,7 +202,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
191 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));202 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));
192 });203 });
193204
194 return ServerSelfUpdate.of({ update });205 return ServerSelfUpdate.of({
206 update,
207 commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId),
208 });
195});209});
196210
197export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(211export 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) => {...@@ -218,6 +218,43 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
218 }),218 }),
219 );219 );
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
221 it.effect("structures persisted environment id filesystem failures", () =>258 it.effect("structures persisted environment id filesystem failures", () =>
222 Effect.gen(function* () {259 Effect.gen(function* () {
223 const fileSystem = yield* FileSystem.FileSystem;260 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/environment/ServerEnvironment.ts+10-1
...@@ -193,6 +193,12 @@ export const make = Effect.gen(function* () {...@@ -193,6 +193,12 @@ export const make = Effect.gen(function* () {
193 desktopManaged: serverConfig.mode === "desktop",193 desktopManaged: serverConfig.mode === "desktop",
194 launcherManaged: launcher.managed,194 launcherManaged: launcher.managed,
195 });195 });
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
197 const descriptor: ExecutionEnvironmentDescriptor = {203 const descriptor: ExecutionEnvironmentDescriptor = {
198 environmentId,204 environmentId,
...@@ -217,7 +223,10 @@ export const make = Effect.gen(function* () {...@@ -217,7 +223,10 @@ export const make = Effect.gen(function* () {
217 threadTitleRegeneration: true,223 threadTitleRegeneration: true,
218 threadPullRequestLinking: true,224 threadPullRequestLinking: true,
219 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),225 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
220 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),226 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
227 ? { serverSelfUpdateProgress: true }
228 : {}),
229 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
221 },230 },
222 };231 };
223232
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts+62
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue,7 type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue,
8 type DesktopHostTelemetrySnapshot,8 type DesktopHostTelemetrySnapshot,
9 DesktopTelemetryControlMessage,9 DesktopTelemetryControlMessage,
10 type DesktopUpdateStatusReport,
10 type ResourceTelemetrySourceStatus,11 type ResourceTelemetrySourceStatus,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
12import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";13import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
...@@ -171,6 +172,29 @@ export class DesktopTelemetryReceiver extends Context.Service<...@@ -171,6 +172,29 @@ export class DesktopTelemetryReceiver extends Context.Service<
171 readonly setDiagnosticsDemand: (172 readonly setDiagnosticsDemand: (
172 enabled: boolean,173 enabled: boolean,
173 ) => Effect.Effect<void, DesktopTelemetryControlError>;174 ) => 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 >;
174 }198 }
175>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {}199>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {}
176200
...@@ -322,6 +346,8 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")...@@ -322,6 +346,8 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")
322 );346 );
323 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);347 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
324 const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4);348 const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4);
349 const latestUpdateReport = yield* Ref.make(Option.none<DesktopUpdateStatusReport>());
350 const updateReportChanges = yield* PubSub.sliding<DesktopUpdateStatusReport>(16);
325 const controlMutex = yield* Semaphore.make(1);351 const controlMutex = yield* Semaphore.make(1);
326 const snapshotMutex = yield* Semaphore.make(1);352 const snapshotMutex = yield* Semaphore.make(1);
327 const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({353 const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({
...@@ -503,6 +529,15 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")...@@ -503,6 +529,15 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")
503 );529 );
504 }530 }
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
506 const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs);541 const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs);
507 return snapshotMutex.withPermits(1)(542 return snapshotMutex.withPermits(1)(
508 recordContact.pipe(543 recordContact.pipe(
...@@ -616,6 +651,24 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")...@@ -616,6 +651,24 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")
616 health: Ref.get(health),651 health: Ref.get(health),
617 subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)),652 subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)),
618 setDiagnosticsDemand,653 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 }),
619 });672 });
620});673});
621674
...@@ -656,6 +709,15 @@ export const layerTest = (...@@ -656,6 +709,15 @@ export const layerTest = (
656 })),709 })),
657 ),710 ),
658 setDiagnosticsDemand: () => Effect.void,711 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 }),
659 ...overrides,721 ...overrides,
660 }),722 }),
661 );723 );
apps/server/src/server.ts+8-1
...@@ -102,6 +102,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"...@@ -102,6 +102,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"
102import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";102import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
103import * as CloudCliState from "./cloud/CliState.ts";103import * as CloudCliState from "./cloud/CliState.ts";
104import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";104import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
105import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts";
105import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";106import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
106import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";107import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
107import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";108import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
...@@ -173,6 +174,12 @@ const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe(...@@ -173,6 +174,12 @@ const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe(
173 Layer.provide(DesktopTelemetryReceiverLayerLive),174 Layer.provide(DesktopTelemetryReceiverLayerLive),
174);175);
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
176const BackgroundLayerLive = BackgroundPolicy.layer.pipe(183const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
177 Layer.provide(HostPowerMonitorLayerLive),184 Layer.provide(HostPowerMonitorLayerLive),
178 Layer.provideMerge(ServerSettingsLayerLive),185 Layer.provideMerge(ServerSettingsLayerLive),
...@@ -491,7 +498,7 @@ export const makeRoutesLayer = Layer.mergeAll(...@@ -491,7 +498,7 @@ export const makeRoutesLayer = Layer.mergeAll(
491 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.498 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
492 Layer.provide(PullRequestServiceLive),499 Layer.provide(PullRequestServiceLive),
493 Layer.provide(PreviewAutomationBroker.layer),500 Layer.provide(PreviewAutomationBroker.layer),
494 Layer.provide(ServerSelfUpdate.layer),501 Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(DesktopAppUpdateLayerLive))),
495 Layer.provide(commandReadinessLayer),502 Layer.provide(commandReadinessLayer),
496 Layer.provide(browserApiCorsLayer),503 Layer.provide(browserApiCorsLayer),
497 Layer.provide(httpCompressionLayer),504 Layer.provide(httpCompressionLayer),
apps/server/src/ws.ts+6
...@@ -1721,6 +1721,12 @@ const makeWsRpcLayer = (...@@ -1721,6 +1721,12 @@ const makeWsRpcLayer = (
1721 ),1721 ),
1722 { "rpc.aggregate": "server" },1722 { "rpc.aggregate": "server" },
1723 ),1723 ),
1724 [WS_METHODS.serverCommitDesktopUpdate]: (input) =>
1725 observeRpcEffect(
1726 WS_METHODS.serverCommitDesktopUpdate,
1727 serverSelfUpdate.commitDesktopUpdate(input.requestId),
1728 { "rpc.aggregate": "server" },
1729 ),
1724 [WS_METHODS.serverUpsertKeybinding]: (rule) =>1730 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
1725 observeRpcEffect(1731 observeRpcEffect(
1726 WS_METHODS.serverUpsertKeybinding,1732 WS_METHODS.serverUpsertKeybinding,
apps/web/src/components/ChatView.tsx+12-3
...@@ -423,6 +423,7 @@ import {...@@ -423,6 +423,7 @@ import {
423 resolveServerConfigVersionMismatch,423 resolveServerConfigVersionMismatch,
424 resolveServerSelfUpdateCapability,424 resolveServerSelfUpdateCapability,
425 serverUpdateGuidance,425 serverUpdateGuidance,
426 supportsDesktopAppUpdate,
426} from "../versionSkew";427} from "../versionSkew";
427import { useAssetUrls } from "../assets/assetUrls";428import { useAssetUrls } from "../assets/assetUrls";
428429
...@@ -2186,6 +2187,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2186,6 +2187,7 @@ function ChatViewContent(props: ChatViewProps) {
2186 : "server";2187 : "server";
2187 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;2188 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
2188 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);2189 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2190 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
2189 const serverUpdateState = useAtomValue(2191 const serverUpdateState = useAtomValue(
2190 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),2192 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
2191 );2193 );
...@@ -2306,19 +2308,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2306,19 +2308,25 @@ function ChatViewContent(props: ChatViewProps) {
2306 "Server update available"2308 "Server update available"
2307 ),2309 ),
2308 description:2310 description:
2309 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"2311 !updateInProgress &&
2312 !updateFailed &&
2313 versionMismatchSelfUpdate === "desktop-managed" &&
2314 !versionMismatchDesktopAppUpdate
2310 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)2315 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2311 : undefined,2316 : undefined,
2312 // The desktop-managed guidance is already the description; the action2317 // 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.
2314 actions:2320 actions:
2315 updateInProgress ||2321 updateInProgress ||
2316 !versionMismatch ||2322 !versionMismatch ||
2317 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (2323 (versionMismatchSelfUpdate === "desktop-managed" &&
2324 !versionMismatchDesktopAppUpdate) ? undefined : (
2318 <ServerUpdateAction2325 <ServerUpdateAction
2319 environmentId={serverUpdateEnvironmentId}2326 environmentId={serverUpdateEnvironmentId}
2320 serverLabel={versionMismatchServerLabel}2327 serverLabel={versionMismatchServerLabel}
2321 selfUpdate={versionMismatchSelfUpdate}2328 selfUpdate={versionMismatchSelfUpdate}
2329 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2322 targetVersion={versionMismatch.clientVersion}2330 targetVersion={versionMismatch.clientVersion}
2323 label={updateFailed ? "Retry" : "Update"}2331 label={updateFailed ? "Retry" : "Update"}
2324 />2332 />
...@@ -2352,6 +2360,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2352,6 +2360,7 @@ function ChatViewContent(props: ChatViewProps) {
2352 versionMismatchDismissKey,2360 versionMismatchDismissKey,
2353 serverUpdateEnvironmentId,2361 serverUpdateEnvironmentId,
2354 versionMismatchSelfUpdate,2362 versionMismatchSelfUpdate,
2363 versionMismatchDesktopAppUpdate,
2355 versionMismatchServerLabel,2364 versionMismatchServerLabel,
2356 ]);2365 ]);
2357 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;2366 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
apps/web/src/components/ServerUpdateAction.test.tsx+43
...@@ -98,6 +98,49 @@ describe("ServerUpdateAction", () => {...@@ -98,6 +98,49 @@ describe("ServerUpdateAction", () => {
9898
99 expect(testState.toast).not.toHaveBeenCalled();99 expect(testState.toast).not.toHaveBeenCalled();
100 });100 });
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 });
101});144});
102145
103describe("ServerUpdateProgress", () => {146describe("ServerUpdateProgress", () => {
apps/web/src/components/ServerUpdateAction.tsx+25-2
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 squashAtomCommandFailure,5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
77
8import { requestConfirmDialog } from "~/confirmDialog";
8import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";9import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
9import { serverEnvironment } from "~/state/server";10import { serverEnvironment } from "~/state/server";
10import { useAtomCommand } from "~/state/use-atom-command";11import { useAtomCommand } from "~/state/use-atom-command";
...@@ -75,15 +76,20 @@ export function ServerUpdateAction({...@@ -75,15 +76,20 @@ export function ServerUpdateAction({
75 environmentId,76 environmentId,
76 serverLabel,77 serverLabel,
77 selfUpdate,78 selfUpdate,
79 desktopAppUpdate = false,
78 targetVersion,80 targetVersion,
79 label = "Update",81 label = "Update",
80}: {82}: {
81 readonly environmentId: EnvironmentId;83 readonly environmentId: EnvironmentId;
82 readonly serverLabel: string;84 readonly serverLabel: string;
83 readonly selfUpdate: ServerSelfUpdateCapability | null;85 readonly selfUpdate: ServerSelfUpdateCapability | null;
86 /** The desktop app supervising this server accepts remote update
87 requests (capabilities.desktopAppUpdate). */
88 readonly desktopAppUpdate?: boolean;
84 readonly targetVersion: string;89 readonly targetVersion: string;
85 readonly label?: string;90 readonly label?: string;
86}) {91}) {
92 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
87 const updateServer = useAtomCommand(serverEnvironment.updateServer, {93 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
88 reportFailure: false,94 reportFailure: false,
89 });95 });
...@@ -106,6 +112,21 @@ export function ServerUpdateAction({...@@ -106,6 +112,21 @@ export function ServerUpdateAction({
106 });112 });
107113
108 const handleUpdate = async () => {114 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 }
109 if (pendingUpdateEnvironmentIds.has(environmentId)) {130 if (pendingUpdateEnvironmentIds.has(environmentId)) {
110 return;131 return;
111 }132 }
...@@ -129,14 +150,16 @@ export function ServerUpdateAction({...@@ -129,14 +150,16 @@ export function ServerUpdateAction({
129 toastManager.add({150 toastManager.add({
130 type: "success",151 type: "success",
131 title: `${serverLabel} updated`,152 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}.`,
133 });156 });
134 } finally {157 } finally {
135 pendingUpdateEnvironmentIds.delete(environmentId);158 pendingUpdateEnvironmentIds.delete(environmentId);
136 }159 }
137 };160 };
138161
139 if (selfUpdate === "desktop-managed") {162 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
140 return (163 return (
141 <span className="text-muted-foreground text-xs">164 <span className="text-muted-foreground text-xs">
142 Update the desktop app on that machine to update this server.165 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";...@@ -104,6 +104,7 @@ import { useUiStateStore } from "~/uiStateStore";
104import {104import {
105 resolveServerConfigVersionMismatch,105 resolveServerConfigVersionMismatch,
106 resolveServerSelfUpdateCapability,106 resolveServerSelfUpdateCapability,
107 supportsDesktopAppUpdate,
107} from "~/versionSkew";108} from "~/versionSkew";
108import { hasCloudPublicConfig } from "~/cloud/publicConfig";109import { hasCloudPublicConfig } from "~/cloud/publicConfig";
109import { useCloudLinkController } from "~/cloud/useCloudLinkController";110import { useCloudLinkController } from "~/cloud/useCloudLinkController";
...@@ -1485,6 +1486,7 @@ function SavedBackendListRow({...@@ -1485,6 +1486,7 @@ function SavedBackendListRow({
1485 environmentId={environmentId}1486 environmentId={environmentId}
1486 serverLabel={`${environment.label} server`}1487 serverLabel={`${environment.label} server`}
1487 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}1488 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1489 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1488 targetVersion={versionMismatch.clientVersion}1490 targetVersion={versionMismatch.clientVersion}
1489 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}1491 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
1490 />1492 />
...@@ -3057,8 +3059,11 @@ export function ConnectionsSettings() {...@@ -3057,8 +3059,11 @@ export function ConnectionsSettings() {
3057 primaryServerUpdateState.status !== "running" ? (3059 primaryServerUpdateState.status !== "running" ? (
3058 <ServerUpdateAction3060 <ServerUpdateAction
3059 environmentId={primaryEnvironmentId}3061 environmentId={primaryEnvironmentId}
3060 serverLabel={primaryEnvironment?.label ?? "this server"}3062 serverLabel={
3063 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3064 }
3061 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}3065 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3066 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3062 targetVersion={primaryVersionMismatch.clientVersion}3067 targetVersion={primaryVersionMismatch.clientVersion}
3063 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}3068 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
3064 />3069 />
apps/web/src/versionSkew.test.ts+22
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 resolveServerSelfUpdateCapability,18 resolveServerSelfUpdateCapability,
19 resolveVersionMismatch,19 resolveVersionMismatch,
20 serverUpdateGuidance,20 serverUpdateGuidance,
21 supportsDesktopAppUpdate,
21} from "./versionSkew";22} from "./versionSkew";
2223
23const MISMATCH_HINT =24const MISMATCH_HINT =
...@@ -192,6 +193,27 @@ describe("versionSkew", () => {...@@ -192,6 +193,27 @@ describe("versionSkew", () => {
192 expect(resolveServerSelfUpdateCapability(null)).toBeNull();193 expect(resolveServerSelfUpdateCapability(null)).toBeNull();
193 });194 });
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
195 it("matches version-drift guidance to the advertised update path", () => {217 it("matches version-drift guidance to the advertised update path", () => {
196 expect(serverUpdateGuidance("respawn", "Remote server")).toBe(218 expect(serverUpdateGuidance("respawn", "Remote server")).toBe(
197 "Update the Remote server so they stay in sync.",219 "Update the Remote server so they stay in sync.",
apps/web/src/versionSkew.ts+8
...@@ -98,6 +98,14 @@ export function resolveServerSelfUpdateCapability(...@@ -98,6 +98,14 @@ export function resolveServerSelfUpdateCapability(
98 return serverConfig?.environment.capabilities.serverSelfUpdate ?? null;98 return serverConfig?.environment.capabilities.serverSelfUpdate ?? null;
99}99}
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
101/** The command to hand users whose server cannot update itself. */109/** The command to hand users whose server cannot update itself. */
102export function manualServerUpdateCommand(targetVersion: string): string {110export function manualServerUpdateCommand(targetVersion: string): string {
103 return `npx t3@${targetVersion}`;111 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...@@ -85,8 +85,16 @@ recorded reason. Older servers without an ID retain version-only reconnect behav
8585
86The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers86The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers
87advertise remote self-update only when they have valid launcher context and a live IPC channel.87advertise 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 a88Desktop-managed servers advertise `desktopAppUpdate` when the desktop telemetry control fd is
89manual command; the old detached foreground respawn path no longer exists.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
91## Source Map99## Source Map
92100
docs/user/updating.md+5-5
...@@ -22,11 +22,11 @@ The update does not remove saved threads, settings, or project files....@@ -22,11 +22,11 @@ The update does not remove saved threads, settings, or project files.
2222
23## Choose the Action You See23## Choose the Action You See
2424
25| Action | What to do |25| Action | What to do |
26| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |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. |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** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. |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. |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
31The available action depends on how that server was started. T3 Code does not update connected31The available action depends on how that server was started. T3 Code does not update connected
32servers silently in the background.32servers silently in the background.
packages/client-runtime/src/connection/registry.test.ts+28
...@@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts";...@@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts";
50import * as RpcSession from "../rpc/session.ts";50import * as RpcSession from "../rpc/session.ts";
51import * as EnvironmentSupervisor from "./supervisor.ts";51import * as EnvironmentSupervisor from "./supervisor.ts";
52import * as ConnectionWakeups from "./wakeups.ts";52import * as ConnectionWakeups from "./wakeups.ts";
53import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";
5354
54const TARGET = new PrimaryConnectionTarget({55const TARGET = new PrimaryConnectionTarget({
55 environmentId: EnvironmentId.make("environment-1"),56 environmentId: EnvironmentId.make("environment-1"),
...@@ -426,6 +427,33 @@ function awaitConnectionState(...@@ -426,6 +427,33 @@ function awaitConnectionState(
426}427}
427428
428describe("EnvironmentRegistry", () => {429describe("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
429 it.effect("does not acquire a session after the registry scope has already closed", () =>457 it.effect("does not acquire a session after the registry scope has already closed", () =>
430 Effect.gen(function* () {458 Effect.gen(function* () {
431 const harness = yield* makeHarness([TARGET]);459 const harness = yield* makeHarness([TARGET]);
packages/client-runtime/src/state/runtime.test.ts+51
...@@ -1,12 +1,14 @@...@@ -1,12 +1,14 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";6import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";7import * as Fiber from "effect/Fiber";
7import * as Latch from "effect/Latch";8import * as Latch from "effect/Latch";
8import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";10import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";
10import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
12import * as SubscriptionRef from "effect/SubscriptionRef";14import * as SubscriptionRef from "effect/SubscriptionRef";
...@@ -833,6 +835,55 @@ describe("runtime command runner", () => {...@@ -833,6 +835,55 @@ describe("runtime command runner", () => {
833 }),835 }),
834 );836 );
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
836 it("deduplicates single-flight commands by key", async () => {887 it("deduplicates single-flight commands by key", async () => {
837 const latch = Latch.makeUnsafe();888 const latch = Latch.makeUnsafe();
838 let executions = 0;889 let executions = 0;
packages/client-runtime/src/state/server.test.ts+112
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
9import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
10import * as Duration from "effect/Duration";10import * as Duration from "effect/Duration";
11import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
13import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
...@@ -33,6 +34,7 @@ import {...@@ -33,6 +34,7 @@ import {
33 makeEnvironmentServerConfigState,34 makeEnvironmentServerConfigState,
34 isLegacyUpdateHandoffLoss,35 isLegacyUpdateHandoffLoss,
35 matchesServerUpdateReadyEvent,36 matchesServerUpdateReadyEvent,
37 matchesServerUpdateResumeEvent,
36 nudgeReconnectDuringUpdateRestart,38 nudgeReconnectDuringUpdateRestart,
37 projectServerWelcome,39 projectServerWelcome,
38 resolveServerConfigValue,40 resolveServerConfigValue,
...@@ -40,6 +42,9 @@ import {...@@ -40,6 +42,9 @@ import {
40 serverUpdateStateForProgressEvent,42 serverUpdateStateForProgressEvent,
41 serverUpdateStateForServerVersion,43 serverUpdateStateForServerVersion,
42 validateServerUpdateReadyEvent,44 validateServerUpdateReadyEvent,
45 waitForNextEnvironmentReconnect,
46 waitForDesktopUpdateTarget,
47 runDesktopCommitWithReconnectObserver,
43} from "./server.ts";48} from "./server.ts";
44import { applyServerConfigProjection } from "./serverConfigProjection.ts";49import { applyServerConfigProjection } from "./serverConfigProjection.ts";
4550
...@@ -81,6 +86,83 @@ function session(client: WsRpcProtocolClient): RpcSession {...@@ -81,6 +86,83 @@ function session(client: WsRpcProtocolClient): RpcSession {
81}86}
8287
83describe("update restart reconnect nudges", () => {88describe("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 );
84 it.effect("retries once per backoff entry instead of only the first", () =>166 it.effect("retries once per backoff entry instead of only the first", () =>
85 Effect.gen(function* () {167 Effect.gen(function* () {
86 const retries = yield* Ref.make(0);168 const retries = yield* Ref.make(0);
...@@ -292,6 +374,36 @@ describe("server state projection", () => {...@@ -292,6 +374,36 @@ describe("server state projection", () => {
292 }),374 }),
293 );375 );
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
295 it("applies every config category to the projected snapshot", () => {407 it("applies every config category to the projected snapshot", () => {
296 const snapshot = applyServerConfigProjection(Option.none(), {408 const snapshot = applyServerConfigProjection(Option.none(), {
297 version: 1,409 version: 1,
packages/client-runtime/src/state/server.ts+156-10
...@@ -10,8 +10,10 @@ import {...@@ -10,8 +10,10 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
12import * as Duration from "effect/Duration";12import * as Duration from "effect/Duration";
13import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
16import * as Fiber from "effect/Fiber";
15import * as Option from "effect/Option";17import * as Option from "effect/Option";
16import * as Queue from "effect/Queue";18import * as Queue from "effect/Queue";
17import * as Ref from "effect/Ref";19import * as Ref from "effect/Ref";
...@@ -132,6 +134,16 @@ export function matchesServerUpdateReadyEvent(...@@ -132,6 +134,16 @@ export function matchesServerUpdateReadyEvent(
132 : event.payload.updateOutcome?.id === result.updateId;134 : event.payload.updateOutcome?.id === result.updateId;
133}135}
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
135export function validateServerUpdateReadyEvent(147export function validateServerUpdateReadyEvent(
136 result: ServerSelfUpdateResult,148 result: ServerSelfUpdateResult,
137 event: ServerLifecycleStreamReadyEvent,149 event: ServerLifecycleStreamReadyEvent,
...@@ -200,6 +212,71 @@ export function nudgeReconnectDuringUpdateRestart(input: {...@@ -200,6 +212,71 @@ export function nudgeReconnectDuringUpdateRestart(input: {
200 );212 );
201}213}
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
203export function serverUpdateStateForProgressEvent(280export function serverUpdateStateForProgressEvent(
204 fromVersion: string,281 fromVersion: string,
205 targetVersion: string,282 targetVersion: string,
...@@ -497,11 +574,12 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -497,11 +574,12 @@ export function createServerEnvironmentAtoms<R, E>(
497 concurrency: configConcurrency,574 concurrency: configConcurrency,
498 execute: (target, atomRegistry) => {575 execute: (target, atomRegistry) => {
499 const stateAtom = serverUpdateStateAtom(target.environmentId);576 const stateAtom = serverUpdateStateAtom(target.environmentId);
500 const targetVersion = target.input.targetVersion;577 let targetVersion = target.input.targetVersion;
501 let fromVersion =578 let fromVersion =
502 atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ??579 atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ??
503 targetVersion;580 targetVersion;
504 let currentStage: ServerUpdateStage = "downloading";581 let currentStage: ServerUpdateStage = "downloading";
582 let desktopCommitLostTransport = false;
505 atomRegistry.set(stateAtom, {583 atomRegistry.set(stateAtom, {
506 status: "running",584 status: "running",
507 stage: currentStage,585 stage: currentStage,
...@@ -511,6 +589,21 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -511,6 +589,21 @@ export function createServerEnvironmentAtoms<R, E>(
511589
512 return Effect.gen(function* () {590 return Effect.gen(function* () {
513 const environmentRegistry = yield* EnvironmentRegistry;591 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 );
514 const result = yield* scheduleAtomCommandEffect(607 const result = yield* scheduleAtomCommandEffect(
515 atomRegistry,608 atomRegistry,
516 configScheduler,609 configScheduler,
...@@ -582,6 +675,28 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -582,6 +675,28 @@ export function createServerEnvironmentAtoms<R, E>(
582 return yield* Effect.failCause(exit.cause);675 return yield* Effect.failCause(exit.cause);
583 });676 });
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
585 currentStage = "resuming";700 currentStage = "resuming";
586 atomRegistry.set(stateAtom, {701 atomRegistry.set(stateAtom, {
587 status: "running",702 status: "running",
...@@ -601,24 +716,55 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -601,24 +716,55 @@ export function createServerEnvironmentAtoms<R, E>(
601 retryNow: environmentRegistry.retryNow(target.environmentId),716 retryNow: environmentRegistry.retryNow(target.environmentId),
602 }).pipe(Effect.forkChild);717 }).pipe(Effect.forkChild);
603718
604 const resumed = yield* environmentRegistry719 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
605 .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {}))726 .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {}))
606 .pipe(727 .pipe(
607 Stream.filter(728 Stream.filter(
608 (event): event is ServerLifecycleStreamReadyEvent =>729 (event): event is ServerLifecycleStreamReadyEvent =>
609 event.type === "ready" && matchesServerUpdateReadyEvent(result, event),730 event.type === "ready" && matchesServerUpdateResumeEvent(result, event),
610 ),731 ),
611 Stream.runHead,732 Stream.runHead,
612 Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT),733 Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT),
613 Effect.map(Option.flatten),734 Effect.map(Option.flatten),
614 );735 );
615 if (Option.isNone(resumed)) {736 const nextReady = waitForReady.pipe(
616 return yield* new ServerUpdateResumeTimeoutError({737 Effect.flatMap(
617 environmentId: target.environmentId,738 Option.match({
618 targetVersion,739 onNone: () =>
619 });740 new ServerUpdateResumeTimeoutError({
620 }741 environmentId: target.environmentId,
621 yield* validateServerUpdateReadyEvent(result, resumed.value);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
623 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);769 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
624 return result;770 return result;
packages/contracts/src/environment.ts+9-2
...@@ -30,8 +30,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T...@@ -30,8 +30,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
3030
31/** How a server can replace itself with another version when asked over RPC.31/** How a server can replace itself with another version when asked over RPC.
32 New servers only advertise the stable launcher-backed "boot-service" path;32 New servers only advertise the stable launcher-backed "boot-service" path;
33 "respawn" remains decodable for compatibility with older servers. */33 "respawn" remains decodable for compatibility with older servers.
34export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);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"]);
35export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;37export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
3638
37/** What update path a client should offer for a server: one of the RPC39/** What update path a client should offer for a server: one of the RPC
...@@ -97,6 +99,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -97,6 +99,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
97 this is false — no update would ever repaint it. Absent on older99 this is false — no update would ever repaint it. Absent on older
98 servers, which may still publish, so only an explicit false skips. */100 servers, which may still publish, so only an explicit false skips. */
99 agentActivityPublishing: Schema.optionalKey(Schema.Boolean),101 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),
100});107});
101export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;108export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
102109
packages/contracts/src/resourceTelemetry.ts+58
...@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";...@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22
3import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { HostPowerSnapshot } from "./background.ts";4import { HostPowerSnapshot } from "./background.ts";
5import { DesktopUpdateStateSchema } from "./ipc.ts";
56
6export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;7export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;
78
...@@ -244,9 +245,37 @@ export const DesktopHostTelemetryHello = Schema.Struct({...@@ -244,9 +245,37 @@ export const DesktopHostTelemetryHello = Schema.Struct({
244});245});
245export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type;246export 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
247export const DesktopHostTelemetryMessage = Schema.Union([275export const DesktopHostTelemetryMessage = Schema.Union([
248 DesktopHostTelemetryHello,276 DesktopHostTelemetryHello,
249 DesktopHostTelemetrySnapshot,277 DesktopHostTelemetrySnapshot,
278 DesktopUpdateStatusReport,
250]);279]);
251export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type;280export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type;
252281
...@@ -266,9 +295,38 @@ export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({...@@ -266,9 +295,38 @@ export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({
266export type DesktopTelemetrySetHostPowerIntervals =295export type DesktopTelemetrySetHostPowerIntervals =
267 typeof DesktopTelemetrySetHostPowerIntervals.Type;296 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
269export const DesktopTelemetryControlMessage = Schema.Union([324export const DesktopTelemetryControlMessage = Schema.Union([
270 DesktopTelemetrySetDiagnosticsDemand,325 DesktopTelemetrySetDiagnosticsDemand,
271 DesktopTelemetrySetHostPowerIntervals,326 DesktopTelemetrySetHostPowerIntervals,
327 DesktopTelemetryRequestDesktopUpdate,
328 DesktopTelemetryCommitDesktopUpdate,
329 DesktopTelemetryCancelDesktopUpdate,
272]);330]);
273export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type;331export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type;
274332
packages/contracts/src/rpc.ts+9
...@@ -166,6 +166,7 @@ import {...@@ -166,6 +166,7 @@ import {
166} from "./previewAutomation.ts";166} from "./previewAutomation.ts";
167import {167import {
168 ServerConfigStreamEvent,168 ServerConfigStreamEvent,
169 DesktopUpdateCommitInput,
169 ServerConfig,170 ServerConfig,
170 ServerProviderUpdateError,171 ServerProviderUpdateError,
171 ServerProviderUpdateInput,172 ServerProviderUpdateInput,
...@@ -276,6 +277,7 @@ export const WS_METHODS = {...@@ -276,6 +277,7 @@ export const WS_METHODS = {
276 serverUpdateProvider: "server.updateProvider",277 serverUpdateProvider: "server.updateProvider",
277 serverUpdateServer: "server.updateServer",278 serverUpdateServer: "server.updateServer",
278 serverUpdateServerWithProgress: "server.updateServerWithProgress",279 serverUpdateServerWithProgress: "server.updateServerWithProgress",
280 serverCommitDesktopUpdate: "server.commitDesktopUpdate",
279 serverUpsertKeybinding: "server.upsertKeybinding",281 serverUpsertKeybinding: "server.upsertKeybinding",
280 serverRemoveKeybinding: "server.removeKeybinding",282 serverRemoveKeybinding: "server.removeKeybinding",
281 serverGetSettings: "server.getSettings",283 serverGetSettings: "server.getSettings",
...@@ -393,6 +395,12 @@ export const WsServerUpdateServerWithProgressRpc = Rpc.make(...@@ -393,6 +395,12 @@ export const WsServerUpdateServerWithProgressRpc = Rpc.make(
393 },395 },
394);396);
395397
398export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, {
399 payload: DesktopUpdateCommitInput,
400 success: ServerSelfUpdateResult,
401 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
402});
403
396export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {404export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
397 payload: Schema.Struct({}),405 payload: Schema.Struct({}),
398 success: ServerSettings,406 success: ServerSettings,
...@@ -1033,6 +1041,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1033,6 +1041,7 @@ export const WsRpcGroup = RpcGroup.make(
1033 WsServerUpdateProviderRpc,1041 WsServerUpdateProviderRpc,
1034 WsServerUpdateServerRpc,1042 WsServerUpdateServerRpc,
1035 WsServerUpdateServerWithProgressRpc,1043 WsServerUpdateServerWithProgressRpc,
1044 WsServerCommitDesktopUpdateRpc,
1036 WsServerUpsertKeybindingRpc,1045 WsServerUpsertKeybindingRpc,
1037 WsServerRemoveKeybindingRpc,1046 WsServerRemoveKeybindingRpc,
1038 WsServerGetSettingsRpc,1047 WsServerGetSettingsRpc,
packages/contracts/src/server.ts+7
...@@ -755,9 +755,16 @@ export const ServerSelfUpdateResult = Schema.Struct({...@@ -755,9 +755,16 @@ export const ServerSelfUpdateResult = Schema.Struct({
755 method: ServerSelfUpdateMethod,755 method: ServerSelfUpdateMethod,
756 /** Launcher-generated correlation ID. Absent when talking to older servers. */756 /** Launcher-generated correlation ID. Absent when talking to older servers. */
757 updateId: Schema.optionalKey(TrimmedNonEmptyString),757 updateId: Schema.optionalKey(TrimmedNonEmptyString),
758 /** Desktop preparation token. Present only for the desktop-app method. */
759 desktopUpdateToken: Schema.optionalKey(TrimmedNonEmptyString),
758});760});
759export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;761export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;
760762
763export const DesktopUpdateCommitInput = Schema.Struct({
764 requestId: TrimmedNonEmptyString,
765});
766export type DesktopUpdateCommitInput = typeof DesktopUpdateCommitInput.Type;
767
761export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]);768export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]);
762export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type;769export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type;
763770