authorgravatar for 42343504+HugoVizcainoSantana@users.noreply.github.comHugo Vizcaino Santana <42343504+HugoVizcainoSantana@users.noreply.github.com> 2026-07-17 13:10:51+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-17 13:10:51+02:00
log1f2797325ba52b93fddfb5aaaee3b18c86800589
tree832fc525c04ea3ffed0883051c0b8fbf66e0dc0f
parentc14a5ca492b4da11da81e482e307222946536300
signature Signed by PGP key B5690EEEBB952194

feat: show nightly update changelog tooltip (#3832)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

12 files changed, 373 insertions(+), 3 deletions(-)

apps/desktop/src/electron/ElectronUpdater.test.ts+14
...@@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({...@@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({
10 autoInstallOnAppQuit: true,10 autoInstallOnAppQuit: true,
11 channel: "latest",11 channel: "latest",
12 disableDifferentialDownload: false,12 disableDifferentialDownload: false,
13 fullChangelog: false,
13 checkForUpdates: vi.fn(() => Promise.resolve(null)),14 checkForUpdates: vi.fn(() => Promise.resolve(null)),
14 downloadUpdate: vi.fn(() => Promise.resolve([])),15 downloadUpdate: vi.fn(() => Promise.resolve([])),
15 on: vi.fn(),16 on: vi.fn(),
...@@ -33,6 +34,7 @@ describe("ElectronUpdater", () => {...@@ -33,6 +34,7 @@ describe("ElectronUpdater", () => {
33 autoUpdaterMock.autoInstallOnAppQuit = true;34 autoUpdaterMock.autoInstallOnAppQuit = true;
34 autoUpdaterMock.channel = "latest";35 autoUpdaterMock.channel = "latest";
35 autoUpdaterMock.disableDifferentialDownload = false;36 autoUpdaterMock.disableDifferentialDownload = false;
37 autoUpdaterMock.fullChangelog = false;
36 autoUpdaterMock.checkForUpdates.mockClear();38 autoUpdaterMock.checkForUpdates.mockClear();
37 autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null));39 autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null));
38 autoUpdaterMock.downloadUpdate.mockClear();40 autoUpdaterMock.downloadUpdate.mockClear();
...@@ -98,6 +100,18 @@ describe("ElectronUpdater", () => {...@@ -98,6 +100,18 @@ describe("ElectronUpdater", () => {
98 }).pipe(Effect.provide(ElectronUpdater.layer)),100 }).pipe(Effect.provide(ElectronUpdater.layer)),
99 );101 );
100102
103 it.effect("sets full changelog mode", () =>
104 Effect.gen(function* () {
105 const updater = yield* ElectronUpdater.ElectronUpdater;
106
107 yield* updater.setFullChangelog(true);
108 assert.equal(autoUpdaterMock.fullChangelog, true);
109
110 yield* updater.setFullChangelog(false);
111 assert.equal(autoUpdaterMock.fullChangelog, false);
112 }).pipe(Effect.provide(ElectronUpdater.layer)),
113 );
114
101 it.effect("preserves quit-and-install flags and the execution-time channel", () =>115 it.effect("preserves quit-and-install flags and the execution-time channel", () =>
102 Effect.gen(function* () {116 Effect.gen(function* () {
103 const cause = new Error("quit and install failed");117 const cause = new Error("quit and install failed");
apps/desktop/src/electron/ElectronUpdater.ts+6
...@@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service<...@@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service<
66 readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>;66 readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>;
67 readonly allowDowngrade: Effect.Effect<boolean>;67 readonly allowDowngrade: Effect.Effect<boolean>;
68 readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>;68 readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>;
69 readonly setFullChangelog: (value: boolean) => Effect.Effect<void>;
69 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;70 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
70 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;71 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
71 readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;72 readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;
...@@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({...@@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({
112 autoUpdater.allowDowngrade = value;113 autoUpdater.allowDowngrade = value;
113 return Effect.void;114 return Effect.void;
114 }),115 }),
116 setFullChangelog: (value) =>
117 Effect.suspend(() => {
118 autoUpdater.fullChangelog = value;
119 return Effect.void;
120 }),
115 setDisableDifferentialDownload: (value) =>121 setDisableDifferentialDownload: (value) =>
116 Effect.suspend(() => {122 Effect.suspend(() => {
117 autoUpdater.disableDifferentialDownload = value;123 autoUpdater.disableDifferentialDownload = value;
apps/desktop/src/updates/DesktopUpdates.test.ts+49
...@@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow;...@@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow;
38function makeHarness(options: UpdatesHarnessOptions = {}) {38function makeHarness(options: UpdatesHarnessOptions = {}) {
39 let checkCount = 0;39 let checkCount = 0;
40 let allowDowngrade = false;40 let allowDowngrade = false;
41 let fullChangelog = false;
41 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];42 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
42 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();43 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
43 const sentStates: DesktopUpdateState[] = [];44 const sentStates: DesktopUpdateState[] = [];
...@@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
73 Effect.sync(() => {74 Effect.sync(() => {
74 allowDowngrade = value;75 allowDowngrade = value;
75 }),76 }),
77 setFullChangelog: (value) =>
78 Effect.sync(() => {
79 fullChangelog = value;
80 }),
76 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,81 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
77 checkForUpdates: Effect.sync(() => {82 checkForUpdates: Effect.sync(() => {
78 checkCount += 1;83 checkCount += 1;
...@@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
186 layer,191 layer,
187 checkCount: () => checkCount,192 checkCount: () => checkCount,
188 feedUrls: () => feedUrls,193 feedUrls: () => feedUrls,
194 fullChangelog: () => fullChangelog,
189 listenerCount: () =>195 listenerCount: () =>
190 Array.from(listeners.values()).reduce(196 Array.from(listeners.values()).reduce(
191 (total, eventListeners) => total + eventListeners.size,197 (total, eventListeners) => total + eventListeners.size,
...@@ -287,6 +293,49 @@ describe("DesktopUpdates", () => {...@@ -287,6 +293,49 @@ describe("DesktopUpdates", () => {
287 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));293 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
288 });294 });
289295
296 it.effect("enables nightly full changelog release notes and broadcasts summaries", () => {
297 const harness = makeHarness();
298
299 return Effect.scoped(
300 Effect.gen(function* () {
301 const updates = yield* DesktopUpdates.DesktopUpdates;
302 yield* updates.configure;
303
304 yield* updates.setChannel("nightly");
305 assert.equal(harness.fullChangelog(), true);
306
307 harness.emit("update-available", {
308 version: "1.2.4-nightly.20260709.766",
309 releaseNotes: [
310 {
311 version: "1.2.4-nightly.20260709.766",
312 note: `<h2>What's Changed</h2><ul><li>feat(client): persist offline environment data by <a>@juliusmarminge</a> in <a>#3795</a></li></ul><h2>Full Changelog</h2>`,
313 },
314 {
315 version: "1.2.4-nightly.20260709.765",
316 note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821",
317 },
318 ],
319 });
320 yield* flushCallbacks;
321
322 const state = yield* updates.getState;
323 assert.equal(state.status, "available");
324 assert.deepEqual(state.releaseNotes, [
325 {
326 version: "1.2.4-nightly.20260709.766",
327 items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"],
328 },
329 {
330 version: "1.2.4-nightly.20260709.765",
331 items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"],
332 },
333 ]);
334 assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes);
335 }),
336 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
337 });
338
290 it.effect("keeps raw updater event failures out of update state", () => {339 it.effect("keeps raw updater event failures out of update state", () => {
291 const harness = makeHarness();340 const harness = makeHarness();
292 const cause = new Error(341 const cause = new Error(
apps/desktop/src/updates/DesktopUpdates.ts+13-2
...@@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts";...@@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
27import * as ElectronWindow from "../electron/ElectronWindow.ts";27import * as ElectronWindow from "../electron/ElectronWindow.ts";
28import * as IpcChannels from "../ipc/channels.ts";28import * as IpcChannels from "../ipc/channels.ts";
29import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";29import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
30import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
30import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";31import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
31import {32import {
32 createInitialDesktopUpdateState,33 createInitialDesktopUpdateState,
...@@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;...@@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
4950
50const UpdateInfo = Schema.Struct({51const UpdateInfo = Schema.Struct({
51 version: Schema.String,52 version: Schema.String,
53 // Left unvalidated on purpose: a malformed release-notes payload must never
54 // fail the decode and block the update state transition. The shape is
55 // validated defensively in normalizeDesktopUpdateReleaseNotes.
56 releaseNotes: Schema.optional(Schema.Unknown),
52});57});
5358
54const DownloadProgressInfo = Schema.Struct({59const DownloadProgressInfo = Schema.Struct({
...@@ -330,10 +335,12 @@ export const make = Effect.gen(function* () {...@@ -330,10 +335,12 @@ export const make = Effect.gen(function* () {
330 yield* electronUpdater.setChannel(channel);335 yield* electronUpdater.setChannel(channel);
331 yield* electronUpdater.setAllowPrerelease(allowsPrerelease);336 yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
332 yield* electronUpdater.setAllowDowngrade(allowsPrerelease);337 yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
338 yield* electronUpdater.setFullChangelog(allowsPrerelease);
333 yield* logUpdaterInfo("using update channel", {339 yield* logUpdaterInfo("using update channel", {
334 channel,340 channel,
335 allowPrerelease: allowsPrerelease,341 allowPrerelease: allowsPrerelease,
336 allowDowngrade: allowsPrerelease,342 allowDowngrade: allowsPrerelease,
343 fullChangelog: allowsPrerelease,
337 });344 });
338 });345 });
339346
...@@ -567,11 +574,15 @@ export const make = Effect.gen(function* () {...@@ -567,11 +574,15 @@ export const make = Effect.gen(function* () {
567 }574 }
568575
569 const checkedAt = yield* currentIsoTimestamp;576 const checkedAt = yield* currentIsoTimestamp;
577 const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version);
570 yield* setState(578 yield* setState(
571 reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt),579 reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes),
572 );580 );
573 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);581 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
574 yield* logUpdaterInfo("update available", { version: info.version });582 yield* logUpdaterInfo("update available", {
583 version: info.version,
584 releaseNoteGroups: releaseNotes.length,
585 });
575 }),586 }),
576 ),587 ),
577 Effect.catchCause((cause) => {588 Effect.catchCause((cause) => {
apps/desktop/src/updates/releaseNotes.test.ts created+67
...@@ -0,0 +1,67 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
4
5describe("normalizeDesktopUpdateReleaseNotes", () => {
6 it("splits a plain string note into items under the fallback version", () => {
7 const notes = normalizeDesktopUpdateReleaseNotes(
8 "## What's changed\n- First fix\n- Second fix",
9 "1.2.3",
10 );
11 expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]);
12 });
13
14 it("keeps per-version groups and drops empty ones", () => {
15 const notes = normalizeDesktopUpdateReleaseNotes(
16 [
17 { version: "1.2.3", note: "- Newer change" },
18 { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" },
19 { version: "1.2.1", note: "- Older change" },
20 ],
21 "1.2.3",
22 );
23 expect(notes).toEqual([
24 { version: "1.2.3", items: ["Newer change"] },
25 { version: "1.2.1", items: ["Older change"] },
26 ]);
27 });
28
29 it("decodes valid HTML entities", () => {
30 const notes = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0");
31 expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]);
32 });
33
34 it("ignores malformed entries instead of throwing", () => {
35 const notes = normalizeDesktopUpdateReleaseNotes(
36 [
37 { version: "1.2.3", note: "- Valid change" },
38 { version: 42, note: "- Bad version type" },
39 { version: "1.2.1", note: { html: "<p>object note</p>" } },
40 "not an object",
41 null,
42 ],
43 "1.2.3",
44 );
45 expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]);
46 });
47
48 it("returns non-empty groups even when preceded by many boilerplate-only groups", () => {
49 const boilerplate = Array.from({ length: 7 }, (_, index) => ({
50 version: `1.3.${9 - index}`,
51 note: "Full changelog: https://example.com/compare/x...y",
52 }));
53 const notes = normalizeDesktopUpdateReleaseNotes(
54 [...boilerplate, { version: "1.3.2", note: "- Older but real change" }],
55 "1.3.9",
56 );
57 expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]);
58 });
59
60 it("does not throw on out-of-range numeric entities and keeps the literal", () => {
61 expect(() =>
62 normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0"),
63 ).not.toThrow();
64 const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0");
65 expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity &#9999999999;"] }]);
66 });
67});
apps/desktop/src/updates/releaseNotes.ts created+125
...@@ -0,0 +1,125 @@
1import type { DesktopUpdateReleaseNote } from "@t3tools/contracts";
2
3interface ElectronReleaseNoteInfo {
4 readonly version: string;
5 readonly note: string | null | undefined;
6}
7
8function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo {
9 if (typeof value !== "object" || value === null) return false;
10 const candidate = value as { readonly version?: unknown; readonly note?: unknown };
11 return (
12 typeof candidate.version === "string" &&
13 (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined)
14 );
15}
16
17const MAX_RELEASE_NOTE_GROUPS = 6;
18const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8;
19const MAX_RELEASE_NOTE_ITEM_LENGTH = 220;
20
21const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
22 amp: "&",
23 apos: "'",
24 gt: ">",
25 lt: "<",
26 nbsp: " ",
27 quot: '"',
28};
29
30function decodeCodePoint(codePoint: number, entity: string): string {
31 // String.fromCodePoint throws RangeError outside the valid Unicode range, and
32 // Number.isFinite alone lets oversized values (e.g. &#9999999999;) through.
33 if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
34 return `&${entity};`;
35 }
36 return String.fromCodePoint(codePoint);
37}
38
39function decodeHtmlEntity(entity: string): string {
40 const named = HTML_ENTITY_REPLACEMENTS[entity];
41 if (named) return named;
42 if (entity.startsWith("#x")) {
43 return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity);
44 }
45 if (entity.startsWith("#")) {
46 return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity);
47 }
48 return `&${entity};`;
49}
50
51function decodeHtmlEntities(input: string): string {
52 return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) =>
53 decodeHtmlEntity(entity),
54 );
55}
56
57function stripMarkup(input: string): string {
58 return decodeHtmlEntities(
59 input
60 .replace(/<br\s*\/?>/gi, "\n")
61 .replace(/<li\b[^>]*>/gi, "\n- ")
62 .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n")
63 .replace(/<[^>]*>/g, "")
64 .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
65 .replace(/\*\*([^*]+)\*\*/g, "$1"),
66 );
67}
68
69function truncateReleaseNoteItem(item: string): string {
70 if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item;
71 return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`;
72}
73
74function isIgnoredReleaseNoteLine(line: string): boolean {
75 const normalized = line
76 .toLowerCase()
77 .replace(/[*_`#]/g, "")
78 .trim();
79 return (
80 normalized === "" ||
81 normalized === "what's changed" ||
82 normalized === "whats changed" ||
83 normalized === "full changelog" ||
84 normalized === "new contributors" ||
85 normalized.startsWith("compare: ") ||
86 normalized.includes("/compare/")
87 );
88}
89
90function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray<string> {
91 if (!note) return [];
92
93 const items: string[] = [];
94 for (const rawLine of stripMarkup(note).split("\n")) {
95 const item = rawLine
96 .trim()
97 .replace(/^[-*]\s+/, "")
98 .replace(/^\d+[.)]\s+/, "")
99 .replace(/\s+/g, " ");
100 if (isIgnoredReleaseNoteLine(item)) continue;
101 items.push(truncateReleaseNoteItem(item));
102 if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break;
103 }
104 return items;
105}
106
107export function normalizeDesktopUpdateReleaseNotes(
108 releaseNotes: unknown,
109 fallbackVersion: string,
110): ReadonlyArray<DesktopUpdateReleaseNote> {
111 const rawNotes =
112 typeof releaseNotes === "string"
113 ? [{ version: fallbackVersion, note: releaseNotes }]
114 : Array.isArray(releaseNotes)
115 ? releaseNotes.filter(isElectronReleaseNoteInfo)
116 : [];
117
118 return rawNotes
119 .map((entry) => ({
120 version: entry.version,
121 items: extractReleaseNoteItems(entry.note),
122 }))
123 .filter((entry) => entry.items.length > 0)
124 .slice(0, MAX_RELEASE_NOTE_GROUPS);
125}
apps/desktop/src/updates/updateMachine.test.ts+24
...@@ -118,6 +118,12 @@ describe("updateMachine", () => {...@@ -118,6 +118,12 @@ describe("updateMachine", () => {
118 });118 });
119119
120 it("tracks available, download start, and progress cleanly", () => {120 it("tracks available, download start, and progress cleanly", () => {
121 const releaseNotes = [
122 {
123 version: "1.1.0",
124 items: ["feat: add update release notes"],
125 },
126 ];
121 const available = reduceDesktopUpdateStateOnUpdateAvailable(127 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122 {128 {
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),129 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
...@@ -126,15 +132,33 @@ describe("updateMachine", () => {...@@ -126,15 +132,33 @@ describe("updateMachine", () => {
126 },132 },
127 "1.1.0",133 "1.1.0",
128 "2026-03-04T00:00:00.000Z",134 "2026-03-04T00:00:00.000Z",
135 releaseNotes,
129 );136 );
130 const downloading = reduceDesktopUpdateStateOnDownloadStart(available);137 const downloading = reduceDesktopUpdateStateOnDownloadStart(available);
131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);138 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132139
133 expect(available.status).toBe("available");140 expect(available.status).toBe("available");
134 expect(available.channel).toBe("latest");141 expect(available.channel).toBe("latest");
142 expect(available.releaseNotes).toBe(releaseNotes);
143 expect(downloading.releaseNotes).toBe(releaseNotes);
135 expect(downloading.status).toBe("downloading");144 expect(downloading.status).toBe("downloading");
136 expect(downloading.downloadPercent).toBe(0);145 expect(downloading.downloadPercent).toBe(0);
137 expect(progress.downloadPercent).toBe(55.5);146 expect(progress.downloadPercent).toBe(55.5);
138 expect(progress.errorContext).toBeNull();147 expect(progress.errorContext).toBeNull();
139 });148 });
149
150 it("clears release notes when checking again", () => {
151 const state = reduceDesktopUpdateStateOnCheckStart(
152 {
153 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"),
154 enabled: true,
155 status: "available",
156 availableVersion: "1.1.0-nightly.1",
157 releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }],
158 },
159 "2026-03-04T00:00:00.000Z",
160 );
161
162 expect(state.releaseNotes).toEqual([]);
163 });
140});164});
apps/desktop/src/updates/updateMachine.ts+6
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type {1import type {
2 DesktopRuntimeInfo,2 DesktopRuntimeInfo,
3 DesktopUpdateChannel,3 DesktopUpdateChannel,
4 DesktopUpdateReleaseNote,
4 DesktopUpdateState,5 DesktopUpdateState,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
67
...@@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState(...@@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState(
29 runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,30 runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
30 availableVersion: null,31 availableVersion: null,
31 downloadedVersion: null,32 downloadedVersion: null,
33 releaseNotes: [],
32 downloadPercent: null,34 downloadPercent: null,
33 checkedAt: null,35 checkedAt: null,
34 message: null,36 message: null,
...@@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart(...@@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart(
45 ...state,47 ...state,
46 status: "checking",48 status: "checking",
47 checkedAt,49 checkedAt,
50 releaseNotes: [],
48 message: null,51 message: null,
49 downloadPercent: null,52 downloadPercent: null,
50 errorContext: null,53 errorContext: null,
...@@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(...@@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
72 state: DesktopUpdateState,75 state: DesktopUpdateState,
73 version: string,76 version: string,
74 checkedAt: string,77 checkedAt: string,
78 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [],
75): DesktopUpdateState {79): DesktopUpdateState {
76 return {80 return {
77 ...state,81 ...state,
78 status: "available",82 status: "available",
79 availableVersion: version,83 availableVersion: version,
80 downloadedVersion: null,84 downloadedVersion: null,
85 releaseNotes,
81 downloadPercent: null,86 downloadPercent: null,
82 checkedAt,87 checkedAt,
83 message: null,88 message: null,
...@@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate(...@@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate(
95 status: "up-to-date",100 status: "up-to-date",
96 availableVersion: null,101 availableVersion: null,
97 downloadedVersion: null,102 downloadedVersion: null,
103 releaseNotes: [],
98 downloadPercent: null,104 downloadPercent: null,
99 checkedAt,105 checkedAt,
100 message: null,106 message: null,
apps/web/src/components/desktopUpdate.logic.test.ts+1
...@@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = {...@@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = {
24 runningUnderArm64Translation: false,24 runningUnderArm64Translation: false,
25 availableVersion: null,25 availableVersion: null,
26 downloadedVersion: null,26 downloadedVersion: null,
27 releaseNotes: [],
27 downloadPercent: null,28 downloadPercent: null,
28 checkedAt: null,29 checkedAt: null,
29 message: null,30 message: null,
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+55-1
...@@ -15,8 +15,48 @@ import {...@@ -15,8 +15,48 @@ import {
15 shouldToastDesktopUpdateActionResult,15 shouldToastDesktopUpdateActionResult,
16} from "../desktopUpdate.logic";16} from "../desktopUpdate.logic";
17import { Alert, AlertDescription, AlertTitle } from "../ui/alert";17import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
18import { Separator } from "../ui/separator";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";19import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1920
21function SidebarUpdateReleaseNotesTooltip({
22 state,
23 tooltip,
24}: {
25 readonly state: NonNullable<ReturnType<typeof useDesktopUpdateState>>;
26 readonly tooltip: string;
27}) {
28 if (state.channel !== "nightly" || state.releaseNotes.length === 0) {
29 return <>{tooltip}</>;
30 }
31
32 return (
33 <div className="w-120 max-w-[calc(100vw-2rem)] text-left">
34 <div className="px-1">
35 <div className="text-sm leading-5 font-medium">{tooltip}</div>
36 </div>
37 <div className="pointer-events-auto max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
38 {state.releaseNotes.map((releaseNote, index) => (
39 <div key={releaseNote.version}>
40 {index > 0 && <Separator className="my-3 bg-border/60" />}
41 <section>
42 <h3 className="text-muted-foreground text-xs leading-4 font-semibold">
43 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
44 </h3>
45 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
46 {releaseNote.items.map((item, itemIndex) => (
47 <li className="list-disc break-words" key={`${releaseNote.version}-${itemIndex}`}>
48 {item}
49 </li>
50 ))}
51 </ul>
52 </section>
53 </div>
54 ))}
55 </div>
56 </div>
57 );
58}
59
20export function SidebarUpdatePill() {60export function SidebarUpdatePill() {
21 const state = useDesktopUpdateState();61 const state = useDesktopUpdateState();
22 const [dismissed, setDismissed] = useState(false);62 const [dismissed, setDismissed] = useState(false);
...@@ -151,7 +191,21 @@ export function SidebarUpdatePill() {...@@ -151,7 +191,21 @@ export function SidebarUpdatePill() {
151 </button>191 </button>
152 }192 }
153 />193 />
154 <TooltipPopup side="top">{tooltip}</TooltipPopup>194 <TooltipPopup
195 align="start"
196 className={
197 state?.channel === "nightly" && state.releaseNotes.length > 0
198 ? "max-w-none text-balance"
199 : undefined
200 }
201 side="top"
202 >
203 {state ? (
204 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
205 ) : (
206 tooltip
207 )}
208 </TooltipPopup>
155 </Tooltip>209 </Tooltip>
156 {action === "download" && (210 {action === "download" && (
157 <Tooltip>211 <Tooltip>
apps/web/src/state/desktopUpdate.test.ts+1
...@@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = {...@@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = {
15 runningUnderArm64Translation: false,15 runningUnderArm64Translation: false,
16 availableVersion: null,16 availableVersion: null,
17 downloadedVersion: null,17 downloadedVersion: null,
18 releaseNotes: [],
18 downloadPercent: null,19 downloadPercent: null,
19 checkedAt: null,20 checkedAt: null,
20 message: null,21 message: null,
packages/contracts/src/ipc.ts+12
...@@ -213,6 +213,7 @@ export interface DesktopUpdateState {...@@ -213,6 +213,7 @@ export interface DesktopUpdateState {
213 runningUnderArm64Translation: boolean;213 runningUnderArm64Translation: boolean;
214 availableVersion: string | null;214 availableVersion: string | null;
215 downloadedVersion: string | null;215 downloadedVersion: string | null;
216 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>;
216 downloadPercent: number | null;217 downloadPercent: number | null;
217 checkedAt: string | null;218 checkedAt: string | null;
218 message: string | null;219 message: string | null;
...@@ -220,6 +221,16 @@ export interface DesktopUpdateState {...@@ -220,6 +221,16 @@ export interface DesktopUpdateState {
220 canRetry: boolean;221 canRetry: boolean;
221}222}
222223
224export interface DesktopUpdateReleaseNote {
225 version: string;
226 items: ReadonlyArray<string>;
227}
228
229export const DesktopUpdateReleaseNoteSchema = Schema.Struct({
230 version: Schema.String,
231 items: Schema.Array(Schema.String),
232});
233
223export const DesktopUpdateStateSchema = Schema.Struct({234export const DesktopUpdateStateSchema = Schema.Struct({
224 enabled: Schema.Boolean,235 enabled: Schema.Boolean,
225 status: DesktopUpdateStatusSchema,236 status: DesktopUpdateStatusSchema,
...@@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({...@@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({
230 runningUnderArm64Translation: Schema.Boolean,241 runningUnderArm64Translation: Schema.Boolean,
231 availableVersion: Schema.NullOr(Schema.String),242 availableVersion: Schema.NullOr(Schema.String),
232 downloadedVersion: Schema.NullOr(Schema.String),243 downloadedVersion: Schema.NullOr(Schema.String),
244 releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema),
233 downloadPercent: Schema.NullOr(Schema.Number),245 downloadPercent: Schema.NullOr(Schema.Number),
234 checkedAt: Schema.NullOr(Schema.String),246 checkedAt: Schema.NullOr(Schema.String),
235 message: Schema.NullOr(Schema.String),247 message: Schema.NullOr(Schema.String),