852 lines · 31.1 KB · TypeScript
RawDownload
import {  DesktopUpdateChannelSchema,  type DesktopRuntimeInfo,  type DesktopUpdateActionResult,  type DesktopUpdateChannel,  type DesktopUpdateCheckResult,  type DesktopUpdateState,} from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";import * as DesktopConfig from "../app/DesktopConfig.ts";import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import * as DesktopObservability from "../app/DesktopObservability.ts";import * as DesktopState from "../app/DesktopState.ts";import * as ElectronUpdater from "../electron/ElectronUpdater.ts";import * as ElectronWindow from "../electron/ElectronWindow.ts";import * as IpcChannels from "../ipc/channels.ts";import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";import {  createInitialDesktopUpdateState,  reduceDesktopUpdateStateOnCheckFailure,  reduceDesktopUpdateStateOnCheckStart,  reduceDesktopUpdateStateOnDownloadComplete,  reduceDesktopUpdateStateOnDownloadFailure,  reduceDesktopUpdateStateOnDownloadProgress,  reduceDesktopUpdateStateOnDownloadStart,  reduceDesktopUpdateStateOnInstallFailure,  reduceDesktopUpdateStateOnNoUpdate,  reduceDesktopUpdateStateOnUpdateAvailable,} from "./updateMachine.ts"; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({  version: Schema.String,  // Left unvalidated on purpose: a malformed release-notes payload must never  // fail the decode and block the update state transition. The shape is  // validated defensively in normalizeDesktopUpdateReleaseNotes.  releaseNotes: Schema.optional(Schema.Unknown),}); const DownloadProgressInfo = Schema.Struct({  percent: Schema.Number,});const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig);const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo);const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<DesktopUpdateActionInProgressError>()(  "DesktopUpdateActionInProgressError",  {    action: Schema.Literals(["check", "download", "install"]),    requestedChannel: DesktopUpdateChannelSchema,  },) {  override get message(): string {    return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`;  }} export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass<DesktopUpdateChannelPersistenceError>()(  "DesktopUpdateChannelPersistenceError",  {    channel: DesktopUpdateChannelSchema,    cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),  },) {  override get message(): string {    return `Failed to persist the ${this.channel} desktop update channel.`;  }} export class DesktopUpdatePollerError extends Schema.TaggedErrorClass<DesktopUpdatePollerError>()(  "DesktopUpdatePollerError",  {    poller: Schema.Literals(["startup", "poll"]),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Desktop update ${this.poller} poller failed.`;  }} export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<DesktopUpdateEventHandlingError>()(  "DesktopUpdateEventHandlingError",  {    event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Failed to handle desktop update ${this.event} event.`;  }} export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()(  "DesktopUpdaterReportedError",  {    operation: Schema.Literals(["check", "download", "install", "background"]),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Desktop updater ${this.operation} operation reported an error.`;  }} export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass<DesktopUpdateUnexpectedActionError>()(  "DesktopUpdateUnexpectedActionError",  {    action: Schema.Literals(["download", "install"]),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Desktop update ${this.action} action failed unexpectedly.`;  }} export type DesktopUpdateConfigureError = never; export const DesktopUpdateSetChannelError = Schema.Union([  DesktopUpdateActionInProgressError,  DesktopUpdateChannelPersistenceError,]);export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type;export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service<  DesktopUpdates,  {    readonly getState: Effect.Effect<DesktopUpdateState>;    readonly emitState: Effect.Effect<void>;    readonly disabledReason: Effect.Effect<Option.Option<string>>;    readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;    readonly setChannel: (      channel: DesktopUpdateChannel,    ) => Effect.Effect<DesktopUpdateState, DesktopUpdateSetChannelError>;    readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;    readonly download: Effect.Effect<DesktopUpdateActionResult>;    readonly install: Effect.Effect<DesktopUpdateActionResult>;  }>()("@t3tools/desktop/updates/DesktopUpdates") {} const {  logInfo: logUpdaterInfo,  logWarning: logUpdaterWarning,  logError: logUpdaterError,} = DesktopObservability.makeComponentLogger("desktop-updater"); function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {  const entries: Record<string, string> = {};  for (const line of raw.split("\n")) {    const match = line.match(/^(\w+):\s*(.+)$/);    if (match?.[1] && match[2]) {      entries[match[1]] = match[2].trim();    }  }   return decodeAppUpdateYmlConfig(entries).pipe(    Effect.map((config) => (config.provider ? Option.some(config) : Option.none())),    Effect.orElseSucceed(() => Option.none<AppUpdateYmlConfig>()),  );} function createBaseUpdateState(  channel: DesktopUpdateChannel,  enabled: boolean,  environment: DesktopEnvironment.DesktopEnvironment["Service"],): DesktopUpdateState {  return {    ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),    enabled,    status: enabled ? "idle" : "disabled",  };} function getCanRetryFromState(state: DesktopUpdateState): boolean {  return state.availableVersion !== null || state.downloadedVersion !== null;} function shouldBroadcastDownloadProgress(  currentState: DesktopUpdateState,  nextPercent: number,): boolean {  if (currentState.status !== "downloading") {    return true;  }   const currentPercent = currentState.downloadPercent;  if (currentPercent === null) {    return true;  }   const previousStep = Math.floor(currentPercent / 10);  const nextStep = Math.floor(nextPercent / 10);  return nextStep !== previousStep || nextPercent === 100;} function getAutoUpdateDisabledReason(args: {  isDevelopment: boolean;  isPackaged: boolean;  platform: NodeJS.Platform;  appImage?: string | undefined;  disabledByEnv: boolean;  hasUpdateFeedConfig: boolean;}): string | null {  if (!args.hasUpdateFeedConfig) {    return "Automatic updates are not available because no update feed is configured.";  }  if (args.isDevelopment || !args.isPackaged) {    return "Automatic updates are only available in packaged production builds.";  }  if (args.disabledByEnv) {    return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";  }  if (args.platform === "linux" && !args.appImage) {    return "Automatic updates on Linux require running the AppImage build.";  }  return null;} function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {  return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";} export const make = Effect.gen(function* () {  const config = yield* DesktopConfig.DesktopConfig;  const pool = yield* DesktopBackendPool.DesktopBackendPool;  const desktopState = yield* DesktopState.DesktopState;  const electronUpdater = yield* ElectronUpdater.ElectronUpdater;  const electronWindow = yield* ElectronWindow.ElectronWindow;  const environment = yield* DesktopEnvironment.DesktopEnvironment;  const fileSystem = yield* FileSystem.FileSystem;  const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;   const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());  const updateCheckInFlightRef = yield* Ref.make(false);  const updateDownloadInFlightRef = yield* Ref.make(false);  const updateInstallInFlightRef = yield* Ref.make(false);  const updaterConfiguredRef = yield* Ref.make(false);  const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);  const updateStateRef = yield* Ref.make<DesktopUpdateState>(    createInitialDesktopUpdateState(      environment.appVersion,      environment.runtimeInfo,      environment.defaultDesktopSettings.updateChannel,    ),  );   const emitState = Ref.get(updateStateRef).pipe(    Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),  );   const setState = (state: DesktopUpdateState): Effect.Effect<void> =>    Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState));   const updateState = (    f: (state: DesktopUpdateState) => DesktopUpdateState,  ): Effect.Effect<DesktopUpdateState> =>    Ref.get(updateStateRef).pipe(      Effect.flatMap((state) => {        const nextState = f(state);        return setState(nextState).pipe(Effect.as(nextState));      }),    );   const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe(    Effect.option,    Effect.flatMap(      Option.match({        onNone: () => Effect.succeed(Option.none<AppUpdateYmlConfig>()),        onSome: parseAppUpdateYml,      }),    ),  );   const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(    Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),  );   const resolveDisabledReason = Effect.gen(function* () {    const hasFeedConfig = yield* hasUpdateFeedConfig;    return Option.fromNullishOr(      getAutoUpdateDisabledReason({        isDevelopment: environment.isDevelopment,        isPackaged: environment.isPackaged,        platform: environment.platform,        appImage: Option.getOrUndefined(config.appImagePath),        disabledByEnv: config.disableAutoUpdate,        hasUpdateFeedConfig: hasFeedConfig,      }),    );  });   const resolveUpdaterErrorContext = Effect.gen(function* () {    if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const;    if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const;    if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const;    return (yield* Ref.get(updateStateRef)).errorContext;  });   const activeUpdateAction = Effect.gen(function* () {    if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const);    if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const);    if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const);    return Option.none<"check" | "download" | "install">();  });   const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (    channel: DesktopUpdateChannel,  ) {    yield* Effect.annotateCurrentSpan({ channel });    const allowsPrerelease = channel === "nightly";    yield* electronUpdater.setChannel(channel);    yield* electronUpdater.setAllowPrerelease(allowsPrerelease);    yield* electronUpdater.setAllowDowngrade(allowsPrerelease);    yield* electronUpdater.setFullChangelog(allowsPrerelease);    yield* logUpdaterInfo("using update channel", {      channel,      allowPrerelease: allowsPrerelease,      allowDowngrade: allowsPrerelease,      fullChangelog: allowsPrerelease,    });  });   const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));   const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) {    yield* Effect.annotateCurrentSpan({ reason });    if (yield* Ref.get(desktopState.quitting)) return false;    if (!(yield* Ref.get(updaterConfiguredRef))) return false;    if (yield* Ref.get(updateCheckInFlightRef)) return false;     const state = yield* Ref.get(updateStateRef);    if (state.status === "downloading" || state.status === "downloaded") {      yield* logUpdaterInfo("skipping update check while update is active", {        reason,        status: state.status,      });      return false;    }     yield* Ref.set(updateCheckInFlightRef, true);    const checkedAt = yield* currentIsoTimestamp;    yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));    yield* logUpdaterInfo("checking for updates", { reason });     return yield* electronUpdater.checkForUpdates.pipe(      Effect.as(true),      Effect.catchTags({        ElectronUpdaterCheckForUpdatesError: Effect.fn(          "desktop.updates.handleCheckForUpdatesFailure",        )(function* (error) {          const failedAt = yield* currentIsoTimestamp;          yield* updateState((current) =>            reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),          );          yield* logUpdaterError(error.message, {            errorTag: error._tag,            channel: error.channel,          });          return true;        }),      }),      Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),    );  });   const downloadAvailableUpdate = Effect.gen(function* () {    const state = yield* Ref.get(updateStateRef);    if (      !(yield* Ref.get(updaterConfiguredRef)) ||      (yield* Ref.get(updateDownloadInFlightRef)) ||      state.status !== "available"    ) {      return { accepted: false, completed: false };    }     yield* Ref.set(updateDownloadInFlightRef, true);    return yield* Effect.gen(function* () {      yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));      yield* electronUpdater.setDisableDifferentialDownload(        isArm64HostRunningIntelBuild(environment.runtimeInfo),      );      yield* logUpdaterInfo("downloading update");      yield* electronUpdater.downloadUpdate;      return { accepted: true, completed: true };    }).pipe(      Effect.catchTags({        ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(          function* (error) {            yield* updateState((current) =>              reduceDesktopUpdateStateOnDownloadFailure(current, error.message),            );            yield* logUpdaterError(error.message, {              errorTag: error._tag,              channel: error.channel,            });            return { accepted: true, completed: false };          },        ),      }),      Effect.onInterrupt(() =>        updateState((current) => (current.status === "downloading" ? state : current)).pipe(          Effect.asVoid,        ),      ),      Effect.catchCause((cause) => {        if (Cause.hasInterruptsOnly(cause)) {          return Effect.failCause(cause);        }        const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause });        return Effect.gen(function* () {          yield* updateState((current) =>            reduceDesktopUpdateStateOnDownloadFailure(current, error.message),          );          yield* logUpdaterError(error.message, {            errorTag: error._tag,            action: error.action,          });          return { accepted: true, completed: false };        });      }),      Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),    );  }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));   const resetInstallAction = Effect.all(    [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)],    { discard: true },  );   const installDownloadedUpdate = Effect.gen(function* () {    const state = yield* Ref.get(updateStateRef);    if (      (yield* Ref.get(desktopState.quitting)) ||      !(yield* Ref.get(updaterConfiguredRef)) ||      state.status !== "downloaded"    ) {      return { accepted: false, completed: false };    }     yield* Ref.set(desktopState.quitting, true);    yield* Ref.set(updateInstallInFlightRef, true);     return yield* Effect.gen(function* () {      // Stop every backend in the pool, not just the primary. With      // parallel WSL + Windows backends, leaving the WSL instance up      // means quitAndInstall's app.quit() exits before the pool's      // scope cascade has a chance to run its stop finalizer, so the      // WSL child gets hard-killed by the OS instead of receiving      // SIGTERM + grace. Stops run concurrently with the same 5s      // budget the primary had on its own.      const instances = yield* pool.list;      yield* Effect.forEach(        instances,        (instance) => instance.stop({ timeout: Duration.seconds(5) }),        { concurrency: "unbounded" },      );      yield* electronWindow.destroyAll;      yield* electronUpdater.quitAndInstall({        isSilent: true,        isForceRunAfter: true,      });      return { accepted: true, completed: false };    }).pipe(      Effect.catchTags({        ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(          function* (error) {            yield* resetInstallAction;            yield* updateState((current) =>              reduceDesktopUpdateStateOnInstallFailure(current, error.message),            );            yield* logUpdaterError(error.message, {              errorTag: error._tag,              channel: error.channel,              isSilent: error.isSilent,              isForceRunAfter: error.isForceRunAfter,            });

Lines 1–500 of 852.