996 lines · 37.0 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 Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as PubSub from "effect/PubSub";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream"; 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 PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel"; interface DesktopPreparedUpdateInstallResult extends DesktopUpdateActionResult {  readonly failed: boolean;} 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.TaggedError<DesktopUpdateActionInProgressError>()(  "DesktopUpdateActionInProgressError",  {    action: Schema.Literals(["check", "download", "install", "channel"]),    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.TaggedError<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.TaggedError<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.TaggedError<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.TaggedError<DesktopUpdaterReportedError>()(  "DesktopUpdaterReportedError",  {    operation: Schema.Literals(["check", "download", "install", "channel", "background"]),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Desktop updater ${this.operation} operation reported an error.`;  }} export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError<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 class DesktopUpdates extends Context.Service<  DesktopUpdates,  {    readonly getState: Effect.Effect<DesktopUpdateState>;    /** True while a check, download, install, or channel change holds the        updater's single action reservation. */    readonly isActionActive: Effect.Effect<boolean>;    /** True only while an install owns the updater action reservation. */    readonly isInstallActive: Effect.Effect<boolean>;    /** Current state plus a stream of every later state change. */    readonly subscribe: Effect.Effect<      {        readonly latest: DesktopUpdateState;        readonly changes: Stream.Stream<DesktopUpdateState>;      },      never,      Scope.Scope    >;    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>;    readonly installPrepared: (      expectedVersion: string,    ) => Effect.Effect<DesktopPreparedUpdateInstallResult>;  }>()("@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";} /** @public Service construction is part of the canonical Effect module API. */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 activeUpdateActionRef = yield* Ref.make<Option.Option<UpdateAction>>(Option.none());  const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();  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 stateChanges = yield* PubSub.sliding<DesktopUpdateState>(16);  // Makes ref writes + publishes atomic against subscribe, so a snapshot  // never overlaps with the first change a subscriber receives.  const stateMutex = yield* Semaphore.make(1);   const emitState = Ref.get(updateStateRef).pipe(    Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),  );   const setState = (state: DesktopUpdateState): Effect.Effect<void> =>    stateMutex      .withPermits(1)(        Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, 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 activeUpdateAction = Ref.get(activeUpdateActionRef);   const tryStartUpdateAction = (action: UpdateAction): Effect.Effect<boolean> =>    Ref.modify(activeUpdateActionRef, (activeAction) =>      Option.isSome(activeAction) ? [false, activeAction] : [true, Option.some(action)],    );   const tryStartChannelChange = Ref.modify(activeUpdateActionRef, (activeAction) =>    Option.isSome(activeAction)      ? [activeAction, activeAction]      : [Option.none<UpdateAction>(), Option.some<UpdateAction>("channel")],  );   const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>    Ref.modify(activeUpdateActionRef, (activeAction) => {      const finished = Option.isSome(activeAction) && activeAction.value === action;      return [finished, finished ? Option.none() : activeAction] as const;    }).pipe(      Effect.flatMap((finished) =>        finished ? PubSub.publish(finishedUpdateActions, action).pipe(Effect.asVoid) : Effect.void,      ),    );   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,    actionReservation: "acquire" | "held" = "acquire",  ) {    yield* Effect.annotateCurrentSpan({ reason });    if (yield* Ref.get(desktopState.quitting)) return false;    if (!(yield* Ref.get(updaterConfiguredRef))) return false;     const state = yield* Ref.get(updateStateRef);    if (state.status === "downloading") {      yield* logUpdaterInfo("skipping update check while update is active", {        reason,        status: state.status,      });      return false;    }     if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false;     const check = Effect.gen(function* () {      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;          }),        }),      );    });     return yield* actionReservation === "held"      ? check      : check.pipe(          Effect.onInterrupt(() => setState(state)),          Effect.ensuring(finishUpdateAction("check")),        );  });   const downloadAvailableUpdate = Effect.gen(function* () {    const state = yield* Ref.get(updateStateRef);    if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") {      return { accepted: false, completed: false };    }     if (!(yield* tryStartUpdateAction("download"))) {      return { accepted: false, completed: false };    }     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(finishUpdateAction("download")),

Lines 1–500 of 996.