667 lines · 24.7 KB · TypeScript
RawDownload
import type {  DesktopRuntimeInfo,  DesktopUpdateActionResult,  DesktopUpdateChannel,  DesktopUpdateCheckResult,  DesktopUpdateState,} from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Data from "effect/Data";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 DesktopBackendManager from "../backend/DesktopBackendManager.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 { 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,}); 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 Data.TaggedError(  "DesktopUpdateActionInProgressError",)<{  readonly action: "check" | "download" | "install";}> {  override get message() {    return `Cannot change update tracks while an update ${this.action} action is in progress.`;  }} export class DesktopUpdatePersistenceError extends Data.TaggedError(  "DesktopUpdatePersistenceError",)<{  readonly cause: DesktopAppSettings.DesktopSettingsWriteError;}> {  override get message() {    return "Failed to persist desktop update settings.";  }} export type DesktopUpdateConfigureError = never; export type DesktopUpdateSetChannelError =  | DesktopUpdateActionInProgressError  | DesktopUpdatePersistenceError; export interface DesktopUpdatesShape {  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>;} export class DesktopUpdates extends Context.Service<DesktopUpdates, DesktopUpdatesShape>()(  "@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.DesktopEnvironmentShape,): 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";} const make = Effect.gen(function* () {  const config = yield* DesktopConfig.DesktopConfig;  const backendManager = yield* DesktopBackendManager.DesktopBackendManager;  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* logUpdaterInfo("using update channel", {      channel,      allowPrerelease: allowsPrerelease,      allowDowngrade: 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.catch(        Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) {          const failedAt = yield* currentIsoTimestamp;          yield* updateState((current) =>            reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),          );          yield* logUpdaterError("failed to check for updates", { message: error.message });          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.catch(        Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) {          yield* updateState((current) =>            reduceDesktopUpdateStateOnDownloadFailure(current, error.message),          );          yield* logUpdaterError("failed to download update", { message: error.message });          return { accepted: true, completed: false };        }),      ),      Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),    );  }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));   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* () {      yield* backendManager.stop({ timeout: Duration.seconds(5) });      yield* electronWindow.destroyAll;      yield* electronUpdater.quitAndInstall({        isSilent: true,        isForceRunAfter: true,      });      return { accepted: true, completed: false };    }).pipe(      Effect.catch(        Effect.fn("desktop.updates.handleInstallFailure")(function* (error) {          yield* Ref.set(updateInstallInFlightRef, false);          yield* updateState((current) =>            reduceDesktopUpdateStateOnInstallFailure(current, error.message),          );          yield* Ref.set(desktopState.quitting, false);          yield* logUpdaterError("failed to install update", { message: error.message });          return { accepted: true, completed: false };        }),      ),    );  }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));   const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {    yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(      Effect.andThen(checkForUpdates("startup")),      Effect.catchCause((cause) =>        logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }),      ),      Effect.forkScoped,    );    yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(      Effect.andThen(checkForUpdates("poll")),      Effect.forever,      Effect.catchCause((cause) =>        logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }),      ),      Effect.forkScoped,    );  }).pipe(Effect.withSpan("desktop.updates.startPollers"));   const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* (    raw: unknown,  ) {    yield* decodeUpdateInfo(raw).pipe(      Effect.flatMap(        Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {          const state = yield* Ref.get(updateStateRef);          if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) {            yield* logUpdaterInfo("ignoring update that does not match selected channel", {              version: info.version,              channel: state.channel,            });            const checkedAt = yield* currentIsoTimestamp;            yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));            yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);            return;          }           const checkedAt = yield* currentIsoTimestamp;          yield* setState(            reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt),          );          yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);          yield* logUpdaterInfo("update available", { version: info.version });        }),      ),      Effect.catchCause((cause) =>        logUpdaterWarning("ignored malformed update-available event", {          cause: Cause.pretty(cause),        }),      ),    );  });   const handleUpdateNotAvailable = Effect.gen(function* () {    const checkedAt = yield* currentIsoTimestamp;    const state = yield* Ref.get(updateStateRef);    yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));    yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);    yield* logUpdaterInfo("no updates available");  }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));   const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (    error: unknown,  ) {    const message = error instanceof Error ? error.message : String(error);    if (yield* Ref.get(updateInstallInFlightRef)) {      yield* Ref.set(updateInstallInFlightRef, false);      yield* Ref.set(desktopState.quitting, false);      yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));      yield* logUpdaterError("updater error", { message });      return;    }     if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) {      const errorContext = yield* resolveUpdaterErrorContext;      const checkedAt = yield* currentIsoTimestamp;      yield* updateState((current) => ({        ...current,        status: "error",        message,        checkedAt,        downloadPercent: null,        errorContext,        canRetry: getCanRetryFromState(current),      }));    }     yield* logUpdaterError("updater error", { message });  });   const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (    raw: unknown,  ) {    yield* decodeDownloadProgressInfo(raw).pipe(      Effect.flatMap(        Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) {          const state = yield* Ref.get(updateStateRef);          const percent = Math.floor(progress.percent);          if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) {            yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent));          }          const milestone = percent - (percent % 10);          const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef);          if (milestone > lastLoggedMilestone) {            yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone);            yield* logUpdaterInfo("download progress", { percent });          }        }),      ),

Lines 1–500 of 667.