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(() => ({
1010 autoInstallOnAppQuit: true,
1111 channel: "latest",
1212 disableDifferentialDownload: false,
13 fullChangelog: false,
1314 checkForUpdates: vi.fn(() => Promise.resolve(null)),
1415 downloadUpdate: vi.fn(() => Promise.resolve([])),
1516 on: vi.fn(),
......@@ -33,6 +34,7 @@ describe("ElectronUpdater", () => {
3334 autoUpdaterMock.autoInstallOnAppQuit = true;
3435 autoUpdaterMock.channel = "latest";
3536 autoUpdaterMock.disableDifferentialDownload = false;
37 autoUpdaterMock.fullChangelog = false;
3638 autoUpdaterMock.checkForUpdates.mockClear();
3739 autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null));
3840 autoUpdaterMock.downloadUpdate.mockClear();
......@@ -98,6 +100,18 @@ describe("ElectronUpdater", () => {
98100 }).pipe(Effect.provide(ElectronUpdater.layer)),
99101 );
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
101115 it.effect("preserves quit-and-install flags and the execution-time channel", () =>
102116 Effect.gen(function* () {
103117 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<
6666 readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>;
6767 readonly allowDowngrade: Effect.Effect<boolean>;
6868 readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>;
69 readonly setFullChangelog: (value: boolean) => Effect.Effect<void>;
6970 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
7071 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
7172 readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;
......@@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({
112113 autoUpdater.allowDowngrade = value;
113114 return Effect.void;
114115 }),
116 setFullChangelog: (value) =>
117 Effect.suspend(() => {
118 autoUpdater.fullChangelog = value;
119 return Effect.void;
120 }),
115121 setDisableDifferentialDownload: (value) =>
116122 Effect.suspend(() => {
117123 autoUpdater.disableDifferentialDownload = value;
apps/desktop/src/updates/DesktopUpdates.test.ts+49
......@@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow;
3838function makeHarness(options: UpdatesHarnessOptions = {}) {
3939 let checkCount = 0;
4040 let allowDowngrade = false;
41 let fullChangelog = false;
4142 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
4243 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
4344 const sentStates: DesktopUpdateState[] = [];
......@@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
7374 Effect.sync(() => {
7475 allowDowngrade = value;
7576 }),
77 setFullChangelog: (value) =>
78 Effect.sync(() => {
79 fullChangelog = value;
80 }),
7681 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
7782 checkForUpdates: Effect.sync(() => {
7883 checkCount += 1;
......@@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
186191 layer,
187192 checkCount: () => checkCount,
188193 feedUrls: () => feedUrls,
194 fullChangelog: () => fullChangelog,
189195 listenerCount: () =>
190196 Array.from(listeners.values()).reduce(
191197 (total, eventListeners) => total + eventListeners.size,
......@@ -287,6 +293,49 @@ describe("DesktopUpdates", () => {
287293 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
288294 });
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
290339 it.effect("keeps raw updater event failures out of update state", () => {
291340 const harness = makeHarness();
292341 const cause = new Error(
apps/desktop/src/updates/DesktopUpdates.ts+13-2
......@@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
2727import * as ElectronWindow from "../electron/ElectronWindow.ts";
2828import * as IpcChannels from "../ipc/channels.ts";
2929import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
30import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
3031import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
3132import {
3233 createInitialDesktopUpdateState,
......@@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
4950
5051const UpdateInfo = Schema.Struct({
5152 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),
5257});
5358
5459const DownloadProgressInfo = Schema.Struct({
......@@ -330,10 +335,12 @@ export const make = Effect.gen(function* () {
330335 yield* electronUpdater.setChannel(channel);
331336 yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
332337 yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
338 yield* electronUpdater.setFullChangelog(allowsPrerelease);
333339 yield* logUpdaterInfo("using update channel", {
334340 channel,
335341 allowPrerelease: allowsPrerelease,
336342 allowDowngrade: allowsPrerelease,
343 fullChangelog: allowsPrerelease,
337344 });
338345 });
339346
......@@ -567,11 +574,15 @@ export const make = Effect.gen(function* () {
567574 }
568575
569576 const checkedAt = yield* currentIsoTimestamp;
577 const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version);
570578 yield* setState(
571 reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt),
579 reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes),
572580 );
573581 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 });
575586 }),
576587 ),
577588 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", () => {
118118 });
119119
120120 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 ];
121127 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122128 {
123129 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
......@@ -126,15 +132,33 @@ describe("updateMachine", () => {
126132 },
127133 "1.1.0",
128134 "2026-03-04T00:00:00.000Z",
135 releaseNotes,
129136 );
130137 const downloading = reduceDesktopUpdateStateOnDownloadStart(available);
131138 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132139
133140 expect(available.status).toBe("available");
134141 expect(available.channel).toBe("latest");
142 expect(available.releaseNotes).toBe(releaseNotes);
143 expect(downloading.releaseNotes).toBe(releaseNotes);
135144 expect(downloading.status).toBe("downloading");
136145 expect(downloading.downloadPercent).toBe(0);
137146 expect(progress.downloadPercent).toBe(55.5);
138147 expect(progress.errorContext).toBeNull();
139148 });
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 });
140164});
apps/desktop/src/updates/updateMachine.ts+6
......@@ -1,6 +1,7 @@
11import type {
22 DesktopRuntimeInfo,
33 DesktopUpdateChannel,
4 DesktopUpdateReleaseNote,
45 DesktopUpdateState,
56} from "@t3tools/contracts";
67
......@@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState(
2930 runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
3031 availableVersion: null,
3132 downloadedVersion: null,
33 releaseNotes: [],
3234 downloadPercent: null,
3335 checkedAt: null,
3436 message: null,
......@@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart(
4547 ...state,
4648 status: "checking",
4749 checkedAt,
50 releaseNotes: [],
4851 message: null,
4952 downloadPercent: null,
5053 errorContext: null,
......@@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
7275 state: DesktopUpdateState,
7376 version: string,
7477 checkedAt: string,
78 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [],
7579): DesktopUpdateState {
7680 return {
7781 ...state,
7882 status: "available",
7983 availableVersion: version,
8084 downloadedVersion: null,
85 releaseNotes,
8186 downloadPercent: null,
8287 checkedAt,
8388 message: null,
......@@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate(
95100 status: "up-to-date",
96101 availableVersion: null,
97102 downloadedVersion: null,
103 releaseNotes: [],
98104 downloadPercent: null,
99105 checkedAt,
100106 message: null,
apps/web/src/components/desktopUpdate.logic.test.ts+1
......@@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = {
2424 runningUnderArm64Translation: false,
2525 availableVersion: null,
2626 downloadedVersion: null,
27 releaseNotes: [],
2728 downloadPercent: null,
2829 checkedAt: null,
2930 message: null,
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+55-1
......@@ -15,8 +15,48 @@ import {
1515 shouldToastDesktopUpdateActionResult,
1616} from "../desktopUpdate.logic";
1717import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
18import { Separator } from "../ui/separator";
1819import { 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
2060export function SidebarUpdatePill() {
2161 const state = useDesktopUpdateState();
2262 const [dismissed, setDismissed] = useState(false);
......@@ -151,7 +191,21 @@ export function SidebarUpdatePill() {
151191 </button>
152192 }
153193 />
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>
155209 </Tooltip>
156210 {action === "download" && (
157211 <Tooltip>
apps/web/src/state/desktopUpdate.test.ts+1
......@@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = {
1515 runningUnderArm64Translation: false,
1616 availableVersion: null,
1717 downloadedVersion: null,
18 releaseNotes: [],
1819 downloadPercent: null,
1920 checkedAt: null,
2021 message: null,
packages/contracts/src/ipc.ts+12
......@@ -213,6 +213,7 @@ export interface DesktopUpdateState {
213213 runningUnderArm64Translation: boolean;
214214 availableVersion: string | null;
215215 downloadedVersion: string | null;
216 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>;
216217 downloadPercent: number | null;
217218 checkedAt: string | null;
218219 message: string | null;
......@@ -220,6 +221,16 @@ export interface DesktopUpdateState {
220221 canRetry: boolean;
221222}
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
223234export const DesktopUpdateStateSchema = Schema.Struct({
224235 enabled: Schema.Boolean,
225236 status: DesktopUpdateStatusSchema,
......@@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({
230241 runningUnderArm64Translation: Schema.Boolean,
231242 availableVersion: Schema.NullOr(Schema.String),
232243 downloadedVersion: Schema.NullOr(Schema.String),
244 releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema),
233245 downloadPercent: Schema.NullOr(Schema.Number),
234246 checkedAt: Schema.NullOr(Schema.String),
235247 message: Schema.NullOr(Schema.String),