1import {
2 DESKTOP_UPDATE_RESTART_MARKER_FILE,
3 DesktopUpdateChannelSchema,
4 type DesktopRuntimeInfo,
5 type DesktopUpdateActionResult,
6 type DesktopUpdateChannel,
7 type DesktopUpdateCheckResult,
8 type DesktopUpdateState,
9} from "@t3tools/contracts";
10import { compareSemverVersions } from "@t3tools/shared/semver";
11import { errorTag } from "@t3tools/shared/observability";
12import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
13import * as Cause from "effect/Cause";
14import * as Context from "effect/Context";
15import * as DateTime from "effect/DateTime";
16import * as Duration from "effect/Duration";
17import * as Effect from "effect/Effect";
18import * as Exit from "effect/Exit";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as PubSub from "effect/PubSub";
23import * as Ref from "effect/Ref";
24import * as Schema from "effect/Schema";
25import * as Scope from "effect/Scope";
26import * as Semaphore from "effect/Semaphore";
27import * as Stream from "effect/Stream";
28
29import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
30import * as DesktopConfig from "../app/DesktopConfig.ts";
31import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
32import * as DesktopObservability from "../app/DesktopObservability.ts";
33import * as DesktopState from "../app/DesktopState.ts";
34import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
35import * as DesktopForkUpdates from "./DesktopForkUpdates.ts";
36import * as ElectronApp from "../electron/ElectronApp.ts";
37import * as DesktopLocalUi from "./DesktopLocalUi.ts";
38import * as ElectronWindow from "../electron/ElectronWindow.ts";
39import * as IpcChannels from "../ipc/channels.ts";
40import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
41import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
42import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
43import {
44 createInitialDesktopUpdateState,
45 reduceDesktopUpdateStateOnCheckFailure,
46 reduceDesktopUpdateStateOnCheckStart,
47 reduceDesktopUpdateStateOnDownloadComplete,
48 reduceDesktopUpdateStateOnDownloadFailure,
49 reduceDesktopUpdateStateOnDownloadProgress,
50 reduceDesktopUpdateStateOnDownloadStart,
51 reduceDesktopUpdateStateOnInstallFailure,
52 reduceDesktopUpdateStateOnNoUpdate,
53 reduceDesktopUpdateStateOnUpdateAvailable,
54 resolveNotAppliedInstallVersion,
55} from "./updateMachine.ts";
56
57const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
58const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
59const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90);
60
61type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel";
62
63interface DesktopPreparedUpdateInstallResult extends DesktopUpdateActionResult {
64 readonly failed: boolean;
65}
66
67const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
68type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
69
70const UpdateInfo = Schema.Struct({
71 version: Schema.String,
72 // Left unvalidated on purpose: a malformed release-notes payload must never
73 // fail the decode and block the update state transition. The shape is
74 // validated defensively in normalizeDesktopUpdateReleaseNotes.
75 releaseNotes: Schema.optional(Schema.Unknown),
76});
77
78const DownloadProgressInfo = Schema.Struct({
79 percent: Schema.Number,
80});
81/** Saved in the runtime folder before an install quits the app, read on the next start. */
82export const DESKTOP_UPDATE_INSTALL_RECORD_FILE = "desktop-update-install.json";
83const DesktopUpdateInstallRecord = Schema.fromJsonString(
84 Schema.Struct({ fromVersion: Schema.String, targetVersion: Schema.String }),
85);
86const decodeInstallRecord = Schema.decodeUnknownEffect(DesktopUpdateInstallRecord);
87const encodeInstallRecord = Schema.encodeEffect(DesktopUpdateInstallRecord);
88
89const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig);
90const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo);
91const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo);
92
93const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
94
95export class DesktopUpdateActionInProgressError extends Schema.TaggedError<DesktopUpdateActionInProgressError>()(
96 "DesktopUpdateActionInProgressError",
97 {
98 action: Schema.Literals(["check", "download", "install", "channel"]),
99 requestedChannel: DesktopUpdateChannelSchema,
100 },
101) {
102 override get message(): string {
103 return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`;
104 }
105}
106
107export class DesktopUpdateChannelPersistenceError extends Schema.TaggedError<DesktopUpdateChannelPersistenceError>()(
108 "DesktopUpdateChannelPersistenceError",
109 {
110 channel: DesktopUpdateChannelSchema,
111 cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
112 },
113) {
114 override get message(): string {
115 return `Failed to persist the ${this.channel} desktop update channel.`;
116 }
117}
118
119export class DesktopUpdatePollerError extends Schema.TaggedError<DesktopUpdatePollerError>()(
120 "DesktopUpdatePollerError",
121 {
122 poller: Schema.Literals(["startup", "poll"]),
123 cause: Schema.Defect(),
124 },
125) {
126 override get message(): string {
127 return `Desktop update ${this.poller} poller failed.`;
128 }
129}
130
131export class DesktopUpdateEventHandlingError extends Schema.TaggedError<DesktopUpdateEventHandlingError>()(
132 "DesktopUpdateEventHandlingError",
133 {
134 event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]),
135 cause: Schema.Defect(),
136 },
137) {
138 override get message(): string {
139 return `Failed to handle desktop update ${this.event} event.`;
140 }
141}
142
143export class DesktopUpdaterReportedError extends Schema.TaggedError<DesktopUpdaterReportedError>()(
144 "DesktopUpdaterReportedError",
145 {
146 operation: Schema.Literals(["check", "download", "install", "channel", "background"]),
147 cause: Schema.Defect(),
148 },
149) {
150 override get message(): string {
151 return `Desktop updater ${this.operation} operation reported an error.`;
152 }
153}
154
155export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError<DesktopUpdateUnexpectedActionError>()(
156 "DesktopUpdateUnexpectedActionError",
157 {
158 action: Schema.Literals(["download", "install"]),
159 cause: Schema.Defect(),
160 },
161) {
162 override get message(): string {
163 return `Desktop update ${this.action} action failed unexpectedly.`;
164 }
165}
166
167export type DesktopUpdateConfigureError = never;
168
169export const DesktopUpdateSetChannelError = Schema.Union([
170 DesktopUpdateActionInProgressError,
171 DesktopUpdateChannelPersistenceError,
172]);
173export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type;
174
175export class DesktopUpdates extends Context.Service<
176 DesktopUpdates,
177 {
178 readonly getState: Effect.Effect<DesktopUpdateState>;
179 /** True while a check, download, install, or channel change holds the
180 updater's single action reservation. */
181 readonly isActionActive: Effect.Effect<boolean>;
182 /** True only while an install owns the updater action reservation. */
183 readonly isInstallActive: Effect.Effect<boolean>;
184 /** Current state plus a stream of every later state change. */
185 readonly subscribe: Effect.Effect<
186 {
187 readonly latest: DesktopUpdateState;
188 readonly changes: Stream.Stream<DesktopUpdateState>;
189 },
190 never,
191 Scope.Scope
192 >;
193 readonly emitState: Effect.Effect<void>;
194 readonly disabledReason: Effect.Effect<Option.Option<string>>;
195 readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;
196 readonly setChannel: (
197 channel: DesktopUpdateChannel,
198 ) => Effect.Effect<DesktopUpdateState, DesktopUpdateSetChannelError>;
199 readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;
200 readonly download: Effect.Effect<DesktopUpdateActionResult>;
201 readonly install: Effect.Effect<DesktopUpdateActionResult>;
202 readonly installPrepared: (
203 expectedVersion: string,
204 ) => Effect.Effect<DesktopPreparedUpdateInstallResult>;
205 }
206>()("@t3tools/desktop/updates/DesktopUpdates") {}
207
208const {
209 logInfo: logUpdaterInfo,
210 logWarning: logUpdaterWarning,
211 logError: logUpdaterError,
212} = DesktopObservability.makeComponentLogger("desktop-updater");
213
214function redactMessage(message: string): string {
215 // Updater HTTP errors append response bodies and headers after the first line.
216 return (message.split(/[\r\n]/, 1)[0] ?? "")
217 .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]")
218 .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]")
219 .replace(
220 /\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*$/gi,
221 "[redacted header]",
222 )
223 .replace(
224 /\b[\w-]*(?:token|password|secret|credential|api[_-]?key)[\w-]*\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
225 "[redacted credential]",
226 )
227 .slice(0, 512);
228}
229
230function summarizeCause(cause: unknown, depth: number): string {
231 if (depth >= 3) return "[truncated cause]";
232 if (Cause.isCause(cause)) {
233 return cause.reasons
234 .slice(0, 3)
235 .map((reason) =>
236 reason._tag === "Fail"
237 ? summarizeCause(reason.error, depth + 1)
238 : reason._tag === "Die"
239 ? summarizeCause(reason.defect, depth + 1)
240 : "Interrupted",
241 )
242 .join("; ");
243 }
244 if (typeof cause === "string") return redactMessage(cause);
245 if (typeof cause !== "object" || cause === null) return errorTag(cause);
246
247 const error = cause as {
248 readonly message?: unknown;
249 readonly code?: unknown;
250 readonly cause?: unknown;
251 };
252 const code =
253 typeof error.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code)
254 ? ` [${error.code}]`
255 : "";
256 const message = typeof error.message === "string" ? `: ${redactMessage(error.message)}` : "";
257 const nested =
258 error.cause === undefined ? "" : `; caused by ${summarizeCause(error.cause, depth + 1)}`;
259 return `${errorTag(cause)}${code}${message}${nested}`;
260}
261
262/** Log a bounded cause summary without stacks, response payloads, or request credentials. */
263export function describeUpdateErrorCause(cause: unknown): string {
264 try {
265 return summarizeCause(cause, 0).slice(0, 1024);
266 } catch {
267 return "UnknownError";
268 }
269}
270
271function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {
272 const entries: Record<string, string> = {};
273 for (const line of raw.split("\n")) {
274 const match = line.match(/^(\w+):\s*(.+)$/);
275 if (match?.[1] && match[2]) {
276 entries[match[1]] = match[2].trim();
277 }
278 }
279
280 return decodeAppUpdateYmlConfig(entries).pipe(
281 Effect.map((config) => (config.provider ? Option.some(config) : Option.none())),
282 Effect.orElseSucceed(() => Option.none<AppUpdateYmlConfig>()),
283 );
284}
285
286function createBaseUpdateState(
287 channel: DesktopUpdateChannel,
288 enabled: boolean,
289 environment: DesktopEnvironment.DesktopEnvironment["Service"],
290): DesktopUpdateState {
291 return {
292 ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),
293 enabled,
294 status: enabled ? "idle" : "disabled",
295 };
296}
297
298function getCanRetryFromState(state: DesktopUpdateState): boolean {
299 return state.availableVersion !== null || state.downloadedVersion !== null;
300}
301
302function shouldBroadcastDownloadProgress(
303 currentState: DesktopUpdateState,
304 nextPercent: number,
305): boolean {
306 if (currentState.status !== "downloading") {
307 return true;
308 }
309
310 const currentPercent = currentState.downloadPercent;
311 if (currentPercent === null) {
312 return true;
313 }
314
315 const previousStep = Math.floor(currentPercent / 10);
316 const nextStep = Math.floor(nextPercent / 10);
317 return nextStep !== previousStep || nextPercent === 100;
318}
319
320function getAutoUpdateDisabledReason(args: {
321 isDevelopment: boolean;
322 isPackaged: boolean;
323 platform: NodeJS.Platform;
324 appImage?: string | undefined;
325 isDebPackage: boolean;
326 disabledByEnv: boolean;
327 hasUpdateFeedConfig: boolean;
328}): string | null {
329 if (!args.hasUpdateFeedConfig) {
330 return "Automatic updates are not available because no update feed is configured.";
331 }
332 if (args.isDevelopment || !args.isPackaged) {
333 return "Automatic updates are only available in packaged production builds.";
334 }
335 if (args.disabledByEnv) {
336 return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
337 }
338 if (args.platform === "linux" && !args.appImage && !args.isDebPackage) {
339 return "Automatic updates on Linux require the AppImage or the .deb package.";
340 }
341 return null;
342}
343
344function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
345 return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
346}
347
348/** @public Service construction is part of the canonical Effect module API. */
349export const make = Effect.gen(function* () {
350 const config = yield* DesktopConfig.DesktopConfig;
351 const pool = yield* DesktopBackendPool.DesktopBackendPool;
352 const desktopState = yield* DesktopState.DesktopState;
353 const electronUpdater = yield* ElectronUpdater.ElectronUpdater;
354 const electronWindow = yield* ElectronWindow.ElectronWindow;
355 const environment = yield* DesktopEnvironment.DesktopEnvironment;
356 const fileSystem = yield* FileSystem.FileSystem;
357 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
358 const fork = yield* DesktopForkUpdates.DesktopForkUpdates;
359 const electronApp = yield* ElectronApp.ElectronApp;
360 const forkRelease = yield* Ref.make<{
361 base: URL;
362 release: typeof ForkReleaseManifest.Type;
363 kind: "ui" | "code" | "shell";
364 } | null>(null);
365
366 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
367 const forkUpdateDirectory = Ref.get(appUpdateYmlConfigRef).pipe(
368 Effect.map((config) => {
369 if (Option.isNone(config) || config.value.provider !== "generic") return;
370 const url = URL.parse(config.value.url ?? "");
371 return url?.searchParams.has("ls") ? url : undefined;
372 }),
373 );
374 const localUi = yield* DesktopLocalUi.DesktopLocalUi;
375 const activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());
376 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();
377 const updaterConfiguredRef = yield* Ref.make(false);
378 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
379 const notAppliedVersionRef = yield* Ref.make(Option.none<string>());
380 const updateStateRef = yield* Ref.make<DesktopUpdateState>({
381 ...createInitialDesktopUpdateState(
382 environment.appVersion,
383 environment.runtimeInfo,
384 environment.defaultDesktopSettings.updateChannel,
385 ),
386 uiVersion: yield* localUi.currentVersion,
387 });
388
389 const stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16);
390 // Makes ref writes + publishes atomic against subscribe, so a snapshot
391 // never overlaps with the first change a subscriber receives.
392 const stateMutex = yield* Semaphore.make(1);
393
394 const emitState = Ref.get(updateStateRef).pipe(
395 Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),
396 );
397
398 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
399 stateMutex
400 .withPermits(1)(
401 Effect.gen(function* () {
402 const next = { ...state, uiVersion: yield* localUi.currentVersion };
403 yield* Ref.set(updateStateRef, next);
404 yield* PubSub.publish(stateChanges, next);
405 }),
406 )
407 .pipe(Effect.andThen(emitState));
408
409 const updateState = (
410 f: (state: DesktopUpdateState) => DesktopUpdateState,
411 ): Effect.Effect<DesktopUpdateState> =>
412 Ref.get(updateStateRef).pipe(
413 Effect.flatMap((state) => {
414 const nextState = f(state);
415 return setState(nextState).pipe(Effect.as(nextState));
416 }),
417 );
418
419 const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe(
420 Effect.option,
421 Effect.flatMap(
422 Option.match({
423 onNone: () => Effect.succeed(Option.none<AppUpdateYmlConfig>()),
424 onSome: parseAppUpdateYml,
425 }),
426 ),
427 );
428
429 // The .deb carries electron-builder's resources/package-type marker.
430 // electron-updater reads the same file and installs updates with dpkg.
431 const isDebPackage =
432 environment.platform === "linux" && environment.isPackaged
433 ? yield* fileSystem
434 .readFileString(environment.path.join(environment.resourcesPath, "package-type"))
435 .pipe(
436 Effect.map((packageType) => packageType.trim() === "deb"),
437 Effect.orElseSucceed(() => false),
438 )
439 : false;
440
441 const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(
442 Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
443 );
444
445 const resolveDisabledReason = Effect.gen(function* () {
446 const hasFeedConfig = yield* hasUpdateFeedConfig;
447 return Option.fromNullishOr(
448 getAutoUpdateDisabledReason({
449 isDevelopment: environment.isDevelopment,
450 isPackaged: environment.isPackaged,
451 platform: environment.platform,
452 appImage: Option.getOrUndefined(config.appImagePath),
453 isDebPackage,
454 disabledByEnv: config.disableAutoUpdate,
455 hasUpdateFeedConfig: hasFeedConfig,
456 }),
457 );
458 });
459
460 const activeUpdateAction = Ref.get(activeUpdateActionRef);
461
462 const tryStartUpdateAction = (action: UpdateAction): Effect.Effect<boolean> =>
463 Ref.modify(activeUpdateActionRef, (activeAction) =>
464 Option.isSome(activeAction) ? [false, activeAction] : [true, Option.some(action)],
465 );
466
467 const tryStartChannelChange = Ref.modify(activeUpdateActionRef, (activeAction) =>
468 Option.isSome(activeAction)
469 ? [activeAction, activeAction]
470 : [Option.none<UpdateAction>(), Option.some<UpdateAction>("channel")],
471 );
472
473 const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>
474 Ref.modify(activeUpdateActionRef, (activeAction) => {
475 const finished = Option.isSome(activeAction) && activeAction.value === action;
476 return [finished, finished ? Option.none() : activeAction] as const;
477 }).pipe(
478 Effect.flatMap((finished) =>
479 finished ? PubSub.publish(finishedUpdateActions, action).pipe(Effect.asVoid) : Effect.void,
480 ),
481 );
482
483 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
484 channel: DesktopUpdateChannel,
485 ) {
486 yield* Effect.annotateCurrentSpan({ channel });
487 const allowsPrerelease = channel === "nightly";
488 yield* electronUpdater.setChannel(channel);
489 yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
490 yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
491 yield* electronUpdater.setFullChangelog(allowsPrerelease);
492 yield* logUpdaterInfo("using update channel", {
493 channel,
494 allowPrerelease: allowsPrerelease,
495 allowDowngrade: allowsPrerelease,
496 fullChangelog: allowsPrerelease,
497 });
498 });
499
500 const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));
501
502 const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (
503 reason: string,
504 actionReservation: "acquire" | "held" = "acquire",
505 ) {
506 yield* Effect.annotateCurrentSpan({ reason });
507 if (yield* Ref.get(desktopState.quitting)) return false;
508 if (!(yield* Ref.get(updaterConfiguredRef))) return false;
509
510 const state = yield* Ref.get(updateStateRef);
511 if (state.status === "downloading") {
512 yield* logUpdaterInfo("skipping update check while update is active", {
513 reason,
514 status: state.status,
515 });
516 return false;
517 }
518
519 if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false;
520
521 const check = Effect.gen(function* () {
522 const checkedAt = yield* currentIsoTimestamp;
523 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
524 yield* logUpdaterInfo("checking for updates", { reason });
525
526 return yield* Effect.gen(function* () {
527 const directory = yield* forkUpdateDirectory;
528 if (directory) {
529 directory.searchParams.delete("ls");
530 const release = yield* fork.check(directory);
531 const kind = yield* fork.kind(release);
532 const target = release.desktop[`${environment.platform}-${environment.processArch}`]!;
533 const effectiveVersion = state.uiVersion ?? state.currentVersion;
534 if (
535 /^0\.0\.[0-9]+$/.test(effectiveVersion) &&
536 compareSemverVersions(target.version, effectiveVersion) <= 0
537 ) {
538 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
539 return true;
540 }
541 yield* Ref.set(forkRelease, { base: directory, release, kind });
542 if (kind !== "shell") {
543 yield* setState({
544 ...reduceDesktopUpdateStateOnUpdateAvailable(state, target.version, checkedAt),
545 installKind: kind === "ui" ? "ui" : "app",
546 });
547 return true;
548 }
549 yield* electronUpdater.setFeedURL({ provider: "generic", url: directory.href });
550 yield* electronUpdater.setChannel(target.app.version);
551 yield* electronUpdater.setAllowDowngrade(false);
552 }
553 yield* electronUpdater.checkForUpdates;
554 return true;
555 }).pipe(
556 Effect.mapError((error) =>
557 error._tag === "ElectronUpdaterCheckForUpdatesError" ||
558 error._tag === "DesktopForkUpdateError"
559 ? error
560 : new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
561 channel: state.channel,
562 cause: error,
563 }),
564 ),
565 Effect.catchTags({
566 DesktopForkUpdateError: (error) =>
567 updateState((current) =>
568 reduceDesktopUpdateStateOnCheckFailure(current, error.message, checkedAt),
569 ).pipe(Effect.as(true)),
570 ElectronUpdaterCheckForUpdatesError: Effect.fn(
571 "desktop.updates.handleCheckForUpdatesFailure",
572 )(function* (error) {
573 const failedAt = yield* currentIsoTimestamp;
574 yield* updateState((current) =>
575 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
576 );
577 yield* logUpdaterError(error.message, {
578 errorTag: error._tag,
579 cause: describeUpdateErrorCause(error.cause),
580 channel: error.channel,
581 });
582 return true;
583 }),
584 }),
585 );
586 });
587
588 return yield* actionReservation === "held"
589 ? check
590 : check.pipe(
591 Effect.onInterrupt(() => setState(state)),
592 Effect.ensuring(finishUpdateAction("check")),
593 );
594 });
595
596 const downloadAvailableUpdate = Effect.gen(function* () {
597 const state = yield* Ref.get(updateStateRef);
598 if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") {
599 return { accepted: false, completed: false };
600 }
601
602 if (!(yield* tryStartUpdateAction("download"))) {
603 return { accepted: false, completed: false };
604 }
605
606 return yield* Effect.gen(function* () {
607 yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));
608 yield* electronUpdater.setDisableDifferentialDownload(
609 isArm64HostRunningIntelBuild(environment.runtimeInfo),
610 );
611 yield* logUpdaterInfo("downloading update");
612 const candidate = yield* Ref.get(forkRelease);
613 const version = state.availableVersion;
614 let uiOnly = false;
615 if (candidate !== null && candidate.kind !== "shell") {
616 uiOnly = (yield* fork.prepare(candidate.base, candidate.release)) === "ui";
617 } else {
618 yield* electronUpdater.downloadUpdate;
619 }
620 yield* updateState((current) => ({
621 ...(version === null
622 ? current
623 : reduceDesktopUpdateStateOnDownloadComplete(current, version)),
624 installKind: uiOnly ? "ui" : "app",
625 }));
626 return { accepted: true, completed: true };
627 }).pipe(
628 Effect.catchTags({
629 DesktopForkUpdateError: (error) =>
630 updateState((current) =>
631 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
632 ).pipe(Effect.as({ accepted: true, completed: false })),
633 ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(
634 function* (error) {
635 yield* updateState((current) =>
636 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
637 );
638 yield* logUpdaterError(error.message, {
639 errorTag: error._tag,
640 cause: describeUpdateErrorCause(error.cause),
641 channel: error.channel,
642 });
643 return { accepted: true, completed: false };
644 },
645 ),
646 }),
647 Effect.onInterrupt(() =>
648 updateState((current) => (current.status === "downloading" ? state : current)).pipe(
649 Effect.asVoid,
650 ),
651 ),
652 Effect.catchCause((cause) => {
653 if (Cause.hasInterruptsOnly(cause)) {
654 return Effect.failCause(cause);
655 }
656 const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause });
657 return Effect.gen(function* () {
658 yield* updateState((current) =>
659 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
660 );
661 yield* logUpdaterError(error.message, {
662 errorTag: error._tag,
663 cause: describeUpdateErrorCause(error.cause),
664 action: error.action,
665 });
666 return { accepted: true, completed: false };
667 });
668 }),
669 Effect.ensuring(finishUpdateAction("download")),
670 );
671 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
672
673 // Tells the primary backend that the coming stop is an update restart, so it
674 // keeps its managed tunnel for the backend the updated app starts. Best
675 // effort: without the marker the backend only re-provisions its tunnel.
676 const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime");
677 const updateRestartMarkerPath = environment.path.join(
678 updateRestartMarkerDir,
679 DESKTOP_UPDATE_RESTART_MARKER_FILE,
680 );
681 const writeUpdateRestartMarker = fileSystem
682 .makeDirectory(updateRestartMarkerDir, { recursive: true })
683 .pipe(
684 Effect.andThen(fileSystem.writeFileString(updateRestartMarkerPath, "")),
685 Effect.catch((error) =>
686 logUpdaterWarning("Could not write the update restart marker.", { errorTag: error._tag }),
687 ),
688 );
689
690 // A failed or interrupted install brings no updated backend, so a later
691 // quit must release the tunnel.
692 const removeUpdateRestartMarker = fileSystem
693 .remove(updateRestartMarkerPath, { force: true })
694 .pipe(Effect.ignore);
695
696 // Records the version an install hands to the OS installer, so the next
697 // start can tell whether it applied. Best effort, like the restart marker.
698 const installRecordPath = environment.path.join(
699 updateRestartMarkerDir,
700 DESKTOP_UPDATE_INSTALL_RECORD_FILE,
701 );
702 const writeInstallRecord = (targetVersion: string) =>
703 encodeInstallRecord({ fromVersion: environment.appVersion, targetVersion }).pipe(
704 Effect.flatMap((text) => fileSystem.writeFileString(installRecordPath, text)),
705 Effect.catch((error) =>
706 logUpdaterWarning("Could not record the update install.", { errorTag: error._tag }),
707 ),
708 );
709 const removeInstallRecord = fileSystem
710 .remove(installRecordPath, { force: true })
711 .pipe(Effect.ignore);
712
713 // Reads the record of the last install. Returns the version it did not
714 // apply, when the app came back on the version it installed from. That
715 // record stays through later starts, until the version changes or a new
716 // install replaces it, so no restart forgets the failed version.
717 const readNotAppliedInstallVersion = fileSystem.readFileString(installRecordPath).pipe(
718 Effect.flatMap(decodeInstallRecord),
719 Effect.option,
720 Effect.map((record) =>
721 Option.flatMap(record, (value) =>
722 Option.fromNullishOr(resolveNotAppliedInstallVersion(value, environment.appVersion)),
723 ),
724 ),
725 Effect.tap((notAppliedVersion) =>
726 Option.isNone(notAppliedVersion) ? removeInstallRecord : Effect.void,
727 ),
728 );
729
730 const baseUpdateState = (channel: DesktopUpdateChannel, enabled: boolean) =>
731 Ref.get(notAppliedVersionRef).pipe(
732 Effect.map((notAppliedVersion) => ({
733 ...createBaseUpdateState(channel, enabled, environment),
734 ...Option.match(notAppliedVersion, {
735 onNone: () => ({}),
736 onSome: (version) => ({ notAppliedVersion: version }),
737 }),
738 })),
739 );
740
741 const resetInstallAction = Effect.all(
742 [
743 finishUpdateAction("install"),
744 Ref.set(desktopState.quitting, false),
745 removeUpdateRestartMarker,
746 removeInstallRecord,
747 ],
748 { discard: true },
749 );
750
751 const recoverFailedInstall = Effect.fn("desktop.updates.recoverFailedInstall")(function* (
752 message: string,
753 ) {
754 const ownsRecovery = yield* Ref.modify(activeUpdateActionRef, (activeAction) =>
755 Option.isSome(activeAction) && activeAction.value === "install"
756 ? ([true, Option.some<UpdateAction>("install-recovery")] as const)
757 : ([false, activeAction] as const),
758 );
759 if (!ownsRecovery) return;
760
761 yield* Ref.set(desktopState.quitting, false);
762 yield* removeUpdateRestartMarker;
763 yield* removeInstallRecord;
764 yield* Effect.gen(function* () {
765 const instances = yield* pool.list;
766 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
767 concurrency: "unbounded",
768 discard: true,
769 }).pipe(Effect.exit);
770 yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
771 if (Exit.isFailure(restartExit)) {
772 yield* logUpdaterError("Desktop update install recovery could not restart every backend.");
773 }
774 }).pipe(
775 Effect.catchCause(() =>
776 logUpdaterError("Desktop update install recovery failed unexpectedly."),
777 ),
778 Effect.ensuring(finishUpdateAction("install-recovery")),
779 );
780 });
781
782 const installDownloadedUpdate = (expectedVersion?: string) =>
783 Effect.scoped(
784 Effect.gen(function* () {
785 const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions);
786 let admission: "admitted" | "refused" | "wait-for-check" = "wait-for-check";
787 while (admission === "wait-for-check") {
788 admission = yield* stateMutex.withPermits(1)(
789 Effect.gen(function* () {
790 const state = yield* Ref.get(updateStateRef);
791 const activeAction = yield* Ref.get(activeUpdateActionRef);
792 const hasExpectedDownload =
793 state.downloadedVersion !== null &&
794 (expectedVersion === undefined || state.downloadedVersion === expectedVersion);
795 if (
796 (yield* Ref.get(desktopState.quitting)) ||
797 !(yield* Ref.get(updaterConfiguredRef)) ||
798 !hasExpectedDownload
799 ) {
800 return "refused" as const;
801 }
802 if (Option.isSome(activeAction)) {
803 return activeAction.value === "check" && expectedVersion !== undefined
804 ? ("wait-for-check" as const)
805 : ("refused" as const);
806 }
807 const hasInstallableDownload =
808 state.status === "downloaded" ||
809 (state.status === "error" &&
810 (state.errorContext === null || state.errorContext === "install"));
811 if (!hasInstallableDownload) return "refused" as const;
812 return (yield* tryStartUpdateAction("install"))
813 ? ("admitted" as const)
814 : ("refused" as const);
815 }),
816 );
817 if (admission === "wait-for-check") {
818 const finishedAction = yield* PubSub.take(actionCompletions).pipe(
819 Effect.timeoutOption(PREPARED_INSTALL_CHECK_WAIT),
820 );
821 if (Option.isNone(finishedAction)) {
822 admission = "refused";
823 }
824 }
825 }
826 if (admission === "refused") {
827 return { accepted: false, completed: false, failed: false };
828 }
829
830 const installing = yield* Ref.get(updateStateRef);
831 if (
832 expectedVersion === undefined &&
833 installing.installKind === "ui" &&
834 installing.downloadedVersion !== null
835 ) {
836 return yield* fork.installUi(installing.downloadedVersion).pipe(
837 Effect.flatMap((applied) =>
838 applied
839 ? updateState((current) => ({
840 ...current,
841 status: "up-to-date",
842 availableVersion: null,
843 downloadedVersion: null,
844 installKind: "app",
845 downloadPercent: null,
846 message: null,
847 errorContext: null,
848 canRetry: false,
849 })).pipe(Effect.as({ accepted: true, completed: true, failed: false }))
850 : Effect.succeed({ accepted: false, completed: false, failed: false }),
851 ),
852 Effect.catch((error) =>
853 updateState((current) =>
854 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
855 ).pipe(Effect.as({ accepted: true, completed: false, failed: true })),
856 ),
857 Effect.ensuring(finishUpdateAction("install")),
858 );
859 }
860
861 yield* Ref.set(desktopState.quitting, true);
862
863 return yield* Effect.gen(function* () {
864 yield* writeUpdateRestartMarker;
865 const { downloadedVersion } = yield* Ref.get(updateStateRef);
866 if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion);
867 // Stop every backend in the pool, not just the primary. With
868 // parallel WSL + Windows backends, leaving the WSL instance up
869 // means quitAndInstall's app.quit() exits before the pool's
870 // scope cascade has a chance to run its stop finalizer, so the
871 // WSL child gets hard-killed by the OS instead of receiving
872 // SIGTERM + grace. Stops run concurrently with the same 5s
873 // budget the primary had on its own.
874 const instances = yield* pool.list;
875 yield* Effect.forEach(
876 instances,
877 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
878 { concurrency: "unbounded" },
879 );
880 const candidate = yield* Ref.get(forkRelease);
881 if (candidate?.kind === "code" && downloadedVersion !== null) {
882 if (!(yield* fork.activateCode(downloadedVersion))) {
883 return yield* new DesktopForkUpdates.DesktopForkUpdateError({
884 message: "The update is no longer prepared. Download it again.",
885 cause: downloadedVersion,
886 });
887 }
888 yield* electronApp.relaunch({});
889 yield* electronApp.quit;
890 } else {
891 yield* electronUpdater.quitAndInstall({ isSilent: true, isForceRunAfter: true });
892 }
893 return { accepted: true, completed: false, failed: false };
894 }).pipe(
895 Effect.catchTags({
896 DesktopForkUpdateError: (error) =>
897 recoverFailedInstall(error.message).pipe(
898 Effect.as({ accepted: true, completed: false, failed: true }),
899 ),
900 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
901 function* (error) {
902 yield* recoverFailedInstall(error.message);
903 yield* logUpdaterError(error.message, {
904 errorTag: error._tag,
905 cause: describeUpdateErrorCause(error.cause),
906 channel: error.channel,
907 isSilent: error.isSilent,
908 isForceRunAfter: error.isForceRunAfter,
909 });
910 return { accepted: true, completed: false, failed: true };
911 },
912 ),
913 }),
914 Effect.onInterrupt(() => resetInstallAction),
915 Effect.catchCause((cause) =>
916 Effect.gen(function* () {
917 if (Cause.hasInterruptsOnly(cause)) {
918 return yield* Effect.failCause(cause);
919 }
920 const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause });
921 yield* recoverFailedInstall(error.message);
922 yield* logUpdaterError(error.message, {
923 errorTag: error._tag,
924 cause: describeUpdateErrorCause(error.cause),
925 action: error.action,
926 });
927 return { accepted: true, completed: false, failed: true };
928 }),
929 ),
930 );
931 }),
932 ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));
933
934 const installWithExpectedVersion = Effect.fn("desktop.updates.install")(function* (
935 expectedVersion?: string,
936 ) {
937 if (yield* Ref.get(desktopState.quitting)) {
938 return {
939 accepted: false,
940 completed: false,
941 failed: false,
942 state: yield* Ref.get(updateStateRef),
943 };
944 }
945 const result = yield* installDownloadedUpdate(expectedVersion);
946 return {
947 accepted: result.accepted,
948 completed: result.completed,
949 failed: result.failed,
950 state: yield* Ref.get(updateStateRef),
951 };
952 });
953
954 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
955 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
956 Effect.andThen(checkForUpdates("startup")),
957 Effect.andThen(
958 Effect.gen(function* () {
959 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
960 }),
961 ),
962 Effect.catchCause((cause) => {
963 if (Cause.hasInterruptsOnly(cause)) {
964 return Effect.void;
965 }
966 const error = new DesktopUpdatePollerError({ poller: "startup", cause });
967 return logUpdaterError(error.message, {
968 errorTag: error._tag,
969 cause: describeUpdateErrorCause(error.cause),
970 poller: error.poller,
971 });
972 }),
973 Effect.forkScoped,
974 );
975 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
976 Effect.andThen(checkForUpdates("poll")),
977 Effect.andThen(
978 Effect.gen(function* () {
979 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
980 }),
981 ),
982 Effect.forever,
983 Effect.catchCause((cause) => {
984 if (Cause.hasInterruptsOnly(cause)) {
985 return Effect.void;
986 }
987 const error = new DesktopUpdatePollerError({ poller: "poll", cause });
988 return logUpdaterError(error.message, {
989 errorTag: error._tag,
990 cause: describeUpdateErrorCause(error.cause),
991 poller: error.poller,
992 });
993 }),
994 Effect.forkScoped,
995 );
996 }).pipe(Effect.withSpan("desktop.updates.startPollers"));
997
998 const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* (
999 raw: unknown,
1000 ) {
1001 yield* decodeUpdateInfo(raw).pipe(
1002 Effect.flatMap(
1003 Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {
1004 const state = yield* Ref.get(updateStateRef);
1005 if (
1006 !(yield* forkUpdateDirectory) &&
1007 resolveDefaultDesktopUpdateChannel(info.version) !== state.channel
1008 ) {
1009 yield* logUpdaterInfo("ignoring update that does not match selected channel", {
1010 version: info.version,
1011 channel: state.channel,
1012 });
1013 const checkedAt = yield* currentIsoTimestamp;
1014 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
1015 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
1016 return;
1017 }
1018
1019 const checkedAt = yield* currentIsoTimestamp;
1020 if (
1021 state.uiVersion &&
1022 state.uiVersion !== state.currentVersion &&
1023 compareSemverVersions(info.version, state.uiVersion) <= 0
1024 ) {
1025 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
1026 return;
1027 }
1028 const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
1029 info.releaseNotes,
1030 info.version,
1031 state.channel,
1032 );
1033 yield* setState(
1034 reduceDesktopUpdateStateOnUpdateAvailable(
1035 state,
1036 info.version,
1037 checkedAt,
1038 releaseNotes,
1039 omittedReleaseCount,
1040 ),
1041 );
1042 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
1043 yield* logUpdaterInfo("update available", {
1044 version: info.version,
1045 releaseNoteGroups: releaseNotes.length,
1046 omittedReleaseCount,
1047 });
1048 }),
1049 ),
1050 Effect.catchCause((cause) => {
1051 if (Cause.hasInterruptsOnly(cause)) {
1052 return Effect.void;
1053 }
1054 const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });
1055 return logUpdaterWarning(error.message, {
1056 errorTag: error._tag,
1057 cause: describeUpdateErrorCause(error.cause),
1058 event: error.event,
1059 });
1060 }),
1061 );
1062 });
1063
1064 const handleUpdateNotAvailable = Effect.gen(function* () {
1065 const checkedAt = yield* currentIsoTimestamp;
1066 const state = yield* Ref.get(updateStateRef);
1067 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
1068 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
1069 yield* logUpdaterInfo("no updates available");
1070 }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));
1071
1072 const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (
1073 cause: unknown,
1074 ) {
1075 const activeAction = yield* activeUpdateAction;
1076 const error = new DesktopUpdaterReportedError({
1077 operation: Option.match(activeAction, {
1078 onNone: () => "background" as const,
1079 onSome: (action) => (action === "install-recovery" ? "install" : action),
1080 }),
1081 cause,
1082 });
1083 if (Option.isSome(activeAction) && activeAction.value === "install") {
1084 yield* recoverFailedInstall(error.message);
1085 yield* logUpdaterError(error.message, {
1086 errorTag: error._tag,
1087 cause: describeUpdateErrorCause(error.cause),
1088 operation: error.operation,
1089 });
1090 return;
1091 }
1092
1093 if (Option.isNone(activeAction)) {
1094 const checkedAt = yield* currentIsoTimestamp;
1095 yield* updateState((current) => ({
1096 ...current,
1097 status: "error",
1098 message: error.message,
1099 checkedAt,
1100 downloadPercent: null,
1101 errorContext: current.errorContext,
1102 canRetry: getCanRetryFromState(current),
1103 }));
1104 }
1105
1106 yield* logUpdaterError(error.message, {
1107 errorTag: error._tag,
1108 cause: describeUpdateErrorCause(error.cause),
1109 operation: error.operation,
1110 });
1111 });
1112
1113 const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (
1114 raw: unknown,
1115 ) {
1116 yield* decodeDownloadProgressInfo(raw).pipe(
1117 Effect.flatMap(
1118 Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) {
1119 const state = yield* Ref.get(updateStateRef);
1120 const percent = Math.floor(progress.percent);
1121 if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) {
1122 yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent));
1123 }
1124 const milestone = percent - (percent % 10);
1125 const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef);
1126 if (milestone > lastLoggedMilestone) {
1127 yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone);
1128 yield* logUpdaterInfo("download progress", { percent });
1129 }
1130 }),
1131 ),
1132 Effect.catchCause((cause) => {
1133 if (Cause.hasInterruptsOnly(cause)) {
1134 return Effect.void;
1135 }
1136 const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });
1137 return logUpdaterWarning(error.message, {
1138 errorTag: error._tag,
1139 cause: describeUpdateErrorCause(error.cause),
1140 event: error.event,
1141 });
1142 }),
1143 );
1144 });
1145
1146 const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* (
1147 raw: unknown,
1148 ) {
1149 yield* decodeUpdateInfo(raw).pipe(
1150 Effect.flatMap(
1151 Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
1152 const state = yield* Ref.get(updateStateRef);
1153 if (
1154 state.uiVersion &&
1155 state.uiVersion !== state.currentVersion &&
1156 compareSemverVersions(info.version, state.uiVersion) <= 0
1157 )
1158 return;
1159 yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
1160 yield* logUpdaterInfo("update downloaded", { version: info.version });
1161 }),
1162 ),
1163 Effect.catchCause((cause) => {
1164 if (Cause.hasInterruptsOnly(cause)) {
1165 return Effect.void;
1166 }
1167 const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });
1168 return logUpdaterWarning(error.message, {
1169 errorTag: error._tag,
1170 cause: describeUpdateErrorCause(error.cause),
1171 event: error.event,
1172 });
1173 }),
1174 );
1175 });
1176
1177 return DesktopUpdates.of({
1178 getState: Ref.get(updateStateRef),
1179 isActionActive: activeUpdateAction.pipe(Effect.map(Option.isSome)),
1180 isInstallActive: activeUpdateAction.pipe(
1181 Effect.map((action) => Option.isSome(action) && action.value === "install"),
1182 ),
1183 subscribe: stateMutex.withPermits(1)(
1184 Effect.gen(function* () {
1185 const subscription = yield* PubSub.subscribe(stateChanges);
1186 const latest = yield* Ref.get(updateStateRef);
1187 return { latest, changes: Stream.fromSubscription(subscription) };
1188 }),
1189 ),
1190 emitState,
1191 disabledReason: resolveDisabledReason,
1192 configure: Effect.gen(function* () {
1193 const context = yield* Effect.context<never>();
1194 const runEffect = (effect: Effect.Effect<void>) => {
1195 void Effect.runPromiseWith(context)(effect);
1196 };
1197
1198 const appUpdateYmlConfig = yield* readAppUpdateYml;
1199 yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig);
1200
1201 const notAppliedVersion = yield* readNotAppliedInstallVersion;
1202 yield* Ref.set(notAppliedVersionRef, notAppliedVersion);
1203 if (Option.isSome(notAppliedVersion)) {
1204 yield* logUpdaterWarning("the last update install did not apply", {
1205 version: environment.appVersion,
1206 targetVersion: notAppliedVersion.value,
1207 });
1208 }
1209
1210 if (config.mockUpdates) {
1211 yield* electronUpdater.setFeedURL({
1212 provider: "generic",
1213 url: `http://localhost:${config.mockUpdateServerPort}`,
1214 } as ElectronUpdater.ElectronUpdaterFeedUrl);
1215 }
1216
1217 const settings = yield* desktopSettings.get;
1218 const enabled = yield* shouldEnableAutoUpdates;
1219 yield* setState(yield* baseUpdateState(settings.updateChannel, enabled));
1220 if (!enabled) {
1221 return;
1222 }
1223 yield* Ref.set(updaterConfiguredRef, true);
1224
1225 yield* electronUpdater.setAutoDownload(false);
1226 yield* electronUpdater.setAutoInstallOnAppQuit(false);
1227 yield* applyAutoUpdaterChannel(settings.updateChannel);
1228 yield* electronUpdater.setDisableDifferentialDownload(
1229 isArm64HostRunningIntelBuild(environment.runtimeInfo),
1230 );
1231
1232 if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) {
1233 yield* logUpdaterInfo(
1234 "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages",
1235 );
1236 }
1237
1238 yield* electronUpdater.on("checking-for-update", () => {
1239 runEffect(
1240 logUpdaterInfo("looking for updates").pipe(
1241 Effect.withSpan("desktop.updates.handleCheckingForUpdate"),
1242 ),
1243 );
1244 });
1245 yield* electronUpdater.on("update-available", (info: unknown) => {
1246 runEffect(handleUpdateAvailable(info));
1247 });
1248 yield* electronUpdater.on("update-not-available", () => {
1249 runEffect(handleUpdateNotAvailable);
1250 });
1251 yield* electronUpdater.on("error", (error: unknown) => {
1252 runEffect(handleUpdaterError(error));
1253 });
1254 yield* electronUpdater.on("download-progress", (progress: unknown) => {
1255 runEffect(handleDownloadProgress(progress));
1256 });
1257 yield* electronUpdater.on("update-downloaded", (info: unknown) => {
1258 runEffect(handleUpdateDownloaded(info));
1259 });
1260
1261 yield* startUpdatePollers;
1262 }).pipe(Effect.withSpan("desktop.updates.configure")),
1263 setChannel: Effect.fn("desktop.updates.setChannel")(function* (
1264 nextChannel: DesktopUpdateChannel,
1265 ) {
1266 yield* Effect.annotateCurrentSpan({ channel: nextChannel });
1267 const activeAction = yield* tryStartChannelChange;
1268 if (Option.isSome(activeAction)) {
1269 return yield* new DesktopUpdateActionInProgressError({
1270 action: activeAction.value === "install-recovery" ? "install" : activeAction.value,
1271 requestedChannel: nextChannel,
1272 });
1273 }
1274
1275 return yield* Effect.gen(function* () {
1276 const state = yield* Ref.get(updateStateRef);
1277 if (nextChannel === state.channel) {
1278 return state;
1279 }
1280
1281 yield* desktopSettings
1282 .setUpdateChannel(nextChannel)
1283 .pipe(
1284 Effect.mapError(
1285 (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
1286 ),
1287 );
1288
1289 const enabled = yield* shouldEnableAutoUpdates;
1290 yield* setState(yield* baseUpdateState(nextChannel, enabled));
1291
1292 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
1293 return yield* Ref.get(updateStateRef);
1294 }
1295
1296 yield* applyAutoUpdaterChannel(nextChannel);
1297 const allowDowngrade = yield* electronUpdater.allowDowngrade;
1298 yield* electronUpdater.setAllowDowngrade(true);
1299 yield* checkForUpdates("channel-change", "held").pipe(
1300 Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
1301 );
1302 return yield* Ref.get(updateStateRef);
1303 }).pipe(Effect.ensuring(finishUpdateAction("channel")));
1304 }),
1305 check: Effect.fn("desktop.updates.check")(function* (reason: string) {
1306 yield* Effect.annotateCurrentSpan({ reason });
1307 if (!(yield* Ref.get(updaterConfiguredRef))) {
1308 return {
1309 checked: false,
1310 state: yield* Ref.get(updateStateRef),
1311 };
1312 }
1313 const checked = yield* checkForUpdates(reason);
1314 if (yield* forkUpdateDirectory) yield* downloadAvailableUpdate;
1315 return {
1316 checked,
1317 state: yield* Ref.get(updateStateRef),
1318 };
1319 }),
1320 download: Effect.gen(function* () {
1321 const result = yield* downloadAvailableUpdate;
1322 return {
1323 accepted: result.accepted,
1324 completed: result.completed,
1325 state: yield* Ref.get(updateStateRef),
1326 };
1327 }).pipe(Effect.withSpan("desktop.updates.download")),
1328 install: installWithExpectedVersion().pipe(
1329 Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })),
1330 ),
1331 installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion),
1332 });
1333});
1334
1335export const layer = Layer.effect(DesktopUpdates, make);