| 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,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 | ); |
| 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 | 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; |
| 38 | function makeHarness(options: UpdatesHarnessOptions = {}) { | 38 | function 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 | }); |
| 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 | 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"; |
| 27 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 27 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 28 | import * as IpcChannels from "../ipc/channels.ts"; | 28 | import * as IpcChannels from "../ipc/channels.ts"; |
| 29 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 29 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 30 | import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; | ||
| 30 | import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; | 31 | import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; |
| 31 | import { | 32 | import { |
| 32 | createInitialDesktopUpdateState, | 33 | createInitialDesktopUpdateState, |
| ... | @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; | ... | @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; |
| 49 | 50 | ||
| 50 | const UpdateInfo = Schema.Struct({ | 51 | const 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 | }); |
| 53 | 58 | ||
| 54 | const DownloadProgressInfo = Schema.Struct({ | 59 | const 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 | }); |
| 339 | 346 | ||
| ... | @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { | ... | @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { |
| 567 | } | 574 | } |
| 568 | 575 | ||
| 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 @@ | ||
| 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,6 +118,12 @@ describe("updateMachine", () => { |
| 118 | }); | 118 | }); |
| 119 | 119 | ||
| 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); |
| 132 | 139 | ||
| 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 @@ |
| 1 | import type { | 1 | import type { |
| 2 | DesktopRuntimeInfo, | 2 | DesktopRuntimeInfo, |
| 3 | DesktopUpdateChannel, | 3 | DesktopUpdateChannel, |
| 4 | DesktopUpdateReleaseNote, | ||
| 4 | DesktopUpdateState, | 5 | DesktopUpdateState, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | ||
| ... | @@ -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"; |
| 17 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | 17 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 18 | import { Separator } from "../ui/separator"; | ||
| 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 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 | export function SidebarUpdatePill() { | 60 | export 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 | } |
| 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 | export const DesktopUpdateStateSchema = Schema.Struct({ | 234 | export 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), |