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. Load more
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")),