| author | |
| committer | |
| log | a354dd9ddc08ef67da7e971ad59a3cd095068325 |
| tree | 1b09f897fa3263431ab762a1bb2e7b43c4e0ce93 |
| parent | 51341f2ac1e25032efac13ef6e9652e9c74b48a3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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.ElectronUpdaterCheckForUpdatesError | 28 | 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 | ); |
| 155 | 156 | ||
| 157 | let testSettings: DesktopAppSettings.DesktopSettings = { | ||
| 158 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | ||
| 159 | }; | ||
| 156 | const setUpdateChannelError = options.setUpdateChannelError; | 160 | const setUpdateChannelError = options.setUpdateChannelError; |
| 157 | const settingsLayer = setUpdateChannelError | 161 | 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; | ||
| 172 | 192 | ||
| 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 | }); |
| 339 | 359 | ||
| 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 | }); |
| 361 | 553 | ||
| 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 | ); |
| 583 | 799 | ||
| 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 { |
| 45 | const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; | 45 | const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; |
| 46 | const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; | 46 | const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; |
| 47 | 47 | ||
| 48 | type UpdateAction = "check" | "download" | "install" | "channel"; | ||
| 49 | |||
| 48 | const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); | 50 | const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); |
| 49 | type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; | 51 | type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; |
| 50 | 52 | ||
| ... | @@ -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)); |
| 68 | export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<DesktopUpdateActionInProgressError>()( | 70 | export 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 |
| 116 | export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()( | 118 | export 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; |
| 256 | 258 | ||
| 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 | }); |
| 315 | 315 | ||
| 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 | }); | ||
| 322 | 317 | ||
| 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 | ); | ||
| 329 | 333 | ||
| 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* () { |
| 346 | 350 | ||
| 347 | const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); | 351 | const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); |
| 348 | 352 | ||
| 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; | ||
| 354 | 360 | ||
| 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 | } |
| 363 | 369 | ||
| 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 }); | ||
| 368 | 371 | ||
| 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 | }); |
| 389 | 401 | ||
| 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 | } |
| 399 | 411 | ||
| 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")); |
| 448 | 459 | ||
| 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 | ); |
| 453 | 464 | ||
| 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 | } |
| 463 | 479 | ||
| 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); | ||
| 466 | 485 | ||
| 467 | return yield* Effect.gen(function* () { | 486 | return yield* Effect.gen(function* () { |
| 468 | // Stop every backend in the pool, not just the primary. With | 487 | // 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 | } |
| 629 | 648 | ||
| 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 | } |
| 783 | 801 | ||
| 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 | } | ||
| 788 | 807 | ||
| 789 | yield* desktopSettings | 808 | 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 | ); |
| 796 | 815 | ||
| 797 | const enabled = yield* shouldEnableAutoUpdates; | 816 | const enabled = yield* shouldEnableAutoUpdates; |
| 798 | yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); | 817 | yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); |
| 799 | 818 | ||
| 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 | } |
| 803 | 822 | ||
| 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 | }); |
| 57 | 57 | ||
| 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 | }); |
| 97 | 148 | ||
| 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 | } |
| 93 | 109 | ||
| ... | @@ -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 | }); |
| 75 | 75 | ||
| 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 | }); |
| 85 | 111 | ||
| 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 | }); |
| 277 | 303 | ||
| 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 | }); |
| 288 | 314 | ||
| 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 |
| 23 | export function resolveDesktopUpdateButtonAction( | 23 | export 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 |
| 120 | export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { | 128 | export 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 | } |