authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-19 13:35:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 02:05:40+05:30
loga354dd9ddc08ef67da7e971ad59a3cd095068325
tree1b09f897fa3263431ab762a1bb2e7b43c4e0ce93
parent51341f2ac1e25032efac13ef6e9652e9c74b48a3
signature Signed by PGP key B5690EEEBB952194

fix(desktop): refresh queued updates before install (#6269)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

6 files changed, 524 insertions(+), 113 deletions(-)

apps/desktop/src/updates/DesktopUpdates.test.ts+267-15
......@@ -27,6 +27,7 @@ interface UpdatesHarnessOptions {
2727 void,
2828 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
2929 >;
30 readonly beforeSetUpdateChannel?: Effect.Effect<void>;
3031 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
3132 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
3233 readonly stopBackend?: Effect.Effect<void>;
......@@ -153,22 +154,41 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
153154 ),
154155 );
155156
157 let testSettings: DesktopAppSettings.DesktopSettings = {
158 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
159 };
156160 const setUpdateChannelError = options.setUpdateChannelError;
157 const settingsLayer = setUpdateChannelError
158 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
159 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
160 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
161 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
162 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
163 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
164 setUpdateChannel: () => Effect.fail(setUpdateChannelError),
165 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
166 setWslDistro: () => Effect.die("unexpected WSL distro change"),
167 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
168 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
169 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
170 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
171 : DesktopAppSettings.layer;
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;
172192
173193 const layer = DesktopUpdates.layer.pipe(
174194 Layer.provideMerge(updaterLayer),
......@@ -337,6 +357,178 @@ describe("DesktopUpdates", () => {
337357 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
338358 });
339359
360 it.effect("checks for newer releases after an update has been downloaded", () => {
361 const harness = makeHarness();
362
363 return Effect.scoped(
364 Effect.gen(function* () {
365 const updates = yield* DesktopUpdates.DesktopUpdates;
366 yield* updates.configure;
367
368 harness.emit("update-available", {
369 version: "1.2.4",
370 releaseNotes: "## What's changed\n- fix: queued update",
371 });
372 yield* flushCallbacks;
373 harness.emit("update-downloaded", { version: "1.2.4" });
374 yield* flushCallbacks;
375
376 const result = yield* updates.check("poll");
377 assert.isTrue(result.checked);
378
379 harness.emit("update-available", { version: "1.2.4" });
380 yield* flushCallbacks;
381
382 const unchangedState = yield* updates.getState;
383 assert.equal(unchangedState.status, "downloaded");
384 assert.equal(unchangedState.downloadedVersion, "1.2.4");
385 assert.deepEqual(unchangedState.releaseNotes, [
386 { version: "1.2.4", items: ["fix: queued update"] },
387 ]);
388
389 const nextResult = yield* updates.check("poll");
390 assert.isTrue(nextResult.checked);
391
392 harness.emit("update-available", { version: "1.2.5" });
393 yield* flushCallbacks;
394
395 const state = yield* updates.getState;
396 assert.equal(state.status, "available");
397 assert.equal(state.availableVersion, "1.2.5");
398 assert.isNull(state.downloadedVersion);
399 }),
400 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
401 });
402
403 it.effect("preserves a queued installer when the feed has no update", () => {
404 const harness = makeHarness();
405
406 return Effect.scoped(
407 Effect.gen(function* () {
408 const updates = yield* DesktopUpdates.DesktopUpdates;
409 yield* updates.configure;
410
411 harness.emit("update-available", {
412 version: "1.2.4",
413 releaseNotes: "## What's changed\n- fix: queued update",
414 });
415 yield* flushCallbacks;
416 harness.emit("update-downloaded", { version: "1.2.4" });
417 yield* flushCallbacks;
418
419 yield* updates.check("poll");
420 harness.emit("update-not-available");
421 yield* flushCallbacks;
422
423 const state = yield* updates.getState;
424 assert.equal(state.status, "downloaded");
425 assert.equal(state.availableVersion, "1.2.4");
426 assert.equal(state.downloadedVersion, "1.2.4");
427 assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]);
428 assert.equal(state.downloadPercent, 100);
429 }),
430 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
431 });
432
433 it.effect("preserves a queued installer when the feed offers another channel", () => {
434 const harness = makeHarness();
435
436 return Effect.scoped(
437 Effect.gen(function* () {
438 const updates = yield* DesktopUpdates.DesktopUpdates;
439 yield* updates.configure;
440
441 harness.emit("update-available", {
442 version: "1.2.4",
443 releaseNotes: "## What's changed\n- fix: queued update",
444 });
445 yield* flushCallbacks;
446 harness.emit("update-downloaded", { version: "1.2.4" });
447 yield* flushCallbacks;
448
449 yield* updates.check("poll");
450 harness.emit("update-available", { version: "1.2.5-nightly.20260710.1" });
451 yield* flushCallbacks;
452
453 const state = yield* updates.getState;
454 assert.equal(state.status, "downloaded");
455 assert.equal(state.availableVersion, "1.2.4");
456 assert.equal(state.downloadedVersion, "1.2.4");
457 assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]);
458 assert.equal(state.downloadPercent, 100);
459 }),
460 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
461 });
462
463 it.effect(
464 "rejects install while a refresh check is in progress and releases the reservation",
465 () =>
466 Effect.gen(function* () {
467 const checkStarted = yield* Deferred.make<void>();
468 const releaseCheck = yield* Deferred.make<void>();
469 const harness = makeHarness({
470 checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
471 Effect.andThen(Deferred.await(releaseCheck)),
472 ),
473 });
474
475 yield* Effect.scoped(
476 Effect.gen(function* () {
477 const updates = yield* DesktopUpdates.DesktopUpdates;
478 yield* updates.configure;
479 harness.emit("update-downloaded", { version: "1.2.4" });
480 yield* flushCallbacks;
481
482 const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
483 yield* Deferred.await(checkStarted);
484
485 const installResult = yield* updates.install;
486 assert.isFalse(installResult.accepted);
487
488 yield* Deferred.succeed(releaseCheck, undefined);
489 const checkResult = yield* Fiber.join(checkFiber);
490 assert.isTrue(checkResult.checked);
491
492 const followUpCheck = yield* updates.check("manual");
493 assert.isTrue(followUpCheck.checked);
494 assert.equal(harness.checkCount(), 2);
495 }),
496 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
497 }),
498 );
499
500 it.effect("rejects refresh checks while install is in progress", () =>
501 Effect.gen(function* () {
502 const installStarted = yield* Deferred.make<void>();
503 const releaseInstall = yield* Deferred.make<void>();
504 const harness = makeHarness({
505 stopBackend: Deferred.succeed(installStarted, undefined).pipe(
506 Effect.andThen(Deferred.await(releaseInstall)),
507 ),
508 });
509
510 yield* Effect.scoped(
511 Effect.gen(function* () {
512 const updates = yield* DesktopUpdates.DesktopUpdates;
513 yield* updates.configure;
514 harness.emit("update-downloaded", { version: "1.2.4" });
515 yield* flushCallbacks;
516
517 const installFiber = yield* updates.install.pipe(Effect.forkScoped);
518 yield* Deferred.await(installStarted);
519
520 const checkResult = yield* updates.check("manual");
521 assert.isFalse(checkResult.checked);
522 assert.equal(harness.checkCount(), 0);
523
524 yield* Deferred.succeed(releaseInstall, undefined);
525 const installResult = yield* Fiber.join(installFiber);
526 assert.isTrue(installResult.accepted);
527 }),
528 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
529 }),
530 );
531
340532 it.effect("keeps raw updater event failures out of update state", () => {
341533 const harness = makeHarness();
342534 const cause = new Error(
......@@ -359,6 +551,30 @@ describe("DesktopUpdates", () => {
359551 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
360552 });
361553
554 it.effect("preserves a queued installer after a background updater error", () => {
555 const harness = makeHarness();
556
557 return Effect.scoped(
558 Effect.gen(function* () {
559 const updates = yield* DesktopUpdates.DesktopUpdates;
560 yield* updates.configure;
561 harness.emit("update-downloaded", { version: "1.2.4" });
562 yield* flushCallbacks;
563
564 harness.emit("error", new Error("background updater failure"));
565 yield* flushCallbacks;
566
567 const state = yield* updates.getState;
568 assert.equal(state.status, "error");
569 assert.equal(state.downloadedVersion, "1.2.4");
570 assert.isNull(state.errorContext);
571
572 const result = yield* updates.install;
573 assert.isTrue(result.accepted);
574 }),
575 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
576 });
577
362578 it.effect("logs bounded updater failure context without exposing the cause", () => {
363579 const cause = new Error(
364580 "request failed for https://user:secret@example.com/update?token=secret",
......@@ -581,6 +797,38 @@ describe("DesktopUpdates", () => {
581797 }),
582798 );
583799
800 it.effect("rejects checks while an update channel change is being persisted", () =>
801 Effect.gen(function* () {
802 const channelChangeStarted = yield* Deferred.make<void>();
803 const releaseChannelChange = yield* Deferred.make<void>();
804 const harness = makeHarness({
805 beforeSetUpdateChannel: Deferred.succeed(channelChangeStarted, undefined).pipe(
806 Effect.andThen(Deferred.await(releaseChannelChange)),
807 ),
808 });
809
810 yield* Effect.scoped(
811 Effect.gen(function* () {
812 const updates = yield* DesktopUpdates.DesktopUpdates;
813 yield* updates.configure;
814
815 const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped);
816 yield* Deferred.await(channelChangeStarted);
817
818 const checkResult = yield* updates.check("manual");
819 assert.isFalse(checkResult.checked);
820 assert.equal(harness.checkCount(), 0);
821
822 yield* Deferred.succeed(releaseChannelChange, undefined);
823 const state = yield* Fiber.join(channelFiber);
824
825 assert.equal(state.channel, "nightly");
826 assert.equal(harness.checkCount(), 1);
827 }),
828 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
829 }),
830 );
831
584832 it.effect("preserves settings failure context when an update channel cannot be persisted", () => {
585833 const diskFailure = new Error("disk exploded");
586834 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({
......@@ -604,6 +852,10 @@ describe("DesktopUpdates", () => {
604852 assert.strictEqual(error.cause.cause, diskFailure);
605853 assert.equal(error.message, "Failed to persist the nightly desktop update channel.");
606854 assert.notInclude(error.message, diskFailure.message);
855
856 const checkResult = yield* updates.check("manual");
857 assert.isTrue(checkResult.checked);
858 assert.equal(harness.checkCount(), 1);
607859 }),
608860 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
609861 });
apps/desktop/src/updates/DesktopUpdates.ts+101-81
......@@ -45,6 +45,8 @@ import {
4545const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
4646const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
4747
48type UpdateAction = "check" | "download" | "install" | "channel";
49
4850const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
4951type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
5052
......@@ -68,7 +70,7 @@ const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
6870export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<DesktopUpdateActionInProgressError>()(
6971 "DesktopUpdateActionInProgressError",
7072 {
71 action: Schema.Literals(["check", "download", "install"]),
73 action: Schema.Literals(["check", "download", "install", "channel"]),
7274 requestedChannel: DesktopUpdateChannelSchema,
7375 },
7476) {
......@@ -116,7 +118,7 @@ export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<Des
116118export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()(
117119 "DesktopUpdaterReportedError",
118120 {
119 operation: Schema.Literals(["check", "download", "install", "background"]),
121 operation: Schema.Literals(["check", "download", "install", "channel", "background"]),
120122 cause: Schema.Defect(),
121123 },
122124) {
......@@ -255,9 +257,7 @@ export const make = Effect.gen(function* () {
255257 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
256258
257259 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
258 const updateCheckInFlightRef = yield* Ref.make(false);
259 const updateDownloadInFlightRef = yield* Ref.make(false);
260 const updateInstallInFlightRef = yield* Ref.make(false);
260 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
261261 const updaterConfiguredRef = yield* Ref.make(false);
262262 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
263263 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
......@@ -313,19 +313,23 @@ export const make = Effect.gen(function* () {
313313 );
314314 });
315315
316 const resolveUpdaterErrorContext = Effect.gen(function* () {
317 if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const;
318 if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const;
319 if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const;
320 return (yield* Ref.get(updateStateRef)).errorContext;
321 });
316 const activeUpdateAction = Ref.get(activeUpdateActionRef);
322317
323 const activeUpdateAction = Effect.gen(function* () {
324 if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const);
325 if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const);
326 if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const);
327 return Option.none<"check" | "download" | "install">();
328 });
318 const tryStartUpdateAction = (action: UpdateAction): Effect.Effect<boolean> =>
319 Ref.modify(activeUpdateActionRef, (activeAction) =>
320 Option.isSome(activeAction) ? [false, activeAction] : [true, Option.some(action)],
321 );
322
323 const tryStartChannelChange = Ref.modify(activeUpdateActionRef, (activeAction) =>
324 Option.isSome(activeAction)
325 ? [activeAction, activeAction]
326 : [Option.none<UpdateAction>(), Option.some<UpdateAction>("channel")],
327 );
328
329 const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>
330 Ref.update(activeUpdateActionRef, (activeAction) =>
331 Option.isSome(activeAction) && activeAction.value === action ? Option.none() : activeAction,
332 );
329333
330334 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
331335 channel: DesktopUpdateChannel,
......@@ -346,14 +350,16 @@ export const make = Effect.gen(function* () {
346350
347351 const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));
348352
349 const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) {
353 const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (
354 reason: string,
355 actionReservation: "acquire" | "held" = "acquire",
356 ) {
350357 yield* Effect.annotateCurrentSpan({ reason });
351358 if (yield* Ref.get(desktopState.quitting)) return false;
352359 if (!(yield* Ref.get(updaterConfiguredRef))) return false;
353 if (yield* Ref.get(updateCheckInFlightRef)) return false;
354360
355361 const state = yield* Ref.get(updateStateRef);
356 if (state.status === "downloading" || state.status === "downloaded") {
362 if (state.status === "downloading") {
357363 yield* logUpdaterInfo("skipping update check while update is active", {
358364 reason,
359365 status: state.status,
......@@ -361,43 +367,48 @@ export const make = Effect.gen(function* () {
361367 return false;
362368 }
363369
364 yield* Ref.set(updateCheckInFlightRef, true);
365 const checkedAt = yield* currentIsoTimestamp;
366 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
367 yield* logUpdaterInfo("checking for updates", { reason });
370 if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false;
368371
369 return yield* electronUpdater.checkForUpdates.pipe(
370 Effect.as(true),
371 Effect.catchTags({
372 ElectronUpdaterCheckForUpdatesError: Effect.fn(
373 "desktop.updates.handleCheckForUpdatesFailure",
374 )(function* (error) {
375 const failedAt = yield* currentIsoTimestamp;
376 yield* updateState((current) =>
377 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
378 );
379 yield* logUpdaterError(error.message, {
380 errorTag: error._tag,
381 channel: error.channel,
382 });
383 return true;
372 const check = Effect.gen(function* () {
373 const checkedAt = yield* currentIsoTimestamp;
374 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
375 yield* logUpdaterInfo("checking for updates", { reason });
376
377 return yield* electronUpdater.checkForUpdates.pipe(
378 Effect.as(true),
379 Effect.catchTags({
380 ElectronUpdaterCheckForUpdatesError: Effect.fn(
381 "desktop.updates.handleCheckForUpdatesFailure",
382 )(function* (error) {
383 const failedAt = yield* currentIsoTimestamp;
384 yield* updateState((current) =>
385 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
386 );
387 yield* logUpdaterError(error.message, {
388 errorTag: error._tag,
389 channel: error.channel,
390 });
391 return true;
392 }),
384393 }),
385 }),
386 Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),
387 );
394 );
395 });
396
397 return yield* actionReservation === "held"
398 ? check
399 : check.pipe(Effect.ensuring(finishUpdateAction("check")));
388400 });
389401
390402 const downloadAvailableUpdate = Effect.gen(function* () {
391403 const state = yield* Ref.get(updateStateRef);
392 if (
393 !(yield* Ref.get(updaterConfiguredRef)) ||
394 (yield* Ref.get(updateDownloadInFlightRef)) ||
395 state.status !== "available"
396 ) {
404 if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") {
405 return { accepted: false, completed: false };
406 }
407
408 if (!(yield* tryStartUpdateAction("download"))) {
397409 return { accepted: false, completed: false };
398410 }
399411
400 yield* Ref.set(updateDownloadInFlightRef, true);
401412 return yield* Effect.gen(function* () {
402413 yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));
403414 yield* electronUpdater.setDisableDifferentialDownload(
......@@ -442,27 +453,35 @@ export const make = Effect.gen(function* () {
442453 return { accepted: true, completed: false };
443454 });
444455 }),
445 Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),
456 Effect.ensuring(finishUpdateAction("download")),
446457 );
447458 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
448459
449460 const resetInstallAction = Effect.all(
450 [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)],
461 [finishUpdateAction("install"), Ref.set(desktopState.quitting, false)],
451462 { discard: true },
452463 );
453464
454465 const installDownloadedUpdate = Effect.gen(function* () {
455466 const state = yield* Ref.get(updateStateRef);
467 const hasInstallableDownload =
468 state.downloadedVersion !== null &&
469 (state.status === "downloaded" ||
470 (state.status === "error" &&
471 (state.errorContext === null || state.errorContext === "install")));
456472 if (
457473 (yield* Ref.get(desktopState.quitting)) ||
458474 !(yield* Ref.get(updaterConfiguredRef)) ||
459 state.status !== "downloaded"
475 !hasInstallableDownload
460476 ) {
461477 return { accepted: false, completed: false };
462478 }
463479
480 if (!(yield* tryStartUpdateAction("install"))) {
481 return { accepted: false, completed: false };
482 }
483
464484 yield* Ref.set(desktopState.quitting, true);
465 yield* Ref.set(updateInstallInFlightRef, true);
466485
467486 return yield* Effect.gen(function* () {
468487 // Stop every backend in the pool, not just the primary. With
......@@ -614,8 +633,8 @@ export const make = Effect.gen(function* () {
614633 operation: Option.getOrElse(activeAction, () => "background" as const),
615634 cause,
616635 });
617 if (yield* Ref.get(updateInstallInFlightRef)) {
618 yield* Ref.set(updateInstallInFlightRef, false);
636 if (Option.isSome(activeAction) && activeAction.value === "install") {
637 yield* finishUpdateAction("install");
619638 yield* Ref.set(desktopState.quitting, false);
620639 yield* updateState((current) =>
621640 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
......@@ -627,8 +646,7 @@ export const make = Effect.gen(function* () {
627646 return;
628647 }
629648
630 if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) {
631 const errorContext = yield* resolveUpdaterErrorContext;
649 if (Option.isNone(activeAction)) {
632650 const checkedAt = yield* currentIsoTimestamp;
633651 yield* updateState((current) => ({
634652 ...current,
......@@ -636,7 +654,7 @@ export const make = Effect.gen(function* () {
636654 message: error.message,
637655 checkedAt,
638656 downloadPercent: null,
639 errorContext,
657 errorContext: current.errorContext,
640658 canRetry: getCanRetryFromState(current),
641659 }));
642660 }
......@@ -773,7 +791,7 @@ export const make = Effect.gen(function* () {
773791 nextChannel: DesktopUpdateChannel,
774792 ) {
775793 yield* Effect.annotateCurrentSpan({ channel: nextChannel });
776 const activeAction = yield* activeUpdateAction;
794 const activeAction = yield* tryStartChannelChange;
777795 if (Option.isSome(activeAction)) {
778796 return yield* new DesktopUpdateActionInProgressError({
779797 action: activeAction.value,
......@@ -781,33 +799,35 @@ export const make = Effect.gen(function* () {
781799 });
782800 }
783801
784 const state = yield* Ref.get(updateStateRef);
785 if (nextChannel === state.channel) {
786 return state;
787 }
802 return yield* Effect.gen(function* () {
803 const state = yield* Ref.get(updateStateRef);
804 if (nextChannel === state.channel) {
805 return state;
806 }
788807
789 yield* desktopSettings
790 .setUpdateChannel(nextChannel)
791 .pipe(
792 Effect.mapError(
793 (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
794 ),
795 );
808 yield* desktopSettings
809 .setUpdateChannel(nextChannel)
810 .pipe(
811 Effect.mapError(
812 (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
813 ),
814 );
796815
797 const enabled = yield* shouldEnableAutoUpdates;
798 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
816 const enabled = yield* shouldEnableAutoUpdates;
817 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
799818
800 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
801 return yield* Ref.get(updateStateRef);
802 }
819 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
820 return yield* Ref.get(updateStateRef);
821 }
803822
804 yield* applyAutoUpdaterChannel(nextChannel);
805 const allowDowngrade = yield* electronUpdater.allowDowngrade;
806 yield* electronUpdater.setAllowDowngrade(true);
807 yield* checkForUpdates("channel-change").pipe(
808 Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
809 );
810 return yield* Ref.get(updateStateRef);
823 yield* applyAutoUpdaterChannel(nextChannel);
824 const allowDowngrade = yield* electronUpdater.allowDowngrade;
825 yield* electronUpdater.setAllowDowngrade(true);
826 yield* checkForUpdates("channel-change", "held").pipe(
827 Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
828 );
829 return yield* Ref.get(updateStateRef);
830 }).pipe(Effect.ensuring(finishUpdateAction("channel")));
811831 }),
812832 check: Effect.fn("desktop.updates.check")(function* (reason: string) {
813833 yield* Effect.annotateCurrentSpan({ reason });
apps/desktop/src/updates/updateMachine.test.ts+80-1
......@@ -55,6 +55,57 @@ describe("updateMachine", () => {
5555 expect(state.canRetry).toBe(true);
5656 });
5757
58 it("preserves an already-downloaded update while checking the feed", () => {
59 const downloadedState = {
60 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
61 enabled: true,
62 status: "downloaded" as const,
63 availableVersion: "1.1.0",
64 downloadedVersion: "1.1.0",
65 releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"] }],
66 downloadPercent: 100,
67 };
68 const checking = reduceDesktopUpdateStateOnCheckStart(
69 downloadedState,
70 "2026-03-04T00:00:00.000Z",
71 );
72 const failed = reduceDesktopUpdateStateOnCheckFailure(
73 checking,
74 "network unavailable",
75 "2026-03-04T00:00:01.000Z",
76 );
77
78 expect(checking.status).toBe("checking");
79 expect(checking.downloadedVersion).toBe("1.1.0");
80 expect(checking.releaseNotes).toEqual(downloadedState.releaseNotes);
81 expect(failed.status).toBe("downloaded");
82 expect(failed.downloadedVersion).toBe("1.1.0");
83 expect(failed.releaseNotes).toEqual(downloadedState.releaseNotes);
84 expect(failed.message).toBeNull();
85 });
86
87 it("keeps the installer when the feed still offers its version", () => {
88 const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }];
89 const state = reduceDesktopUpdateStateOnUpdateAvailable(
90 {
91 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
92 enabled: true,
93 status: "downloaded",
94 availableVersion: "1.1.0",
95 downloadedVersion: "1.1.0",
96 releaseNotes,
97 downloadPercent: 100,
98 },
99 "1.1.0",
100 "2026-03-04T00:00:00.000Z",
101 );
102
103 expect(state.status).toBe("downloaded");
104 expect(state.downloadedVersion).toBe("1.1.0");
105 expect(state.releaseNotes).toEqual(releaseNotes);
106 expect(state.downloadPercent).toBe(100);
107 });
108
58109 it("preserves available version on download failure for retry", () => {
59110 const state = reduceDesktopUpdateStateOnDownloadFailure(
60111 {
......@@ -95,7 +146,8 @@ describe("updateMachine", () => {
95146 expect(failedInstall.canRetry).toBe(true);
96147 });
97148
98 it("clears stale download state when no update is available", () => {
149 it("preserves a downloaded update when no update is available", () => {
150 const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }];
99151 const state = reduceDesktopUpdateStateOnNoUpdate(
100152 {
101153 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
......@@ -103,6 +155,32 @@ describe("updateMachine", () => {
103155 status: "error",
104156 availableVersion: "1.1.0",
105157 downloadedVersion: "1.1.0",
158 releaseNotes,
159 message: "old failure",
160 errorContext: "download",
161 canRetry: true,
162 },
163 "2026-03-04T00:00:00.000Z",
164 );
165
166 expect(state.status).toBe("downloaded");
167 expect(state.availableVersion).toBe("1.1.0");
168 expect(state.downloadedVersion).toBe("1.1.0");
169 expect(state.releaseNotes).toBe(releaseNotes);
170 expect(state.downloadPercent).toBe(100);
171 expect(state.message).toBeNull();
172 expect(state.errorContext).toBeNull();
173 expect(state.canRetry).toBe(true);
174 });
175
176 it("clears stale available state when no update is available", () => {
177 const state = reduceDesktopUpdateStateOnNoUpdate(
178 {
179 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
180 enabled: true,
181 status: "error",
182 availableVersion: "1.1.0",
183 releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"] }],
106184 message: "old failure",
107185 errorContext: "download",
108186 canRetry: true,
......@@ -113,6 +191,7 @@ describe("updateMachine", () => {
113191 expect(state.status).toBe("up-to-date");
114192 expect(state.availableVersion).toBeNull();
115193 expect(state.downloadedVersion).toBeNull();
194 expect(state.releaseNotes).toEqual([]);
116195 expect(state.message).toBeNull();
117196 expect(state.errorContext).toBeNull();
118197 });
apps/desktop/src/updates/updateMachine.ts+36-7
......@@ -43,13 +43,14 @@ export function reduceDesktopUpdateStateOnCheckStart(
4343 state: DesktopUpdateState,
4444 checkedAt: string,
4545): DesktopUpdateState {
46 const hasDownloadedUpdate = state.downloadedVersion !== null;
4647 return {
4748 ...state,
4849 status: "checking",
4950 checkedAt,
50 releaseNotes: [],
51 releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [],
5152 message: null,
52 downloadPercent: null,
53 downloadPercent: hasDownloadedUpdate ? 100 : null,
5354 errorContext: null,
5455 canRetry: false,
5556 };
......@@ -60,6 +61,18 @@ export function reduceDesktopUpdateStateOnCheckFailure(
6061 message: string,
6162 checkedAt: string,
6263): DesktopUpdateState {
64 if (state.downloadedVersion !== null) {
65 return {
66 ...state,
67 status: "downloaded",
68 message: null,
69 checkedAt,
70 downloadPercent: 100,
71 errorContext: null,
72 canRetry: true,
73 };
74 }
75
6376 return {
6477 ...state,
6578 status: "error",
......@@ -77,17 +90,20 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
7790 checkedAt: string,
7891 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [],
7992): DesktopUpdateState {
93 const isDownloadedVersion = state.downloadedVersion === version;
94 const nextReleaseNotes =
95 isDownloadedVersion && releaseNotes.length === 0 ? state.releaseNotes : releaseNotes;
8096 return {
8197 ...state,
82 status: "available",
98 status: isDownloadedVersion ? "downloaded" : "available",
8399 availableVersion: version,
84 downloadedVersion: null,
85 releaseNotes,
86 downloadPercent: null,
100 downloadedVersion: isDownloadedVersion ? version : null,
101 releaseNotes: nextReleaseNotes,
102 downloadPercent: isDownloadedVersion ? 100 : null,
87103 checkedAt,
88104 message: null,
89105 errorContext: null,
90 canRetry: false,
106 canRetry: isDownloadedVersion,
91107 };
92108}
93109
......@@ -95,6 +111,19 @@ export function reduceDesktopUpdateStateOnNoUpdate(
95111 state: DesktopUpdateState,
96112 checkedAt: string,
97113): DesktopUpdateState {
114 if (state.downloadedVersion !== null) {
115 return {
116 ...state,
117 status: "downloaded",
118 availableVersion: state.downloadedVersion,
119 downloadPercent: 100,
120 checkedAt,
121 message: null,
122 errorContext: null,
123 canRetry: true,
124 };
125 }
126
98127 return {
99128 ...state,
100129 status: "up-to-date",
apps/web/src/components/desktopUpdate.logic.test.ts+30-4
......@@ -73,14 +73,40 @@ describe("desktop update button state", () => {
7373 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
7474 });
7575
76 it("prefers install when a downloaded version already exists", () => {
76 it("keeps install action available after a background updater error", () => {
77 const state: DesktopUpdateState = {
78 ...baseState,
79 status: "error",
80 downloadedVersion: "1.1.0",
81 availableVersion: "1.1.0",
82 message: "background updater error",
83 errorContext: null,
84 canRetry: true,
85 };
86 expect(shouldShowDesktopUpdateButton(state)).toBe(true);
87 expect(resolveDesktopUpdateButtonAction(state)).toBe("install");
88 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install");
89 });
90
91 it("prefers a newly available release over a stale downloaded version", () => {
7792 const state: DesktopUpdateState = {
7893 ...baseState,
7994 status: "available",
95 availableVersion: "1.2.0",
96 downloadedVersion: "1.1.0",
97 };
98 expect(resolveDesktopUpdateButtonAction(state)).toBe("download");
99 });
100
101 it("hides the install action while checking for a newer release", () => {
102 const state: DesktopUpdateState = {
103 ...baseState,
104 status: "checking",
80105 availableVersion: "1.1.0",
81106 downloadedVersion: "1.1.0",
107 downloadPercent: 100,
82108 };
83 expect(resolveDesktopUpdateButtonAction(state)).toBe("install");
109 expect(resolveDesktopUpdateButtonAction(state)).toBe("none");
84110 });
85111
86112 it("hides the button for non-actionable check errors", () => {
......@@ -275,7 +301,7 @@ describe("canCheckForUpdate", () => {
275301 );
276302 });
277303
278 it("returns false once an update has been downloaded", () => {
304 it("returns true once an update has been downloaded so newer releases can be found", () => {
279305 expect(
280306 canCheckForUpdate({
281307 ...baseState,
......@@ -283,7 +309,7 @@ describe("canCheckForUpdate", () => {
283309 availableVersion: "1.1.0",
284310 downloadedVersion: "1.1.0",
285311 }),
286 ).toBe(false);
312 ).toBe(true);
287313 });
288314
289315 it("returns true when idle", () => {
apps/web/src/components/desktopUpdate.logic.ts+10-5
......@@ -23,7 +23,12 @@ export function getDesktopUpdateReleaseUrl(version: string | null): string | nul
2323export function resolveDesktopUpdateButtonAction(
2424 state: DesktopUpdateState,
2525): DesktopUpdateButtonAction {
26 if (state.downloadedVersion) {
26 if (
27 state.downloadedVersion &&
28 (state.status === "downloaded" ||
29 (state.status === "error" &&
30 (state.errorContext === null || state.errorContext === "install")))
31 ) {
2732 return "install";
2833 }
2934 if (state.status === "available") {
......@@ -89,6 +94,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
8994 if (state.errorContext === "install" && state.downloadedVersion) {
9095 return `Install failed for ${state.downloadedVersion}. Click to retry.`;
9196 }
97 if (state.downloadedVersion) {
98 return `Update ${state.downloadedVersion} downloaded. Click to restart and install.`;
99 }
92100 return state.message ?? "Update failed";
93101 }
94102 return "Up to date";
......@@ -120,9 +128,6 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
120128export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {
121129 if (!state || !state.enabled) return false;
122130 return (
123 state.status !== "checking" &&
124 state.status !== "downloading" &&
125 state.status !== "downloaded" &&
126 state.status !== "disabled"
131 state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled"
127132 );
128133}