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 {...@@ -27,6 +27,7 @@ interface UpdatesHarnessOptions {
27 void,27 void,
28 ElectronUpdater.ElectronUpdaterCheckForUpdatesError28 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
29 >;29 >;
30 readonly beforeSetUpdateChannel?: Effect.Effect<void>;
30 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;31 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
31 readonly setDisableDifferentialDownload?: Effect.Effect<void>;32 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
32 readonly stopBackend?: Effect.Effect<void>;33 readonly stopBackend?: Effect.Effect<void>;
...@@ -153,22 +154,41 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -153,22 +154,41 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
153 ),154 ),
154 );155 );
155156
157 let testSettings: DesktopAppSettings.DesktopSettings = {
158 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
159 };
156 const setUpdateChannelError = options.setUpdateChannelError;160 const setUpdateChannelError = options.setUpdateChannelError;
157 const settingsLayer = setUpdateChannelError161 const settingsLayer =
158 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {162 setUpdateChannelError || options.beforeSetUpdateChannel
159 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),163 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
160 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),164 get: Effect.sync(() => testSettings),
161 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),165 load: Effect.sync(() => testSettings),
162 setServerExposureMode: () => Effect.die("unexpected server exposure update"),166 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
163 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),167 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
164 setUpdateChannel: () => Effect.fail(setUpdateChannelError),168 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
165 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),169 setUpdateChannel: (channel) =>
166 setWslDistro: () => Effect.die("unexpected WSL distro change"),170 setUpdateChannelError
167 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),171 ? Effect.fail(setUpdateChannelError)
168 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),172 : (options.beforeSetUpdateChannel ?? Effect.void).pipe(
169 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),173 Effect.andThen(
170 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])174 Effect.sync(() => {
171 : DesktopAppSettings.layer;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
173 const layer = DesktopUpdates.layer.pipe(193 const layer = DesktopUpdates.layer.pipe(
174 Layer.provideMerge(updaterLayer),194 Layer.provideMerge(updaterLayer),
...@@ -337,6 +357,178 @@ describe("DesktopUpdates", () => {...@@ -337,6 +357,178 @@ describe("DesktopUpdates", () => {
337 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));357 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
338 });358 });
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
340 it.effect("keeps raw updater event failures out of update state", () => {532 it.effect("keeps raw updater event failures out of update state", () => {
341 const harness = makeHarness();533 const harness = makeHarness();
342 const cause = new Error(534 const cause = new Error(
...@@ -359,6 +551,30 @@ describe("DesktopUpdates", () => {...@@ -359,6 +551,30 @@ describe("DesktopUpdates", () => {
359 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));551 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
360 });552 });
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
362 it.effect("logs bounded updater failure context without exposing the cause", () => {578 it.effect("logs bounded updater failure context without exposing the cause", () => {
363 const cause = new Error(579 const cause = new Error(
364 "request failed for https://user:secret@example.com/update?token=secret",580 "request failed for https://user:secret@example.com/update?token=secret",
...@@ -581,6 +797,38 @@ describe("DesktopUpdates", () => {...@@ -581,6 +797,38 @@ describe("DesktopUpdates", () => {
581 }),797 }),
582 );798 );
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
584 it.effect("preserves settings failure context when an update channel cannot be persisted", () => {832 it.effect("preserves settings failure context when an update channel cannot be persisted", () => {
585 const diskFailure = new Error("disk exploded");833 const diskFailure = new Error("disk exploded");
586 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({834 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({
...@@ -604,6 +852,10 @@ describe("DesktopUpdates", () => {...@@ -604,6 +852,10 @@ describe("DesktopUpdates", () => {
604 assert.strictEqual(error.cause.cause, diskFailure);852 assert.strictEqual(error.cause.cause, diskFailure);
605 assert.equal(error.message, "Failed to persist the nightly desktop update channel.");853 assert.equal(error.message, "Failed to persist the nightly desktop update channel.");
606 assert.notInclude(error.message, diskFailure.message);854 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);
607 }),859 }),
608 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));860 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
609 });861 });
apps/desktop/src/updates/DesktopUpdates.ts+101-81
...@@ -45,6 +45,8 @@ import {...@@ -45,6 +45,8 @@ import {
45const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";45const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
46const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";46const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
4747
48type UpdateAction = "check" | "download" | "install" | "channel";
49
48const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);50const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
49type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;51type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
5052
...@@ -68,7 +70,7 @@ const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));...@@ -68,7 +70,7 @@ const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
68export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<DesktopUpdateActionInProgressError>()(70export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<DesktopUpdateActionInProgressError>()(
69 "DesktopUpdateActionInProgressError",71 "DesktopUpdateActionInProgressError",
70 {72 {
71 action: Schema.Literals(["check", "download", "install"]),73 action: Schema.Literals(["check", "download", "install", "channel"]),
72 requestedChannel: DesktopUpdateChannelSchema,74 requestedChannel: DesktopUpdateChannelSchema,
73 },75 },
74) {76) {
...@@ -116,7 +118,7 @@ export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<Des...@@ -116,7 +118,7 @@ export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<Des
116export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()(118export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()(
117 "DesktopUpdaterReportedError",119 "DesktopUpdaterReportedError",
118 {120 {
119 operation: Schema.Literals(["check", "download", "install", "background"]),121 operation: Schema.Literals(["check", "download", "install", "channel", "background"]),
120 cause: Schema.Defect(),122 cause: Schema.Defect(),
121 },123 },
122) {124) {
...@@ -255,9 +257,7 @@ export const make = Effect.gen(function* () {...@@ -255,9 +257,7 @@ export const make = Effect.gen(function* () {
255 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;257 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
256258
257 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());259 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
258 const updateCheckInFlightRef = yield* Ref.make(false);260 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
259 const updateDownloadInFlightRef = yield* Ref.make(false);
260 const updateInstallInFlightRef = yield* Ref.make(false);
261 const updaterConfiguredRef = yield* Ref.make(false);261 const updaterConfiguredRef = yield* Ref.make(false);
262 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);262 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
263 const updateStateRef = yield* Ref.make<DesktopUpdateState>(263 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
...@@ -313,19 +313,23 @@ export const make = Effect.gen(function* () {...@@ -313,19 +313,23 @@ export const make = Effect.gen(function* () {
313 );313 );
314 });314 });
315315
316 const resolveUpdaterErrorContext = Effect.gen(function* () {316 const activeUpdateAction = Ref.get(activeUpdateActionRef);
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 });
322317
323 const activeUpdateAction = Effect.gen(function* () {318 const tryStartUpdateAction = (action: UpdateAction): Effect.Effect<boolean> =>
324 if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const);319 Ref.modify(activeUpdateActionRef, (activeAction) =>
325 if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const);320 Option.isSome(activeAction) ? [false, activeAction] : [true, Option.some(action)],
326 if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const);321 );
327 return Option.none<"check" | "download" | "install">();322
328 });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
330 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (334 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
331 channel: DesktopUpdateChannel,335 channel: DesktopUpdateChannel,
...@@ -346,14 +350,16 @@ export const make = Effect.gen(function* () {...@@ -346,14 +350,16 @@ export const make = Effect.gen(function* () {
346350
347 const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));351 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 ) {
350 yield* Effect.annotateCurrentSpan({ reason });357 yield* Effect.annotateCurrentSpan({ reason });
351 if (yield* Ref.get(desktopState.quitting)) return false;358 if (yield* Ref.get(desktopState.quitting)) return false;
352 if (!(yield* Ref.get(updaterConfiguredRef))) return false;359 if (!(yield* Ref.get(updaterConfiguredRef))) return false;
353 if (yield* Ref.get(updateCheckInFlightRef)) return false;
354360
355 const state = yield* Ref.get(updateStateRef);361 const state = yield* Ref.get(updateStateRef);
356 if (state.status === "downloading" || state.status === "downloaded") {362 if (state.status === "downloading") {
357 yield* logUpdaterInfo("skipping update check while update is active", {363 yield* logUpdaterInfo("skipping update check while update is active", {
358 reason,364 reason,
359 status: state.status,365 status: state.status,
...@@ -361,43 +367,48 @@ export const make = Effect.gen(function* () {...@@ -361,43 +367,48 @@ export const make = Effect.gen(function* () {
361 return false;367 return false;
362 }368 }
363369
364 yield* Ref.set(updateCheckInFlightRef, true);370 if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false;
365 const checkedAt = yield* currentIsoTimestamp;
366 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
367 yield* logUpdaterInfo("checking for updates", { reason });
368371
369 return yield* electronUpdater.checkForUpdates.pipe(372 const check = Effect.gen(function* () {
370 Effect.as(true),373 const checkedAt = yield* currentIsoTimestamp;
371 Effect.catchTags({374 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
372 ElectronUpdaterCheckForUpdatesError: Effect.fn(375 yield* logUpdaterInfo("checking for updates", { reason });
373 "desktop.updates.handleCheckForUpdatesFailure",376
374 )(function* (error) {377 return yield* electronUpdater.checkForUpdates.pipe(
375 const failedAt = yield* currentIsoTimestamp;378 Effect.as(true),
376 yield* updateState((current) =>379 Effect.catchTags({
377 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),380 ElectronUpdaterCheckForUpdatesError: Effect.fn(
378 );381 "desktop.updates.handleCheckForUpdatesFailure",
379 yield* logUpdaterError(error.message, {382 )(function* (error) {
380 errorTag: error._tag,383 const failedAt = yield* currentIsoTimestamp;
381 channel: error.channel,384 yield* updateState((current) =>
382 });385 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
383 return true;386 );
387 yield* logUpdaterError(error.message, {
388 errorTag: error._tag,
389 channel: error.channel,
390 });
391 return true;
392 }),
384 }),393 }),
385 }),394 );
386 Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),395 });
387 );396
397 return yield* actionReservation === "held"
398 ? check
399 : check.pipe(Effect.ensuring(finishUpdateAction("check")));
388 });400 });
389401
390 const downloadAvailableUpdate = Effect.gen(function* () {402 const downloadAvailableUpdate = Effect.gen(function* () {
391 const state = yield* Ref.get(updateStateRef);403 const state = yield* Ref.get(updateStateRef);
392 if (404 if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") {
393 !(yield* Ref.get(updaterConfiguredRef)) ||405 return { accepted: false, completed: false };
394 (yield* Ref.get(updateDownloadInFlightRef)) ||406 }
395 state.status !== "available"407
396 ) {408 if (!(yield* tryStartUpdateAction("download"))) {
397 return { accepted: false, completed: false };409 return { accepted: false, completed: false };
398 }410 }
399411
400 yield* Ref.set(updateDownloadInFlightRef, true);
401 return yield* Effect.gen(function* () {412 return yield* Effect.gen(function* () {
402 yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));413 yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));
403 yield* electronUpdater.setDisableDifferentialDownload(414 yield* electronUpdater.setDisableDifferentialDownload(
...@@ -442,27 +453,35 @@ export const make = Effect.gen(function* () {...@@ -442,27 +453,35 @@ export const make = Effect.gen(function* () {
442 return { accepted: true, completed: false };453 return { accepted: true, completed: false };
443 });454 });
444 }),455 }),
445 Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),456 Effect.ensuring(finishUpdateAction("download")),
446 );457 );
447 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));458 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
448459
449 const resetInstallAction = Effect.all(460 const resetInstallAction = Effect.all(
450 [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)],461 [finishUpdateAction("install"), Ref.set(desktopState.quitting, false)],
451 { discard: true },462 { discard: true },
452 );463 );
453464
454 const installDownloadedUpdate = Effect.gen(function* () {465 const installDownloadedUpdate = Effect.gen(function* () {
455 const state = yield* Ref.get(updateStateRef);466 const state = yield* Ref.get(updateStateRef);
467 const hasInstallableDownload =
468 state.downloadedVersion !== null &&
469 (state.status === "downloaded" ||
470 (state.status === "error" &&
471 (state.errorContext === null || state.errorContext === "install")));
456 if (472 if (
457 (yield* Ref.get(desktopState.quitting)) ||473 (yield* Ref.get(desktopState.quitting)) ||
458 !(yield* Ref.get(updaterConfiguredRef)) ||474 !(yield* Ref.get(updaterConfiguredRef)) ||
459 state.status !== "downloaded"475 !hasInstallableDownload
460 ) {476 ) {
461 return { accepted: false, completed: false };477 return { accepted: false, completed: false };
462 }478 }
463479
480 if (!(yield* tryStartUpdateAction("install"))) {
481 return { accepted: false, completed: false };
482 }
483
464 yield* Ref.set(desktopState.quitting, true);484 yield* Ref.set(desktopState.quitting, true);
465 yield* Ref.set(updateInstallInFlightRef, true);
466485
467 return yield* Effect.gen(function* () {486 return yield* Effect.gen(function* () {
468 // Stop every backend in the pool, not just the primary. With487 // Stop every backend in the pool, not just the primary. With
...@@ -614,8 +633,8 @@ export const make = Effect.gen(function* () {...@@ -614,8 +633,8 @@ export const make = Effect.gen(function* () {
614 operation: Option.getOrElse(activeAction, () => "background" as const),633 operation: Option.getOrElse(activeAction, () => "background" as const),
615 cause,634 cause,
616 });635 });
617 if (yield* Ref.get(updateInstallInFlightRef)) {636 if (Option.isSome(activeAction) && activeAction.value === "install") {
618 yield* Ref.set(updateInstallInFlightRef, false);637 yield* finishUpdateAction("install");
619 yield* Ref.set(desktopState.quitting, false);638 yield* Ref.set(desktopState.quitting, false);
620 yield* updateState((current) =>639 yield* updateState((current) =>
621 reduceDesktopUpdateStateOnInstallFailure(current, error.message),640 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
...@@ -627,8 +646,7 @@ export const make = Effect.gen(function* () {...@@ -627,8 +646,7 @@ export const make = Effect.gen(function* () {
627 return;646 return;
628 }647 }
629648
630 if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) {649 if (Option.isNone(activeAction)) {
631 const errorContext = yield* resolveUpdaterErrorContext;
632 const checkedAt = yield* currentIsoTimestamp;650 const checkedAt = yield* currentIsoTimestamp;
633 yield* updateState((current) => ({651 yield* updateState((current) => ({
634 ...current,652 ...current,
...@@ -636,7 +654,7 @@ export const make = Effect.gen(function* () {...@@ -636,7 +654,7 @@ export const make = Effect.gen(function* () {
636 message: error.message,654 message: error.message,
637 checkedAt,655 checkedAt,
638 downloadPercent: null,656 downloadPercent: null,
639 errorContext,657 errorContext: current.errorContext,
640 canRetry: getCanRetryFromState(current),658 canRetry: getCanRetryFromState(current),
641 }));659 }));
642 }660 }
...@@ -773,7 +791,7 @@ export const make = Effect.gen(function* () {...@@ -773,7 +791,7 @@ export const make = Effect.gen(function* () {
773 nextChannel: DesktopUpdateChannel,791 nextChannel: DesktopUpdateChannel,
774 ) {792 ) {
775 yield* Effect.annotateCurrentSpan({ channel: nextChannel });793 yield* Effect.annotateCurrentSpan({ channel: nextChannel });
776 const activeAction = yield* activeUpdateAction;794 const activeAction = yield* tryStartChannelChange;
777 if (Option.isSome(activeAction)) {795 if (Option.isSome(activeAction)) {
778 return yield* new DesktopUpdateActionInProgressError({796 return yield* new DesktopUpdateActionInProgressError({
779 action: activeAction.value,797 action: activeAction.value,
...@@ -781,33 +799,35 @@ export const make = Effect.gen(function* () {...@@ -781,33 +799,35 @@ export const make = Effect.gen(function* () {
781 });799 });
782 }800 }
783801
784 const state = yield* Ref.get(updateStateRef);802 return yield* Effect.gen(function* () {
785 if (nextChannel === state.channel) {803 const state = yield* Ref.get(updateStateRef);
786 return state;804 if (nextChannel === state.channel) {
787 }805 return state;
806 }
788807
789 yield* desktopSettings808 yield* desktopSettings
790 .setUpdateChannel(nextChannel)809 .setUpdateChannel(nextChannel)
791 .pipe(810 .pipe(
792 Effect.mapError(811 Effect.mapError(
793 (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),812 (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
794 ),813 ),
795 );814 );
796815
797 const enabled = yield* shouldEnableAutoUpdates;816 const enabled = yield* shouldEnableAutoUpdates;
798 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));817 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
799818
800 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {819 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
801 return yield* Ref.get(updateStateRef);820 return yield* Ref.get(updateStateRef);
802 }821 }
803822
804 yield* applyAutoUpdaterChannel(nextChannel);823 yield* applyAutoUpdaterChannel(nextChannel);
805 const allowDowngrade = yield* electronUpdater.allowDowngrade;824 const allowDowngrade = yield* electronUpdater.allowDowngrade;
806 yield* electronUpdater.setAllowDowngrade(true);825 yield* electronUpdater.setAllowDowngrade(true);
807 yield* checkForUpdates("channel-change").pipe(826 yield* checkForUpdates("channel-change", "held").pipe(
808 Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),827 Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
809 );828 );
810 return yield* Ref.get(updateStateRef);829 return yield* Ref.get(updateStateRef);
830 }).pipe(Effect.ensuring(finishUpdateAction("channel")));
811 }),831 }),
812 check: Effect.fn("desktop.updates.check")(function* (reason: string) {832 check: Effect.fn("desktop.updates.check")(function* (reason: string) {
813 yield* Effect.annotateCurrentSpan({ reason });833 yield* Effect.annotateCurrentSpan({ reason });
apps/desktop/src/updates/updateMachine.test.ts+80-1
...@@ -55,6 +55,57 @@ describe("updateMachine", () => {...@@ -55,6 +55,57 @@ describe("updateMachine", () => {
55 expect(state.canRetry).toBe(true);55 expect(state.canRetry).toBe(true);
56 });56 });
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
58 it("preserves available version on download failure for retry", () => {109 it("preserves available version on download failure for retry", () => {
59 const state = reduceDesktopUpdateStateOnDownloadFailure(110 const state = reduceDesktopUpdateStateOnDownloadFailure(
60 {111 {
...@@ -95,7 +146,8 @@ describe("updateMachine", () => {...@@ -95,7 +146,8 @@ describe("updateMachine", () => {
95 expect(failedInstall.canRetry).toBe(true);146 expect(failedInstall.canRetry).toBe(true);
96 });147 });
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"] }];
99 const state = reduceDesktopUpdateStateOnNoUpdate(151 const state = reduceDesktopUpdateStateOnNoUpdate(
100 {152 {
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),153 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
...@@ -103,6 +155,32 @@ describe("updateMachine", () => {...@@ -103,6 +155,32 @@ describe("updateMachine", () => {
103 status: "error",155 status: "error",
104 availableVersion: "1.1.0",156 availableVersion: "1.1.0",
105 downloadedVersion: "1.1.0",157 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"] }],
106 message: "old failure",184 message: "old failure",
107 errorContext: "download",185 errorContext: "download",
108 canRetry: true,186 canRetry: true,
...@@ -113,6 +191,7 @@ describe("updateMachine", () => {...@@ -113,6 +191,7 @@ describe("updateMachine", () => {
113 expect(state.status).toBe("up-to-date");191 expect(state.status).toBe("up-to-date");
114 expect(state.availableVersion).toBeNull();192 expect(state.availableVersion).toBeNull();
115 expect(state.downloadedVersion).toBeNull();193 expect(state.downloadedVersion).toBeNull();
194 expect(state.releaseNotes).toEqual([]);
116 expect(state.message).toBeNull();195 expect(state.message).toBeNull();
117 expect(state.errorContext).toBeNull();196 expect(state.errorContext).toBeNull();
118 });197 });
apps/desktop/src/updates/updateMachine.ts+36-7
...@@ -43,13 +43,14 @@ export function reduceDesktopUpdateStateOnCheckStart(...@@ -43,13 +43,14 @@ export function reduceDesktopUpdateStateOnCheckStart(
43 state: DesktopUpdateState,43 state: DesktopUpdateState,
44 checkedAt: string,44 checkedAt: string,
45): DesktopUpdateState {45): DesktopUpdateState {
46 const hasDownloadedUpdate = state.downloadedVersion !== null;
46 return {47 return {
47 ...state,48 ...state,
48 status: "checking",49 status: "checking",
49 checkedAt,50 checkedAt,
50 releaseNotes: [],51 releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [],
51 message: null,52 message: null,
52 downloadPercent: null,53 downloadPercent: hasDownloadedUpdate ? 100 : null,
53 errorContext: null,54 errorContext: null,
54 canRetry: false,55 canRetry: false,
55 };56 };
...@@ -60,6 +61,18 @@ export function reduceDesktopUpdateStateOnCheckFailure(...@@ -60,6 +61,18 @@ export function reduceDesktopUpdateStateOnCheckFailure(
60 message: string,61 message: string,
61 checkedAt: string,62 checkedAt: string,
62): DesktopUpdateState {63): 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
63 return {76 return {
64 ...state,77 ...state,
65 status: "error",78 status: "error",
...@@ -77,17 +90,20 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(...@@ -77,17 +90,20 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
77 checkedAt: string,90 checkedAt: string,
78 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [],91 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [],
79): DesktopUpdateState {92): DesktopUpdateState {
93 const isDownloadedVersion = state.downloadedVersion === version;
94 const nextReleaseNotes =
95 isDownloadedVersion && releaseNotes.length === 0 ? state.releaseNotes : releaseNotes;
80 return {96 return {
81 ...state,97 ...state,
82 status: "available",98 status: isDownloadedVersion ? "downloaded" : "available",
83 availableVersion: version,99 availableVersion: version,
84 downloadedVersion: null,100 downloadedVersion: isDownloadedVersion ? version : null,
85 releaseNotes,101 releaseNotes: nextReleaseNotes,
86 downloadPercent: null,102 downloadPercent: isDownloadedVersion ? 100 : null,
87 checkedAt,103 checkedAt,
88 message: null,104 message: null,
89 errorContext: null,105 errorContext: null,
90 canRetry: false,106 canRetry: isDownloadedVersion,
91 };107 };
92}108}
93109
...@@ -95,6 +111,19 @@ export function reduceDesktopUpdateStateOnNoUpdate(...@@ -95,6 +111,19 @@ export function reduceDesktopUpdateStateOnNoUpdate(
95 state: DesktopUpdateState,111 state: DesktopUpdateState,
96 checkedAt: string,112 checkedAt: string,
97): DesktopUpdateState {113): 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
98 return {127 return {
99 ...state,128 ...state,
100 status: "up-to-date",129 status: "up-to-date",
apps/web/src/components/desktopUpdate.logic.test.ts+30-4
...@@ -73,14 +73,40 @@ describe("desktop update button state", () => {...@@ -73,14 +73,40 @@ describe("desktop update button state", () => {
73 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");73 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
74 });74 });
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", () => {
77 const state: DesktopUpdateState = {92 const state: DesktopUpdateState = {
78 ...baseState,93 ...baseState,
79 status: "available",94 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",
80 availableVersion: "1.1.0",105 availableVersion: "1.1.0",
81 downloadedVersion: "1.1.0",106 downloadedVersion: "1.1.0",
107 downloadPercent: 100,
82 };108 };
83 expect(resolveDesktopUpdateButtonAction(state)).toBe("install");109 expect(resolveDesktopUpdateButtonAction(state)).toBe("none");
84 });110 });
85111
86 it("hides the button for non-actionable check errors", () => {112 it("hides the button for non-actionable check errors", () => {
...@@ -275,7 +301,7 @@ describe("canCheckForUpdate", () => {...@@ -275,7 +301,7 @@ describe("canCheckForUpdate", () => {
275 );301 );
276 });302 });
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", () => {
279 expect(305 expect(
280 canCheckForUpdate({306 canCheckForUpdate({
281 ...baseState,307 ...baseState,
...@@ -283,7 +309,7 @@ describe("canCheckForUpdate", () => {...@@ -283,7 +309,7 @@ describe("canCheckForUpdate", () => {
283 availableVersion: "1.1.0",309 availableVersion: "1.1.0",
284 downloadedVersion: "1.1.0",310 downloadedVersion: "1.1.0",
285 }),311 }),
286 ).toBe(false);312 ).toBe(true);
287 });313 });
288314
289 it("returns true when idle", () => {315 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...@@ -23,7 +23,12 @@ export function getDesktopUpdateReleaseUrl(version: string | null): string | nul
23export function resolveDesktopUpdateButtonAction(23export function resolveDesktopUpdateButtonAction(
24 state: DesktopUpdateState,24 state: DesktopUpdateState,
25): DesktopUpdateButtonAction {25): 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 ) {
27 return "install";32 return "install";
28 }33 }
29 if (state.status === "available") {34 if (state.status === "available") {
...@@ -89,6 +94,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string...@@ -89,6 +94,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
89 if (state.errorContext === "install" && state.downloadedVersion) {94 if (state.errorContext === "install" && state.downloadedVersion) {
90 return `Install failed for ${state.downloadedVersion}. Click to retry.`;95 return `Install failed for ${state.downloadedVersion}. Click to retry.`;
91 }96 }
97 if (state.downloadedVersion) {
98 return `Update ${state.downloadedVersion} downloaded. Click to restart and install.`;
99 }
92 return state.message ?? "Update failed";100 return state.message ?? "Update failed";
93 }101 }
94 return "Up to date";102 return "Up to date";
...@@ -120,9 +128,6 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu...@@ -120,9 +128,6 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
120export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {128export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {
121 if (!state || !state.enabled) return false;129 if (!state || !state.enabled) return false;
122 return (130 return (
123 state.status !== "checking" &&131 state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled"
124 state.status !== "downloading" &&
125 state.status !== "downloaded" &&
126 state.status !== "disabled"
127 );132 );
128}133}