| author | |
| committer | |
| log | 9f7861aadd257313efefdc690e440d23d771f315 |
| tree | 790fd105686354e6e67ba7e9cedd54535716972a |
| parent | 9243ead1c3eebb6316321012cf42a21737965a56 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 524 insertions(+), 80 deletions(-)
apps/desktop/src/settings/DesktopAppSettings.ts+47-20| ... | ... | @@ -83,12 +83,6 @@ export class DesktopSettingsWriteError extends Schema.TaggedErrorClass<DesktopSe |
| 83 | 83 | } |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | const writeError = ( | |
| 87 | operation: DesktopSettingsWriteOperation, | |
| 88 | path: string, | |
| 89 | cause: unknown, | |
| 90 | ): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause }); | |
| 91 | ||
| 92 | 86 | export class DesktopAppSettings extends Context.Service< |
| 93 | 87 | DesktopAppSettings, |
| 94 | 88 | { |
| ... | ... | @@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp |
| 244 | 238 | const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; |
| 245 | 239 | const encoded = yield* encodeDesktopSettingsJson( |
| 246 | 240 | toDesktopSettingsDocument(input.settings, input.defaultSettings), |
| 247 | ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause))); | |
| 248 | yield* input.fileSystem | |
| 249 | .makeDirectory(directory, { recursive: true }) | |
| 250 | .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); | |
| 251 | yield* input.fileSystem | |
| 252 | .writeFileString(tempPath, `${encoded}\n`) | |
| 253 | .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); | |
| 254 | yield* input.fileSystem | |
| 255 | .rename(tempPath, input.settingsPath) | |
| 256 | .pipe( | |
| 257 | Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), | |
| 258 | ); | |
| 241 | ).pipe( | |
| 242 | Effect.mapError( | |
| 243 | (cause) => | |
| 244 | new DesktopSettingsWriteError({ | |
| 245 | operation: "encode-document", | |
| 246 | path: input.settingsPath, | |
| 247 | cause, | |
| 248 | }), | |
| 249 | ), | |
| 250 | ); | |
| 251 | yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( | |
| 252 | Effect.mapError( | |
| 253 | (cause) => | |
| 254 | new DesktopSettingsWriteError({ | |
| 255 | operation: "create-directory", | |
| 256 | path: directory, | |
| 257 | cause, | |
| 258 | }), | |
| 259 | ), | |
| 260 | ); | |
| 261 | yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( | |
| 262 | Effect.mapError( | |
| 263 | (cause) => | |
| 264 | new DesktopSettingsWriteError({ | |
| 265 | operation: "write-temporary-file", | |
| 266 | path: tempPath, | |
| 267 | cause, | |
| 268 | }), | |
| 269 | ), | |
| 270 | ); | |
| 271 | yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( | |
| 272 | Effect.mapError( | |
| 273 | (cause) => | |
| 274 | new DesktopSettingsWriteError({ | |
| 275 | operation: "replace-settings-file", | |
| 276 | path: input.settingsPath, | |
| 277 | cause, | |
| 278 | }), | |
| 279 | ), | |
| 280 | ); | |
| 259 | 281 | }); |
| 260 | 282 | |
| 261 | 283 | export const make = Effect.gen(function* () { |
| ... | ... | @@ -276,8 +298,13 @@ export const make = Effect.gen(function* () { |
| 276 | 298 | |
| 277 | 299 | return crypto.randomUUIDv4.pipe( |
| 278 | 300 | Effect.map((uuid) => uuid.replace(/-/g, "")), |
| 279 | Effect.mapError((cause) => | |
| 280 | writeError("create-temporary-file-name", environment.desktopSettingsPath, cause), | |
| 301 | Effect.mapError( | |
| 302 | (cause) => | |
| 303 | new DesktopSettingsWriteError({ | |
| 304 | operation: "create-temporary-file-name", | |
| 305 | path: environment.desktopSettingsPath, | |
| 306 | cause, | |
| 307 | }), | |
| 281 | 308 | ), |
| 282 | 309 | Effect.flatMap((suffix) => |
| 283 | 310 | writeSettings({ |
apps/desktop/src/updates/DesktopUpdates.test.ts+259-3| ... | ... | @@ -7,7 +7,10 @@ import * as Duration from "effect/Duration"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as Fiber from "effect/Fiber"; |
| 9 | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Logger from "effect/Logger"; | |
| 10 | 11 | import * as Option from "effect/Option"; |
| 12 | import * as References from "effect/References"; | |
| 13 | import * as Ref from "effect/Ref"; | |
| 11 | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 12 | 15 | |
| 13 | 16 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; |
| ... | ... | @@ -24,6 +27,9 @@ interface UpdatesHarnessOptions { |
| 24 | 27 | void, |
| 25 | 28 | ElectronUpdater.ElectronUpdaterCheckForUpdatesError |
| 26 | 29 | >; |
| 30 | readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; | |
| 31 | readonly setDisableDifferentialDownload?: Effect.Effect<void>; | |
| 32 | readonly stopBackend?: Effect.Effect<void>; | |
| 27 | 33 | readonly env?: Record<string, string | undefined>; |
| 28 | 34 | } |
| 29 | 35 | |
| ... | ... | @@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 67 | 73 | Effect.sync(() => { |
| 68 | 74 | allowDowngrade = value; |
| 69 | 75 | }), |
| 70 | setDisableDifferentialDownload: () => Effect.void, | |
| 76 | setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, | |
| 71 | 77 | checkForUpdates: Effect.sync(() => { |
| 72 | 78 | checkCount += 1; |
| 73 | 79 | }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), |
| ... | ... | @@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 103 | 109 | |
| 104 | 110 | const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { |
| 105 | 111 | start: Effect.void, |
| 106 | stop: () => Effect.void, | |
| 112 | stop: () => options.stopBackend ?? Effect.void, | |
| 107 | 113 | currentConfig: Effect.succeed(Option.none()), |
| 108 | 114 | snapshot: Effect.succeed({ |
| 109 | 115 | desiredRunning: false, |
| ... | ... | @@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 138 | 144 | ), |
| 139 | 145 | ); |
| 140 | 146 | |
| 147 | const setUpdateChannelError = options.setUpdateChannelError; | |
| 148 | const settingsLayer = setUpdateChannelError | |
| 149 | ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { | |
| 150 | get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), | |
| 151 | load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), | |
| 152 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), | |
| 153 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), | |
| 154 | setUpdateChannel: () => Effect.fail(setUpdateChannelError), | |
| 155 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) | |
| 156 | : DesktopAppSettings.layer; | |
| 157 | ||
| 141 | 158 | const layer = DesktopUpdates.layer.pipe( |
| 142 | 159 | Layer.provideMerge(updaterLayer), |
| 143 | 160 | Layer.provideMerge(windowLayer), |
| 144 | 161 | Layer.provideMerge(backendLayer), |
| 145 | 162 | Layer.provideMerge(DesktopState.layer), |
| 146 | Layer.provideMerge(DesktopAppSettings.layer), | |
| 163 | Layer.provideMerge(settingsLayer), | |
| 147 | 164 | Layer.provideMerge( |
| 148 | 165 | DesktopConfig.layerTest({ |
| 149 | 166 | T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, |
| ... | ... | @@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 175 | 192 | } |
| 176 | 193 | |
| 177 | 194 | describe("DesktopUpdates", () => { |
| 195 | it("preserves complete causes for update poller and event failures", () => { | |
| 196 | const cause = Cause.combine( | |
| 197 | Cause.fail(new Error("updater failed")), | |
| 198 | Cause.die(new Error("updater defect")), | |
| 199 | ); | |
| 200 | const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ | |
| 201 | poller: "startup", | |
| 202 | cause, | |
| 203 | }); | |
| 204 | const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ | |
| 205 | event: "download-progress", | |
| 206 | cause, | |
| 207 | }); | |
| 208 | const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ | |
| 209 | operation: "download", | |
| 210 | cause, | |
| 211 | }); | |
| 212 | const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ | |
| 213 | action: "install", | |
| 214 | cause, | |
| 215 | }); | |
| 216 | ||
| 217 | assert.strictEqual(pollerError.cause, cause); | |
| 218 | assert.equal(pollerError.poller, "startup"); | |
| 219 | assert.equal(pollerError.message, "Desktop update startup poller failed."); | |
| 220 | assert.strictEqual(eventError.cause, cause); | |
| 221 | assert.equal(eventError.event, "download-progress"); | |
| 222 | assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); | |
| 223 | assert.strictEqual(reportedError.cause, cause); | |
| 224 | assert.equal(reportedError.operation, "download"); | |
| 225 | assert.equal(reportedError.message, "Desktop updater download operation reported an error."); | |
| 226 | assert.strictEqual(unexpectedActionError.cause, cause); | |
| 227 | assert.equal(unexpectedActionError.action, "install"); | |
| 228 | assert.equal( | |
| 229 | unexpectedActionError.message, | |
| 230 | "Desktop update install action failed unexpectedly.", | |
| 231 | ); | |
| 232 | }); | |
| 233 | ||
| 178 | 234 | it.effect("configures the updater and runs startup checks on the test clock", () => { |
| 179 | 235 | const harness = makeHarness(); |
| 180 | 236 | |
| ... | ... | @@ -222,6 +278,178 @@ describe("DesktopUpdates", () => { |
| 222 | 278 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 223 | 279 | }); |
| 224 | 280 | |
| 281 | it.effect("keeps raw updater event failures out of update state", () => { | |
| 282 | const harness = makeHarness(); | |
| 283 | const cause = new Error( | |
| 284 | "request failed for https://user:secret@example.com/update?token=secret", | |
| 285 | ); | |
| 286 | ||
| 287 | return Effect.scoped( | |
| 288 | Effect.gen(function* () { | |
| 289 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 290 | yield* updates.configure; | |
| 291 | ||
| 292 | harness.emit("error", cause); | |
| 293 | yield* flushCallbacks; | |
| 294 | ||
| 295 | const state = yield* updates.getState; | |
| 296 | assert.equal(state.status, "error"); | |
| 297 | assert.equal(state.message, "Desktop updater background operation reported an error."); | |
| 298 | assert.notInclude(state.message ?? "", "secret"); | |
| 299 | }), | |
| 300 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 301 | }); | |
| 302 | ||
| 303 | it.effect("logs bounded updater failure context without exposing the cause", () => { | |
| 304 | const cause = new Error( | |
| 305 | "request failed for https://user:secret@example.com/update?token=secret", | |
| 306 | ); | |
| 307 | const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ | |
| 308 | channel: null, | |
| 309 | cause, | |
| 310 | }); | |
| 311 | const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) }); | |
| 312 | const loggedAnnotations: Array<Record<string, unknown>> = []; | |
| 313 | const logger = Logger.make(({ fiber }) => { | |
| 314 | const annotations = fiber.getRef(References.CurrentLogAnnotations); | |
| 315 | if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") { | |
| 316 | loggedAnnotations.push(annotations); | |
| 317 | } | |
| 318 | }); | |
| 319 | ||
| 320 | return Effect.scoped( | |
| 321 | Effect.gen(function* () { | |
| 322 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 323 | yield* updates.configure; | |
| 324 | ||
| 325 | yield* updates.check("manual"); | |
| 326 | ||
| 327 | const state = yield* updates.getState; | |
| 328 | const loggedAnnotation = loggedAnnotations.at(-1); | |
| 329 | assert.isDefined(loggedAnnotation); | |
| 330 | assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); | |
| 331 | assert.isNull(loggedAnnotation.channel); | |
| 332 | assert.notProperty(loggedAnnotation, "error"); | |
| 333 | assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); | |
| 334 | assert.equal( | |
| 335 | state.message, | |
| 336 | "Electron updater failed to check for updates on channel default.", | |
| 337 | ); | |
| 338 | assert.notInclude(state.message ?? "", "secret"); | |
| 339 | }), | |
| 340 | ).pipe( | |
| 341 | Effect.provide( | |
| 342 | Layer.mergeAll( | |
| 343 | TestClock.layer(), | |
| 344 | harness.layer, | |
| 345 | Logger.layer([logger], { mergeWithExisting: false }), | |
| 346 | ), | |
| 347 | ), | |
| 348 | ); | |
| 349 | }); | |
| 350 | ||
| 351 | it.effect("recovers download state after an unexpected setup failure", () => { | |
| 352 | let disableDifferentialCalls = 0; | |
| 353 | const harness = makeHarness({ | |
| 354 | setDisableDifferentialDownload: Effect.suspend(() => { | |
| 355 | disableDifferentialCalls += 1; | |
| 356 | return disableDifferentialCalls === 1 | |
| 357 | ? Effect.void | |
| 358 | : Effect.die(new Error("download setup failed")); | |
| 359 | }), | |
| 360 | }); | |
| 361 | ||
| 362 | return Effect.scoped( | |
| 363 | Effect.gen(function* () { | |
| 364 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 365 | yield* updates.configure; | |
| 366 | harness.emit("update-available", { version: "1.2.4" }); | |
| 367 | yield* flushCallbacks; | |
| 368 | ||
| 369 | const result = yield* updates.download; | |
| 370 | assert.isTrue(result.accepted); | |
| 371 | assert.isFalse(result.completed); | |
| 372 | ||
| 373 | const failedState = yield* updates.getState; | |
| 374 | assert.equal(failedState.status, "available"); | |
| 375 | assert.equal(failedState.errorContext, "download"); | |
| 376 | assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); | |
| 377 | ||
| 378 | const changedState = yield* updates.setChannel("nightly"); | |
| 379 | assert.equal(changedState.channel, "nightly"); | |
| 380 | }), | |
| 381 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 382 | }); | |
| 383 | ||
| 384 | it.effect("restores download state and permits retry after interruption", () => | |
| 385 | Effect.gen(function* () { | |
| 386 | const actionStarted = yield* Deferred.make<void>(); | |
| 387 | let disableDifferentialCalls = 0; | |
| 388 | const harness = makeHarness({ | |
| 389 | setDisableDifferentialDownload: Effect.suspend(() => { | |
| 390 | disableDifferentialCalls += 1; | |
| 391 | if (disableDifferentialCalls === 1) { | |
| 392 | return Effect.void; | |
| 393 | } | |
| 394 | if (disableDifferentialCalls === 2) { | |
| 395 | return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); | |
| 396 | } | |
| 397 | return Effect.void; | |
| 398 | }), | |
| 399 | }); | |
| 400 | ||
| 401 | yield* Effect.scoped( | |
| 402 | Effect.gen(function* () { | |
| 403 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 404 | yield* updates.configure; | |
| 405 | harness.emit("update-available", { version: "1.2.4" }); | |
| 406 | yield* flushCallbacks; | |
| 407 | ||
| 408 | const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); | |
| 409 | yield* Deferred.await(actionStarted); | |
| 410 | yield* Fiber.interrupt(downloadFiber); | |
| 411 | ||
| 412 | const interruptedState = yield* updates.getState; | |
| 413 | assert.equal(interruptedState.status, "available"); | |
| 414 | assert.isNull(interruptedState.message); | |
| 415 | ||
| 416 | const retry = yield* updates.download; | |
| 417 | assert.isTrue(retry.accepted); | |
| 418 | assert.isTrue(retry.completed); | |
| 419 | }), | |
| 420 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 421 | }), | |
| 422 | ); | |
| 423 | ||
| 424 | it.effect("clears quitting state after an unexpected install setup failure", () => { | |
| 425 | const harness = makeHarness({ | |
| 426 | stopBackend: Effect.die(new Error("backend stop failed")), | |
| 427 | }); | |
| 428 | ||
| 429 | return Effect.scoped( | |
| 430 | Effect.gen(function* () { | |
| 431 | const desktopState = yield* DesktopState.DesktopState; | |
| 432 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 433 | yield* updates.configure; | |
| 434 | harness.emit("update-downloaded", { version: "1.2.4" }); | |
| 435 | yield* flushCallbacks; | |
| 436 | ||
| 437 | const result = yield* updates.install; | |
| 438 | assert.isTrue(result.accepted); | |
| 439 | assert.isFalse(result.completed); | |
| 440 | assert.isFalse(yield* Ref.get(desktopState.quitting)); | |
| 441 | ||
| 442 | const failedState = yield* updates.getState; | |
| 443 | assert.equal(failedState.status, "downloaded"); | |
| 444 | assert.equal(failedState.errorContext, "install"); | |
| 445 | assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); | |
| 446 | ||
| 447 | const changedState = yield* updates.setChannel("nightly"); | |
| 448 | assert.equal(changedState.channel, "nightly"); | |
| 449 | }), | |
| 450 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 451 | }); | |
| 452 | ||
| 225 | 453 | it.effect("persists channel changes through the settings service", () => { |
| 226 | 454 | const harness = makeHarness(); |
| 227 | 455 | |
| ... | ... | @@ -284,6 +512,7 @@ describe("DesktopUpdates", () => { |
| 284 | 512 | const error = Cause.squash(exit.cause); |
| 285 | 513 | assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); |
| 286 | 514 | assert.equal(error.action, "check"); |
| 515 | assert.equal(error.requestedChannel, "nightly"); | |
| 287 | 516 | } |
| 288 | 517 | |
| 289 | 518 | yield* Deferred.succeed(releaseCheck, undefined); |
| ... | ... | @@ -292,4 +521,31 @@ describe("DesktopUpdates", () => { |
| 292 | 521 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 293 | 522 | }), |
| 294 | 523 | ); |
| 524 | ||
| 525 | it.effect("preserves settings failure context when an update channel cannot be persisted", () => { | |
| 526 | const diskFailure = new Error("disk exploded"); | |
| 527 | const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ | |
| 528 | operation: "replace-settings-file", | |
| 529 | path: "/tmp/settings.json", | |
| 530 | cause: diskFailure, | |
| 531 | }); | |
| 532 | const harness = makeHarness({ setUpdateChannelError: settingsFailure }); | |
| 533 | ||
| 534 | return Effect.scoped( | |
| 535 | Effect.gen(function* () { | |
| 536 | const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 537 | yield* updates.configure; | |
| 538 | ||
| 539 | const error = yield* updates.setChannel("nightly").pipe(Effect.flip); | |
| 540 | ||
| 541 | assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); | |
| 542 | assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); | |
| 543 | assert.equal(error.channel, "nightly"); | |
| 544 | assert.strictEqual(error.cause, settingsFailure); | |
| 545 | assert.strictEqual(error.cause.cause, diskFailure); | |
| 546 | assert.equal(error.message, "Failed to persist the nightly desktop update channel."); | |
| 547 | assert.notInclude(error.message, diskFailure.message); | |
| 548 | }), | |
| 549 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 550 | }); | |
| 295 | 551 | }); |
apps/desktop/src/updates/DesktopUpdates.ts+218-57| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | import type { | |
| 2 | DesktopRuntimeInfo, | |
| 3 | DesktopUpdateActionResult, | |
| 4 | DesktopUpdateChannel, | |
| 5 | DesktopUpdateCheckResult, | |
| 6 | DesktopUpdateState, | |
| 1 | import { | |
| 2 | DesktopUpdateChannelSchema, | |
| 3 | type DesktopRuntimeInfo, | |
| 4 | type DesktopUpdateActionResult, | |
| 5 | type DesktopUpdateChannel, | |
| 6 | type DesktopUpdateCheckResult, | |
| 7 | type DesktopUpdateState, | |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import * as Cause from "effect/Cause"; |
| 9 | 10 | import * as Context from "effect/Context"; |
| ... | ... | @@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass< |
| 63 | 64 | "DesktopUpdateActionInProgressError", |
| 64 | 65 | { |
| 65 | 66 | action: Schema.Literals(["check", "download", "install"]), |
| 67 | requestedChannel: DesktopUpdateChannelSchema, | |
| 68 | }, | |
| 69 | ) { | |
| 70 | override get message(): string { | |
| 71 | return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass<DesktopUpdateChannelPersistenceError>()( | |
| 76 | "DesktopUpdateChannelPersistenceError", | |
| 77 | { | |
| 78 | channel: DesktopUpdateChannelSchema, | |
| 79 | cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), | |
| 80 | }, | |
| 81 | ) { | |
| 82 | override get message(): string { | |
| 83 | return `Failed to persist the ${this.channel} desktop update channel.`; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | export class DesktopUpdatePollerError extends Schema.TaggedErrorClass<DesktopUpdatePollerError>()( | |
| 88 | "DesktopUpdatePollerError", | |
| 89 | { | |
| 90 | poller: Schema.Literals(["startup", "poll"]), | |
| 91 | cause: Schema.Defect(), | |
| 66 | 92 | }, |
| 67 | 93 | ) { |
| 68 | 94 | override get message(): string { |
| 69 | return `Cannot change update tracks while an update ${this.action} action is in progress.`; | |
| 95 | return `Desktop update ${this.poller} poller failed.`; | |
| 70 | 96 | } |
| 71 | 97 | } |
| 72 | 98 | |
| 73 | export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass<DesktopUpdatePersistenceError>()( | |
| 74 | "DesktopUpdatePersistenceError", | |
| 99 | export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<DesktopUpdateEventHandlingError>()( | |
| 100 | "DesktopUpdateEventHandlingError", | |
| 75 | 101 | { |
| 102 | event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]), | |
| 76 | 103 | cause: Schema.Defect(), |
| 77 | 104 | }, |
| 78 | 105 | ) { |
| 79 | 106 | override get message(): string { |
| 80 | const detail = this.cause instanceof Error ? this.cause.message : String(this.cause); | |
| 81 | return `Failed to persist desktop update settings: ${detail}`; | |
| 107 | return `Failed to handle desktop update ${this.event} event.`; | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()( | |
| 112 | "DesktopUpdaterReportedError", | |
| 113 | { | |
| 114 | operation: Schema.Literals(["check", "download", "install", "background"]), | |
| 115 | cause: Schema.Defect(), | |
| 116 | }, | |
| 117 | ) { | |
| 118 | override get message(): string { | |
| 119 | return `Desktop updater ${this.operation} operation reported an error.`; | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass<DesktopUpdateUnexpectedActionError>()( | |
| 124 | "DesktopUpdateUnexpectedActionError", | |
| 125 | { | |
| 126 | action: Schema.Literals(["download", "install"]), | |
| 127 | cause: Schema.Defect(), | |
| 128 | }, | |
| 129 | ) { | |
| 130 | override get message(): string { | |
| 131 | return `Desktop update ${this.action} action failed unexpectedly.`; | |
| 82 | 132 | } |
| 83 | 133 | } |
| 84 | 134 | |
| 85 | 135 | export type DesktopUpdateConfigureError = never; |
| 86 | 136 | |
| 87 | export type DesktopUpdateSetChannelError = | |
| 88 | | DesktopUpdateActionInProgressError | |
| 89 | | DesktopUpdatePersistenceError; | |
| 137 | export const DesktopUpdateSetChannelError = Schema.Union([ | |
| 138 | DesktopUpdateActionInProgressError, | |
| 139 | DesktopUpdateChannelPersistenceError, | |
| 140 | ]); | |
| 141 | export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; | |
| 142 | export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); | |
| 90 | 143 | |
| 91 | 144 | export class DesktopUpdates extends Context.Service< |
| 92 | 145 | DesktopUpdates, |
| ... | ... | @@ -308,16 +361,21 @@ export const make = Effect.gen(function* () { |
| 308 | 361 | |
| 309 | 362 | return yield* electronUpdater.checkForUpdates.pipe( |
| 310 | 363 | Effect.as(true), |
| 311 | Effect.catch( | |
| 312 | Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { | |
| 364 | Effect.catchTags({ | |
| 365 | ElectronUpdaterCheckForUpdatesError: Effect.fn( | |
| 366 | "desktop.updates.handleCheckForUpdatesFailure", | |
| 367 | )(function* (error) { | |
| 313 | 368 | const failedAt = yield* currentIsoTimestamp; |
| 314 | 369 | yield* updateState((current) => |
| 315 | 370 | reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), |
| 316 | 371 | ); |
| 317 | yield* logUpdaterError("failed to check for updates", { message: error.message }); | |
| 372 | yield* logUpdaterError(error.message, { | |
| 373 | errorTag: error._tag, | |
| 374 | channel: error.channel, | |
| 375 | }); | |
| 318 | 376 | return true; |
| 319 | 377 | }), |
| 320 | ), | |
| 378 | }), | |
| 321 | 379 | Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), |
| 322 | 380 | ); |
| 323 | 381 | }); |
| ... | ... | @@ -342,19 +400,50 @@ export const make = Effect.gen(function* () { |
| 342 | 400 | yield* electronUpdater.downloadUpdate; |
| 343 | 401 | return { accepted: true, completed: true }; |
| 344 | 402 | }).pipe( |
| 345 | Effect.catch( | |
| 346 | Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { | |
| 403 | Effect.catchTags({ | |
| 404 | ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( | |
| 405 | function* (error) { | |
| 406 | yield* updateState((current) => | |
| 407 | reduceDesktopUpdateStateOnDownloadFailure(current, error.message), | |
| 408 | ); | |
| 409 | yield* logUpdaterError(error.message, { | |
| 410 | errorTag: error._tag, | |
| 411 | channel: error.channel, | |
| 412 | }); | |
| 413 | return { accepted: true, completed: false }; | |
| 414 | }, | |
| 415 | ), | |
| 416 | }), | |
| 417 | Effect.onInterrupt(() => | |
| 418 | updateState((current) => (current.status === "downloading" ? state : current)).pipe( | |
| 419 | Effect.asVoid, | |
| 420 | ), | |
| 421 | ), | |
| 422 | Effect.catchCause((cause) => { | |
| 423 | if (Cause.hasInterruptsOnly(cause)) { | |
| 424 | return Effect.failCause(cause); | |
| 425 | } | |
| 426 | const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); | |
| 427 | return Effect.gen(function* () { | |
| 347 | 428 | yield* updateState((current) => |
| 348 | 429 | reduceDesktopUpdateStateOnDownloadFailure(current, error.message), |
| 349 | 430 | ); |
| 350 | yield* logUpdaterError("failed to download update", { message: error.message }); | |
| 431 | yield* logUpdaterError(error.message, { | |
| 432 | errorTag: error._tag, | |
| 433 | action: error.action, | |
| 434 | }); | |
| 351 | 435 | return { accepted: true, completed: false }; |
| 352 | }), | |
| 353 | ), | |
| 436 | }); | |
| 437 | }), | |
| 354 | 438 | Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), |
| 355 | 439 | ); |
| 356 | 440 | }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); |
| 357 | 441 | |
| 442 | const resetInstallAction = Effect.all( | |
| 443 | [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)], | |
| 444 | { discard: true }, | |
| 445 | ); | |
| 446 | ||
| 358 | 447 | const installDownloadedUpdate = Effect.gen(function* () { |
| 359 | 448 | const state = yield* Ref.get(updateStateRef); |
| 360 | 449 | if ( |
| ... | ... | @@ -377,14 +466,38 @@ export const make = Effect.gen(function* () { |
| 377 | 466 | }); |
| 378 | 467 | return { accepted: true, completed: false }; |
| 379 | 468 | }).pipe( |
| 380 | Effect.catch( | |
| 381 | Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { | |
| 382 | yield* Ref.set(updateInstallInFlightRef, false); | |
| 469 | Effect.catchTags({ | |
| 470 | ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( | |
| 471 | function* (error) { | |
| 472 | yield* resetInstallAction; | |
| 473 | yield* updateState((current) => | |
| 474 | reduceDesktopUpdateStateOnInstallFailure(current, error.message), | |
| 475 | ); | |
| 476 | yield* logUpdaterError(error.message, { | |
| 477 | errorTag: error._tag, | |
| 478 | channel: error.channel, | |
| 479 | isSilent: error.isSilent, | |
| 480 | isForceRunAfter: error.isForceRunAfter, | |
| 481 | }); | |
| 482 | return { accepted: true, completed: false }; | |
| 483 | }, | |
| 484 | ), | |
| 485 | }), | |
| 486 | Effect.onInterrupt(() => resetInstallAction), | |
| 487 | Effect.catchCause((cause) => | |
| 488 | Effect.gen(function* () { | |
| 489 | if (Cause.hasInterruptsOnly(cause)) { | |
| 490 | return yield* Effect.failCause(cause); | |
| 491 | } | |
| 492 | yield* resetInstallAction; | |
| 493 | const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); | |
| 383 | 494 | yield* updateState((current) => |
| 384 | 495 | reduceDesktopUpdateStateOnInstallFailure(current, error.message), |
| 385 | 496 | ); |
| 386 | yield* Ref.set(desktopState.quitting, false); | |
| 387 | yield* logUpdaterError("failed to install update", { message: error.message }); | |
| 497 | yield* logUpdaterError(error.message, { | |
| 498 | errorTag: error._tag, | |
| 499 | action: error.action, | |
| 500 | }); | |
| 388 | 501 | return { accepted: true, completed: false }; |
| 389 | 502 | }), |
| 390 | 503 | ), |
| ... | ... | @@ -394,17 +507,31 @@ export const make = Effect.gen(function* () { |
| 394 | 507 | const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () { |
| 395 | 508 | yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( |
| 396 | 509 | Effect.andThen(checkForUpdates("startup")), |
| 397 | Effect.catchCause((cause) => | |
| 398 | logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), | |
| 399 | ), | |
| 510 | Effect.catchCause((cause) => { | |
| 511 | if (Cause.hasInterruptsOnly(cause)) { | |
| 512 | return Effect.void; | |
| 513 | } | |
| 514 | const error = new DesktopUpdatePollerError({ poller: "startup", cause }); | |
| 515 | return logUpdaterError(error.message, { | |
| 516 | errorTag: error._tag, | |
| 517 | poller: error.poller, | |
| 518 | }); | |
| 519 | }), | |
| 400 | 520 | Effect.forkScoped, |
| 401 | 521 | ); |
| 402 | 522 | yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( |
| 403 | 523 | Effect.andThen(checkForUpdates("poll")), |
| 404 | 524 | Effect.forever, |
| 405 | Effect.catchCause((cause) => | |
| 406 | logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), | |
| 407 | ), | |
| 525 | Effect.catchCause((cause) => { | |
| 526 | if (Cause.hasInterruptsOnly(cause)) { | |
| 527 | return Effect.void; | |
| 528 | } | |
| 529 | const error = new DesktopUpdatePollerError({ poller: "poll", cause }); | |
| 530 | return logUpdaterError(error.message, { | |
| 531 | errorTag: error._tag, | |
| 532 | poller: error.poller, | |
| 533 | }); | |
| 534 | }), | |
| 408 | 535 | Effect.forkScoped, |
| 409 | 536 | ); |
| 410 | 537 | }).pipe(Effect.withSpan("desktop.updates.startPollers")); |
| ... | ... | @@ -435,11 +562,16 @@ export const make = Effect.gen(function* () { |
| 435 | 562 | yield* logUpdaterInfo("update available", { version: info.version }); |
| 436 | 563 | }), |
| 437 | 564 | ), |
| 438 | Effect.catchCause((cause) => | |
| 439 | logUpdaterWarning("ignored malformed update-available event", { | |
| 440 | cause: Cause.pretty(cause), | |
| 441 | }), | |
| 442 | ), | |
| 565 | Effect.catchCause((cause) => { | |
| 566 | if (Cause.hasInterruptsOnly(cause)) { | |
| 567 | return Effect.void; | |
| 568 | } | |
| 569 | const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); | |
| 570 | return logUpdaterWarning(error.message, { | |
| 571 | errorTag: error._tag, | |
| 572 | event: error.event, | |
| 573 | }); | |
| 574 | }), | |
| 443 | 575 | ); |
| 444 | 576 | }); |
| 445 | 577 | |
| ... | ... | @@ -452,14 +584,23 @@ export const make = Effect.gen(function* () { |
| 452 | 584 | }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); |
| 453 | 585 | |
| 454 | 586 | const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( |
| 455 | error: unknown, | |
| 587 | cause: unknown, | |
| 456 | 588 | ) { |
| 457 | const message = error instanceof Error ? error.message : String(error); | |
| 589 | const activeAction = yield* activeUpdateAction; | |
| 590 | const error = new DesktopUpdaterReportedError({ | |
| 591 | operation: Option.getOrElse(activeAction, () => "background" as const), | |
| 592 | cause, | |
| 593 | }); | |
| 458 | 594 | if (yield* Ref.get(updateInstallInFlightRef)) { |
| 459 | 595 | yield* Ref.set(updateInstallInFlightRef, false); |
| 460 | 596 | yield* Ref.set(desktopState.quitting, false); |
| 461 | yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); | |
| 462 | yield* logUpdaterError("updater error", { message }); | |
| 597 | yield* updateState((current) => | |
| 598 | reduceDesktopUpdateStateOnInstallFailure(current, error.message), | |
| 599 | ); | |
| 600 | yield* logUpdaterError(error.message, { | |
| 601 | errorTag: error._tag, | |
| 602 | operation: error.operation, | |
| 603 | }); | |
| 463 | 604 | return; |
| 464 | 605 | } |
| 465 | 606 | |
| ... | ... | @@ -469,7 +610,7 @@ export const make = Effect.gen(function* () { |
| 469 | 610 | yield* updateState((current) => ({ |
| 470 | 611 | ...current, |
| 471 | 612 | status: "error", |
| 472 | message, | |
| 613 | message: error.message, | |
| 473 | 614 | checkedAt, |
| 474 | 615 | downloadPercent: null, |
| 475 | 616 | errorContext, |
| ... | ... | @@ -477,7 +618,10 @@ export const make = Effect.gen(function* () { |
| 477 | 618 | })); |
| 478 | 619 | } |
| 479 | 620 | |
| 480 | yield* logUpdaterError("updater error", { message }); | |
| 621 | yield* logUpdaterError(error.message, { | |
| 622 | errorTag: error._tag, | |
| 623 | operation: error.operation, | |
| 624 | }); | |
| 481 | 625 | }); |
| 482 | 626 | |
| 483 | 627 | const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( |
| ... | ... | @@ -499,11 +643,16 @@ export const make = Effect.gen(function* () { |
| 499 | 643 | } |
| 500 | 644 | }), |
| 501 | 645 | ), |
| 502 | Effect.catchCause((cause) => | |
| 503 | logUpdaterWarning("ignored malformed download-progress event", { | |
| 504 | cause: Cause.pretty(cause), | |
| 505 | }), | |
| 506 | ), | |
| 646 | Effect.catchCause((cause) => { | |
| 647 | if (Cause.hasInterruptsOnly(cause)) { | |
| 648 | return Effect.void; | |
| 649 | } | |
| 650 | const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); | |
| 651 | return logUpdaterWarning(error.message, { | |
| 652 | errorTag: error._tag, | |
| 653 | event: error.event, | |
| 654 | }); | |
| 655 | }), | |
| 507 | 656 | ); |
| 508 | 657 | }); |
| 509 | 658 | |
| ... | ... | @@ -518,11 +667,16 @@ export const make = Effect.gen(function* () { |
| 518 | 667 | yield* logUpdaterInfo("update downloaded", { version: info.version }); |
| 519 | 668 | }), |
| 520 | 669 | ), |
| 521 | Effect.catchCause((cause) => | |
| 522 | logUpdaterWarning("ignored malformed update-downloaded event", { | |
| 523 | cause: Cause.pretty(cause), | |
| 524 | }), | |
| 525 | ), | |
| 670 | Effect.catchCause((cause) => { | |
| 671 | if (Cause.hasInterruptsOnly(cause)) { | |
| 672 | return Effect.void; | |
| 673 | } | |
| 674 | const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); | |
| 675 | return logUpdaterWarning(error.message, { | |
| 676 | errorTag: error._tag, | |
| 677 | event: error.event, | |
| 678 | }); | |
| 679 | }), | |
| 526 | 680 | ); |
| 527 | 681 | }); |
| 528 | 682 | |
| ... | ... | @@ -598,7 +752,10 @@ export const make = Effect.gen(function* () { |
| 598 | 752 | yield* Effect.annotateCurrentSpan({ channel: nextChannel }); |
| 599 | 753 | const activeAction = yield* activeUpdateAction; |
| 600 | 754 | if (Option.isSome(activeAction)) { |
| 601 | return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); | |
| 755 | return yield* new DesktopUpdateActionInProgressError({ | |
| 756 | action: activeAction.value, | |
| 757 | requestedChannel: nextChannel, | |
| 758 | }); | |
| 602 | 759 | } |
| 603 | 760 | |
| 604 | 761 | const state = yield* Ref.get(updateStateRef); |
| ... | ... | @@ -608,7 +765,11 @@ export const make = Effect.gen(function* () { |
| 608 | 765 | |
| 609 | 766 | yield* desktopSettings |
| 610 | 767 | .setUpdateChannel(nextChannel) |
| 611 | .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); | |
| 768 | .pipe( | |
| 769 | Effect.mapError( | |
| 770 | (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }), | |
| 771 | ), | |
| 772 | ); | |
| 612 | 773 | |
| 613 | 774 | const enabled = yield* shouldEnableAutoUpdates; |
| 614 | 775 | yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); |