feat(updater): add GitHub releases desktop updater

This commit is contained in:
4gray committed 2026-06-28 16:35:51 +02:00
1 parent a84090026b
commit b64e187a5e
49 files changed
+2995 -114

No files matched your search

@@ -41,6 +41,30 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
`;
test.describe('Electron Settings', () => {
test('@settings @electron shows manual app update fallback when self-update is unavailable', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible();
await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible();
await expect(
app.mainWindow.getByTestId('app-update-open-release')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('app-update-download')
).toHaveCount(0);
await expect(
app.mainWindow.getByTestId('app-update-install')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@settings @electron gates external MPV playback behind double-click when enabled', async ({
dataDir,
}) => {
@@ -2,6 +2,14 @@ import {
DB_WORKER_OPERATIONS,
DbWorkerOperation,
} from '../workers/database-worker.types';
import {
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
APP_UPDATE_GET_RELEASE_NOTES,
APP_UPDATE_GET_STATUS,
APP_UPDATE_INSTALL,
APP_UPDATE_STATUS_CHANGED,
} from '@iptvnator/shared/interfaces';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import {
dbPreloadCases,
@@ -130,6 +138,58 @@ describe('main preload DB IPC contract', () => {
);
});
it('exposes app update commands and status events through the typed bridge', async () => {
const api = getExposedApi();
const callback = jest.fn();
const status = {
currentVersion: '0.22.0',
latestVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: 'available',
supportedSelfUpdate: true,
};
await api.getAppUpdateStatus();
await api.checkForAppUpdate();
await api.downloadAppUpdate();
await api.installAppUpdate();
await api.getAppUpdateReleaseNotes({
direction: 'previous',
version: 'v0.23.0',
});
const unsubscribe = api.onAppUpdateStatusChange(callback);
const handler = mockIpcRenderer.on.mock.calls.at(-1)?.[1];
handler({}, status);
unsubscribe();
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
APP_UPDATE_GET_STATUS
);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(APP_UPDATE_CHECK);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
APP_UPDATE_DOWNLOAD
);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(APP_UPDATE_INSTALL);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
APP_UPDATE_GET_RELEASE_NOTES,
{
direction: 'previous',
version: 'v0.23.0',
}
);
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
APP_UPDATE_STATUS_CHANGED,
expect.any(Function)
);
expect(callback).toHaveBeenCalledWith(status);
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
APP_UPDATE_STATUS_CHANGED,
handler
);
});
it('forwards request-scoped DB operation events and unregisters the listener', () => {
const api = getExposedApi();
const callback = jest.fn();
@@ -5,6 +5,8 @@ import type {
EmbeddedMpvSession,
EmbeddedMpvSupport,
ElectronBridgeApi,
ElectronBridgeAppUpdateStatus,
ElectronBridgeAppUpdateReleaseNotesRequest,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
ElectronBridgeEpgLookupOptions,
@@ -47,6 +49,12 @@ const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
const WINDOW_CLOSE = 'WINDOW:CLOSE';
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
const APP_UPDATE_GET_STATUS = 'APP_UPDATE:GET_STATUS';
const APP_UPDATE_CHECK = 'APP_UPDATE:CHECK';
const APP_UPDATE_DOWNLOAD = 'APP_UPDATE:DOWNLOAD';
const APP_UPDATE_INSTALL = 'APP_UPDATE:INSTALL';
const APP_UPDATE_GET_RELEASE_NOTES = 'APP_UPDATE:GET_RELEASE_NOTES';
const APP_UPDATE_STATUS_CHANGED = 'APP_UPDATE:STATUS_CHANGED';
const dbSaveContentProgressListeners = new Set<
(
@@ -292,6 +300,23 @@ const electronApi: ElectronBridgeApi = {
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
getAppUpdateStatus: () => ipcRenderer.invoke(APP_UPDATE_GET_STATUS),
checkForAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_CHECK),
downloadAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_DOWNLOAD),
installAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_INSTALL),
getAppUpdateReleaseNotes: (
request?: ElectronBridgeAppUpdateReleaseNotesRequest
) => ipcRenderer.invoke(APP_UPDATE_GET_RELEASE_NOTES, request),
onAppUpdateStatusChange: (
callback: (status: ElectronBridgeAppUpdateStatus) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
status: ElectronBridgeAppUpdateStatus
) => callback(status);
ipcRenderer.on(APP_UPDATE_STATUS_CHANGED, handler);
return () => ipcRenderer.off(APP_UPDATE_STATUS_CHANGED, handler);
},
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
@@ -1,4 +1,3 @@
export const rendererAppPort = 4200;
export const rendererAppName = 'web'; // options.name.split('-')[0] + '-web'
export const electronAppName = 'iptvnator';
export const updateServerUrl = 'https://deployment-server-url.com'; // TODO: insert your update server url here
@@ -0,0 +1,101 @@
import {
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
APP_UPDATE_GET_RELEASE_NOTES,
APP_UPDATE_GET_STATUS,
APP_UPDATE_INSTALL,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
const mockHandlers = new Map<string, (...args: unknown[]) => unknown>();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
mockHandlers.set(channel, handler);
}),
},
}));
describe('AppUpdateEvents', () => {
beforeEach(() => {
jest.resetModules();
mockHandlers.clear();
});
it('registers updater IPC handlers against the provided service', async () => {
const status: ElectronBridgeAppUpdateStatus = {
currentVersion: '0.22.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
};
const service = {
checkForUpdates: jest.fn().mockResolvedValue({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
}),
downloadUpdate: jest.fn().mockResolvedValue({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
}),
getStatus: jest.fn(() => status),
getReleaseNotes: jest.fn().mockResolvedValue({
bodyMarkdown: '## Release notes',
hasNext: false,
hasPrevious: true,
htmlUrl:
'https://github.com/4gray/iptvnator/releases/tag/v0.23.0',
publishedAt: '2026-06-28T00:00:00.000Z',
releaseName: 'v0.23.0',
tagName: 'v0.23.0',
version: '0.23.0',
}),
installUpdate: jest.fn(() => ({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
})),
};
const { default: AppUpdateEvents } = await import('./app-update.events');
AppUpdateEvents.bootstrapAppUpdateEvents(service);
expect([...mockHandlers.keys()].sort()).toEqual([
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
APP_UPDATE_GET_RELEASE_NOTES,
APP_UPDATE_GET_STATUS,
APP_UPDATE_INSTALL,
]);
expect(await mockHandlers.get(APP_UPDATE_GET_STATUS)?.()).toEqual({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
});
expect(await mockHandlers.get(APP_UPDATE_CHECK)?.()).toEqual({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
});
expect(await mockHandlers.get(APP_UPDATE_DOWNLOAD)?.()).toEqual({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
});
expect(await mockHandlers.get(APP_UPDATE_INSTALL)?.()).toEqual({
...status,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
expect(
await mockHandlers.get(APP_UPDATE_GET_RELEASE_NOTES)?.(undefined, {
version: '0.23.0',
})
).toMatchObject({
bodyMarkdown: '## Release notes',
tagName: 'v0.23.0',
});
expect(service.getReleaseNotes).toHaveBeenCalledWith({
version: '0.23.0',
});
});
});
@@ -0,0 +1,40 @@
import { ipcMain } from 'electron';
import {
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
APP_UPDATE_GET_RELEASE_NOTES,
APP_UPDATE_GET_STATUS,
APP_UPDATE_INSTALL,
ElectronBridgeAppUpdateReleaseNotes,
ElectronBridgeAppUpdateReleaseNotesRequest,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateService } from '../services/app-update.service';
export interface AppUpdateServiceContract {
getStatus(): ElectronBridgeAppUpdateStatus;
checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus>;
downloadUpdate(): Promise<ElectronBridgeAppUpdateStatus>;
installUpdate(): ElectronBridgeAppUpdateStatus;
getReleaseNotes(
request?: ElectronBridgeAppUpdateReleaseNotesRequest
): Promise<ElectronBridgeAppUpdateReleaseNotes>;
}
export default class AppUpdateEvents {
static bootstrapAppUpdateEvents(
service: AppUpdateServiceContract | AppUpdateService
): Electron.IpcMain {
ipcMain.handle(APP_UPDATE_GET_STATUS, () => service.getStatus());
ipcMain.handle(APP_UPDATE_CHECK, () => service.checkForUpdates());
ipcMain.handle(APP_UPDATE_DOWNLOAD, () => service.downloadUpdate());
ipcMain.handle(APP_UPDATE_INSTALL, () => service.installUpdate());
ipcMain.handle(APP_UPDATE_GET_RELEASE_NOTES, (_event, request) =>
service.getReleaseNotes(
request as ElectronBridgeAppUpdateReleaseNotesRequest
)
);
return ipcMain;
}
}
@@ -1,69 +0,0 @@
import { app, autoUpdater, dialog, MessageBoxOptions } from 'electron';
import { platform, arch } from 'os';
import { updateServerUrl } from '../constants';
import App from '../app';
export default class UpdateEvents {
// initialize auto update service - most be invoked only in production
static initAutoUpdateService() {
const platform_arch =
platform() === 'win32' ? platform() : platform() + '_' + arch();
const version = app.getVersion();
const feed: Electron.FeedURLOptions = {
url: `${updateServerUrl}/update/${platform_arch}/${version}`,
};
if (!App.isDevelopmentMode()) {
console.log('Initializing auto update service...\n');
autoUpdater.setFeedURL(feed);
UpdateEvents.checkForUpdates();
}
}
// check for updates - most be invoked after initAutoUpdateService() and only in production
static checkForUpdates() {
if (!App.isDevelopmentMode() && autoUpdater.getFeedURL() !== '') {
autoUpdater.checkForUpdates();
}
}
}
autoUpdater.on(
'update-downloaded',
(event, releaseNotes, releaseName, releaseDate) => {
const dialogOpts: MessageBoxOptions = {
type: 'info' as const,
buttons: ['Restart', 'Later'],
title: 'Application Update',
message: process.platform === 'win32' ? releaseNotes : releaseName,
detail:
'A new version has been downloaded. Restart the application to apply the updates.',
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
if (returnValue.response === 0) autoUpdater.quitAndInstall();
});
}
);
autoUpdater.on('checking-for-update', () => {
console.log('Checking for updates...\n');
});
autoUpdater.on('update-available', () => {
console.log('New update available!\n');
});
autoUpdater.on('update-not-available', () => {
console.log('Up to date!\n');
});
autoUpdater.on('before-quit-for-update', () => {
console.log('Application update is about to begin...\n');
});
autoUpdater.on('error', (message) => {
console.error('There was a problem updating the application');
console.error(message, '\n');
});
@@ -0,0 +1,275 @@
import { EventEmitter } from 'events';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateService } from './app-update.service';
class FakeUpdater extends EventEmitter {
autoDownload = true;
autoInstallOnAppQuit = true;
checkForUpdates = jest.fn().mockResolvedValue(null);
downloadUpdate = jest.fn().mockResolvedValue([]);
quitAndInstall = jest.fn();
}
const githubReleases = [
{
body: '## New\n\nFresh build',
draft: false,
html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.24.0',
name: 'v0.24.0',
prerelease: false,
published_at: '2026-06-29T00:00:00.000Z',
tag_name: 'v0.24.0',
},
{
body: '## Current\n\nUpdate details',
draft: false,
html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.23.0',
name: 'v0.23.0',
prerelease: false,
published_at: '2026-06-28T00:00:00.000Z',
tag_name: 'v0.23.0',
},
{
body: 'beta notes',
draft: false,
html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.22.5-beta',
name: 'v0.22.5-beta',
prerelease: true,
published_at: '2026-06-27T00:00:00.000Z',
tag_name: 'v0.22.5-beta',
},
{
body: '## Older\n\nBug fixes',
draft: false,
html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.22.0',
name: 'v0.22.0',
prerelease: false,
published_at: '2026-06-20T00:00:00.000Z',
tag_name: 'v0.22.0',
},
];
function createReleaseFetcher(pages: unknown[][] = [githubReleases]) {
return jest.fn(async (url: string) => {
const parsedUrl = new URL(url);
const page = Number(parsedUrl.searchParams.get('page') ?? '1');
return {
json: jest.fn().mockResolvedValue(pages[page - 1] ?? []),
ok: true,
status: 200,
statusText: 'OK',
};
});
}
function createWindow() {
return {
isDestroyed: jest.fn(() => false),
webContents: {
send: jest.fn(),
},
};
}
function createService(
overrides: {
fetcher?: ReturnType<typeof createReleaseFetcher>;
isPackaged?: boolean;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
} = {}
) {
const updater = new FakeUpdater();
const win = createWindow();
const service = new AppUpdateService({
app: {
getVersion: () => '0.22.0',
isPackaged: overrides.isPackaged ?? true,
},
getMainWindow: () => win,
platform: overrides.platform ?? 'darwin',
processEnv: overrides.env ?? {},
releaseFetcher: overrides.fetcher,
updater,
});
return { service, updater, win };
}
describe('AppUpdateService', () => {
it('reports unsupported status outside packaged builds', () => {
const { service, updater } = createService({ isPackaged: false });
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,
});
expect(updater.autoDownload).toBe(false);
});
it('supports Linux self-update only for AppImage builds', () => {
const unsupported = createService({
platform: 'linux',
env: {},
}).service.getStatus();
const appImage = createService({
platform: 'linux',
env: { APPIMAGE: '/Applications/IPTVnator.AppImage' },
}).service.getStatus();
expect(unsupported.supportedSelfUpdate).toBe(false);
expect(unsupported.status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported
);
expect(appImage.supportedSelfUpdate).toBe(true);
expect(appImage.status).toBe(ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle);
});
it('checks for updates without downloading automatically', async () => {
const { service, updater } = createService();
await service.checkForUpdates();
expect(updater.autoDownload).toBe(false);
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
expect(service.getStatus().status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking
);
});
it('checks packaged non-AppImage Linux builds through GitHub manual fallback', async () => {
const fetcher = createReleaseFetcher();
const { service, updater } = createService({
env: {},
fetcher,
platform: 'linux',
});
await service.checkForUpdates();
expect(updater.checkForUpdates).not.toHaveBeenCalled();
expect(fetcher).toHaveBeenCalledWith(
'https://api.github.com/repos/4gray/iptvnator/releases?per_page=10&page=1',
expect.objectContaining({
headers: expect.objectContaining({
Accept: 'application/vnd.github+json',
}),
})
);
expect(service.getStatus()).toMatchObject({
latestVersion: '0.24.0',
release: {
releaseName: 'v0.24.0',
version: '0.24.0',
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: false,
});
});
it('starts a packaged startup check without downloading automatically', async () => {
const { service, updater } = createService();
await service.checkForUpdatesOnStartup();
expect(updater.autoDownload).toBe(false);
expect(updater.downloadUpdate).not.toHaveBeenCalled();
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
});
it('loads release notes lazily and navigates between cached versions', async () => {
const fetcher = createReleaseFetcher();
const { service } = createService({ fetcher });
const current = await service.getReleaseNotes({ version: '0.23.0' });
const older = await service.getReleaseNotes({
direction: 'previous',
version: current.tagName,
});
const newer = await service.getReleaseNotes({
direction: 'next',
version: older.tagName,
});
expect(fetcher).toHaveBeenCalledTimes(1);
expect(current).toMatchObject({
bodyMarkdown: '## Current\n\nUpdate details',
hasNext: true,
hasPrevious: true,
tagName: 'v0.23.0',
version: '0.23.0',
});
expect(older).toMatchObject({
bodyMarkdown: '## Older\n\nBug fixes',
hasNext: true,
hasPrevious: false,
tagName: 'v0.22.0',
});
expect(newer.tagName).toBe('v0.23.0');
});
it('stores available release details from updater events', () => {
const { service } = createService();
service.handleUpdateAvailable({
version: '0.23.0',
releaseName: 'Release v0.23.0',
releaseDate: '2026-06-28T10:00:00.000Z',
releaseNotes: 'Bug fixes',
});
expect(service.getStatus()).toMatchObject({
latestVersion: '0.23.0',
release: {
releaseDate: '2026-06-28T10:00:00.000Z',
releaseName: 'Release v0.23.0',
releaseNotes: 'Bug fixes',
version: '0.23.0',
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
});
it('downloads only after an update is available and exposes progress', async () => {
const { service, updater } = createService();
service.handleUpdateAvailable({ version: '0.23.0' });
const downloadPromise = service.downloadUpdate();
service.handleDownloadProgress({
bytesPerSecond: 2048,
percent: 42.5,
total: 1000,
transferred: 425,
});
await downloadPromise;
expect(updater.downloadUpdate).toHaveBeenCalledTimes(1);
expect(service.getStatus()).toMatchObject({
progress: {
bytesPerSecond: 2048,
percent: 42.5,
total: 1000,
transferred: 425,
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
});
});
it('marks a downloaded update ready to install and installs on request', () => {
const { service, updater } = createService();
service.handleUpdateAvailable({ version: '0.23.0' });
service.handleUpdateDownloaded({ version: '0.23.0' });
const status: ElectronBridgeAppUpdateStatus = service.getStatus();
service.installUpdate();
expect(status.status).toBe(ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded);
expect(updater.quitAndInstall).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,584 @@
import {
APP_UPDATE_STATUS_CHANGED,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateRelease,
ElectronBridgeAppUpdateReleaseNotes,
ElectronBridgeAppUpdateReleaseNotesRequest,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
export const APP_UPDATE_MANUAL_DOWNLOAD_URL =
'https://github.com/4gray/iptvnator/releases/latest';
const GITHUB_RELEASES_API_URL =
'https://api.github.com/repos/4gray/iptvnator/releases';
const GITHUB_RELEASES_PER_PAGE = 10;
interface AppUpdateAppAdapter {
getVersion(): string;
isPackaged: boolean;
}
interface AppUpdateWebContents {
send(channel: string, payload: ElectronBridgeAppUpdateStatus): void;
}
interface AppUpdateWindow {
isDestroyed(): boolean;
webContents: AppUpdateWebContents;
}
interface AppUpdateInfo {
version: string;
releaseDate?: string;
releaseName?: string | null;
releaseNotes?: string | unknown[] | null;
}
interface AppUpdateProgressInfo {
bytesPerSecond?: number;
percent: number;
total?: number;
transferred?: number;
}
interface AppUpdaterAdapter {
autoDownload: boolean;
autoInstallOnAppQuit?: boolean;
checkForUpdates(): Promise<unknown>;
downloadUpdate(): Promise<string[]>;
quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
on(event: 'checking-for-update', listener: () => void): this;
on(event: 'update-available', listener: (info: AppUpdateInfo) => void): this;
on(
event: 'update-not-available',
listener: (info: AppUpdateInfo) => void
): this;
on(
event: 'download-progress',
listener: (progress: AppUpdateProgressInfo) => void
): this;
on(
event: 'update-downloaded',
listener: (info: AppUpdateInfo) => void
): this;
on(event: 'error', listener: (error: Error) => void): this;
}
interface GitHubReleaseResponse {
body?: string | null;
draft?: boolean;
html_url?: string;
name?: string | null;
prerelease?: boolean;
published_at?: string | null;
tag_name?: string;
}
interface CachedGitHubRelease {
bodyMarkdown: string;
draft: boolean;
htmlUrl: string;
prerelease: boolean;
publishedAt?: string | null;
releaseName?: string | null;
tagName: string;
version: string;
}
interface ReleaseFetchResponse {
json(): Promise<unknown>;
ok: boolean;
status: number;
statusText: string;
}
type ReleaseFetcher = (
url: string,
init?: { headers?: Record<string, string> }
) => Promise<ReleaseFetchResponse>;
export interface AppUpdateServiceOptions {
app: AppUpdateAppAdapter;
updater: AppUpdaterAdapter;
getMainWindow: () => AppUpdateWindow | null | undefined;
platform?: NodeJS.Platform;
processEnv?: NodeJS.ProcessEnv;
releaseFetcher?: ReleaseFetcher;
}
function isSelfUpdateSupported(
isPackaged: boolean,
platform: NodeJS.Platform,
processEnv: NodeJS.ProcessEnv
): boolean {
if (!isPackaged) {
return false;
}
if (platform === 'darwin' || platform === 'win32') {
return true;
}
return platform === 'linux' && Boolean(processEnv.APPIMAGE);
}
function getInitialStatus(supportedSelfUpdate: boolean) {
return supportedSelfUpdate
? ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle
: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported;
}
function toRelease(info: AppUpdateInfo): ElectronBridgeAppUpdateRelease {
return {
version: info.version,
releaseDate: info.releaseDate,
releaseName: info.releaseName,
releaseNotes:
typeof info.releaseNotes === 'string' ? info.releaseNotes : null,
};
}
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function normalizeVersion(value: string | null | undefined): string {
return (value ?? '').trim().replace(/^v/i, '');
}
function parseVersionParts(value: string): [number, number, number] | null {
const match = normalizeVersion(value).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function isVersionGreaterThan(candidate: string, current: string): boolean {
const candidateParts = parseVersionParts(candidate);
const currentParts = parseVersionParts(current);
if (!candidateParts || !currentParts) {
return false;
}
for (let index = 0; index < candidateParts.length; index += 1) {
if (candidateParts[index] !== currentParts[index]) {
return candidateParts[index] > currentParts[index];
}
}
return false;
}
function toReleaseInfo(
release: CachedGitHubRelease
): ElectronBridgeAppUpdateRelease {
return {
version: release.version,
releaseDate: release.publishedAt ?? undefined,
releaseName: release.releaseName,
releaseNotes: null,
};
}
function toReleaseNotes(
release: CachedGitHubRelease,
index: number,
releaseCount: number,
loadedAllReleases: boolean
): ElectronBridgeAppUpdateReleaseNotes {
return {
version: release.version,
tagName: release.tagName,
releaseName: release.releaseName,
publishedAt: release.publishedAt,
bodyMarkdown: release.bodyMarkdown,
htmlUrl: release.htmlUrl,
hasNext: index > 0,
hasPrevious: index < releaseCount - 1 || !loadedAllReleases,
};
}
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
return Boolean(
value &&
typeof value === 'object' &&
'tag_name' in value &&
typeof (value as GitHubReleaseResponse).tag_name === 'string'
);
}
export class AppUpdateService {
private readonly currentVersion: string;
private readonly isPackaged: boolean;
private readonly supportedSelfUpdate: boolean;
private readonly releaseFetcher: ReleaseFetcher;
private readonly releases: CachedGitHubRelease[] = [];
private loadedReleasePages = 0;
private loadedAllReleases = false;
private status: ElectronBridgeAppUpdateStatus;
constructor(private readonly options: AppUpdateServiceOptions) {
this.currentVersion = options.app.getVersion();
this.isPackaged = options.app.isPackaged;
this.supportedSelfUpdate = isSelfUpdateSupported(
options.app.isPackaged,
options.platform ?? process.platform,
options.processEnv ?? process.env
);
this.releaseFetcher =
options.releaseFetcher ??
((url, init) => fetch(url, init) as Promise<ReleaseFetchResponse>);
this.status = {
currentVersion: this.currentVersion,
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
status: getInitialStatus(this.supportedSelfUpdate),
supportedSelfUpdate: this.supportedSelfUpdate,
};
options.updater.autoDownload = false;
options.updater.autoInstallOnAppQuit = false;
this.attachUpdaterEvents(options.updater);
}
getStatus(): ElectronBridgeAppUpdateStatus {
return { ...this.status };
}
async checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus> {
if (!this.isPackaged) {
this.setStatus({
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
});
return this.getStatus();
}
this.setStatus({
error: undefined,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
});
try {
if (this.supportedSelfUpdate) {
await this.options.updater.checkForUpdates();
} else {
await this.checkGitHubReleaseForManualUpdate();
}
} catch (error) {
this.handleError(error);
}
return this.getStatus();
}
async checkForUpdatesOnStartup(): Promise<ElectronBridgeAppUpdateStatus> {
if (!this.isPackaged) {
return this.getStatus();
}
return this.checkForUpdates();
}
async getReleaseNotes(
request: ElectronBridgeAppUpdateReleaseNotesRequest = {}
): Promise<ElectronBridgeAppUpdateReleaseNotes> {
await this.ensureReleasePageLoaded(1);
let index = await this.findReleaseIndex(request.version);
if (index === -1 && !request.version) {
index = 0;
}
if (index === -1) {
throw new Error(
`Release notes were not found for ${request.version ?? 'latest release'}`
);
}
if (request.direction === 'previous') {
index += 1;
while (index >= this.releases.length && !this.loadedAllReleases) {
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
}
} else if (request.direction === 'next') {
index -= 1;
}
const release = this.releases[index];
if (!release) {
throw new Error('No release notes are available in that direction');
}
return toReleaseNotes(
release,
index,
this.releases.length,
this.loadedAllReleases
);
}
async downloadUpdate(): Promise<ElectronBridgeAppUpdateStatus> {
if (!this.supportedSelfUpdate) {
this.setStatus({
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
});
return this.getStatus();
}
if (
this.status.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available &&
this.status.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
) {
return this.getStatus();
}
this.setStatus({
error: undefined,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
});
try {
await this.options.updater.downloadUpdate();
} catch (error) {
this.handleError(error);
}
return this.getStatus();
}
installUpdate(): ElectronBridgeAppUpdateStatus {
if (
this.supportedSelfUpdate &&
this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
) {
this.options.updater.quitAndInstall();
}
return this.getStatus();
}
handleUpdateAvailable(info: AppUpdateInfo): void {
const release = toRelease(info);
this.setStatus({
error: undefined,
latestVersion: release.version,
release,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
}
handleUpdateNotAvailable(info: AppUpdateInfo): void {
this.setStatus({
error: undefined,
latestVersion: info.version,
release: toRelease(info),
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
}
handleDownloadProgress(progress: AppUpdateProgressInfo): void {
this.setStatus({
progress: {
bytesPerSecond: progress.bytesPerSecond,
percent: progress.percent,
total: progress.total,
transferred: progress.transferred,
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
});
}
handleUpdateDownloaded(info: AppUpdateInfo): void {
const release = toRelease(info);
this.setStatus({
latestVersion: release.version,
release,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
}
handleError(error: unknown): void {
this.setStatus({
error: normalizeError(error),
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
}
private attachUpdaterEvents(updater: AppUpdaterAdapter): void {
updater.on('checking-for-update', () => {
this.setStatus({
error: undefined,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
});
});
updater.on('update-available', (info) =>
this.handleUpdateAvailable(info)
);
updater.on('update-not-available', (info) =>
this.handleUpdateNotAvailable(info)
);
updater.on('download-progress', (progress) =>
this.handleDownloadProgress(progress)
);
updater.on('update-downloaded', (info) =>
this.handleUpdateDownloaded(info)
);
updater.on('error', (error) => this.handleError(error));
}
private async checkGitHubReleaseForManualUpdate(): Promise<void> {
await this.ensureReleasePageLoaded(1);
const latestRelease = this.releases[0];
if (!latestRelease) {
this.setStatus({
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
return;
}
if (isVersionGreaterThan(latestRelease.version, this.currentVersion)) {
this.setStatus({
error: undefined,
latestVersion: latestRelease.version,
release: toReleaseInfo(latestRelease),
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
return;
}
this.setStatus({
error: undefined,
latestVersion: latestRelease.version,
release: toReleaseInfo(latestRelease),
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
}
private async findReleaseIndex(
version: string | undefined
): Promise<number> {
if (!version) {
return -1;
}
const normalizedVersion = normalizeVersion(version);
while (true) {
const index = this.releases.findIndex(
(release) =>
normalizeVersion(release.version) === normalizedVersion ||
normalizeVersion(release.tagName) === normalizedVersion
);
if (index !== -1 || this.loadedAllReleases) {
return index;
}
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
}
}
private async ensureReleasePageLoaded(page: number): Promise<void> {
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
await this.loadReleasePage(this.loadedReleasePages + 1);
}
}
private async loadReleasePage(page: number): Promise<void> {
const url = `${GITHUB_RELEASES_API_URL}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
const response = await this.releaseFetcher(url, {
headers: {
Accept: 'application/vnd.github+json',
},
});
if (!response.ok) {
throw new Error(
`GitHub releases request failed: ${response.status} ${response.statusText}`
);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('GitHub releases response was not an array');
}
for (const release of payload) {
if (!isGitHubRelease(release)) {
continue;
}
if (release.draft || release.prerelease) {
continue;
}
const tagName = release.tag_name;
const version = normalizeVersion(tagName);
if (
this.releases.some(
(cachedRelease) => cachedRelease.tagName === tagName
)
) {
continue;
}
this.releases.push({
bodyMarkdown: release.body ?? '',
draft: Boolean(release.draft),
htmlUrl:
release.html_url ??
`${APP_UPDATE_MANUAL_DOWNLOAD_URL.replace('/latest', '')}/tag/${tagName}`,
prerelease: Boolean(release.prerelease),
publishedAt: release.published_at,
releaseName: release.name ?? tagName,
tagName,
version,
});
}
this.loadedReleasePages = page;
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
}
private setStatus(
update: Partial<
Omit<
ElectronBridgeAppUpdateStatus,
'currentVersion' | 'manualDownloadUrl' | 'supportedSelfUpdate'
>
>
): void {
this.status = {
...this.status,
...update,
currentVersion: this.currentVersion,
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
supportedSelfUpdate: this.supportedSelfUpdate,
};
this.emitStatus();
}
private emitStatus(): void {
const mainWindow = this.options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(APP_UPDATE_STATUS_CHANGED, this.getStatus());
}
}
+10 -5
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow } from 'electron';
import { getElectronUserDataPath } from '@iptvnator/shared/database';
import { autoUpdater } from 'electron-updater';
import fixPath from 'fix-path';
import App from './app/app';
import { initDatabase } from './app/database/connection';
@@ -13,6 +14,7 @@ import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from './app/events/embedded-mpv.events';
import EpgEvents from './app/events/epg.events';
import AppUpdateEvents from './app/events/app-update.events';
import { shutdownMpvSession } from './app/events/mpv-session.service';
import PlayerEvents from './app/events/player.events';
import { shutdownVlcSession } from './app/events/vlc-session.service';
@@ -23,6 +25,7 @@ import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
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';
@@ -102,6 +105,13 @@ export default class Main {
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
const appUpdateService = new AppUpdateService({
app,
getMainWindow: () => App.mainWindow,
updater: autoUpdater,
});
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
void appUpdateService.checkForUpdatesOnStartup();
// Set main window for downloads and reset stale downloads
if (App.mainWindow) {
@@ -113,11 +123,6 @@ export default class Main {
trace('startup', 'reset-stale-downloads:done');
}
// initialize auto updater service
if (!App.isDevelopmentMode()) {
// UpdateEvents.initAutoUpdateService();
}
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
}
@@ -0,0 +1,106 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
const availableStatus: ElectronBridgeAppUpdateStatus = {
currentVersion: '0.22.0',
latestVersion: '0.23.0',
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: true,
};
describe('AppUpdateNotificationPanelComponent', () => {
let fixture: ComponentFixture<AppUpdateNotificationPanelComponent>;
const originalElectron = window.electron;
let statusHandler: ((status: ElectronBridgeAppUpdateStatus) => void) | null;
beforeEach(async () => {
statusHandler = null;
window.electron = {
downloadAppUpdate: jest.fn().mockResolvedValue({
...availableStatus,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
}),
getAppUpdateStatus: jest.fn().mockResolvedValue({
...availableStatus,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
}),
onAppUpdateStatusChange: jest.fn((handler) => {
statusHandler = handler;
return jest.fn();
}),
} as unknown as typeof window.electron;
await TestBed.configureTestingModule({
imports: [
AppUpdateNotificationPanelComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: { open: jest.fn() },
},
],
}).compileComponents();
fixture = TestBed.createComponent(AppUpdateNotificationPanelComponent);
fixture.detectChanges();
await fixture.whenStable();
});
afterEach(() => {
window.electron = originalElectron;
});
it('shows a startup update notification when the main process reports an available update', () => {
statusHandler?.(availableStatus);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('[data-test-id="app-update-notification"]')
).not.toBeNull();
});
it('opens release notes without dismissing the notification', () => {
statusHandler?.(availableStatus);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification-release-notes"]'
) as HTMLButtonElement
).click();
expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
expect.objectContaining({
data: { initialVersion: '0.23.0' },
})
);
expect(
fixture.nativeElement.querySelector('[data-test-id="app-update-notification"]')
).not.toBeNull();
});
it('starts downloading the update from the notification action', async () => {
statusHandler?.(availableStatus);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification-download"]'
) as HTMLButtonElement
).click();
await fixture.whenStable();
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,260 @@
import { Component, computed, OnDestroy, OnInit, signal, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@Component({
selector: 'app-update-notification-panel',
imports: [MatButtonModule, MatIconModule, TranslatePipe],
template: `
@if (isVisible(); as visible) {
<section
class="app-update-notification"
data-test-id="app-update-notification"
>
<header class="app-update-notification__header">
<span>
<mat-icon>system_update</mat-icon>
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}
</span>
<button
mat-icon-button
type="button"
(click)="dismiss()"
[attr.aria-label]="'CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</header>
<div class="app-update-notification__body">
<strong>
{{
labelKey()
| translate
: {
version:
status()?.latestVersion ||
status()?.currentVersion,
}
}}
</strong>
@if (status()?.progress; as progress) {
<progress [value]="progress.percent" max="100"></progress>
}
</div>
<div class="app-update-notification__actions">
<button
mat-stroked-button
type="button"
(click)="openReleaseNotes()"
data-test-id="app-update-notification-release-notes"
>
<mat-icon>article</mat-icon>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</button>
@if (status()?.status === appUpdateStatuses.Downloaded) {
<button
mat-flat-button
type="button"
(click)="installUpdate()"
data-test-id="app-update-notification-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="
status()?.status === appUpdateStatuses.Downloading
"
(click)="downloadUpdate()"
data-test-id="app-update-notification-download"
>
<mat-icon>download</mat-icon>
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
</button>
}
</div>
</section>
}
`,
styles: [
`
.app-update-notification {
position: fixed;
right: 20px;
bottom: 20px;
width: 320px;
max-width: calc(100vw - 40px);
z-index: 901;
overflow: hidden;
border: 1px solid var(--app-separator);
border-radius: 12px;
background: var(--app-widget-bg);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.08),
0 8px 24px rgba(0, 0, 0, 0.18),
0 16px 48px rgba(0, 0, 0, 0.12);
}
.app-update-notification__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 8px 12px;
border-bottom: 1px solid var(--app-separator);
}
.app-update-notification__header span {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-heading-color);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-update-notification__header mat-icon {
color: var(--app-selection-color);
}
.app-update-notification__body {
display: grid;
gap: 8px;
padding: 12px;
color: var(--app-body-color);
font-size: 0.88rem;
}
.app-update-notification__body progress {
width: 100%;
}
.app-update-notification__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
padding: 0 12px 12px;
}
@media (max-width: 560px) {
.app-update-notification {
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
max-width: none;
}
}
`,
],
})
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
private readonly dialog = inject(MatDialog);
private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
readonly dismissedVersion = signal<string | null>(null);
readonly labelKey = computed(() =>
this.status()?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
: 'SETTINGS.APP_UPDATE_AVAILABLE'
);
readonly isVisible = computed(() => {
const status = this.status();
if (!status?.latestVersion) {
return false;
}
if (this.dismissedVersion() === status.latestVersion) {
return false;
}
return (
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
ngOnInit(): void {
if (!window.electron?.getAppUpdateStatus) {
return;
}
this.unsubscribeStatus =
window.electron.onAppUpdateStatusChange?.((status) => {
this.status.set(status);
}) ?? null;
void window.electron.getAppUpdateStatus().then((status) => {
this.status.set(status);
});
}
ngOnDestroy(): void {
this.unsubscribeStatus?.();
this.unsubscribeStatus = null;
}
openReleaseNotes(): void {
const latestVersion = this.status()?.latestVersion;
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion: latestVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
async downloadUpdate(): Promise<void> {
const status = this.status();
if (!status) {
return;
}
if (!status.supportedSelfUpdate) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
return;
}
if (!window.electron?.downloadAppUpdate) {
return;
}
this.status.set(await window.electron.downloadAppUpdate());
}
async installUpdate(): Promise<void> {
if (!window.electron?.installAppUpdate) {
return;
}
this.status.set(await window.electron.installAppUpdate());
}
dismiss(): void {
this.dismissedVersion.set(this.status()?.latestVersion ?? null);
}
}
+2
View File
@@ -2,6 +2,8 @@
<app-epg-progress-panel />
<app-update-notification-panel />
@if (usesCustomWindowControls) {
<app-window-controls />
}
+7 -1
View File
@@ -28,13 +28,19 @@ import {
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { SettingsService } from './services/settings.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
const debugAppComponent = createDevLogger('AppComponent');
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent],
imports: [
AppUpdateNotificationPanelComponent,
EpgProgressPanelComponent,
RouterOutlet,
WindowControlsComponent,
],
})
export class AppComponent implements OnInit {
@HostBinding('class.macos-platform') get isMacOS() {
@@ -0,0 +1,97 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
const releaseNotes = {
bodyMarkdown: '## v0.23.0\n\n- Added **desktop updater**',
hasNext: false,
hasPrevious: true,
htmlUrl: 'https://github.com/4gray/iptvnator/releases/tag/v0.23.0',
publishedAt: '2026-06-28T00:00:00.000Z',
releaseName: 'v0.23.0',
tagName: 'v0.23.0',
version: '0.23.0',
};
describe('AppUpdateReleaseNotesDialogComponent', () => {
let fixture: ComponentFixture<AppUpdateReleaseNotesDialogComponent>;
const originalElectron = window.electron;
beforeEach(async () => {
window.electron = {
getAppUpdateReleaseNotes: jest.fn().mockResolvedValue(releaseNotes),
} as unknown as typeof window.electron;
await TestBed.configureTestingModule({
imports: [
AppUpdateReleaseNotesDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: { initialVersion: '0.23.0' },
},
{
provide: MatDialogRef,
useValue: { close: jest.fn() },
},
],
}).compileComponents();
fixture = TestBed.createComponent(AppUpdateReleaseNotesDialogComponent);
});
afterEach(() => {
window.electron = originalElectron;
});
it('loads and renders markdown release notes for the initial version', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenCalledWith({
version: '0.23.0',
});
expect(
fixture.nativeElement.querySelector('[data-test-id="release-notes-body"] h2')
?.textContent
).toContain('v0.23.0');
expect(
fixture.nativeElement.querySelector(
'[data-test-id="release-notes-body"] strong'
)?.textContent
).toContain('desktop updater');
});
it('loads previous notes lazily without closing the dialog', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
(
window.electron.getAppUpdateReleaseNotes as jest.Mock
).mockResolvedValueOnce({
...releaseNotes,
bodyMarkdown: '## v0.22.0\n\nOlder release',
hasNext: true,
hasPrevious: false,
tagName: 'v0.22.0',
version: '0.22.0',
});
(
fixture.nativeElement.querySelector(
'[data-test-id="release-notes-previous"]'
) as HTMLButtonElement
).click();
await fixture.whenStable();
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenLastCalledWith({
direction: 'previous',
version: 'v0.23.0',
});
});
});
@@ -0,0 +1,240 @@
import {
Component,
computed,
inject,
OnInit,
SecurityContext,
signal,
} from '@angular/core';
import { DatePipe } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { DomSanitizer } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
import { marked } from 'marked';
import {
ElectronBridgeAppUpdateReleaseNotes,
ElectronBridgeAppUpdateReleaseNotesDirection,
} from '@iptvnator/shared/interfaces';
export interface AppUpdateReleaseNotesDialogData {
initialVersion?: string;
}
@Component({
selector: 'app-update-release-notes-dialog',
imports: [
MatButtonModule,
MatDialogModule,
MatIconModule,
MatProgressSpinnerModule,
DatePipe,
TranslatePipe,
],
template: `
<h2 mat-dialog-title>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</h2>
<mat-dialog-content class="release-notes-dialog mat-typography">
<div class="release-notes-dialog__toolbar">
<button
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasPrevious"
(click)="load('previous')"
data-test-id="release-notes-previous"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div class="release-notes-dialog__version">
<strong>{{ notes()?.releaseName || notes()?.tagName }}</strong>
@if (notes()?.publishedAt; as publishedAt) {
<span>{{ publishedAt | date: 'mediumDate' }}</span>
}
</div>
<button
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasNext"
(click)="load('next')"
data-test-id="release-notes-next"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
@if (loading()) {
<div class="release-notes-dialog__loading">
<mat-spinner diameter="28" />
</div>
} @else if (error()) {
<p class="release-notes-dialog__error">{{ error() }}</p>
} @else {
<article
class="release-notes-dialog__body"
data-test-id="release-notes-body"
[innerHTML]="renderedMarkdown()"
></article>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (notes()?.htmlUrl; as htmlUrl) {
<button mat-button type="button" (click)="openRelease(htmlUrl)">
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
</button>
}
<button mat-flat-button type="button" (click)="close()">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.release-notes-dialog {
min-width: min(640px, calc(100vw - 64px));
max-width: min(760px, calc(100vw - 32px));
}
.release-notes-dialog__toolbar {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 40px;
align-items: center;
gap: 8px;
padding: 4px 0 12px;
border-bottom: 1px solid var(--app-separator);
}
.release-notes-dialog__version {
min-width: 0;
text-align: center;
}
.release-notes-dialog__version strong,
.release-notes-dialog__version span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-notes-dialog__version span {
margin-top: 2px;
color: var(--app-muted-color);
font-size: 0.78rem;
}
.release-notes-dialog__loading {
display: flex;
justify-content: center;
padding: 32px;
}
.release-notes-dialog__error {
color: var(--app-error-color, #ef4444);
}
.release-notes-dialog__body {
max-height: min(58vh, 560px);
overflow: auto;
padding: 12px 2px 0;
color: var(--app-body-color);
}
.release-notes-dialog__body h1,
.release-notes-dialog__body h2,
.release-notes-dialog__body h3 {
color: var(--app-heading-color);
letter-spacing: 0;
}
.release-notes-dialog__body h1 {
font-size: 1.3rem;
}
.release-notes-dialog__body h2 {
font-size: 1.1rem;
}
.release-notes-dialog__body h3 {
font-size: 1rem;
}
.release-notes-dialog__body code {
border-radius: 4px;
padding: 1px 4px;
background: var(--app-hover-overlay);
}
`,
],
})
export class AppUpdateReleaseNotesDialogComponent implements OnInit {
private readonly data = inject<AppUpdateReleaseNotesDialogData>(
MAT_DIALOG_DATA
);
private readonly dialogRef = inject(
MatDialogRef<AppUpdateReleaseNotesDialogComponent>
);
private readonly sanitizer = inject(DomSanitizer);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly notes = signal<ElectronBridgeAppUpdateReleaseNotes | null>(null);
readonly renderedMarkdown = computed(() => {
const markdown = this.notes()?.bodyMarkdown ?? '';
const html = marked.parse(markdown, { async: false }) as string;
return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
});
ngOnInit(): void {
void this.load();
}
async load(
direction?: ElectronBridgeAppUpdateReleaseNotesDirection
): Promise<void> {
if (!window.electron?.getAppUpdateReleaseNotes) {
this.error.set('Release notes are not available in this build.');
return;
}
const version = direction
? this.notes()?.tagName
: this.data.initialVersion;
this.loading.set(true);
this.error.set(null);
try {
this.notes.set(
await window.electron.getAppUpdateReleaseNotes({
direction,
version,
})
);
} catch (error) {
this.error.set(error instanceof Error ? error.message : String(error));
} finally {
this.loading.set(false);
}
}
openRelease(url: string): void {
window.open(url, '_blank', 'noreferrer');
}
close(): void {
this.dialogRef.close();
}
}
@@ -24,6 +24,99 @@
</div>
</div>
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
<div class="setting-item setting-item--full-control app-update-panel">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control app-update-panel__control">
<div class="app-update-status" data-test-id="app-update-status">
<strong>
{{
appUpdateStatusLabelKey()
| translate
: {
version:
updateStatus.latestVersion ||
updateStatus.currentVersion,
}
}}
</strong>
@if (updateStatus.error) {
<span>{{ updateStatus.error }}</span>
}
@if (updateStatus.progress; as progress) {
<progress
[value]="progress.percent"
max="100"
data-test-id="app-update-progress"
></progress>
}
</div>
<div class="app-update-actions">
<button
mat-stroked-button
type="button"
(click)="checkForAppUpdate.emit()"
[disabled]="isAppUpdateBusy()"
data-test-id="app-update-check"
>
<mat-icon>sync</mat-icon>
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
</button>
@if (canDownloadAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="downloadAppUpdate.emit()"
data-test-id="app-update-download"
>
<mat-icon>download</mat-icon>
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
</button>
}
@if (canOpenAppUpdateReleaseNotes()) {
<button
mat-stroked-button
type="button"
(click)="openAppUpdateReleaseNotes.emit()"
data-test-id="app-update-release-notes"
>
<mat-icon>article</mat-icon>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES'
| translate
}}
</button>
}
@if (canInstallAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="installAppUpdate.emit()"
data-test-id="app-update-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
}
@if (canOpenManualAppUpdate()) {
<button
mat-stroked-button
type="button"
(click)="openManualAppUpdate.emit()"
data-test-id="app-update-open-release"
>
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
</button>
}
</div>
</div>
</div>
}
@if (isDesktop()) {
<div class="settings-note">
<strong>{{
@@ -0,0 +1,101 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
function getButton(fixture: ComponentFixture<SettingsAboutSectionComponent>, id: string) {
return fixture.nativeElement.querySelector(
`[data-test-id="${id}"]`
) as HTMLButtonElement | null;
}
function configureComponent(
fixture: ComponentFixture<SettingsAboutSectionComponent>,
status: ElectronBridgeAppUpdateStatus
) {
fixture.componentRef.setInput('activeSection', 'about');
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('version', '0.22.0');
fixture.componentRef.setInput('updateMessage', '');
fixture.componentRef.setInput('appUpdateStatus', status);
fixture.detectChanges();
}
describe('SettingsAboutSectionComponent app updates', () => {
let fixture: ComponentFixture<SettingsAboutSectionComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SettingsAboutSectionComponent,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(SettingsAboutSectionComponent);
});
it('emits a download request when a supported update is available', () => {
const download = jest.fn();
fixture.componentInstance.downloadAppUpdate.subscribe(download);
const openNotes = jest.fn();
fixture.componentInstance.openAppUpdateReleaseNotes.subscribe(openNotes);
configureComponent(fixture, {
currentVersion: '0.22.0',
latestVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: true,
});
getButton(fixture, 'app-update-download')?.click();
expect(getButton(fixture, 'app-update-download')).toBeTruthy();
expect(download).toHaveBeenCalledTimes(1);
getButton(fixture, 'app-update-release-notes')?.click();
expect(getButton(fixture, 'app-update-release-notes')).toBeTruthy();
expect(openNotes).toHaveBeenCalledTimes(1);
});
it('emits an install request after the update has downloaded', () => {
const install = jest.fn();
fixture.componentInstance.installAppUpdate.subscribe(install);
configureComponent(fixture, {
currentVersion: '0.22.0',
latestVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
supportedSelfUpdate: true,
});
getButton(fixture, 'app-update-install')?.click();
expect(getButton(fixture, 'app-update-install')).toBeTruthy();
expect(install).toHaveBeenCalledTimes(1);
});
it('emits a manual release request for unsupported Linux packages', () => {
const openManual = jest.fn();
fixture.componentInstance.openManualAppUpdate.subscribe(openManual);
configureComponent(fixture, {
currentVersion: '0.22.0',
latestVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
getButton(fixture, 'app-update-open-release')?.click();
expect(getButton(fixture, 'app-update-open-release')).toBeTruthy();
expect(openManual).toHaveBeenCalledTimes(1);
});
});
@@ -1,10 +1,21 @@
import { Component, input, ViewEncapsulation } from '@angular/core';
import {
Component,
computed,
input,
output,
ViewEncapsulation,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-settings-about-section',
imports: [MatIconModule, TranslateModule],
imports: [MatButtonModule, MatIconModule, TranslateModule],
templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
@@ -14,4 +25,83 @@ export class SettingsAboutSectionComponent {
readonly isDesktop = input(false);
readonly version = input<string | undefined>();
readonly updateMessage = input<string | undefined>();
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(null);
readonly checkForAppUpdate = output<void>();
readonly downloadAppUpdate = output<void>();
readonly installAppUpdate = output<void>();
readonly openManualAppUpdate = output<void>();
readonly openAppUpdateReleaseNotes = output<void>();
readonly isAppUpdateBusy = computed(() => {
const status = this.appUpdateStatus()?.status;
return (
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking ||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
);
});
readonly canDownloadAppUpdate = computed(() => {
const status = this.appUpdateStatus();
return (
status?.supportedSelfUpdate === true &&
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
);
});
readonly canInstallAppUpdate = computed(() => {
const status = this.appUpdateStatus();
return (
status?.supportedSelfUpdate === true &&
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
readonly canOpenAppUpdateReleaseNotes = computed(() => {
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
);
});
readonly canOpenManualAppUpdate = computed(() => {
const status = this.appUpdateStatus();
return Boolean(status && !status.supportedSelfUpdate);
});
readonly appUpdateStatusLabelKey = computed(() => {
const status =
this.appUpdateStatus()?.status ??
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle;
switch (status) {
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
return 'SETTINGS.APP_UPDATE_CHECKING';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
return 'SETTINGS.APP_UPDATE_AVAILABLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
return 'SETTINGS.APP_UPDATE_NOT_AVAILABLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
return 'SETTINGS.APP_UPDATE_DOWNLOADING';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
return 'SETTINGS.APP_UPDATE_DOWNLOADED';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported:
return 'SETTINGS.APP_UPDATE_MANUAL_AVAILABLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
return 'SETTINGS.APP_UPDATE_ERROR';
default:
return 'SETTINGS.APP_UPDATE_IDLE';
}
});
}
@@ -105,6 +105,12 @@
[isDesktop]="isDesktop"
[version]="version"
[updateMessage]="updateMessage"
[appUpdateStatus]="appUpdateStatus()"
(checkForAppUpdate)="checkForAppUpdate()"
(downloadAppUpdate)="downloadAppUpdate()"
(installAppUpdate)="installAppUpdate()"
(openManualAppUpdate)="openManualAppUpdate()"
(openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()"
/>
<footer class="action-bar" data-test-id="settings-action-bar">
@@ -203,6 +203,47 @@
justify-content: stretch;
}
.app-update-panel__control {
align-items: stretch;
justify-content: space-between;
flex-wrap: wrap;
}
.app-update-status {
min-width: min(100%, 260px);
display: flex;
flex: 1 1 280px;
flex-direction: column;
gap: 6px;
color: var(--settings-muted);
font-size: 0.84rem;
line-height: 1.45;
strong {
color: var(--app-body-color);
font-size: 0.9rem;
font-weight: 650;
}
progress {
width: min(100%, 320px);
height: 8px;
accent-color: var(--app-selection-color);
}
}
.app-update-actions {
display: flex;
flex: 0 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
button {
app-region: no-drag;
}
}
.setting-item--external-player-arguments {
grid-template-columns: 1fr;
gap: 10px;
@@ -35,6 +35,8 @@ import {
} from '@iptvnator/services';
import {
EmbeddedMpvSupport,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
Language,
PlaylistMeta,
StartupBehavior,
@@ -43,6 +45,7 @@ import {
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { SettingsComponent } from './settings.component';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { signal } from '@angular/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
@@ -103,6 +106,13 @@ const DEFAULT_SETTINGS = {
preferUploadedEpgOverXtream: false,
};
const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
currentVersion: '0.22.0',
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
};
class MockSettingsStore {
private _settings = signal(DEFAULT_SETTINGS);
@@ -300,9 +310,22 @@ describe('SettingsComponent', () => {
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
getAppUpdateStatus: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
getChannelPrograms: jest.fn().mockResolvedValue([]),
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
checkForAppUpdate: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
downloadAppUpdate: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
installAppUpdate: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
onAppUpdateStatusChange: jest.fn(() => jest.fn()),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
@@ -355,6 +378,73 @@ describe('SettingsComponent', () => {
expect(component).toBeTruthy();
});
it('loads the desktop app update status and subscribes to status pushes', async () => {
const pushedStatus: ElectronBridgeAppUpdateStatus = {
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
};
await fixture.whenStable();
const statusHandler = (
window.electron.onAppUpdateStatusChange as jest.Mock
).mock.calls[0][0] as (status: ElectronBridgeAppUpdateStatus) => void;
statusHandler(pushedStatus);
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(1);
expect(component.appUpdateStatus()).toEqual(pushedStatus);
});
it('forwards app update actions to the desktop bridge', async () => {
await component.checkForAppUpdate();
await component.downloadAppUpdate();
await component.installAppUpdate();
expect(window.electron.checkForAppUpdate).toHaveBeenCalledTimes(1);
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
expect(window.electron.installAppUpdate).toHaveBeenCalledTimes(1);
});
it('opens the manual release URL from unsupported update status', () => {
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
component.appUpdateStatus.set({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
component.openManualAppUpdate();
expect(openSpy).toHaveBeenCalledWith(
DEFAULT_APP_UPDATE_STATUS.manualDownloadUrl,
'_blank',
'noreferrer'
);
});
it('opens release notes dialog for the latest update version', () => {
const openSpy = jest
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue(createDialogRef(false));
component.appUpdateStatus.set({
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
component.openAppUpdateReleaseNotes();
expect(openSpy).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
expect.objectContaining({
data: {
initialVersion: '0.23.0',
},
})
);
});
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,
ElectronBridgeAppUpdateStatus,
Language,
StreamFormat,
Theme,
@@ -82,6 +83,7 @@ import {
buildSettingsPlaylistDeleteSummary,
} from './settings-playlist-summary.utils';
import { SettingsSnackbarService } from './settings-snackbar.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
@Component({
templateUrl: './settings.component.html',
@@ -186,6 +188,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Update message to show */
updateMessage = '';
readonly appUpdateStatus = signal<ElectronBridgeAppUpdateStatus | null>(
null
);
/** EPG availability flag */
epgAvailable$ = this.store.select(selectIsEpgAvailable);
@@ -228,6 +233,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.playlistDeleteSummary().total > 0
);
private unsubscribeAppUpdateStatus: (() => void) | null = null;
readonly removeAllProgressLabel = computed(() => {
return buildRemoveAllProgressLabel({
isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
@@ -251,6 +258,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.bindDashboardControlsEnabledState();
void this.loadEmbeddedMpvSupport();
this.checkAppVersion();
this.bindAppUpdateStatusEvents();
void this.loadAppUpdateStatus();
void this.fetchLocalIpAddresses();
if (!this.isDialog) {
@@ -291,9 +300,78 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.unsubscribeAppUpdateStatus?.();
this.unsubscribeAppUpdateStatus = null;
this.settingsCtx.reset();
}
private bindAppUpdateStatusEvents(): void {
if (!this.isDesktop || !window.electron?.onAppUpdateStatusChange) {
return;
}
this.unsubscribeAppUpdateStatus =
window.electron.onAppUpdateStatusChange((status) => {
this.appUpdateStatus.set(status);
});
}
private async loadAppUpdateStatus(): Promise<void> {
if (!this.isDesktop || !window.electron?.getAppUpdateStatus) {
return;
}
this.appUpdateStatus.set(await window.electron.getAppUpdateStatus());
}
async checkForAppUpdate(): Promise<void> {
if (!this.isDesktop || !window.electron?.checkForAppUpdate) {
return;
}
this.appUpdateStatus.set(await window.electron.checkForAppUpdate());
}
async downloadAppUpdate(): Promise<void> {
if (!this.isDesktop || !window.electron?.downloadAppUpdate) {
return;
}
this.appUpdateStatus.set(await window.electron.downloadAppUpdate());
}
async installAppUpdate(): Promise<void> {
if (!this.isDesktop || !window.electron?.installAppUpdate) {
return;
}
this.appUpdateStatus.set(await window.electron.installAppUpdate());
}
openManualAppUpdate(): void {
const manualDownloadUrl = this.appUpdateStatus()?.manualDownloadUrl;
if (!manualDownloadUrl) {
return;
}
window.open(manualDownloadUrl, '_blank', 'noreferrer');
}
openAppUpdateReleaseNotes(): void {
this.matDialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion:
this.appUpdateStatus()?.latestVersion ??
this.appUpdateStatus()?.release?.version,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
/**
* Fetches local IP addresses for remote control URL display
*/
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "داكن",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "داكن",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Цёмная",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Dunkel",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Σκούρο",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Dark",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Oscuro",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Sombre",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Scuro",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "ダーク",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "어둡게",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Donker",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Ciemny",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Escuro",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES": "Недавно использованные источники",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream."
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream.",
"APP_UPDATE_TITLE": "Обновление приложения",
"APP_UPDATE_DESCRIPTION": "Проверка обновлений desktop-приложения через GitHub Releases.",
"APP_UPDATE_IDLE": "Можно проверить обновления",
"APP_UPDATE_CHECKING": "Проверяем обновления…",
"APP_UPDATE_AVAILABLE": "Доступна версия {{version}}",
"APP_UPDATE_NOT_AVAILABLE": "Вы используете последнюю версию",
"APP_UPDATE_DOWNLOADING": "Загружается версия {{version}}…",
"APP_UPDATE_DOWNLOADED": "Версия {{version}} готова к установке",
"APP_UPDATE_MANUAL_AVAILABLE": "Версия {{version}} доступна для ручной установки",
"APP_UPDATE_ERROR": "Не удалось проверить обновление",
"APP_UPDATE_CHECK": "Проверить снова",
"APP_UPDATE_DOWNLOAD": "Обновить",
"APP_UPDATE_INSTALL": "Перезагрузить приложение",
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Тёмная",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "Koyu",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "深色",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"APP_UPDATE_TITLE": "Application update",
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
"APP_UPDATE_IDLE": "Ready to check for updates",
"APP_UPDATE_CHECKING": "Checking for updates…",
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
"APP_UPDATE_ERROR": "Update check failed",
"APP_UPDATE_CHECK": "Check again",
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Release notes"
},
"THEMES": {
"DARK_THEME": "深色",