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
19 changes: 17 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 @@ -47,8 +48,16 @@ const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;

const UpdateReleaseNoteInfo = Schema.Struct({
version: Schema.String,
note: Schema.NullOr(Schema.String),
});

const UpdateInfo = Schema.Struct({
version: Schema.String,
releaseNotes: Schema.optional(
Schema.NullOr(Schema.Union([Schema.String, Schema.Array(UpdateReleaseNoteInfo)])),
),
});

const DownloadProgressInfo = Schema.Struct({
Expand Down Expand Up @@ -330,10 +339,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 +578,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
41 changes: 41 additions & 0 deletions apps/desktop/src/updates/releaseNotes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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("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;"] }]);
});
});
118 changes: 118 additions & 0 deletions apps/desktop/src/updates/releaseNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { DesktopUpdateReleaseNote } from "@t3tools/contracts";

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

type ElectronReleaseNotes = string | ReadonlyArray<ElectronReleaseNoteInfo> | null | 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): 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: ElectronReleaseNotes,
fallbackVersion: string,
): ReadonlyArray<DesktopUpdateReleaseNote> {
const rawNotes =
typeof releaseNotes === "string"
? [{ version: fallbackVersion, note: releaseNotes }]
: Array.isArray(releaseNotes)
? releaseNotes
: [];

return rawNotes
.slice(0, MAX_RELEASE_NOTE_GROUPS)
.map((entry) => ({
version: entry.version,
items: extractReleaseNoteItems(entry.note),
}))
.filter((entry) => entry.items.length > 0);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
24 changes: 24 additions & 0 deletions apps/desktop/src/updates/updateMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ describe("updateMachine", () => {
});

it("tracks available, download start, and progress cleanly", () => {
const releaseNotes = [
{
version: "1.1.0",
items: ["feat: add update release notes"],
},
];
const available = reduceDesktopUpdateStateOnUpdateAvailable(
{
...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
Expand All @@ -126,15 +132,33 @@ describe("updateMachine", () => {
},
"1.1.0",
"2026-03-04T00:00:00.000Z",
releaseNotes,
);
const downloading = reduceDesktopUpdateStateOnDownloadStart(available);
const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);

expect(available.status).toBe("available");
expect(available.channel).toBe("latest");
expect(available.releaseNotes).toBe(releaseNotes);
expect(downloading.releaseNotes).toBe(releaseNotes);
expect(downloading.status).toBe("downloading");
expect(downloading.downloadPercent).toBe(0);
expect(progress.downloadPercent).toBe(55.5);
expect(progress.errorContext).toBeNull();
});

it("clears release notes when checking again", () => {
const state = reduceDesktopUpdateStateOnCheckStart(
{
...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"),
enabled: true,
status: "available",
availableVersion: "1.1.0-nightly.1",
releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }],
},
"2026-03-04T00:00:00.000Z",
);

expect(state.releaseNotes).toEqual([]);
});
});
Loading
Loading