diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts index 029bcaac2b487..6d6167f233c04 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.test.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -61,6 +61,24 @@ describe("resolveStartupRecoveryOffer", () => { }); }); + it("explains why the offered update did not install", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ + status: "downloaded", + downloadedVersion: "0.1.4", + errorContext: "install", + message: "Not enough disk space to install the update.", + }), + "0.1.2", + ), + ).toEqual({ + _tag: "Update", + version: "0.1.4", + installError: "Not enough disk space to install the update.", + }); + }); + it("does not offer an update that already failed to apply here", () => { expect( resolveStartupRecoveryOffer( diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index c8228183aad91..acb2fd455f68c 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -29,7 +29,7 @@ const { logInfo, logWarning } = DesktopObservability.makeComponentLogger( export type StartupRecoveryOffer = | { readonly _tag: "Checking" } - | { readonly _tag: "Update"; readonly version: string } + | { readonly _tag: "Update"; readonly version: string; readonly installError?: string } | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null } | { readonly _tag: "Reinstall"; readonly version: string } | { readonly _tag: "Releases" } @@ -55,7 +55,10 @@ export function resolveStartupRecoveryOffer( update.downloadedVersion ?? (update.status === "available" ? update.availableVersion : null); // A version that already failed to apply here would loop through install and relaunch. if (updateVersion !== null && updateVersion !== update.notAppliedVersion) { - return { _tag: "Update", version: updateVersion }; + const installError = update.errorContext === "install" ? update.message?.trim() : undefined; + return installError + ? { _tag: "Update", version: updateVersion, installError } + : { _tag: "Update", version: updateVersion }; } // A newer release can know a key this one refuses; an older one or the releases page cannot help. if (managedPolicy) return { _tag: "ManagedPolicy" }; @@ -84,7 +87,7 @@ function describeOffer(offer: StartupRecoveryOffer): { text: string; action: str return { text: "Checking for a newer version…", action: "" }; case "Update": return { - text: `Version ${offer.version} is available. It can contain a fix.`, + text: offer.installError ?? `Version ${offer.version} is available. It can contain a fix.`, action: actionLink("update", `Update to ${offer.version}`, true), }; case "Downloading": diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 003009f7190fa..c4a0e562ba518 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -70,6 +70,7 @@ import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; +import * as DesktopDiskSpace from "./updates/DesktopDiskSpace.ts"; import * as DesktopUpdatePolicy from "./updates/DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; @@ -229,6 +230,7 @@ const layerDesktopApplication = Layer.mergeAll( Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge( DesktopUpdates.layer.pipe( + Layer.provide(DesktopDiskSpace.layer), Layer.provide(DesktopUpdatePolicy.layer), // The same layer as the foundation's, so the policy is still read once. Layer.provide(DesktopManagedPolicy.layer), diff --git a/apps/desktop/src/updates/DesktopDiskSpace.test.ts b/apps/desktop/src/updates/DesktopDiskSpace.test.ts new file mode 100644 index 0000000000000..cf42a2c40a615 --- /dev/null +++ b/apps/desktop/src/updates/DesktopDiskSpace.test.ts @@ -0,0 +1,35 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 Path from "effect/Path"; + +import * as DesktopDiskSpace from "./DesktopDiskSpace.ts"; + +describe("DesktopDiskSpace", () => { + it.effect("sums nested files without following symlinks", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const diskSpace = yield* DesktopDiskSpace.DesktopDiskSpace; + const root = yield* fileSystem.makeTempDirectoryScoped(); + const outside = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.makeDirectory(path.join(root, "resources", "app"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, "app.exe"), "x".repeat(100)); + yield* fileSystem.writeFileString( + path.join(root, "resources", "app", "a.js"), + "x".repeat(20), + ); + yield* fileSystem.writeFileString(path.join(outside, "big.bin"), "x".repeat(5000)); + yield* fileSystem.symlink(path.join(outside, "big.bin"), path.join(root, "big-link")); + yield* fileSystem.symlink(outside, path.join(root, "outside-link")); + + assert.equal(yield* diskSpace.directoryBytes(root), 120); + assert.isTrue( + Exit.isFailure(yield* Effect.exit(diskSpace.directoryBytes(path.join(root, "missing")))), + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(DesktopDiskSpace.layer, NodeServices.layer))), + ); +}); diff --git a/apps/desktop/src/updates/DesktopDiskSpace.ts b/apps/desktop/src/updates/DesktopDiskSpace.ts new file mode 100644 index 0000000000000..7f20e1389d94c --- /dev/null +++ b/apps/desktop/src/updates/DesktopDiskSpace.ts @@ -0,0 +1,76 @@ +// @effect-diagnostics nodeBuiltinImport:off -- FileSystem has no statfs, and its stat follows symlinks. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +const MAX_DIRECTORY_ENTRIES = 100_000; + +// Electron's fs reports .asar archives as empty directories; original-fs sees the real files. +const NodeFSP = ((process.getBuiltinModule("original-fs") as typeof NodeFS | undefined) ?? NodeFS) + .promises; + +export class DesktopDiskSpaceError extends Schema.TaggedError()( + "DesktopDiskSpaceError", + { + operation: Schema.Literals(["freeBytes", "directoryBytes"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not measure disk space (${this.operation}).`; + } +} + +export class DesktopDiskSpace extends Context.Service< + DesktopDiskSpace, + { + /** The temp directory a spawned installer inherits. */ + readonly tempDirectory: string; + /** Bytes the current user can still write on the volume holding `path`. */ + readonly freeBytes: (path: string) => Effect.Effect; + /** Total size of the regular files below `path`, without following symlinks. */ + readonly directoryBytes: (path: string) => Effect.Effect; + } +>()("@t3tools/desktop/updates/DesktopDiskSpace") {} + +async function sumDirectoryBytes(root: string): Promise { + let total = 0; + let entries = 0; + const pending = [root]; + for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) { + // A partial total would understate the space the installer needs, so any failure fails the walk. + const children = await NodeFSP.readdir(directory, { withFileTypes: true }); + entries += children.length; + if (entries > MAX_DIRECTORY_ENTRIES) { + throw new Error(`More than ${MAX_DIRECTORY_ENTRIES} entries below the directory.`); + } + for (const child of children) { + const childPath = NodePath.join(directory, child.name); + if (child.isDirectory()) pending.push(childPath); + else if (child.isFile()) total += (await NodeFSP.lstat(childPath)).size; + } + } + return total; +} + +export const layer = Layer.sync(DesktopDiskSpace, () => ({ + tempDirectory: NodeOS.tmpdir(), + freeBytes: Effect.fn("DesktopDiskSpace.freeBytes")(function* (path: string) { + const stats = yield* Effect.tryPromise({ + try: () => NodeFSP.statfs(path), + catch: (cause) => new DesktopDiskSpaceError({ operation: "freeBytes", cause }), + }); + return stats.bavail * stats.bsize; + }), + directoryBytes: Effect.fn("DesktopDiskSpace.directoryBytes")(function* (path: string) { + return yield* Effect.tryPromise({ + try: () => sumDirectoryBytes(path), + catch: (cause) => new DesktopDiskSpaceError({ operation: "directoryBytes", cause }), + }); + }), +})); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 7ee2ff5dd51c1..8af51f055fb9f 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -727,6 +727,95 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + // regression: #27486 a silent NSIS update on a full volume left a half-written install that no longer launched + it.effect("keeps a Windows update retryable when the temp volume lacks space", () => { + const megabyte = 1024 * 1024; + const diskSpace = { + tempFreeBytes: 200 * megabyte, + installerBytes: 150 * megabyte, + installDirectoryBytes: 400 * megabyte, + }; + const harness = makeHarness({ platform: "win32", diskSpace }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { + version: "1.2.4", + downloadedFile: "C:\\Users\\me\\AppData\\Local\\t3code-updater\\pending\\installer.exe", + }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.equal(harness.quitAndInstalls(), 0, "the installer ran without enough disk space"); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.isTrue((yield* (yield* pool.primary).snapshot).desiredRunning); + assert.isFalse(result.completed); + assert.equal(result.state.status, "downloaded"); + assert.equal(result.state.downloadedVersion, "1.2.4"); + assert.equal(result.state.errorContext, "install"); + assert.isTrue(result.state.canRetry); + assert.match(result.state.message ?? "", /space/i); + + diskSpace.tempFreeBytes = 2000 * megabyte; + const retry = yield* updates.install; + assert.equal(harness.quitAndInstalls(), 1); + assert.isNull(retry.state.message, "a successful retry still reports the earlier refusal"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a Windows update retryable when a separate install volume is full", () => { + const megabyte = 1024 * 1024; + const harness = makeHarness({ + platform: "win32", + diskSpace: { + tempFreeBytes: 2000 * megabyte, + installFreeBytes: 200 * megabyte, + installerBytes: 150 * megabyte, + installDirectoryBytes: 400 * megabyte, + }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { + version: "1.2.4", + downloadedFile: "C:\\Users\\me\\AppData\\Local\\t3code-updater\\pending\\installer.exe", + }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.equal(harness.quitAndInstalls(), 0); + assert.match(result.state.message ?? "", /space/i); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("installs a Windows update when disk space cannot be measured", () => { + const harness = makeHarness({ platform: "win32" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { + version: "1.2.4", + downloadedFile: "C:\\Users\\me\\AppData\\Local\\t3code-updater\\pending\\installer.exe", + }); + yield* flushCallbacks; + + yield* updates.install; + assert.equal(harness.quitAndInstalls(), 1, "an unmeasured volume blocked the update"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("restores only desired-running instances after an installer error event", () => { const starts: string[] = []; const stops: string[] = []; diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index db31e0bc112ba..a49fde88af7a0 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -38,6 +38,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; +import * as DesktopDiskSpace from "./DesktopDiskSpace.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import { normalizeDesktopUpdateReleaseNotes, @@ -80,6 +81,7 @@ const UpdateInfo = Schema.Struct({ releaseNotes: Schema.optional(Schema.Unknown), releaseName: Schema.optional(Schema.NullOr(Schema.String)), releaseDate: Schema.optional(Schema.String), + downloadedFile: Schema.optional(Schema.String), }); const DownloadProgressInfo = Schema.Struct({ @@ -364,6 +366,10 @@ function getAutoUpdateDisabledReason(args: { return null; } +const MEGABYTE = 1024 * 1024; +// Covers cluster rounding and a release that grows; refusing is recoverable, a partial install is not. +const INSTALL_SPACE_MARGIN_BYTES = 128 * MEGABYTE; + function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } @@ -379,6 +385,7 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const diskSpace = yield* DesktopDiskSpace.DesktopDiskSpace; const policy = yield* DesktopUpdatePolicy.DesktopUpdatePolicy; // Any user can set the mock flag, so it never replaces an administrator's update source. const mockUpdates = config.mockUpdates && policy.message === null; @@ -392,6 +399,7 @@ export const make = Effect.gen(function* () { const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); + const downloadedFileRef = yield* Ref.make(Option.none()); // The release a track switch's database check cleared. A build crosses to another track only // through such a switch, and never below that release: version order alone cannot tell whether // a Stable knows a Nightly's migrations. @@ -795,6 +803,57 @@ export const make = Effect.gen(function* () { ); }); + // NSIS deletes the old install before staging its package in %TEMP% and copying it in, so the + // %TEMP% volume peaks near installer + install folder. A separate install volume keeps the old + // files when the old uninstaller cannot run, so it needs room for a second install folder. + const findInstallSpaceShortfall = Effect.fn("desktop.updates.findInstallSpaceShortfall")( + function* () { + if (environment.platform !== "win32") return Option.none(); + const installer = yield* Ref.get(downloadedFileRef); + if (Option.isNone(installer)) { + yield* logUpdaterWarning("skipping the install disk space check without an installer path"); + return Option.none(); + } + const installDirectory = environment.path.dirname(environment.resourcesPath); + return yield* Effect.all({ + tempFreeBytes: diskSpace.freeBytes(diskSpace.tempDirectory), + installFreeBytes: diskSpace.freeBytes(installDirectory), + installerBytes: fileSystem + .stat(installer.value) + .pipe(Effect.map((info) => Number(info.size))), + installDirectoryBytes: diskSpace.directoryBytes(installDirectory), + }).pipe( + Effect.flatMap( + ({ tempFreeBytes, installFreeBytes, installerBytes, installDirectoryBytes }) => { + const tempRequiredBytes = + installerBytes + installDirectoryBytes + INSTALL_SPACE_MARGIN_BYTES; + const [freeBytes, requiredBytes] = + tempFreeBytes < tempRequiredBytes + ? [tempFreeBytes, tempRequiredBytes] + : [installFreeBytes, installDirectoryBytes + INSTALL_SPACE_MARGIN_BYTES]; + if (freeBytes >= requiredBytes) return Effect.succeed(Option.none()); + return logUpdaterWarning("not enough disk space to install the update", { + freeBytes, + requiredBytes, + }).pipe( + Effect.as( + Option.some( + `Not enough disk space to install the update: needs about ${Math.ceil(requiredBytes / MEGABYTE)} MB free, ${Math.floor(freeBytes / MEGABYTE)} MB available.`, + ), + ), + ); + }, + ), + Effect.catch((error) => + logUpdaterWarning("could not check disk space before installing the update", { + errorTag: error._tag, + cause: describeUpdateErrorCause(error), + }).pipe(Effect.as(Option.none())), + ), + ); + }, + ); + const installDownloadedUpdate = (expectedVersion?: string) => Effect.scoped( Effect.gen(function* () { @@ -843,6 +902,23 @@ export const make = Effect.gen(function* () { return { accepted: false, completed: false, failed: false }; } + const spaceShortfall = yield* findInstallSpaceShortfall().pipe( + Effect.onInterrupt(() => finishUpdateAction("install")), + ); + if (Option.isSome(spaceShortfall)) { + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, spaceShortfall.value), + ); + yield* finishUpdateAction("install"); + return { accepted: true, completed: false, failed: true }; + } + // A refusal's message would otherwise read as this attempt's failure. + yield* updateState((current) => + current.errorContext === "install" + ? { ...current, message: null, errorContext: null } + : current, + ); + yield* Ref.set(desktopState.quitting, true); yield* Ref.set(installRunningBackendsRef, []); @@ -1124,6 +1200,7 @@ export const make = Effect.gen(function* () { } return; } + yield* Ref.set(downloadedFileRef, Option.fromNullishOr(info.downloadedFile)); yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index b118d15ced674..8c28912baca7c 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as ByteSize from "effect/ByteSize"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; @@ -15,6 +16,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopDiskSpace from "./DesktopDiskSpace.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; @@ -44,6 +46,13 @@ export interface UpdatesHarnessOptions { readonly savedChannel?: DesktopAppSettings.DesktopSettings["updateChannel"]; /** Adds a WSL backend beside the primary, as on a Windows host with WSL enabled. */ readonly wslBackend?: boolean; + /** Sizes a Windows install preflight reads, live; without `installFreeBytes` temp and install share a volume. */ + readonly diskSpace?: { + tempFreeBytes: number; + readonly installFreeBytes?: number; + readonly installerBytes: number; + readonly installDirectoryBytes: number; + }; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -243,6 +252,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; + const diskSpace = options.diskSpace; const updateRestartMarkers = new Set(); const updateMarkerContents = new Map(); const installRecords = new Map(); @@ -266,6 +276,14 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), ); }, + ...(diskSpace === undefined + ? {} + : { + stat: () => + Effect.succeed({ + size: ByteSize.bytes(diskSpace.installerBytes), + } as FileSystem.File.Info), + }), makeDirectory: () => Effect.void, writeFileString: (path, data) => Effect.sync(() => { @@ -290,8 +308,27 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), }); + const unmeasured = (operation: DesktopDiskSpace.DesktopDiskSpaceError["operation"]) => + Effect.fail(new DesktopDiskSpace.DesktopDiskSpaceError({ operation, cause: "unmeasured" })); + const layerDiskSpace = Layer.succeed(DesktopDiskSpace.DesktopDiskSpace, { + tempDirectory: "C:\\Users\\me\\AppData\\Local\\Temp", + freeBytes: (path) => + diskSpace === undefined + ? unmeasured("freeBytes") + : Effect.sync(() => + path.startsWith("C:\\Users\\me\\AppData\\Local\\Temp") + ? diskSpace.tempFreeBytes + : (diskSpace.installFreeBytes ?? diskSpace.tempFreeBytes), + ), + directoryBytes: () => + diskSpace === undefined + ? unmeasured("directoryBytes") + : Effect.succeed(diskSpace.installDirectoryBytes), + } satisfies DesktopDiskSpace.DesktopDiskSpace["Service"]); + const layer = DesktopUpdates.layer.pipe( Layer.provide(layerFileSystem), + Layer.provide(layerDiskSpace), Layer.provide(DesktopUpdatePolicy.layerTest(options.policy)), Layer.provideMerge(layerUpdater), Layer.provideMerge(layerWindow), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea874c2b18c30..b5d4a82b3dbc6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -38,6 +38,7 @@ import { MobileAppSettingsRow } from "./MobileAppSettingsRow"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, + getDesktopUpdateActionError, getDesktopUpdateDisabledReason, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, @@ -244,6 +245,18 @@ function AboutVersionSection() { } void bridge .installUpdate() + .then((result) => { + const actionError = getDesktopUpdateActionError(result); + if (actionError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + } + }) .catch((error: unknown) => { toastManager.add( stackedThreadToast({