From 75635ffbef0c686b5f208d9fbf74e13c5f25d516 Mon Sep 17 00:00:00 2001 From: Hugo Vizcaino Santana Date: Thu, 9 Jul 2026 13:13:06 +0200 Subject: [PATCH 1/4] feat: show nightly update changelog tooltip --- .../src/electron/ElectronUpdater.test.ts | 14 +++ apps/desktop/src/electron/ElectronUpdater.ts | 6 + .../src/updates/DesktopUpdates.test.ts | 49 ++++++++ apps/desktop/src/updates/DesktopUpdates.ts | 19 ++- apps/desktop/src/updates/releaseNotes.ts | 111 ++++++++++++++++++ .../desktop/src/updates/updateMachine.test.ts | 24 ++++ apps/desktop/src/updates/updateMachine.ts | 6 + .../components/desktopUpdate.logic.test.ts | 1 + .../components/sidebar/SidebarUpdatePill.tsx | 60 +++++++++- apps/web/src/state/desktopUpdate.test.ts | 1 + packages/contracts/src/ipc.ts | 12 ++ 11 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/updates/releaseNotes.ts diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 8fcc34f41c2..c2acc9ce120 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ autoInstallOnAppQuit: true, channel: "latest", disableDifferentialDownload: false, + fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.autoInstallOnAppQuit = true; autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.fullChangelog = false; autoUpdaterMock.checkForUpdates.mockClear(); autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => { }).pipe(Effect.provide(ElectronUpdater.layer)), ); + it.effect("sets full changelog mode", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFullChangelog(true); + assert.equal(autoUpdaterMock.fullChangelog, true); + + yield* updater.setFullChangelog(false); + assert.equal(autoUpdaterMock.fullChangelog, false); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + it.effect("preserves quit-and-install flags and the execution-time channel", () => Effect.gen(function* () { const cause = new Error("quit and install failed"); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 435fbd00228..4157d29a9df 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service< readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({ autoUpdater.allowDowngrade = value; return Effect.void; }), + setFullChangelog: (value) => + Effect.suspend(() => { + autoUpdater.fullChangelog = value; + return Effect.void; + }), setDisableDifferentialDownload: (value) => Effect.suspend(() => { autoUpdater.disableDifferentialDownload = value; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506..5ae92bbee96 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; + let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.setChannel("nightly"); + assert.equal(harness.fullChangelog(), true); + + harness.emit("update-available", { + version: "1.2.4-nightly.20260709.766", + releaseNotes: [ + { + version: "1.2.4-nightly.20260709.766", + note: `

What's Changed

Full Changelog

`, + }, + { + version: "1.2.4-nightly.20260709.765", + note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", + }, + ], + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.deepEqual(state.releaseNotes, [ + { + version: "1.2.4-nightly.20260709.766", + items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + }, + { + version: "1.2.4-nightly.20260709.765", + items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + }, + ]); + assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("keeps raw updater event failures out of update state", () => { const harness = makeHarness(); const cause = new Error( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aabb0830b0f..da367ad33a8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ 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, @@ -47,8 +48,16 @@ const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; +const UpdateReleaseNoteInfo = Schema.Struct({ + version: Schema.String, + note: Schema.NullOr(Schema.String), +}); + const UpdateInfo = Schema.Struct({ version: Schema.String, + releaseNotes: Schema.optional( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(UpdateReleaseNoteInfo)])), + ), }); const DownloadProgressInfo = Schema.Struct({ @@ -330,10 +339,12 @@ export const make = Effect.gen(function* () { 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, }); }); @@ -567,11 +578,15 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { version: info.version }); + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: releaseNotes.length, + }); }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts new file mode 100644 index 00000000000..07e8ed28a63 --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -0,0 +1,111 @@ +import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +interface ElectronReleaseNoteInfo { + readonly version: string; + readonly note: string | null; +} + +type ElectronReleaseNotes = string | ReadonlyArray | null | undefined; + +const MAX_RELEASE_NOTE_GROUPS = 6; +const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; +const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; + +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeHtmlEntity(entity: string): string { + const named = HTML_ENTITY_REPLACEMENTS[entity]; + if (named) return named; + if (entity.startsWith("#x")) { + const codePoint = Number.parseInt(entity.slice(2), 16); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : `&${entity};`; + } + if (entity.startsWith("#")) { + const codePoint = Number.parseInt(entity.slice(1), 10); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : `&${entity};`; + } + return `&${entity};`; +} + +function decodeHtmlEntities(input: string): string { + return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => + decodeHtmlEntity(entity), + ); +} + +function stripMarkup(input: string): string { + return decodeHtmlEntities( + input + .replace(//gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"), + ); +} + +function truncateReleaseNoteItem(item: string): string { + if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; + return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = line + .toLowerCase() + .replace(/[*_`#]/g, "") + .trim(); + return ( + normalized === "" || + normalized === "what's changed" || + normalized === "whats changed" || + normalized === "full changelog" || + normalized === "new contributors" || + normalized.startsWith("compare: ") || + normalized.includes("/compare/") + ); +} + +function extractReleaseNoteItems(note: string | null): ReadonlyArray { + if (!note) return []; + + const items: string[] = []; + for (const rawLine of stripMarkup(note).split("\n")) { + const item = rawLine + .trim() + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .replace(/\s+/g, " "); + if (isIgnoredReleaseNoteLine(item)) continue; + items.push(truncateReleaseNoteItem(item)); + if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + } + return items; +} + +export function normalizeDesktopUpdateReleaseNotes( + releaseNotes: ElectronReleaseNotes, + fallbackVersion: string, +): ReadonlyArray { + const rawNotes = + typeof releaseNotes === "string" + ? [{ version: fallbackVersion, note: releaseNotes }] + : Array.isArray(releaseNotes) + ? releaseNotes + : []; + + return rawNotes + .slice(0, MAX_RELEASE_NOTE_GROUPS) + .map((entry) => ({ + version: entry.version, + items: extractReleaseNoteItems(entry.note), + })) + .filter((entry) => entry.items.length > 0); +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 4b2a87c7ce0..040411f76f4 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -118,6 +118,12 @@ describe("updateMachine", () => { }); it("tracks available, download start, and progress cleanly", () => { + const releaseNotes = [ + { + version: "1.1.0", + items: ["feat: add update release notes"], + }, + ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -126,15 +132,33 @@ describe("updateMachine", () => { }, "1.1.0", "2026-03-04T00:00:00.000Z", + releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); + expect(available.releaseNotes).toBe(releaseNotes); + expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); + + it("clears release notes when checking again", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + status: "available", + availableVersion: "1.1.0-nightly.1", + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.releaseNotes).toEqual([]); + }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index b5037225774..fef51bbb8ab 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateReleaseNote, DesktopUpdateState, } from "@t3tools/contracts"; @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( ...state, status: "checking", checkedAt, + releaseNotes: [], message: null, downloadPercent: null, errorContext: null, @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( state: DesktopUpdateState, version: string, checkedAt: string, + releaseNotes: ReadonlyArray = [], ): DesktopUpdateState { return { ...state, status: "available", availableVersion: version, downloadedVersion: null, + releaseNotes, downloadPercent: null, checkedAt, message: null, @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( status: "up-to-date", availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt, message: null, diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index a05bc33a10f..65083b5b0bf 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index c3ac56d1092..f1326c5d28c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -15,8 +15,52 @@ import { shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +function SidebarUpdateReleaseNotesTooltip({ + state, + tooltip, +}: { + readonly state: NonNullable>; + readonly tooltip: string; +}) { + if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + return <>{tooltip}; + } + + const updateVersion = state.availableVersion ?? state.downloadedVersion ?? "available"; + + return ( +
+
+
+ Update {updateVersion} ready to download +
+
+
+ {state.releaseNotes.map((releaseNote, index) => ( +
+ {index > 0 && } +
+

+ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} +

+
    + {releaseNote.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ ))} +
+
+ ); +} + export function SidebarUpdatePill() { const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); @@ -151,7 +195,21 @@ export function SidebarUpdatePill() { } /> - {tooltip} + 0 + ? "max-w-none text-balance" + : undefined + } + side="top" + > + {state ? ( + + ) : ( + tooltip + )} + {action === "download" && ( diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index a2bcbd19a33..0c05b2d4539 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bbb4d934ca2..2408a005bfe 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -213,6 +213,7 @@ export interface DesktopUpdateState { runningUnderArm64Translation: boolean; availableVersion: string | null; downloadedVersion: string | null; + releaseNotes: ReadonlyArray; downloadPercent: number | null; checkedAt: string | null; message: string | null; @@ -220,6 +221,16 @@ export interface DesktopUpdateState { canRetry: boolean; } +export interface DesktopUpdateReleaseNote { + version: string; + items: ReadonlyArray; +} + +export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ + version: Schema.String, + items: Schema.Array(Schema.String), +}); + export const DesktopUpdateStateSchema = Schema.Struct({ enabled: Schema.Boolean, status: DesktopUpdateStatusSchema, @@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ runningUnderArm64Translation: Schema.Boolean, availableVersion: Schema.NullOr(Schema.String), downloadedVersion: Schema.NullOr(Schema.String), + releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), downloadPercent: Schema.NullOr(Schema.Number), checkedAt: Schema.NullOr(Schema.String), message: Schema.NullOr(Schema.String), From 997e093f1f4bbe1d530a12e1555002fda8621f88 Mon Sep 17 00:00:00 2001 From: Hugo Vizcaino Santana Date: Thu, 9 Jul 2026 16:48:33 +0200 Subject: [PATCH 2/4] fix: address review feedback on nightly changelog tooltip - releaseNotes: guard decodeHtmlEntity against out-of-range code points so a malformed release note can no longer throw and drop the update notice - SidebarUpdatePill: use the status-aware pill tooltip as the heading instead of a hardcoded "ready to download" string, keeping it coherent across downloading/downloaded/error states - SidebarUpdatePill: allow the changelog area to scroll (overflow-y-auto) instead of silently clipping long notes - add releaseNotes normalization tests, including the out-of-range entity case Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/updates/releaseNotes.test.ts | 41 +++++++++++++++++++ apps/desktop/src/updates/releaseNotes.ts | 15 +++++-- .../components/sidebar/SidebarUpdatePill.tsx | 8 +--- 3 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/updates/releaseNotes.test.ts diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts new file mode 100644 index 00000000000..18024761529 --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; + +describe("normalizeDesktopUpdateReleaseNotes", () => { + it("splits a plain string note into items under the fallback version", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + "## What's changed\n- First fix\n- Second fix", + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + }); + + it("keeps per-version groups and drops empty ones", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.2.1", note: "- Older change" }, + ], + "1.2.3", + ); + expect(notes).toEqual([ + { version: "1.2.3", items: ["Newer change"] }, + { version: "1.2.1", items: ["Older change"] }, + ]); + }); + + it("decodes valid HTML entities", () => { + const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + }); + + it("does not throw on out-of-range numeric entities and keeps the literal", () => { + expect(() => + normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), + ).not.toThrow(); + const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 07e8ed28a63..7dba123d663 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -20,16 +20,23 @@ const HTML_ENTITY_REPLACEMENTS: Readonly> = { quot: '"', }; +function decodeCodePoint(codePoint: number, entity: string): string { + // String.fromCodePoint throws RangeError outside the valid Unicode range, and + // Number.isFinite alone lets oversized values (e.g. �) through. + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return `&${entity};`; + } + return String.fromCodePoint(codePoint); +} + function decodeHtmlEntity(entity: string): string { const named = HTML_ENTITY_REPLACEMENTS[entity]; if (named) return named; if (entity.startsWith("#x")) { - const codePoint = Number.parseInt(entity.slice(2), 16); - return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : `&${entity};`; + return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); } if (entity.startsWith("#")) { - const codePoint = Number.parseInt(entity.slice(1), 10); - return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : `&${entity};`; + return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); } return `&${entity};`; } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index f1326c5d28c..fe5a3a032bf 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -29,16 +29,12 @@ function SidebarUpdateReleaseNotesTooltip({ return <>{tooltip}; } - const updateVersion = state.availableVersion ?? state.downloadedVersion ?? "available"; - return (
-
- Update {updateVersion} ready to download -
+
{tooltip}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } From 5c49a367c233bf34f3bb2ea86cb3e2a176f44b35 Mon Sep 17 00:00:00 2001 From: Hugo Vizcaino Santana Date: Thu, 9 Jul 2026 16:58:34 +0200 Subject: [PATCH 3/4] fix: make nightly changelog tooltip scrollable under pointer-events-none The TooltipPopup positioner sets pointer-events-none, so the overflow-y-auto scroll container could not receive scroll/hover events and long changelogs stayed clipped. Re-enable pointer events on the scroll container. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/sidebar/SidebarUpdatePill.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index fe5a3a032bf..9c7cdf53f6f 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -34,7 +34,7 @@ function SidebarUpdateReleaseNotesTooltip({
{tooltip}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } From dc03b5d90ad475965a3cc800715f7b48f79129da Mon Sep 17 00:00:00 2001 From: Hugo Vizcaino Santana Date: Fri, 17 Jul 2026 09:00:20 +0200 Subject: [PATCH 4/4] fix: harden release notes handling against malformed payloads - DesktopUpdates: leave releaseNotes unvalidated in the UpdateInfo schema so a malformed payload can never fail the decode and leave the updater stuck in checking/downloading without surfacing the update - releaseNotes: validate entries defensively with a type guard, dropping malformed ones instead of throwing, and cap version groups after filtering empty ones so boilerplate-only groups no longer hide real notes - SidebarUpdatePill: key changelog bullets by index to avoid duplicate React keys when a version group repeats the same line - extend releaseNotes tests with malformed-entry and boilerplate-cap cases --- apps/desktop/src/updates/DesktopUpdates.ts | 12 +++------ apps/desktop/src/updates/releaseNotes.test.ts | 26 +++++++++++++++++++ apps/desktop/src/updates/releaseNotes.ts | 21 ++++++++++----- .../components/sidebar/SidebarUpdatePill.tsx | 4 +-- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index da367ad33a8..7357907e178 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -48,16 +48,12 @@ const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; -const UpdateReleaseNoteInfo = Schema.Struct({ - version: Schema.String, - note: Schema.NullOr(Schema.String), -}); - const UpdateInfo = Schema.Struct({ version: Schema.String, - releaseNotes: Schema.optional( - Schema.NullOr(Schema.Union([Schema.String, Schema.Array(UpdateReleaseNoteInfo)])), - ), + // 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({ diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 18024761529..9d6bbaea6bc 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -31,6 +31,32 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); }); + it("ignores malformed entries instead of throwing", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Valid change" }, + { version: 42, note: "- Bad version type" }, + { version: "1.2.1", note: { html: "

object note

" } }, + "not an object", + null, + ], + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + }); + + it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { + const boilerplate = Array.from({ length: 7 }, (_, index) => ({ + version: `1.3.${9 - index}`, + note: "Full changelog: https://example.com/compare/x...y", + })); + const notes = normalizeDesktopUpdateReleaseNotes( + [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], + "1.3.9", + ); + expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + }); + it("does not throw on out-of-range numeric entities and keeps the literal", () => { expect(() => normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 7dba123d663..69857c92b3f 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -2,10 +2,17 @@ import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; interface ElectronReleaseNoteInfo { readonly version: string; - readonly note: string | null; + readonly note: string | null | undefined; } -type ElectronReleaseNotes = string | ReadonlyArray | null | undefined; +function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { readonly version?: unknown; readonly note?: unknown }; + return ( + typeof candidate.version === "string" && + (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) + ); +} const MAX_RELEASE_NOTE_GROUPS = 6; const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; @@ -80,7 +87,7 @@ function isIgnoredReleaseNoteLine(line: string): boolean { ); } -function extractReleaseNoteItems(note: string | null): ReadonlyArray { +function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { if (!note) return []; const items: string[] = []; @@ -98,21 +105,21 @@ function extractReleaseNoteItems(note: string | null): ReadonlyArray { } export function normalizeDesktopUpdateReleaseNotes( - releaseNotes: ElectronReleaseNotes, + releaseNotes: unknown, fallbackVersion: string, ): ReadonlyArray { const rawNotes = typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] : Array.isArray(releaseNotes) - ? releaseNotes + ? releaseNotes.filter(isElectronReleaseNoteInfo) : []; return rawNotes - .slice(0, MAX_RELEASE_NOTE_GROUPS) .map((entry) => ({ version: entry.version, items: extractReleaseNoteItems(entry.note), })) - .filter((entry) => entry.items.length > 0); + .filter((entry) => entry.items.length > 0) + .slice(0, MAX_RELEASE_NOTE_GROUPS); } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 9c7cdf53f6f..fc15862fa46 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -43,8 +43,8 @@ function SidebarUpdateReleaseNotesTooltip({ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
    - {releaseNote.items.map((item) => ( -
  • + {releaseNote.items.map((item, itemIndex) => ( +
  • {item}
  • ))}