diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..604fbeda3c9f1 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,5 +1,8 @@ +import * as NodeModule from "node:module"; + import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; +import { computeReleaseNotes } from "electron-updater/out/providers/GitHubProvider.js"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -22,6 +25,57 @@ import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +type ReleaseFeed = Parameters[2]; +type RunningVersion = Parameters[0]; + +// electron-updater's own dependencies, which apps/desktop does not declare. +const requireFromUpdater = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("electron-updater"), +); +const { parseXml } = requireFromUpdater("builder-util-runtime") as { + parseXml: (xml: string) => ReleaseFeed; +}; +const semver = requireFromUpdater("semver") as { parse: (version: string) => RunningVersion }; + +const releaseTitle = (version: string) => + version.includes("-nightly.") + ? `T3 Code Nightly ${version} (0123456789ab)` + : `T3 Code v${version}`; + +const releaseFeed = (...versions: ReadonlyArray) => + parseXml( + `${versions + .map( + (version) => + `${releaseTitle(version)}<ul><li>${version} change</li></ul>`, + ) + .join("")}`, + ); + +/** The update info electron-updater's GitHub provider reports when /releases/latest names `version`. */ +const stableUpdateInfo = ( + feed: ReleaseFeed, + version: string, + runningVersion: string, + fullChangelog: boolean, +) => { + const latestRelease = + feed + .getElements("entry") + .find((entry) => entry.element("link").attribute("href").endsWith(`/tag/v${version}`)) ?? + feed.element("entry"); + return { + version, + releaseName: latestRelease.elementValueOrEmpty("title"), + releaseNotes: computeReleaseNotes( + semver.parse(runningVersion), + fullChangelog, + feed, + latestRelease, + ), + }; +}; + describe("DesktopUpdates", () => { it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { @@ -109,6 +163,51 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect.each([ + { + name: "shows no notes for a Stable release missing from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"], + expected: [], + }, + { + name: "shows a Stable release's own notes from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + { + name: "shows a Stable release's notes when switching down from a newer Nightly", + runningVersion: "1.2.5-nightly.20261027.1", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + ])("$name", ({ runningVersion, feedVersions, expected }) => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit( + "update-available", + stableUpdateInfo( + releaseFeed(...feedVersions), + "1.2.4", + runningVersion, + harness.fullChangelog(), + ), + ); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.availableVersion, "1.2.4"); + assert.deepEqual(state.releaseNotes, expected); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("preserves a queued installer when the feed has no update", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 14688e5258067..36492ca7b9063 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -36,7 +36,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; -import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; +import { + normalizeDesktopUpdateReleaseNotes, + releaseNotesOfOfferedVersion, +} from "./releaseNotes.ts"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -72,6 +75,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseName: Schema.optional(Schema.NullOr(Schema.String)), releaseDate: Schema.optional(Schema.String), }); @@ -939,7 +943,7 @@ export const make = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( - info.releaseNotes, + releaseNotesOfOfferedVersion(info), info.version, state.channel, info.releaseDate, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index a319898222092..715c1767e6a04 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -171,6 +171,22 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * Untagged notes come from the feed entry named by releaseName, which is the newest entry when the + * offered release is not on the feed's first page. Keep them only when that entry is the offered one. + */ +export function releaseNotesOfOfferedVersion(info: { + readonly version: string; + readonly releaseNotes?: unknown; + readonly releaseName?: string | null | undefined; +}): unknown { + if (typeof info.releaseNotes !== "string" || !info.releaseName) return info.releaseNotes; + const namesVersion = new RegExp(`(?:^|[^\\w.-])v?${escapeRegExp(info.version)}(?![\\w.-])`); + return namesVersion.test(info.releaseName) ? info.releaseNotes : undefined; +} + /** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown,