Skip to content
14 changes: 14 additions & 0 deletions apps/desktop/src/electron/ElectronUpdater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({
autoInstallOnAppQuit: true,
channel: "latest",
disableDifferentialDownload: false,
fullChangelog: false,
checkForUpdates: vi.fn(() => Promise.resolve(null)),
downloadUpdate: vi.fn(() => Promise.resolve([])),
on: vi.fn(),
Expand All @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => {
autoUpdaterMock.autoInstallOnAppQuit = true;
autoUpdaterMock.channel = "latest";
autoUpdaterMock.disableDifferentialDownload = false;
autoUpdaterMock.fullChangelog = false;
autoUpdaterMock.checkForUpdates.mockClear();
autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null));
autoUpdaterMock.downloadUpdate.mockClear();
Expand Down Expand Up @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => {
}).pipe(Effect.provide(ElectronUpdater.layer)),
);

it.effect("sets full changelog mode", () =>
Effect.gen(function* () {
const updater = yield* ElectronUpdater.ElectronUpdater;

yield* updater.setFullChangelog(true);
assert.equal(autoUpdaterMock.fullChangelog, true);

yield* updater.setFullChangelog(false);
assert.equal(autoUpdaterMock.fullChangelog, false);
}).pipe(Effect.provide(ElectronUpdater.layer)),
);

it.effect("preserves quit-and-install flags and the execution-time channel", () =>
Effect.gen(function* () {
const cause = new Error("quit and install failed");
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/electron/ElectronUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service<
readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>;
readonly allowDowngrade: Effect.Effect<boolean>;
readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>;
readonly setFullChangelog: (value: boolean) => Effect.Effect<void>;
readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;
Expand Down Expand Up @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({
autoUpdater.allowDowngrade = value;
return Effect.void;
}),
setFullChangelog: (value) =>
Effect.suspend(() => {
autoUpdater.fullChangelog = value;
return Effect.void;
}),
setDisableDifferentialDownload: (value) =>
Effect.suspend(() => {
autoUpdater.disableDifferentialDownload = value;
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow;
function makeHarness(options: UpdatesHarnessOptions = {}) {
let checkCount = 0;
let allowDowngrade = false;
let fullChangelog = false;
const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
const sentStates: DesktopUpdateState[] = [];
Expand Down Expand Up @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
Effect.sync(() => {
allowDowngrade = value;
}),
setFullChangelog: (value) =>
Effect.sync(() => {
fullChangelog = value;
}),
setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
checkForUpdates: Effect.sync(() => {
checkCount += 1;
Expand Down Expand Up @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
layer,
checkCount: () => checkCount,
feedUrls: () => feedUrls,
fullChangelog: () => fullChangelog,
listenerCount: () =>
Array.from(listeners.values()).reduce(
(total, eventListeners) => total + eventListeners.size,
Expand Down Expand Up @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});

it.effect("enables nightly full changelog release notes and broadcasts summaries", () => {
const harness = makeHarness();

return Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;

yield* updates.setChannel("nightly");
assert.equal(harness.fullChangelog(), true);

harness.emit("update-available", {
version: "1.2.4-nightly.20260709.766",
releaseNotes: [
{
version: "1.2.4-nightly.20260709.766",
note: `<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>`,
},
{
version: "1.2.4-nightly.20260709.765",
note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821",
},
],
});
yield* flushCallbacks;

const state = yield* updates.getState;
assert.equal(state.status, "available");
assert.deepEqual(state.releaseNotes, [
{
version: "1.2.4-nightly.20260709.766",
items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"],
},
{
version: "1.2.4-nightly.20260709.765",
items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"],
},
]);
assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});

it.effect("keeps raw updater event failures out of update state", () => {
const harness = makeHarness();
const cause = new Error(
Expand Down
15 changes: 13 additions & 2 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as IpcChannels from "../ipc/channels.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
import {
createInitialDesktopUpdateState,
Expand All @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;

const UpdateInfo = Schema.Struct({
version: Schema.String,
// Left unvalidated on purpose: a malformed release-notes payload must never
// fail the decode and block the update state transition. The shape is
// validated defensively in normalizeDesktopUpdateReleaseNotes.
releaseNotes: Schema.optional(Schema.Unknown),
});

const DownloadProgressInfo = Schema.Struct({
Expand Down Expand Up @@ -330,10 +335,12 @@ export const make = Effect.gen(function* () {
yield* electronUpdater.setChannel(channel);
yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
yield* electronUpdater.setFullChangelog(allowsPrerelease);
yield* logUpdaterInfo("using update channel", {
channel,
allowPrerelease: allowsPrerelease,
allowDowngrade: allowsPrerelease,
fullChangelog: allowsPrerelease,
});
});

Expand Down Expand Up @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () {
}

const checkedAt = yield* currentIsoTimestamp;
const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version);
yield* setState(
reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt),
reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes),
);
yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
yield* logUpdaterInfo("update available", { version: info.version });
yield* logUpdaterInfo("update available", {
version: info.version,
releaseNoteGroups: releaseNotes.length,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}),
),
Effect.catchCause((cause) => {
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/updates/releaseNotes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vite-plus/test";

import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";

describe("normalizeDesktopUpdateReleaseNotes", () => {
it("splits a plain string note into items under the fallback version", () => {
const notes = normalizeDesktopUpdateReleaseNotes(
"## What's changed\n- First fix\n- Second fix",
"1.2.3",
);
expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]);
});

it("keeps per-version groups and drops empty ones", () => {
const notes = normalizeDesktopUpdateReleaseNotes(
[
{ version: "1.2.3", note: "- Newer change" },
{ version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" },
{ version: "1.2.1", note: "- Older change" },
],
"1.2.3",
);
expect(notes).toEqual([
{ version: "1.2.3", items: ["Newer change"] },
{ version: "1.2.1", items: ["Older change"] },
]);
});

it("decodes valid HTML entities", () => {
const notes = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0");
expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]);
});

it("ignores malformed entries instead of throwing", () => {
const notes = normalizeDesktopUpdateReleaseNotes(
[
{ version: "1.2.3", note: "- Valid change" },
{ version: 42, note: "- Bad version type" },
{ version: "1.2.1", note: { html: "<p>object note</p>" } },
"not an object",
null,
],
"1.2.3",
);
expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]);
});

it("returns non-empty groups even when preceded by many boilerplate-only groups", () => {
const boilerplate = Array.from({ length: 7 }, (_, index) => ({
version: `1.3.${9 - index}`,
note: "Full changelog: https://example.com/compare/x...y",
}));
const notes = normalizeDesktopUpdateReleaseNotes(
[...boilerplate, { version: "1.3.2", note: "- Older but real change" }],
"1.3.9",
);
expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]);
});

it("does not throw on out-of-range numeric entities and keeps the literal", () => {
expect(() =>
normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0"),
).not.toThrow();
const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0");
expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity &#9999999999;"] }]);
});
});
125 changes: 125 additions & 0 deletions apps/desktop/src/updates/releaseNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { DesktopUpdateReleaseNote } from "@t3tools/contracts";

interface ElectronReleaseNoteInfo {
readonly version: string;
readonly note: string | null | undefined;
}

function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { readonly version?: unknown; readonly note?: unknown };
return (
typeof candidate.version === "string" &&
(typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined)
);
}

const MAX_RELEASE_NOTE_GROUPS = 6;
const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8;
const MAX_RELEASE_NOTE_ITEM_LENGTH = 220;

const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: " ",
quot: '"',
};

function decodeCodePoint(codePoint: number, entity: string): string {
// String.fromCodePoint throws RangeError outside the valid Unicode range, and
// Number.isFinite alone lets oversized values (e.g. &#9999999999;) through.
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return `&${entity};`;
}
return String.fromCodePoint(codePoint);
}

function decodeHtmlEntity(entity: string): string {
const named = HTML_ENTITY_REPLACEMENTS[entity];
if (named) return named;
if (entity.startsWith("#x")) {
return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity);
}
if (entity.startsWith("#")) {
return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity);
}
return `&${entity};`;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

function decodeHtmlEntities(input: string): string {
return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) =>
decodeHtmlEntity(entity),
);
}

function stripMarkup(input: string): string {
return decodeHtmlEntities(
input
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<li\b[^>]*>/gi, "\n- ")
.replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1"),
);
}

function truncateReleaseNoteItem(item: string): string {
if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item;
return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`;
}

function isIgnoredReleaseNoteLine(line: string): boolean {
const normalized = line
.toLowerCase()
.replace(/[*_`#]/g, "")
.trim();
return (
normalized === "" ||
normalized === "what's changed" ||
normalized === "whats changed" ||
normalized === "full changelog" ||
normalized === "new contributors" ||
normalized.startsWith("compare: ") ||
normalized.includes("/compare/")
);
}

function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray<string> {
if (!note) return [];

const items: string[] = [];
for (const rawLine of stripMarkup(note).split("\n")) {
const item = rawLine
.trim()
.replace(/^[-*]\s+/, "")
.replace(/^\d+[.)]\s+/, "")
.replace(/\s+/g, " ");
if (isIgnoredReleaseNoteLine(item)) continue;
items.push(truncateReleaseNoteItem(item));
if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break;
}
return items;
}

export function normalizeDesktopUpdateReleaseNotes(
releaseNotes: unknown,
fallbackVersion: string,
): ReadonlyArray<DesktopUpdateReleaseNote> {
const rawNotes =
typeof releaseNotes === "string"
? [{ version: fallbackVersion, note: releaseNotes }]
: Array.isArray(releaseNotes)
? releaseNotes.filter(isElectronReleaseNoteInfo)
: [];

return rawNotes
.map((entry) => ({
version: entry.version,
items: extractReleaseNoteItems(entry.note),
}))
.filter((entry) => entry.items.length > 0)
.slice(0, MAX_RELEASE_NOTE_GROUPS);
}
Loading
Loading