fix(settings): show current release notes (#1101)

* fix(settings): show current release notes

* fix(updater): use app version for local release notes

* fix(settings): constrain release note images

* fix(settings): prefer current notes without updates
This commit is contained in:
4gray authored and GitHub committed 2026-06-29 11:03:15 +02:00
1 parent b1119189e2
commit c0e9cd5de3
28 files changed
+217 -37

No files matched your search

@@ -139,6 +139,26 @@ describe('AppUpdateService', () => {
expect(updaterFactory).not.toHaveBeenCalled();
});
it('uses the build app version instead of the Electron runtime version', () => {
const service = new AppUpdateService({
app: {
getVersion: () => '41.7.2',
isPackaged: false,
},
appVersion: '0.22.0',
getMainWindow: () => createWindow(),
updater: jest.fn(() => new FakeUpdater()),
});
expect(service.getStatus()).toEqual({
currentVersion: '0.22.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
});
it('checks GitHub releases without resolving the updater on unsupported packaged Linux builds', async () => {
const fetcher = createReleaseFetcher();
const updaterFactory = jest.fn(() => new FakeUpdater());
@@ -332,6 +352,22 @@ describe('AppUpdateService', () => {
expect(newer.tagName).toBe('v0.23.0');
});
it('falls back to latest stable release notes when a local dev version is unpublished', async () => {
const fetcher = createReleaseFetcher();
const { service } = createService({ fetcher, isPackaged: false });
await expect(
service.getReleaseNotes({
fallbackToLatest: true,
version: '0.25.0',
})
).resolves.toMatchObject({
bodyMarkdown: '## New\n\nFresh build',
tagName: 'v0.24.0',
version: '0.24.0',
});
});
it('stores available release details from updater events', () => {
const { service } = createService();
@@ -104,6 +104,7 @@ type ReleaseFetcher = (
export interface AppUpdateServiceOptions {
app: AppUpdateAppAdapter;
appVersion?: string;
updater: AppUpdaterAdapterProvider;
getMainWindow: () => AppUpdateWindow | null | undefined;
platform?: NodeJS.Platform;
@@ -182,6 +183,13 @@ function isVersionGreaterThan(candidate: string, current: string): boolean {
return false;
}
function resolveCurrentVersion(
app: AppUpdateAppAdapter,
appVersion: string | undefined
): string {
return normalizeVersion(appVersion) || normalizeVersion(app.getVersion());
}
function toReleaseInfo(
release: CachedGitHubRelease
): ElectronBridgeAppUpdateRelease {
@@ -234,7 +242,10 @@ export class AppUpdateService {
private status: ElectronBridgeAppUpdateStatus;
constructor(private readonly options: AppUpdateServiceOptions) {
this.currentVersion = options.app.getVersion();
this.currentVersion = resolveCurrentVersion(
options.app,
options.appVersion
);
this.isPackaged = options.app.isPackaged;
this.supportedSelfUpdate = isSelfUpdateSupported(
options.app.isPackaged,
@@ -312,11 +323,18 @@ export class AppUpdateService {
async getReleaseNotes(
request: ElectronBridgeAppUpdateReleaseNotesRequest = {}
): Promise<ElectronBridgeAppUpdateReleaseNotes> {
await this.ensureReleasePageLoaded(1);
const canFallbackToLatest =
!request.direction && (!request.version || request.fallbackToLatest);
if (canFallbackToLatest) {
await this.ensureFirstStableReleaseLoaded();
} else {
await this.ensureReleasePageLoaded(1);
}
let index = await this.findReleaseIndex(request.version);
if (index === -1 && !request.version) {
if (index === -1 && canFallbackToLatest) {
index = 0;
}
+2
View File
@@ -29,6 +29,7 @@ import { AppUpdateService } from './app/services/app-update.service';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
app.setName('iptvnator');
@@ -88,6 +89,7 @@ export default class Main {
const appUpdateService = new AppUpdateService({
app,
appVersion: environment.version,
getMainWindow: () => App.mainWindow,
updater: () => autoUpdater,
});
@@ -31,7 +31,10 @@ describe('AppUpdateReleaseNotesDialogComponent', () => {
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: { initialVersion: '0.23.0' },
useValue: {
fallbackToLatest: true,
initialVersion: '0.23.0',
},
},
{
provide: MatDialogRef,
@@ -53,6 +56,7 @@ describe('AppUpdateReleaseNotesDialogComponent', () => {
fixture.detectChanges();
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenCalledWith({
fallbackToLatest: true,
version: '0.23.0',
});
expect(
@@ -66,6 +70,27 @@ describe('AppUpdateReleaseNotesDialogComponent', () => {
).toContain('desktop updater');
});
it('constrains rendered markdown images to the dialog width', async () => {
(
window.electron.getAppUpdateReleaseNotes as jest.Mock
).mockResolvedValueOnce({
...releaseNotes,
bodyMarkdown:
'![Application screenshot](https://example.com/screenshot.png)',
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const image = fixture.nativeElement.querySelector(
'[data-test-id="release-notes-body"] img'
) as HTMLImageElement;
expect(image).toBeTruthy();
expect(image.classList).toContain('release-notes-dialog__image');
});
it('loads previous notes lazily without closing the dialog', async () => {
fixture.detectChanges();
await fixture.whenStable();
@@ -5,6 +5,7 @@ import {
OnInit,
SecurityContext,
signal,
ViewEncapsulation,
} from '@angular/core';
import { DatePipe } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
@@ -21,10 +22,29 @@ import { marked } from 'marked';
import {
ElectronBridgeAppUpdateReleaseNotes,
ElectronBridgeAppUpdateReleaseNotesDirection,
ElectronBridgeAppUpdateReleaseNotesRequest,
} from '@iptvnator/shared/interfaces';
const RELEASE_NOTES_IMAGE_CLASS = 'release-notes-dialog__image';
export interface AppUpdateReleaseNotesDialogData {
initialVersion?: string;
fallbackToLatest?: boolean;
}
function decorateReleaseNotesHtml(html: string): string {
if (typeof document === 'undefined') {
return html;
}
const template = document.createElement('template');
template.innerHTML = html;
template.content.querySelectorAll('img').forEach((image) => {
image.classList.add(RELEASE_NOTES_IMAGE_CLASS);
});
return template.innerHTML;
}
@Component({
@@ -37,6 +57,7 @@ export interface AppUpdateReleaseNotesDialogData {
DatePipe,
TranslatePipe,
],
encapsulation: ViewEncapsulation.None,
template: `
<h2 mat-dialog-title>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
@@ -175,6 +196,16 @@ export interface AppUpdateReleaseNotesDialogData {
padding: 1px 4px;
background: var(--app-hover-overlay);
}
.release-notes-dialog__body img,
.release-notes-dialog__image {
display: block;
box-sizing: border-box;
max-width: 100%;
height: auto;
margin: 16px auto;
object-fit: contain;
}
`,
],
})
@@ -193,8 +224,10 @@ export class AppUpdateReleaseNotesDialogComponent implements OnInit {
readonly renderedMarkdown = computed(() => {
const markdown = this.notes()?.bodyMarkdown ?? '';
const html = marked.parse(markdown, { async: false }) as string;
const sanitizedHtml =
this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
return decorateReleaseNotesHtml(sanitizedHtml);
});
ngOnInit(): void {
@@ -212,16 +245,30 @@ export class AppUpdateReleaseNotesDialogComponent implements OnInit {
const version = direction
? this.notes()?.tagName
: this.data.initialVersion;
const fallbackToLatest = direction
? undefined
: this.data.fallbackToLatest;
this.loading.set(true);
this.error.set(null);
try {
const request: ElectronBridgeAppUpdateReleaseNotesRequest = {};
if (direction) {
request.direction = direction;
}
if (version) {
request.version = version;
}
if (fallbackToLatest) {
request.fallbackToLatest = true;
}
this.notes.set(
await window.electron.getAppUpdateReleaseNotes({
direction,
version,
})
await window.electron.getAppUpdateReleaseNotes(request)
);
} catch (error) {
this.error.set(error instanceof Error ? error.message : String(error));
@@ -81,6 +81,24 @@ describe('SettingsAboutSectionComponent app updates', () => {
expect(install).toHaveBeenCalledTimes(1);
});
it('shows release notes for the current version when no update is available', () => {
const openNotes = jest.fn();
fixture.componentInstance.openAppUpdateReleaseNotes.subscribe(openNotes);
configureComponent(fixture, {
currentVersion: '0.22.0',
latestVersion: '0.22.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
supportedSelfUpdate: true,
});
getButton(fixture, 'app-update-release-notes')?.click();
expect(getButton(fixture, 'app-update-release-notes')).toBeTruthy();
expect(openNotes).toHaveBeenCalledTimes(1);
});
it('emits a manual release request for unsupported Linux packages', () => {
const openManual = jest.fn();
fixture.componentInstance.openManualAppUpdate.subscribe(openManual);
@@ -64,13 +64,8 @@ export class SettingsAboutSectionComponent {
const status = this.appUpdateStatus();
return Boolean(
status?.latestVersion &&
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle &&
status.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking &&
status.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable &&
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error
status?.currentVersion &&
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking
);
});
@@ -500,6 +500,32 @@ describe('SettingsComponent', () => {
);
});
it('opens release notes dialog for the current version when no update is available', () => {
const openSpy = jest
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue(createDialogRef(false));
component.appUpdateStatus.set({
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.21.0',
release: {
version: '0.21.0',
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
component.openAppUpdateReleaseNotes();
expect(openSpy).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
expect.objectContaining({
data: {
initialVersion: DEFAULT_APP_UPDATE_STATUS.currentVersion,
fallbackToLatest: true,
},
})
);
});
it('should render a compact page header outside dialog mode', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
@@ -37,6 +37,7 @@ import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EmbeddedMpvSupport,
CoverSize,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
Language,
StreamFormat,
@@ -398,12 +399,23 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
openAppUpdateReleaseNotes(): void {
const status = this.appUpdateStatus();
const isUpdateRelease =
status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
status?.status ===
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded;
const initialReleaseNotesVersion = isUpdateRelease
? (status?.latestVersion ??
status?.release?.version ??
status?.currentVersion)
: status?.currentVersion;
this.matDialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion:
this.appUpdateStatus()?.latestVersion ??
this.appUpdateStatus()?.release?.version,
...(!isUpdateRelease ? { fallbackToLatest: true } : {}),
initialVersion: initialReleaseNotesVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "داكن",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "داكن",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Цёмная",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Dunkel",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Σκούρο",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Dark",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Oscuro",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Sombre",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Scuro",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "ダーク",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "어둡게",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Donker",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Ciemny",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Escuro",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Обновить",
"APP_UPDATE_INSTALL": "Перезагрузить приложение",
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "Что нового"
},
"THEMES": {
"DARK_THEME": "Тёмная",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "Koyu",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "深色",
+1 -1
View File
@@ -410,7 +410,7 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
"APP_UPDATE_RELEASE_NOTES": "What's new"
},
"THEMES": {
"DARK_THEME": "深色",
@@ -198,6 +198,7 @@ export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
export interface ElectronBridgeAppUpdateReleaseNotesRequest {
version?: string;
direction?: ElectronBridgeAppUpdateReleaseNotesDirection;
fallbackToLatest?: boolean;
}
export interface ElectronBridgeAppUpdateReleaseNotes {