fix(updater): improve release notes and banner spacing
This commit is contained in:
parent
435d0d4e1d
commit
948e00aedb
5 changed files with 210 additions and 15 deletions
|
|
@ -13,6 +13,10 @@
|
||||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import {
|
||||||
|
formatUpdaterReleaseNotes,
|
||||||
|
getUpdaterReleaseNoteForVersion,
|
||||||
|
} from '@shared/utils/releaseNotes';
|
||||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||||
import { app, net } from 'electron';
|
import { app, net } from 'electron';
|
||||||
import electronUpdater from 'electron-updater';
|
import electronUpdater from 'electron-updater';
|
||||||
|
|
@ -97,6 +101,7 @@ export class UpdaterService {
|
||||||
constructor() {
|
constructor() {
|
||||||
autoUpdater.autoDownload = false;
|
autoUpdater.autoDownload = false;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
autoUpdater.fullChangelog = true;
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
}
|
}
|
||||||
|
|
@ -241,11 +246,14 @@ export class UpdaterService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latestReleaseNote = getUpdaterReleaseNoteForVersion(info.releaseNotes, info.version);
|
||||||
|
const releaseNotes = formatUpdaterReleaseNotes(info.releaseNotes);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldSkipReleaseForUpdater({
|
shouldSkipReleaseForUpdater({
|
||||||
tag_name: `v${info.version}`,
|
tag_name: `v${info.version}`,
|
||||||
name: typeof info.releaseName === 'string' ? info.releaseName : undefined,
|
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}`);
|
logger.warn(`Suppressing updater notification for locally marked release ${info.version}`);
|
||||||
|
|
@ -278,7 +286,7 @@ export class UpdaterService {
|
||||||
this.sendStatus({
|
this.sendStatus({
|
||||||
type: 'available',
|
type: 'available',
|
||||||
version: info.version,
|
version: info.version,
|
||||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
releaseNotes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
* Visible during download and after the update is ready to install.
|
* Visible during download and after the update is ready to install.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { CheckCircle, Loader2, X } from 'lucide-react';
|
import { CheckCircle, Loader2, X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -34,14 +35,20 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
||||||
const isDownloading = updateStatus === 'downloading';
|
const isDownloading = updateStatus === 'downloading';
|
||||||
const percent = Math.round(downloadProgress);
|
const percent = Math.round(downloadProgress);
|
||||||
const clampedPercent = Math.max(0, Math.min(percent, 100));
|
const clampedPercent = Math.max(0, Math.min(percent, 100));
|
||||||
|
const isMacElectron =
|
||||||
|
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative border-b px-4 py-2.5"
|
className="relative border-b px-4 py-2.5"
|
||||||
style={{
|
style={
|
||||||
backgroundColor: 'var(--color-surface)',
|
{
|
||||||
borderColor: 'var(--color-border)',
|
backgroundColor: 'var(--color-surface)',
|
||||||
}}
|
borderColor: 'var(--color-border)',
|
||||||
|
paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : undefined,
|
||||||
|
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<div className="pr-8">
|
<div className="pr-8">
|
||||||
|
|
@ -79,10 +86,13 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
||||||
<button
|
<button
|
||||||
onClick={installUpdate}
|
onClick={installUpdate}
|
||||||
className="ml-auto rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
className="ml-auto rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||||
style={{
|
style={
|
||||||
borderColor: 'var(--color-border-emphasis)',
|
{
|
||||||
color: 'var(--color-text)',
|
borderColor: 'var(--color-border-emphasis)',
|
||||||
}}
|
color: 'var(--color-text)',
|
||||||
|
WebkitAppRegion: isMacElectron ? 'no-drag' : undefined,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Restart now
|
Restart now
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -93,7 +103,12 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
||||||
<button
|
<button
|
||||||
onClick={dismissUpdateBanner}
|
onClick={dismissUpdateBanner}
|
||||||
className="absolute right-3 top-1/2 shrink-0 -translate-y-1/2 rounded p-0.5 transition-colors hover:bg-white/10"
|
className="absolute right-3 top-1/2 shrink-0 -translate-y-1/2 rounded p-0.5 transition-colors hover:bg-white/10"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={
|
||||||
|
{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
WebkitAppRegion: isMacElectron ? 'no-drag' : undefined,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { isElectronMode } from '@renderer/api';
|
||||||
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
|
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||||
|
import { stripDownloadsSection } from '@shared/utils/releaseNotes';
|
||||||
import { ExternalLink, X } from 'lucide-react';
|
import { ExternalLink, X } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -94,10 +95,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const isDownloaded = updateStatus === 'downloaded';
|
const isDownloaded = updateStatus === 'downloaded';
|
||||||
|
|
||||||
// Strip "Downloads" section (and everything after it) from release notes
|
const filteredNotes = releaseNotes ? stripDownloadsSection(releaseNotes) : releaseNotes;
|
||||||
const filteredNotes = releaseNotes
|
|
||||||
? releaseNotes.replace(/\n#{1,3}\s+Downloads[\s\S]*$/i, '').trimEnd()
|
|
||||||
: releaseNotes;
|
|
||||||
|
|
||||||
const releaseUrl = availableVersion
|
const releaseUrl = availableVersion
|
||||||
? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}`
|
? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}`
|
||||||
|
|
|
||||||
88
src/shared/utils/releaseNotes.ts
Normal file
88
src/shared/utils/releaseNotes.ts
Normal file
|
|
@ -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 = /^<h[1-6][^>]*>\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;
|
||||||
|
}
|
||||||
86
test/shared/utils/releaseNotes.test.ts
Normal file
86
test/shared/utils/releaseNotes.test.ts
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><td>installer links</td></tr>
|
||||||
|
</table>`)
|
||||||
|
).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
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><td>installer links</td></tr>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
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<h2>Downloads</h2>\n<table>links</table>')).toBe(
|
||||||
|
'Fixed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats full-changelog updater notes as a version list', () => {
|
||||||
|
const notes = formatUpdaterReleaseNotes([
|
||||||
|
{
|
||||||
|
version: '2.0.2',
|
||||||
|
note: `Fixed launch reliability.
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
<table>links</table>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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\n<table>links</table>')).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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue