| author | |
| committer | |
| log | 1f2797325ba52b93fddfb5aaaee3b18c86800589 |
| tree | 832fc525c04ea3ffed0883051c0b8fbf66e0dc0f |
| parent | c14a5ca492b4da11da81e482e307222946536300 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 10 | autoInstallOnAppQuit: true, |
| 11 | 11 | channel: "latest", |
| 12 | 12 | disableDifferentialDownload: false, |
| 13 | fullChangelog: false, | |
| 13 | 14 | checkForUpdates: vi.fn(() => Promise.resolve(null)), |
| 14 | 15 | downloadUpdate: vi.fn(() => Promise.resolve([])), |
| 15 | 16 | on: vi.fn(), |
| ... | ... | @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { |
| 33 | 34 | autoUpdaterMock.autoInstallOnAppQuit = true; |
| 34 | 35 | autoUpdaterMock.channel = "latest"; |
| 35 | 36 | autoUpdaterMock.disableDifferentialDownload = false; |
| 37 | autoUpdaterMock.fullChangelog = false; | |
| 36 | 38 | autoUpdaterMock.checkForUpdates.mockClear(); |
| 37 | 39 | autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); |
| 38 | 40 | autoUpdaterMock.downloadUpdate.mockClear(); |
| ... | ... | @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => { |
| 98 | 100 | }).pipe(Effect.provide(ElectronUpdater.layer)), |
| 99 | 101 | ); |
| 100 | 102 | |
| 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 | 115 | it.effect("preserves quit-and-install flags and the execution-time channel", () => |
| 102 | 116 | Effect.gen(function* () { |
| 103 | 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 | 66 | readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>; |
| 67 | 67 | readonly allowDowngrade: Effect.Effect<boolean>; |
| 68 | 68 | readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>; |
| 69 | readonly setFullChangelog: (value: boolean) => Effect.Effect<void>; | |
| 69 | 70 | readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>; |
| 70 | 71 | readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>; |
| 71 | 72 | readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>; |
| ... | ... | @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({ |
| 112 | 113 | autoUpdater.allowDowngrade = value; |
| 113 | 114 | return Effect.void; |
| 114 | 115 | }), |
| 116 | setFullChangelog: (value) => | |
| 117 | Effect.suspend(() => { | |
| 118 | autoUpdater.fullChangelog = value; | |
| 119 | return Effect.void; | |
| 120 | }), | |
| 115 | 121 | setDisableDifferentialDownload: (value) => |
| 116 | 122 | Effect.suspend(() => { |
| 117 | 123 | autoUpdater.disableDifferentialDownload = value; |
apps/desktop/src/updates/DesktopUpdates.test.ts+49| ... | ... | @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow; |
| 38 | 38 | function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 39 | 39 | let checkCount = 0; |
| 40 | 40 | let allowDowngrade = false; |
| 41 | let fullChangelog = false; | |
| 41 | 42 | const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; |
| 42 | 43 | const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>(); |
| 43 | 44 | const sentStates: DesktopUpdateState[] = []; |
| ... | ... | @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 73 | 74 | Effect.sync(() => { |
| 74 | 75 | allowDowngrade = value; |
| 75 | 76 | }), |
| 77 | setFullChangelog: (value) => | |
| 78 | Effect.sync(() => { | |
| 79 | fullChangelog = value; | |
| 80 | }), | |
| 76 | 81 | setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, |
| 77 | 82 | checkForUpdates: Effect.sync(() => { |
| 78 | 83 | checkCount += 1; |
| ... | ... | @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 186 | 191 | layer, |
| 187 | 192 | checkCount: () => checkCount, |
| 188 | 193 | feedUrls: () => feedUrls, |
| 194 | fullChangelog: () => fullChangelog, | |
| 189 | 195 | listenerCount: () => |
| 190 | 196 | Array.from(listeners.values()).reduce( |
| 191 | 197 | (total, eventListeners) => total + eventListeners.size, |
| ... | ... | @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => { |
| 287 | 293 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 288 | 294 | }); |
| 289 | 295 | |
| 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 | 339 | it.effect("keeps raw updater event failures out of update state", () => { |
| 291 | 340 | const harness = makeHarness(); |
| 292 | 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 | 27 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 28 | 28 | import * as IpcChannels from "../ipc/channels.ts"; |
| 29 | 29 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 30 | import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; | |
| 30 | 31 | import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; |
| 31 | 32 | import { |
| 32 | 33 | createInitialDesktopUpdateState, |
| ... | ... | @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; |
| 49 | 50 | |
| 50 | 51 | const UpdateInfo = Schema.Struct({ |
| 51 | 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 | }); |
| 53 | 58 | |
| 54 | 59 | const DownloadProgressInfo = Schema.Struct({ |
| ... | ... | @@ -330,10 +335,12 @@ export const make = Effect.gen(function* () { |
| 330 | 335 | yield* electronUpdater.setChannel(channel); |
| 331 | 336 | yield* electronUpdater.setAllowPrerelease(allowsPrerelease); |
| 332 | 337 | yield* electronUpdater.setAllowDowngrade(allowsPrerelease); |
| 338 | yield* electronUpdater.setFullChangelog(allowsPrerelease); | |
| 333 | 339 | yield* logUpdaterInfo("using update channel", { |
| 334 | 340 | channel, |
| 335 | 341 | allowPrerelease: allowsPrerelease, |
| 336 | 342 | allowDowngrade: allowsPrerelease, |
| 343 | fullChangelog: allowsPrerelease, | |
| 337 | 344 | }); |
| 338 | 345 | }); |
| 339 | 346 | |
| ... | ... | @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { |
| 567 | 574 | } |
| 568 | 575 | |
| 569 | 576 | const checkedAt = yield* currentIsoTimestamp; |
| 577 | const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); | |
| 570 | 578 | yield* setState( |
| 571 | reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), | |
| 579 | reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), | |
| 572 | 580 | ); |
| 573 | 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 | 588 | Effect.catchCause((cause) => { |
apps/desktop/src/updates/releaseNotes.test.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface ElectronReleaseNoteInfo { | |
| 4 | readonly version: string; | |
| 5 | readonly note: string | null | undefined; | |
| 6 | } | |
| 7 | ||
| 8 | function 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 | ||
| 17 | const MAX_RELEASE_NOTE_GROUPS = 6; | |
| 18 | const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; | |
| 19 | const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; | |
| 20 | ||
| 21 | const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = { | |
| 22 | amp: "&", | |
| 23 | apos: "'", | |
| 24 | gt: ">", | |
| 25 | lt: "<", | |
| 26 | nbsp: " ", | |
| 27 | quot: '"', | |
| 28 | }; | |
| 29 | ||
| 30 | function 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 | ||
| 39 | function 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 | ||
| 51 | function 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 | ||
| 57 | function 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 | ||
| 69 | function 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 | ||
| 74 | function 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 | ||
| 90 | function 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 | ||
| 107 | export 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 | 118 | }); |
| 119 | 119 | |
| 120 | 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 | 127 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| 122 | 128 | { |
| 123 | 129 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), |
| ... | ... | @@ -126,15 +132,33 @@ describe("updateMachine", () => { |
| 126 | 132 | }, |
| 127 | 133 | "1.1.0", |
| 128 | 134 | "2026-03-04T00:00:00.000Z", |
| 135 | releaseNotes, | |
| 129 | 136 | ); |
| 130 | 137 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); |
| 131 | 138 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); |
| 132 | 139 | |
| 133 | 140 | expect(available.status).toBe("available"); |
| 134 | 141 | expect(available.channel).toBe("latest"); |
| 142 | expect(available.releaseNotes).toBe(releaseNotes); | |
| 143 | expect(downloading.releaseNotes).toBe(releaseNotes); | |
| 135 | 144 | expect(downloading.status).toBe("downloading"); |
| 136 | 145 | expect(downloading.downloadPercent).toBe(0); |
| 137 | 146 | expect(progress.downloadPercent).toBe(55.5); |
| 138 | 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 | 1 | import type { |
| 2 | 2 | DesktopRuntimeInfo, |
| 3 | 3 | DesktopUpdateChannel, |
| 4 | DesktopUpdateReleaseNote, | |
| 4 | 5 | DesktopUpdateState, |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | |
| ... | ... | @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( |
| 29 | 30 | runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, |
| 30 | 31 | availableVersion: null, |
| 31 | 32 | downloadedVersion: null, |
| 33 | releaseNotes: [], | |
| 32 | 34 | downloadPercent: null, |
| 33 | 35 | checkedAt: null, |
| 34 | 36 | message: null, |
| ... | ... | @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( |
| 45 | 47 | ...state, |
| 46 | 48 | status: "checking", |
| 47 | 49 | checkedAt, |
| 50 | releaseNotes: [], | |
| 48 | 51 | message: null, |
| 49 | 52 | downloadPercent: null, |
| 50 | 53 | errorContext: null, |
| ... | ... | @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( |
| 72 | 75 | state: DesktopUpdateState, |
| 73 | 76 | version: string, |
| 74 | 77 | checkedAt: string, |
| 78 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [], | |
| 75 | 79 | ): DesktopUpdateState { |
| 76 | 80 | return { |
| 77 | 81 | ...state, |
| 78 | 82 | status: "available", |
| 79 | 83 | availableVersion: version, |
| 80 | 84 | downloadedVersion: null, |
| 85 | releaseNotes, | |
| 81 | 86 | downloadPercent: null, |
| 82 | 87 | checkedAt, |
| 83 | 88 | message: null, |
| ... | ... | @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( |
| 95 | 100 | status: "up-to-date", |
| 96 | 101 | availableVersion: null, |
| 97 | 102 | downloadedVersion: null, |
| 103 | releaseNotes: [], | |
| 98 | 104 | downloadPercent: null, |
| 99 | 105 | checkedAt, |
| 100 | 106 | message: null, |
apps/web/src/components/desktopUpdate.logic.test.ts+1| ... | ... | @@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = { |
| 24 | 24 | runningUnderArm64Translation: false, |
| 25 | 25 | availableVersion: null, |
| 26 | 26 | downloadedVersion: null, |
| 27 | releaseNotes: [], | |
| 27 | 28 | downloadPercent: null, |
| 28 | 29 | checkedAt: null, |
| 29 | 30 | message: null, |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+55-1| ... | ... | @@ -15,8 +15,48 @@ import { |
| 15 | 15 | shouldToastDesktopUpdateActionResult, |
| 16 | 16 | } from "../desktopUpdate.logic"; |
| 17 | 17 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 18 | import { Separator } from "../ui/separator"; | |
| 18 | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 19 | 20 | |
| 21 | function 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 | ||
| 20 | 60 | export function SidebarUpdatePill() { |
| 21 | 61 | const state = useDesktopUpdateState(); |
| 22 | 62 | const [dismissed, setDismissed] = useState(false); |
| ... | ... | @@ -151,7 +191,21 @@ export function SidebarUpdatePill() { |
| 151 | 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 | 209 | </Tooltip> |
| 156 | 210 | {action === "download" && ( |
| 157 | 211 | <Tooltip> |
apps/web/src/state/desktopUpdate.test.ts+1| ... | ... | @@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = { |
| 15 | 15 | runningUnderArm64Translation: false, |
| 16 | 16 | availableVersion: null, |
| 17 | 17 | downloadedVersion: null, |
| 18 | releaseNotes: [], | |
| 18 | 19 | downloadPercent: null, |
| 19 | 20 | checkedAt: null, |
| 20 | 21 | message: null, |
packages/contracts/src/ipc.ts+12| ... | ... | @@ -213,6 +213,7 @@ export interface DesktopUpdateState { |
| 213 | 213 | runningUnderArm64Translation: boolean; |
| 214 | 214 | availableVersion: string | null; |
| 215 | 215 | downloadedVersion: string | null; |
| 216 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>; | |
| 216 | 217 | downloadPercent: number | null; |
| 217 | 218 | checkedAt: string | null; |
| 218 | 219 | message: string | null; |
| ... | ... | @@ -220,6 +221,16 @@ export interface DesktopUpdateState { |
| 220 | 221 | canRetry: boolean; |
| 221 | 222 | } |
| 222 | 223 | |
| 224 | export interface DesktopUpdateReleaseNote { | |
| 225 | version: string; | |
| 226 | items: ReadonlyArray<string>; | |
| 227 | } | |
| 228 | ||
| 229 | export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ | |
| 230 | version: Schema.String, | |
| 231 | items: Schema.Array(Schema.String), | |
| 232 | }); | |
| 233 | ||
| 223 | 234 | export const DesktopUpdateStateSchema = Schema.Struct({ |
| 224 | 235 | enabled: Schema.Boolean, |
| 225 | 236 | status: DesktopUpdateStatusSchema, |
| ... | ... | @@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ |
| 230 | 241 | runningUnderArm64Translation: Schema.Boolean, |
| 231 | 242 | availableVersion: Schema.NullOr(Schema.String), |
| 232 | 243 | downloadedVersion: Schema.NullOr(Schema.String), |
| 244 | releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), | |
| 233 | 245 | downloadPercent: Schema.NullOr(Schema.Number), |
| 234 | 246 | checkedAt: Schema.NullOr(Schema.String), |
| 235 | 247 | message: Schema.NullOr(Schema.String), |