From 948e00aedbd96577bd98ab2fb863591e628a444f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 12:30:06 +0300 Subject: [PATCH] fix(updater): improve release notes and banner spacing --- .../services/infrastructure/UpdaterService.ts | 12 ++- .../components/common/UpdateBanner.tsx | 33 +++++-- .../components/common/UpdateDialog.tsx | 6 +- src/shared/utils/releaseNotes.ts | 88 +++++++++++++++++++ test/shared/utils/releaseNotes.test.ts | 86 ++++++++++++++++++ 5 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 src/shared/utils/releaseNotes.ts create mode 100644 test/shared/utils/releaseNotes.test.ts diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 495ba50d..da24fed6 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -13,6 +13,10 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { + formatUpdaterReleaseNotes, + getUpdaterReleaseNoteForVersion, +} from '@shared/utils/releaseNotes'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { app, net } from 'electron'; import electronUpdater from 'electron-updater'; @@ -97,6 +101,7 @@ export class UpdaterService { constructor() { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.fullChangelog = true; this.bindEvents(); } @@ -241,11 +246,14 @@ export class UpdaterService { return; } + const latestReleaseNote = getUpdaterReleaseNoteForVersion(info.releaseNotes, info.version); + const releaseNotes = formatUpdaterReleaseNotes(info.releaseNotes); + if ( shouldSkipReleaseForUpdater({ tag_name: `v${info.version}`, name: typeof info.releaseName === 'string' ? info.releaseName : undefined, - body: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + body: latestReleaseNote, }) ) { logger.warn(`Suppressing updater notification for locally marked release ${info.version}`); @@ -278,7 +286,7 @@ export class UpdaterService { this.sendStatus({ type: 'available', version: info.version, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + releaseNotes, }); } diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx index 62d6a0b2..c3234fe7 100644 --- a/src/renderer/components/common/UpdateBanner.tsx +++ b/src/renderer/components/common/UpdateBanner.tsx @@ -4,6 +4,7 @@ * Visible during download and after the update is ready to install. */ +import { isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; import { CheckCircle, Loader2, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -34,14 +35,20 @@ export const UpdateBanner = (): React.JSX.Element | null => { const isDownloading = updateStatus === 'downloading'; const percent = Math.round(downloadProgress); const clampedPercent = Math.max(0, Math.min(percent, 100)); + const isMacElectron = + isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); return (
{isDownloading ? (
@@ -79,10 +86,13 @@ export const UpdateBanner = (): React.JSX.Element | null => { @@ -93,7 +103,12 @@ export const UpdateBanner = (): React.JSX.Element | null => { diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index 13138050..72fa533d 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -13,6 +13,7 @@ import { isElectronMode } from '@renderer/api'; import { markdownComponents } from '@renderer/components/chat/markdownComponents'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; +import { stripDownloadsSection } from '@shared/utils/releaseNotes'; import { ExternalLink, X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -94,10 +95,7 @@ export const UpdateDialog = (): React.JSX.Element | null => { const isDownloaded = updateStatus === 'downloaded'; - // Strip "Downloads" section (and everything after it) from release notes - const filteredNotes = releaseNotes - ? releaseNotes.replace(/\n#{1,3}\s+Downloads[\s\S]*$/i, '').trimEnd() - : releaseNotes; + const filteredNotes = releaseNotes ? stripDownloadsSection(releaseNotes) : releaseNotes; const releaseUrl = availableVersion ? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}` diff --git a/src/shared/utils/releaseNotes.ts b/src/shared/utils/releaseNotes.ts new file mode 100644 index 00000000..4bee0313 --- /dev/null +++ b/src/shared/utils/releaseNotes.ts @@ -0,0 +1,88 @@ +interface ReleaseNoteEntry { + readonly version?: unknown; + readonly note?: unknown; +} + +function isDownloadsHeading(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + + const markdownHeading = /^#{1,6}\s*(.+?)\s*#*\s*$/.exec(trimmed); + const htmlHeading = /^]*>\s*(.+?)\s*<\/h[1-6]>\s*$/i.exec(trimmed); + const headingText = markdownHeading?.[1] ?? htmlHeading?.[1]; + if (!headingText) { + return false; + } + + const words = headingText.toLowerCase().match(/[a-z0-9]+/g) ?? []; + return words.length > 0 && words.every((word) => word === 'download' || word === 'downloads'); +} + +export function stripDownloadsSection(markdown: string): string { + const lines = markdown.split(/\r?\n/u); + const downloadsHeadingIndex = lines.findIndex(isDownloadsHeading); + if (downloadsHeadingIndex === -1) { + return markdown.trimEnd(); + } + + return lines.slice(0, downloadsHeadingIndex).join('\n').trimEnd(); +} + +function normalizeReleaseNoteEntry(entry: unknown): { version: string; note: string } | null { + if (!entry || typeof entry !== 'object') { + return null; + } + + const { version, note } = entry as ReleaseNoteEntry; + if (typeof version !== 'string' || version.trim() === '') { + return null; + } + + return { + version: version.trim().replace(/^v/i, ''), + note: typeof note === 'string' ? note : '', + }; +} + +export function getUpdaterReleaseNoteForVersion( + releaseNotes: unknown, + version: string +): string | undefined { + if (typeof releaseNotes === 'string') { + return releaseNotes; + } + + if (!Array.isArray(releaseNotes)) { + return undefined; + } + + const normalizedVersion = version.trim().replace(/^v/i, ''); + return ( + releaseNotes + .map(normalizeReleaseNoteEntry) + .find((entry) => entry?.version === normalizedVersion)?.note || undefined + ); +} + +export function formatUpdaterReleaseNotes(releaseNotes: unknown): string | undefined { + if (typeof releaseNotes === 'string') { + const stripped = stripDownloadsSection(releaseNotes); + return stripped || undefined; + } + + if (!Array.isArray(releaseNotes)) { + return undefined; + } + + const formattedNotes = releaseNotes + .map(normalizeReleaseNoteEntry) + .filter((entry): entry is { version: string; note: string } => entry !== null) + .map(({ version, note }) => { + const strippedNote = stripDownloadsSection(note); + return [`## v${version}`, strippedNote || '_No release notes provided._'].join('\n\n'); + }); + + return formattedNotes.length > 0 ? formattedNotes.join('\n\n') : undefined; +} diff --git a/test/shared/utils/releaseNotes.test.ts b/test/shared/utils/releaseNotes.test.ts new file mode 100644 index 00000000..395ae1db --- /dev/null +++ b/test/shared/utils/releaseNotes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatUpdaterReleaseNotes, + getUpdaterReleaseNoteForVersion, + stripDownloadsSection, +} from '../../../src/shared/utils/releaseNotes'; + +describe('releaseNotes utilities', () => { + it('strips markdown Downloads sections even when they start the note', () => { + expect( + stripDownloadsSection(`### Downloads + + + +
installer links
`) + ).toBe(''); + }); + + it('strips Downloads sections without removing inline download mentions', () => { + const notes = `Patch release focused on smoother downloads. + +### Fixed + +- Download progress no longer gets stuck. + +### Downloads + + + +
installer links
`; + + expect(stripDownloadsSection(notes)).toBe(`Patch release focused on smoother downloads. + +### Fixed + +- Download progress no longer gets stuck.`); + }); + + it('strips html Downloads headings', () => { + expect(stripDownloadsSection('Fixed\n\n

Downloads

\nlinks
')).toBe( + 'Fixed' + ); + }); + + it('formats full-changelog updater notes as a version list', () => { + const notes = formatUpdaterReleaseNotes([ + { + version: '2.0.2', + note: `Fixed launch reliability. + +### Downloads + +links
`, + }, + { + version: '2.0.1', + note: 'Improved provider settings.', + }, + ]); + + expect(notes).toBe(`## v2.0.2 + +Fixed launch reliability. + +## v2.0.1 + +Improved provider settings.`); + }); + + it('strips Downloads from single-release string notes', () => { + expect(formatUpdaterReleaseNotes('Fixed\n\n### Downloads\nlinks
')).toBe('Fixed'); + }); + + it('reads only the requested release note from full-changelog arrays', () => { + expect( + getUpdaterReleaseNoteForVersion( + [ + { version: '2.0.2', note: 'Latest note' }, + { version: '2.0.1', note: 'Older [skip-updater] note' }, + ], + 'v2.0.2' + ) + ).toBe('Latest note'); + }); +});