feat(updater): add GitHub releases desktop updater

Adds the GitHub Releases desktop updater with release notes, startup notification, packaging metadata, tests, and CI fixes for Electron E2E.
This commit is contained in:
4gray authored and GitHub committed 2026-06-28 22:21:20 +02:00
1 parent 3cb228d931
commit b1119189e2
54 files changed
+3569 -134

No files matched your search

+134 -3
View File
@@ -360,7 +360,12 @@ jobs:
node -e " node -e "
const fs = require('fs'); const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('electron-builder.json', 'utf8')); const pkg = JSON.parse(fs.readFileSync('electron-builder.json', 'utf8'));
pkg.mac.target.arch = ['${{ matrix.arch }}']; const targets = Array.isArray(pkg.mac?.target) ? pkg.mac.target : [pkg.mac.target];
for (const target of targets) {
if (target && typeof target === 'object') {
target.arch = ['${{ matrix.arch }}'];
}
}
fs.writeFileSync('electron-builder.json', JSON.stringify(pkg, null, 4)); fs.writeFileSync('electron-builder.json', JSON.stringify(pkg, null, 4));
" "
@@ -471,7 +476,7 @@ jobs:
# TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` # TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested. # after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') && '1' || '0' }} IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') && '1' || '0' }}
run: pnpm run make:app run: pnpm run make:app -- --publishPolicy=never
- name: Verify signed macOS app - name: Verify signed macOS app
if: matrix.os == 'macos' && github.event_name != 'pull_request' if: matrix.os == 'macos' && github.event_name != 'pull_request'
@@ -530,7 +535,7 @@ jobs:
# TEMPORARY PR TEST: change this back to '0' after manually # TEMPORARY PR TEST: change this back to '0' after manually
# testing the macOS PR artifact with Embedded MPV included. # testing the macOS PR artifact with Embedded MPV included.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }} IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }}
run: pnpm run make:app run: pnpm run make:app -- --publishPolicy=never
- name: Verify packaged worker layout - name: Verify packaged worker layout
shell: bash shell: bash
@@ -588,6 +593,8 @@ jobs:
path: | path: |
dist/executables/**/*.dmg dist/executables/**/*.dmg
dist/executables/**/*.zip dist/executables/**/*.zip
dist/executables/**/latest-mac.yml
dist/executables/**/*.blockmap
retention-days: 7 retention-days: 7
- name: Upload artifacts (Linux) - name: Upload artifacts (Linux)
@@ -602,6 +609,8 @@ jobs:
dist/executables/**/*.AppImage dist/executables/**/*.AppImage
dist/executables/**/*.tar.gz dist/executables/**/*.tar.gz
dist/executables/**/*.pacman dist/executables/**/*.pacman
dist/executables/**/latest-linux*.yml
dist/executables/**/*.blockmap
retention-days: 7 retention-days: 7
- name: Upload artifacts (Flatpak) - name: Upload artifacts (Flatpak)
@@ -622,6 +631,8 @@ jobs:
dist/executables/**/*.exe dist/executables/**/*.exe
dist/executables/**/*.msi dist/executables/**/*.msi
dist/executables/**/*.zip dist/executables/**/*.zip
dist/executables/**/latest.yml
dist/executables/**/*.blockmap
retention-days: 7 retention-days: 7
create-release: create-release:
@@ -644,6 +655,119 @@ jobs:
- name: Display structure of downloaded files - name: Display structure of downloaded files
run: ls -R artifacts run: ls -R artifacts
- name: Merge macOS updater metadata
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const candidates = [
'artifacts/macos-x64-artifacts/latest-mac.yml',
'artifacts/macos-arm64-artifacts/latest-mac.yml',
].filter((filePath) => fs.existsSync(filePath));
if (candidates.length === 0) {
console.log('No macOS update metadata found; skipping merge.');
process.exit(0);
}
function extractFilesBlock(text, filePath) {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => line === 'files:');
if (start === -1) {
throw new Error(`Missing files block in ${filePath}`);
}
const block = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\S/.test(line) && line.trim() !== '') {
break;
}
if (line.trim() !== '') {
block.push(line);
}
}
return block;
}
function splitFileEntries(block) {
const entries = [];
let current = [];
for (const line of block) {
if (/^\s*-\s+url:/.test(line) && current.length > 0) {
entries.push(current);
current = [];
}
current.push(line);
}
if (current.length > 0) {
entries.push(current);
}
return entries;
}
function replaceFilesBlock(text, mergedBlock) {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => line === 'files:');
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\S/.test(line) && line.trim() !== '') {
end = index;
break;
}
}
return [
...lines.slice(0, start + 1),
...mergedBlock,
...lines.slice(end),
].join('\n').replace(/\n*$/, '\n');
}
const mergedEntries = [];
const seenUrls = new Set();
for (const filePath of candidates) {
const text = fs.readFileSync(filePath, 'utf8');
const entries = splitFileEntries(extractFilesBlock(text, filePath));
for (const entry of entries) {
const urlLine = entry.find((line) => /^\s*-\s+url:/.test(line));
const url = urlLine?.replace(/^\s*-\s+url:\s*/, '').trim();
if (!url || seenUrls.has(url)) {
continue;
}
seenUrls.add(url);
mergedEntries.push(...entry);
}
}
const merged = replaceFilesBlock(
fs.readFileSync(candidates[0], 'utf8'),
mergedEntries
);
fs.writeFileSync('artifacts/latest-mac.yml', merged);
console.log(`Merged ${candidates.length} macOS update metadata files.`);
NODE
- name: Get version from package.json - name: Get version from package.json
id: package-version id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
@@ -659,18 +783,25 @@ jobs:
files: | files: |
artifacts/macos-x64-artifacts/*-x64.dmg artifacts/macos-x64-artifacts/*-x64.dmg
artifacts/macos-x64-artifacts/*-x64.zip artifacts/macos-x64-artifacts/*-x64.zip
artifacts/macos-x64-artifacts/*.blockmap
artifacts/macos-arm64-artifacts/*-arm64.dmg artifacts/macos-arm64-artifacts/*-arm64.dmg
artifacts/macos-arm64-artifacts/*-arm64.zip artifacts/macos-arm64-artifacts/*-arm64.zip
artifacts/macos-arm64-artifacts/*.blockmap
artifacts/latest-mac.yml
artifacts/linux-artifacts/*.AppImage artifacts/linux-artifacts/*.AppImage
artifacts/linux-artifacts/*.deb artifacts/linux-artifacts/*.deb
artifacts/linux-artifacts/*.rpm artifacts/linux-artifacts/*.rpm
artifacts/linux-artifacts/*.snap artifacts/linux-artifacts/*.snap
artifacts/linux-artifacts/*.tar.gz artifacts/linux-artifacts/*.tar.gz
artifacts/linux-artifacts/*.pacman artifacts/linux-artifacts/*.pacman
artifacts/linux-artifacts/latest-linux*.yml
artifacts/linux-artifacts/*.blockmap
artifacts/linux-flatpak-artifacts/*.flatpak artifacts/linux-flatpak-artifacts/*.flatpak
artifacts/windows-artifacts/*-setup.exe artifacts/windows-artifacts/*-setup.exe
artifacts/windows-artifacts/*.msi artifacts/windows-artifacts/*.msi
artifacts/windows-artifacts/*.zip artifacts/windows-artifacts/*.zip
artifacts/windows-artifacts/latest.yml
artifacts/windows-artifacts/*.blockmap
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -12,6 +12,7 @@ jobs:
electron-e2e-tests: electron-e2e-tests:
name: Electron E2E on ${{ matrix.os }} name: Electron E2E on ${{ matrix.os }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 45
env: env:
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1'
NX_SKIP_NX_CACHE: true NX_SKIP_NX_CACHE: true
@@ -40,6 +40,10 @@ export const defaultStalkerPortalName = 'Mock Stalker Portal';
export const defaultXtreamUsername = 'user1'; export const defaultXtreamUsername = 'user1';
export const defaultXtreamPassword = 'pass1'; export const defaultXtreamPassword = 'pass1';
export const defaultStalkerMacAddress = '00:1A:79:00:00:01'; export const defaultStalkerMacAddress = '00:1A:79:00:00:01';
const electronAppCloseTimeoutMs = Number(
process.env['IPTVNATOR_E2E_CLOSE_TIMEOUT_MS'] ?? '10000'
);
const electronAppKillWaitMs = 2000;
export type PortalProvider = 'stalker' | 'xtream'; export type PortalProvider = 'stalker' | 'xtream';
@@ -153,6 +157,7 @@ export async function launchElectronApp(
NODE_ENV: 'test', NODE_ENV: 'test',
}, },
}); });
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp); const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow); await waitForAppReady(mainWindow);
@@ -166,16 +171,83 @@ export async function launchElectronApp(
}; };
} }
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
if (!process.env['CI']) {
return;
}
const childProcess = electronApp.process();
childProcess.stdout?.on('data', (chunk: Buffer) => {
console.log(`[electron stdout] ${chunk.toString().trimEnd()}`);
});
childProcess.stderr?.on('data', (chunk: Buffer) => {
console.error(`[electron stderr] ${chunk.toString().trimEnd()}`);
});
childProcess.once('exit', (code, signal) => {
console.log(
`[electron process exit] code=${code ?? '<null>'} signal=${
signal ?? '<null>'
}`
);
});
}
export async function closeElectronApp( export async function closeElectronApp(
app: LaunchedElectronApp app: LaunchedElectronApp
): Promise<void> { ): Promise<void> {
try { try {
await app.electronApp.close(); const closePromise = app.electronApp.close();
const closed = await waitForPromiseWithTimeout(
closePromise,
electronAppCloseTimeoutMs
);
if (closed) {
return;
}
console.warn(
`Electron app did not close within ${electronAppCloseTimeoutMs}ms; killing process`
);
const childProcess = app.electronApp.process();
if (!childProcess.killed) {
childProcess.kill();
}
await waitForPromiseWithTimeout(
closePromise.catch(() => undefined),
electronAppKillWaitMs
);
} catch (error) { } catch (error) {
console.warn('Failed to close Electron app cleanly:', error); console.warn('Failed to close Electron app cleanly:', error);
} }
} }
async function waitForPromiseWithTimeout(
promise: Promise<unknown>,
timeoutMs: number
): Promise<boolean> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(
() => resolvePromise(false),
timeoutMs
);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function assertPackagedRendererBuildIsElectronSafe(): void { function assertPackagedRendererBuildIsElectronSafe(): void {
if (!existsSync(packagedRendererIndexPath)) { if (!existsSync(packagedRendererIndexPath)) {
throw new Error( throw new Error(
@@ -41,6 +41,30 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
`; `;
test.describe('Electron Settings', () => { 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 ({ test('@settings @electron gates external MPV playback behind double-click when enabled', async ({
dataDir, dataDir,
}) => { }) => {
@@ -2,6 +2,14 @@ import {
DB_WORKER_OPERATIONS, DB_WORKER_OPERATIONS,
DbWorkerOperation, DbWorkerOperation,
} from '../workers/database-worker.types'; } 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 type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import { import {
dbPreloadCases, 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', () => { it('forwards request-scoped DB operation events and unregisters the listener', () => {
const api = getExposedApi(); const api = getExposedApi();
const callback = jest.fn(); const callback = jest.fn();
@@ -1,10 +1,20 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { contextBridge, ipcRenderer, webUtils } from 'electron';
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/ipc-commands';
import type { import type {
EmbeddedMpvBounds, EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions, EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSession, EmbeddedMpvSession,
EmbeddedMpvSupport, EmbeddedMpvSupport,
ElectronBridgeApi, ElectronBridgeApi,
ElectronBridgeAppUpdateReleaseNotesRequest,
ElectronBridgeAppUpdateStatus,
ElectronBridgeDbOperationEvent, ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload, ElectronBridgeDownloadStartPayload,
ElectronBridgeEpgLookupOptions, ElectronBridgeEpgLookupOptions,
@@ -292,6 +302,23 @@ const electronApi: ElectronBridgeApi = {
}, },
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform, 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), minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE), toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE), closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
+75 -2
View File
@@ -4,6 +4,8 @@ jest.mock('electron', () => ({
app: { app: {
getPath: jest.fn(() => '/tmp'), getPath: jest.fn(() => '/tmp'),
isPackaged: false, isPackaged: false,
isReady: jest.fn(() => false),
on: jest.fn(),
}, },
BrowserWindow: jest.fn(), BrowserWindow: jest.fn(),
Menu: { Menu: {
@@ -37,28 +39,49 @@ import {
isTrustedRendererNavigationUrl, isTrustedRendererNavigationUrl,
} from './app'; } from './app';
import App from './app'; import App from './app';
import { app as electronApp } from 'electron'; import { app as electronApp, BrowserWindow, screen } from 'electron';
import { store } from './services/store.service';
type MockMainWindow = { type MockMainWindow = {
center: jest.Mock<void, []>;
getNormalBounds: jest.Mock<object, []>;
loadFile: jest.Mock<Promise<void>, [string]>; loadFile: jest.Mock<Promise<void>, [string]>;
loadURL: jest.Mock<Promise<void>, [string]>; loadURL: jest.Mock<Promise<void>, [string]>;
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
setMenu: jest.Mock<void, [unknown]>;
show: jest.Mock<void, []>;
webContents: { webContents: {
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
openDevTools: jest.Mock<void, []>; openDevTools: jest.Mock<void, []>;
setWindowOpenHandler: jest.Mock<void, [unknown]>;
}; };
}; };
function createMockMainWindow(): MockMainWindow { function createMockMainWindow(): MockMainWindow {
return { return {
center: jest.fn<void, []>(),
getNormalBounds: jest.fn<object, []>().mockReturnValue({}),
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(), loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(), loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: { webContents: {
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
openDevTools: jest.fn<void, []>(), openDevTools: jest.fn<void, []>(),
setWindowOpenHandler: jest.fn<void, [unknown]>(),
}, },
}; };
} }
type AppInternals = { type AppInternals = {
mainWindow: MockMainWindow; loadedMainWindow: MockMainWindow | null;
mainWindow: MockMainWindow | null;
mainWindowLoadPromise: Promise<void> | null;
onReady: () => void;
rendererLoadingEnabled: boolean;
loadMainWindow: () => Promise<void>; loadMainWindow: () => Promise<void>;
}; };
@@ -70,7 +93,16 @@ describe('Electron app security helpers', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
delete process.env.ELECTRON_IS_DEV; delete process.env.ELECTRON_IS_DEV;
const appInternals = getAppInternals();
appInternals.loadedMainWindow = null;
appInternals.mainWindow = null;
appInternals.mainWindowLoadPromise = null;
appInternals.rendererLoadingEnabled = false;
(electronApp as unknown as { isPackaged: boolean }).isPackaged = false; (electronApp as unknown as { isPackaged: boolean }).isPackaged = false;
(screen.getPrimaryDisplay as jest.Mock).mockReturnValue({
workAreaSize: { height: 720, width: 1280 },
});
(store.get as jest.Mock).mockReturnValue(undefined);
}); });
it('creates an explicitly hardened BrowserWindow webPreferences object', () => { it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
@@ -175,6 +207,47 @@ describe('Electron app security helpers', () => {
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
it('defers renderer loading until the main process explicitly enables it', async () => {
const appInternals = getAppInternals();
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
appInternals.onReady();
expect(BrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
show: false,
webPreferences: expect.objectContaining({
preload: expect.stringContaining('main.preload.js'),
}),
})
);
expect(mainWindow.loadURL).not.toHaveBeenCalled();
expect(mainWindow.loadFile).not.toHaveBeenCalled();
await appInternals.loadMainWindow();
expect(mainWindow.loadURL).toHaveBeenCalledWith(
'http://localhost:4200'
);
});
it('creates the main window immediately when Electron is already ready', () => {
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
(electronApp.isReady as jest.Mock).mockReturnValue(true);
App.main(electronApp, BrowserWindow);
expect(BrowserWindow).toHaveBeenCalled();
expect(electronApp.on).not.toHaveBeenCalledWith(
'ready',
expect.any(Function)
);
expect(mainWindow.loadURL).not.toHaveBeenCalled();
expect(mainWindow.loadFile).not.toHaveBeenCalled();
});
it('clears only service worker registrations and cache storage', async () => { it('clears only service worker registrations and cache storage', async () => {
await clearElectronServiceWorkerStorage(); await clearElectronServiceWorkerStorage();
+54 -9
View File
@@ -198,9 +198,12 @@ function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
export default class App { export default class App {
// Keep a global reference of the window object, if you don't, the window will // Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected. // be closed automatically when the JavaScript object is garbage collected.
static mainWindow: Electron.BrowserWindow; static mainWindow: Electron.BrowserWindow | null = null;
static application: Electron.App; static application: Electron.App;
static BrowserWindow; static BrowserWindow;
private static loadedMainWindow: Electron.BrowserWindow | null = null;
private static mainWindowLoadPromise: Promise<void> | null = null;
private static rendererLoadingEnabled = false;
private static shouldOpenDevTools() { private static shouldOpenDevTools() {
return process.env.ELECTRON_OPEN_DEVTOOLS === '1'; return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
@@ -230,15 +233,21 @@ export default class App {
App.mainWindow = null; App.mainWindow = null;
} }
private static startMainWindowLoad(): void {
void App.loadMainWindow().catch((error) => {
console.error('Failed to load main window:', error);
});
}
private static onReady() { private static onReady() {
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
if (rendererAppName) { if (rendererAppName) {
App.initMainWindow(); App.initMainWindow();
void App.loadMainWindow().catch((error) => { if (App.rendererLoadingEnabled) {
console.error('Failed to load main window:', error); App.startMainWindowLoad();
}); }
} }
} }
@@ -248,6 +257,9 @@ export default class App {
if (App.mainWindow === null) { if (App.mainWindow === null) {
App.onReady(); App.onReady();
} }
if (App.rendererLoadingEnabled) {
App.startMainWindowLoad();
}
} }
private static handleRendererNavigation( private static handleRendererNavigation(
@@ -363,6 +375,8 @@ export default class App {
// in an array if your app supports multi windows, this is the time // in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element. // when you should delete the corresponding element.
App.mainWindow = null; App.mainWindow = null;
App.loadedMainWindow = null;
App.mainWindowLoadPromise = null;
}); });
App.mainWindow.on('close', () => { App.mainWindow.on('close', () => {
@@ -409,22 +423,49 @@ export default class App {
}); });
} }
private static async loadMainWindow(): Promise<void> { private static async loadMainWindowContent(
mainWindow: Electron.BrowserWindow
): Promise<void> {
// load the index.html of the app. // load the index.html of the app.
if (App.isDevelopmentMode()) { if (App.isDevelopmentMode()) {
const loadPromise = App.mainWindow.loadURL( const loadPromise = mainWindow.loadURL(
`http://localhost:${rendererAppPort}` `http://localhost:${rendererAppPort}`
); );
if (App.shouldOpenDevTools()) { if (App.shouldOpenDevTools()) {
App.mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }
await loadPromise; await loadPromise;
} else { } else {
await clearElectronServiceWorkerStorage(); await clearElectronServiceWorkerStorage();
await App.mainWindow.loadFile(getPackagedRendererIndexPath()); await mainWindow.loadFile(getPackagedRendererIndexPath());
} }
} }
static async loadMainWindow(): Promise<void> {
App.rendererLoadingEnabled = true;
if (!rendererAppName || !App.mainWindow) {
return;
}
if (App.loadedMainWindow === App.mainWindow) {
return;
}
if (!App.mainWindowLoadPromise) {
const mainWindow = App.mainWindow;
App.mainWindowLoadPromise = App.loadMainWindowContent(mainWindow)
.then(() => {
App.loadedMainWindow = mainWindow;
})
.finally(() => {
App.mainWindowLoadPromise = null;
});
}
await App.mainWindowLoadPromise;
}
static main(app: Electron.App, browserWindow: typeof BrowserWindow) { static main(app: Electron.App, browserWindow: typeof BrowserWindow) {
// we pass the Electron.App object and the // we pass the Electron.App object and the
// Electron.BrowserWindow into this function // Electron.BrowserWindow into this function
@@ -435,7 +476,11 @@ export default class App {
App.application = app; App.application = app;
App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed. App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed.
App.application.on('ready', App.onReady); // App is ready to load data if (App.application.isReady()) {
App.onReady();
} else {
App.application.on('ready', App.onReady); // App is ready to load data
}
App.application.on('activate', App.onActivate); // App is activated App.application.on('activate', App.onActivate); // App is activated
App.application.on('before-quit', () => { App.application.on('before-quit', () => {
if (App.mainWindow) if (App.mainWindow)
@@ -1,4 +1,3 @@
export const rendererAppPort = 4200; export const rendererAppPort = 4200;
export const rendererAppName = 'web'; // options.name.split('-')[0] + '-web' export const rendererAppName = 'web'; // options.name.split('-')[0] + '-web'
export const electronAppName = 'iptvnator'; 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,416 @@
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(true);
});
it('does not resolve the updater adapter outside self-update builds', async () => {
const updaterFactory = jest.fn(() => new FakeUpdater());
const service = new AppUpdateService({
app: {
getVersion: () => '0.0',
isPackaged: false,
},
getMainWindow: () => createWindow(),
updater: updaterFactory,
});
expect(service.getStatus()).toEqual({
currentVersion: '0.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
await service.checkForUpdatesOnStartup();
expect(updaterFactory).not.toHaveBeenCalled();
});
it('checks GitHub releases without resolving the updater on unsupported packaged Linux builds', async () => {
const fetcher = createReleaseFetcher();
const updaterFactory = jest.fn(() => new FakeUpdater());
const service = new AppUpdateService({
app: {
getVersion: () => '0.22.0',
isPackaged: true,
},
getMainWindow: () => createWindow(),
platform: 'linux',
processEnv: {},
releaseFetcher: fetcher,
updater: updaterFactory,
});
await service.checkForUpdates();
expect(updaterFactory).not.toHaveBeenCalled();
expect(fetcher).toHaveBeenCalledWith(
'https://api.github.com/repos/4gray/iptvnator/releases?per_page=10&page=1',
expect.any(Object)
);
expect(service.getStatus()).toEqual(
expect.objectContaining({
latestVersion: '0.24.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: 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('deduplicates concurrent update checks', async () => {
const { service, updater } = createService();
let resolveCheck: ((value: unknown) => void) | undefined;
updater.checkForUpdates.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCheck = resolve;
})
);
const firstCheck = service.checkForUpdates();
const secondCheck = service.checkForUpdates();
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
expect(service.getStatus().status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking
);
resolveCheck?.(null);
await expect(Promise.all([firstCheck, secondCheck])).resolves.toEqual([
service.getStatus(),
service.getStatus(),
]);
await service.checkForUpdates();
expect(updater.checkForUpdates).toHaveBeenCalledTimes(2);
});
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',
'User-Agent': 'iptvnator/0.22.0',
}),
})
);
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('loads additional GitHub release pages when the first page has no stable releases', async () => {
const prereleasePage = Array.from({ length: 10 }, (_, index) => ({
...githubReleases[2],
name: `v0.25.${index}-beta`,
tag_name: `v0.25.${index}-beta`,
}));
const fetcher = createReleaseFetcher([
prereleasePage,
[githubReleases[0]],
]);
const { service } = createService({
env: {},
fetcher,
platform: 'linux',
});
await service.checkForUpdates();
expect(fetcher).toHaveBeenCalledTimes(2);
expect(
fetcher.mock.calls.map(([url]) =>
new URL(url).searchParams.get('page')
)
).toEqual(['1', '2']);
expect(service.getStatus()).toMatchObject({
latestVersion: '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('ignores duplicate download requests while a download is already running', async () => {
const { service, updater } = createService();
let resolveDownload: (() => void) | undefined;
updater.downloadUpdate.mockReturnValueOnce(
new Promise<string[]>((resolve) => {
resolveDownload = () => resolve([]);
})
);
service.handleUpdateAvailable({ version: '0.23.0' });
const firstDownload = service.downloadUpdate();
const secondStatus = await service.downloadUpdate();
resolveDownload?.();
await firstDownload;
expect(updater.downloadUpdate).toHaveBeenCalledTimes(1);
expect(secondStatus.status).toBe(
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,621 @@
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;
}
type AppUpdaterAdapterProvider = AppUpdaterAdapter | (() => AppUpdaterAdapter);
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: AppUpdaterAdapterProvider;
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 readonly updater: AppUpdaterAdapter | null = null;
private loadedReleasePages = 0;
private loadedAllReleases = false;
private checkForUpdatesPromise: Promise<ElectronBridgeAppUpdateStatus> | null =
null;
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,
};
if (this.supportedSelfUpdate) {
this.updater = this.resolveUpdater(options.updater);
this.updater.autoDownload = false;
this.updater.autoInstallOnAppQuit = false;
this.attachUpdaterEvents(this.updater);
}
}
getStatus(): ElectronBridgeAppUpdateStatus {
return { ...this.status };
}
async checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus> {
if (this.checkForUpdatesPromise) {
return this.checkForUpdatesPromise;
}
this.checkForUpdatesPromise = this.runCheckForUpdates().finally(() => {
this.checkForUpdatesPromise = null;
});
return this.checkForUpdatesPromise;
}
private async runCheckForUpdates(): 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.updater) {
await this.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.updater) {
this.setStatus({
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
});
return this.getStatus();
}
if (
this.status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
) {
return this.getStatus();
}
this.setStatus({
error: undefined,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
});
try {
await this.updater.downloadUpdate();
} catch (error) {
this.handleError(error);
}
return this.getStatus();
}
installUpdate(): ElectronBridgeAppUpdateStatus {
if (
this.updater &&
this.status.status ===
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
) {
this.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 resolveUpdater(
updater: AppUpdaterAdapterProvider
): AppUpdaterAdapter {
return typeof updater === 'function' ? updater() : updater;
}
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.ensureFirstStableReleaseLoaded();
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 ensureFirstStableReleaseLoaded(): Promise<void> {
while (!this.loadedAllReleases && this.releases.length === 0) {
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
}
}
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',
'User-Agent': `iptvnator/${this.currentVersion}`,
},
});
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()
);
}
}
+26 -11
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import { getElectronUserDataPath } from '@iptvnator/shared/database'; import { getElectronUserDataPath } from '@iptvnator/shared/database';
import { autoUpdater } from 'electron-updater';
import fixPath from 'fix-path'; import fixPath from 'fix-path';
import App from './app/app'; import App from './app/app';
import { initDatabase } from './app/database/connection'; import { initDatabase } from './app/database/connection';
@@ -13,6 +14,7 @@ import EmbeddedMpvEvents, {
shutdownEmbeddedMpv, shutdownEmbeddedMpv,
} from './app/events/embedded-mpv.events'; } from './app/events/embedded-mpv.events';
import EpgEvents from './app/events/epg.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 { shutdownMpvSession } from './app/events/mpv-session.service';
import PlayerEvents from './app/events/player.events'; import PlayerEvents from './app/events/player.events';
import { shutdownVlcSession } from './app/events/vlc-session.service'; 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 SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events'; import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; 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 { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events'; import WindowEvents from './app/events/window.events';
import XtreamEvents from './app/events/xtream.events'; import XtreamEvents from './app/events/xtream.events';
@@ -83,12 +86,12 @@ export default class Main {
trace('startup', 'bootstrap-events:start'); trace('startup', 'bootstrap-events:start');
} }
// Initialize database before other events const appUpdateService = new AppUpdateService({
await initDatabase(); app,
getMainWindow: () => App.mainWindow,
if (isStartupTraceEnabled()) { updater: () => autoUpdater,
trace('startup', 'init-database:done'); });
} AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
ElectronEvents.bootstrapElectronEvents(); ElectronEvents.bootstrapElectronEvents();
WindowEvents.bootstrapWindowEvents(); WindowEvents.bootstrapWindowEvents();
@@ -107,17 +110,29 @@ export default class Main {
if (App.mainWindow) { if (App.mainWindow) {
setDownloadsMainWindow(App.mainWindow); setDownloadsMainWindow(App.mainWindow);
} }
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
// before the main process has installed handlers.
await App.loadMainWindow();
void appUpdateService.checkForUpdatesOnStartup();
// Initialize the database after the first renderer load is underway so
// Linux Electron E2E can observe a BrowserWindow even when SQLite
// startup or download recovery is slow. IPC handlers call getDatabase()
// lazily and share the same initialization promise.
await initDatabase();
if (isStartupTraceEnabled()) {
trace('startup', 'init-database:done');
}
await resetStaleDownloads(); await resetStaleDownloads();
if (isStartupTraceEnabled()) { if (isStartupTraceEnabled()) {
trace('startup', 'reset-stale-downloads:done'); trace('startup', 'reset-stale-downloads:done');
} }
// initialize auto updater service
if (!App.isDevelopmentMode()) {
// UpdateEvents.initAutoUpdateService();
}
if (isStartupTraceEnabled()) { if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done'); trace('startup', 'bootstrap-events:done');
} }
@@ -0,0 +1,140 @@
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);
});
it('labels unsupported platform action as a GitHub release link', () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
statusHandler?.({
...availableStatus,
supportedSelfUpdate: false,
});
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification-download"]'
) as HTMLButtonElement;
expect(button.textContent).toContain(
'SETTINGS.APP_UPDATE_OPEN_RELEASE'
);
expect(button.querySelector('mat-icon')?.textContent?.trim()).toBe(
'open_in_new'
);
button.click();
expect(window.electron.downloadAppUpdate).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith(
availableStatus.manualDownloadUrl,
'_blank',
'noreferrer'
);
open.mockRestore();
});
});
@@ -0,0 +1,281 @@
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>{{ primaryActionIcon() }}</mat-icon>
{{ primaryActionLabelKey() | 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 primaryActionLabelKey = computed(() =>
this.status()?.supportedSelfUpdate === false
? 'SETTINGS.APP_UPDATE_OPEN_RELEASE'
: 'SETTINGS.APP_UPDATE_DOWNLOAD'
);
readonly primaryActionIcon = computed(() =>
this.status()?.supportedSelfUpdate === false
? 'open_in_new'
: 'download'
);
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-epg-progress-panel />
<app-update-notification-panel />
@if (usesCustomWindowControls) { @if (usesCustomWindowControls) {
<app-window-controls /> <app-window-controls />
} }
+7 -1
View File
@@ -28,13 +28,19 @@ import {
createDevLogger, createDevLogger,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { SettingsService } from './services/settings.service'; import { SettingsService } from './services/settings.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
const debugAppComponent = createDevLogger('AppComponent'); const debugAppComponent = createDevLogger('AppComponent');
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent], imports: [
AppUpdateNotificationPanelComponent,
EpgProgressPanelComponent,
RouterOutlet,
WindowControlsComponent,
],
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
@HostBinding('class.macos-platform') get isMacOS() { @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>
</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()) { @if (isDesktop()) {
<div class="settings-note"> <div class="settings-note">
<strong>{{ <strong>{{
@@ -0,0 +1,116 @@
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);
});
it('uses a neutral status label before unsupported packages have checked GitHub releases', () => {
configureComponent(fixture, {
currentVersion: '0.22.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
expect(fixture.componentInstance.appUpdateStatusLabelKey()).toBe(
'SETTINGS.APP_UPDATE_IDLE'
);
expect(getButton(fixture, 'app-update-open-release')).toBeTruthy();
});
});
@@ -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 { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
@Component({ @Component({
selector: 'app-settings-about-section', selector: 'app-settings-about-section',
imports: [MatIconModule, TranslateModule], imports: [MatButtonModule, MatIconModule, TranslateModule],
templateUrl: './settings-about-section.component.html', templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'], styles: [':host { display: contents; }'],
@@ -14,4 +25,83 @@ export class SettingsAboutSectionComponent {
readonly isDesktop = input(false); readonly isDesktop = input(false);
readonly version = input<string | undefined>(); readonly version = input<string | undefined>();
readonly updateMessage = 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_IDLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
return 'SETTINGS.APP_UPDATE_ERROR';
default:
return 'SETTINGS.APP_UPDATE_IDLE';
}
});
} }
@@ -105,6 +105,12 @@
[isDesktop]="isDesktop" [isDesktop]="isDesktop"
[version]="version" [version]="version"
[updateMessage]="updateMessage" [updateMessage]="updateMessage"
[appUpdateStatus]="appUpdateStatus()"
(checkForAppUpdate)="checkForAppUpdate()"
(downloadAppUpdate)="downloadAppUpdate()"
(installAppUpdate)="installAppUpdate()"
(openManualAppUpdate)="openManualAppUpdate()"
(openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()"
/> />
<footer class="action-bar" data-test-id="settings-action-bar"> <footer class="action-bar" data-test-id="settings-action-bar">
@@ -203,6 +203,47 @@
justify-content: stretch; 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 { .setting-item--external-player-arguments {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;
@@ -35,6 +35,8 @@ import {
} from '@iptvnator/services'; } from '@iptvnator/services';
import { import {
EmbeddedMpvSupport, EmbeddedMpvSupport,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
Language, Language,
PlaylistMeta, PlaylistMeta,
StartupBehavior, StartupBehavior,
@@ -43,6 +45,7 @@ import {
VideoPlayer, VideoPlayer,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { SettingsComponent } from './settings.component'; import { SettingsComponent } from './settings.component';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util';
@@ -103,6 +106,13 @@ const DEFAULT_SETTINGS = {
preferUploadedEpgOverXtream: false, 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 { class MockSettingsStore {
private _settings = signal(DEFAULT_SETTINGS); private _settings = signal(DEFAULT_SETTINGS);
@@ -153,8 +163,10 @@ interface SettingsSectionScrollDirectiveTestApi {
} }
interface SettingsComponentPrivateTestApi { interface SettingsComponentPrivateTestApi {
loadAppUpdateStatus(): Promise<void>;
matDialog: MatDialog; matDialog: MatDialog;
waitForUiFeedbackFrame(): Promise<void>; waitForUiFeedbackFrame(): Promise<void>;
waitForAppUpdateStatusRetry(): Promise<void>;
} }
describe('SettingsComponent', () => { describe('SettingsComponent', () => {
@@ -300,9 +312,22 @@ describe('SettingsComponent', () => {
fetchEpg: jest.fn().mockResolvedValue({ success: true }), fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }), forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'), getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
getAppUpdateStatus: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
getChannelPrograms: jest.fn().mockResolvedValue([]), getChannelPrograms: jest.fn().mockResolvedValue([]),
getEpgChannelsByRange: jest.fn().mockResolvedValue([]), getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
getLocalIpAddresses: 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(), openInMpv: jest.fn(),
openInVlc: jest.fn(), openInVlc: jest.fn(),
platform: 'linux', platform: 'linux',
@@ -355,6 +380,126 @@ describe('SettingsComponent', () => {
expect(component).toBeTruthy(); 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('retries the initial app update status load when IPC handlers are still starting', async () => {
const retriedStatus: ElectronBridgeAppUpdateStatus = {
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
};
await fixture.whenStable();
(window.electron.getAppUpdateStatus as jest.Mock)
.mockReset()
.mockRejectedValueOnce(new Error('No handler registered'))
.mockResolvedValueOnce(retriedStatus);
jest.spyOn(
privateApi(component),
'waitForAppUpdateStatusRetry'
).mockResolvedValue(undefined);
await privateApi(component).loadAppUpdateStatus();
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(2);
expect(component.appUpdateStatus()).toEqual(retriedStatus);
});
it('waits for the desktop app update bridge method before loading status', async () => {
const retriedStatus: ElectronBridgeAppUpdateStatus = {
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
};
await fixture.whenStable();
delete (window.electron as Partial<typeof window.electron>)
.getAppUpdateStatus;
let retryCount = 0;
const getStatus = jest.fn().mockResolvedValue(retriedStatus);
jest.spyOn(
privateApi(component),
'waitForAppUpdateStatusRetry'
).mockImplementation(async () => {
retryCount += 1;
if (retryCount === 1) {
window.electron.getAppUpdateStatus = getStatus;
}
});
await privateApi(component).loadAppUpdateStatus();
expect(retryCount).toBe(1);
expect(getStatus).toHaveBeenCalledTimes(1);
expect(component.appUpdateStatus()).toEqual(retriedStatus);
});
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', () => { it('should render a compact page header outside dialog mode', () => {
const nativeElement = fixture.nativeElement as HTMLElement; const nativeElement = fixture.nativeElement as HTMLElement;
@@ -37,6 +37,7 @@ import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { import {
EmbeddedMpvSupport, EmbeddedMpvSupport,
CoverSize, CoverSize,
ElectronBridgeAppUpdateStatus,
Language, Language,
StreamFormat, StreamFormat,
Theme, Theme,
@@ -82,6 +83,10 @@ import {
buildSettingsPlaylistDeleteSummary, buildSettingsPlaylistDeleteSummary,
} from './settings-playlist-summary.utils'; } from './settings-playlist-summary.utils';
import { SettingsSnackbarService } from './settings-snackbar.service'; import { SettingsSnackbarService } from './settings-snackbar.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60;
const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250;
@Component({ @Component({
templateUrl: './settings.component.html', templateUrl: './settings.component.html',
@@ -186,6 +191,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Update message to show */ /** Update message to show */
updateMessage = ''; updateMessage = '';
readonly appUpdateStatus = signal<ElectronBridgeAppUpdateStatus | null>(
null
);
/** EPG availability flag */ /** EPG availability flag */
epgAvailable$ = this.store.select(selectIsEpgAvailable); epgAvailable$ = this.store.select(selectIsEpgAvailable);
@@ -228,6 +236,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.playlistDeleteSummary().total > 0 this.playlistDeleteSummary().total > 0
); );
private unsubscribeAppUpdateStatus: (() => void) | null = null;
readonly removeAllProgressLabel = computed(() => { readonly removeAllProgressLabel = computed(() => {
return buildRemoveAllProgressLabel({ return buildRemoveAllProgressLabel({
isRemovingAllPlaylists: this.isRemovingAllPlaylists(), isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
@@ -251,6 +261,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.bindDashboardControlsEnabledState(); this.bindDashboardControlsEnabledState();
void this.loadEmbeddedMpvSupport(); void this.loadEmbeddedMpvSupport();
this.checkAppVersion(); this.checkAppVersion();
this.bindAppUpdateStatusEvents();
void this.loadAppUpdateStatus();
void this.fetchLocalIpAddresses(); void this.fetchLocalIpAddresses();
if (!this.isDialog) { if (!this.isDialog) {
@@ -291,9 +303,114 @@ export class SettingsComponent implements OnInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.unsubscribeAppUpdateStatus?.();
this.unsubscribeAppUpdateStatus = null;
this.settingsCtx.reset(); 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) {
return;
}
let lastError: unknown;
for (
let attempt = 1;
attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS;
attempt += 1
) {
const electron = window.electron;
if (electron?.getAppUpdateStatus) {
try {
this.appUpdateStatus.set(
await electron.getAppUpdateStatus()
);
return;
} catch (error) {
lastError = error;
}
}
if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) {
console.warn(
'Failed to load app update status:',
lastError ??
new Error('Desktop app update bridge is unavailable')
);
return;
}
await this.waitForAppUpdateStatusRetry();
}
}
private async waitForAppUpdateStatusRetry(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS);
});
}
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 * 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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "داكن", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "داكن", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Цёмная", "DARK_THEME": "Цёмная",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.", "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream", "DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.", "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": { "THEMES": {
"DARK_THEME": "Dunkel", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Σκούρο", "DARK_THEME": "Σκούρο",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES": "Recently used sources", "DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "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": "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": { "THEMES": {
"DARK_THEME": "Dark", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Oscuro", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Sombre", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Scuro", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "ダーク", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "어둡게", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Donker", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Ciemny", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Escuro", "DARK_THEME": "Escuro",
+16 -1
View File
@@ -395,7 +395,22 @@
"DASHBOARD_RAIL_SOURCES": "Недавно использованные источники", "DASHBOARD_RAIL_SOURCES": "Недавно использованные источники",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.", "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream", "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": { "THEMES": {
"DARK_THEME": "Тёмная", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "Koyu", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "深色", "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_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "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.",
"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": { "THEMES": {
"DARK_THEME": "深色", "DARK_THEME": "深色",
+40
View File
@@ -54,6 +54,46 @@ Do not add broad protocol allow-lists for renderer navigation. If a new
desktop-only flow needs to open a URL outside IPTVnator, route it through the desktop-only flow needs to open a URL outside IPTVnator, route it through the
default browser unless the app window is deliberately meant to host that URL. default browser unless the app window is deliberately meant to host that URL.
## Desktop Auto Updates
Desktop application updates are owned by the Electron main process. Renderer
code talks only through the typed preload bridge:
- `window.electron.getAppUpdateStatus()`
- `window.electron.checkForAppUpdate()`
- `window.electron.downloadAppUpdate()`
- `window.electron.installAppUpdate()`
- `window.electron.getAppUpdateReleaseNotes(...)`
- `window.electron.onAppUpdateStatusChange(...)`
The backend implementation lives in
`apps/electron-backend/src/app/services/app-update.service.ts` and registers IPC
handlers through `apps/electron-backend/src/app/events/app-update.events.ts`.
The service uses `electron-updater` with `autoDownload = false`, so renderer
actions explicitly ask to download and install. The main process starts one
packaged startup check after IPC registration; it never downloads during that
check. After a successful download, the renderer may show a restart/install
action backed by `quitAndInstall()`.
Self-update is supported only for packaged macOS builds, Windows NSIS builds,
and Linux AppImage builds. Other Linux packages still point users to the latest
GitHub Release through `manualDownloadUrl` because Snap, Flatpak, DEB, RPM, and
Pacman installs have package-manager/store-specific update ownership. Packaged
non-AppImage Linux builds may still check GitHub Releases for a newer stable
version, but their update action remains a browser handoff to GitHub.
Release notes are also fetched by the main process from the public GitHub
Releases API. The updater service caches release pages lazily and filters out
draft/prerelease entries before returning Markdown to the renderer. The Angular
dialog renders that Markdown with the local Markdown renderer and Angular HTML
sanitization; do not bypass sanitizer for release body content.
Release CI must publish both installer artifacts and updater metadata generated
by electron-builder (`latest.yml`, `latest-mac.yml`, `latest-linux*.yml`, and
blockmap files when present). macOS must keep a signed/notarized app bundle,
produce both `dmg` and `zip` targets, and publish a single merged
`latest-mac.yml` containing both x64 and arm64 zip entries.
## Content Security Policy ## Content Security Policy
The Angular shell defines a baseline CSP in `apps/web/src/index.html`. The Angular shell defines a baseline CSP in `apps/web/src/index.html`.
+17 -4
View File
@@ -2,6 +2,13 @@
"appId": "com.fourgray.iptvnator", "appId": "com.fourgray.iptvnator",
"productName": "IPTVnator", "productName": "IPTVnator",
"artifactName": "${name}-${version}-${os}-${arch}.${ext}", "artifactName": "${name}-${version}-${os}-${arch}.${ext}",
"publish": [
{
"provider": "github",
"owner": "4gray",
"repo": "iptvnator"
}
],
"directories": { "directories": {
"buildResources": "dist/apps", "buildResources": "dist/apps",
"output": "dist/executables" "output": "dist/executables"
@@ -34,10 +41,16 @@
}, },
"extraResources": ["dist/apps/electron-backend/workers"], "extraResources": ["dist/apps/electron-backend/workers"],
"mac": { "mac": {
"target": { "target": [
"target": "dmg", {
"arch": ["x64", "arm64"] "target": "dmg",
}, "arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"category": "public.app-category.video", "category": "public.app-category.video",
"forceCodeSigning": true, "forceCodeSigning": true,
"gatekeeperAssess": false, "gatekeeperAssess": false,
@@ -123,6 +123,20 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
export type ElectronBridgeDownloadStatus = export type ElectronBridgeDownloadStatus =
(typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES]; (typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES];
export const ELECTRON_BRIDGE_APP_UPDATE_STATUSES = {
Unsupported: 'unsupported',
Idle: 'idle',
Checking: 'checking',
Available: 'available',
NotAvailable: 'not-available',
Downloading: 'downloading',
Downloaded: 'downloaded',
Error: 'error',
} as const;
export type ElectronBridgeAppUpdateStatusValue =
(typeof ELECTRON_BRIDGE_APP_UPDATE_STATUSES)[keyof typeof ELECTRON_BRIDGE_APP_UPDATE_STATUSES];
export const ELECTRON_BRIDGE_GLOBAL_RECENTLY_ADDED_KINDS = { export const ELECTRON_BRIDGE_GLOBAL_RECENTLY_ADDED_KINDS = {
All: 'all', All: 'all',
Series: 'series', Series: 'series',
@@ -154,6 +168,49 @@ export interface ElectronBridgeWindowState {
isFullScreen: boolean; isFullScreen: boolean;
} }
export interface ElectronBridgeAppUpdateRelease {
version: string;
releaseDate?: string;
releaseName?: string | null;
releaseNotes?: string | null;
}
export interface ElectronBridgeAppUpdateProgress {
bytesPerSecond?: number;
percent: number;
total?: number;
transferred?: number;
}
export interface ElectronBridgeAppUpdateStatus {
status: ElectronBridgeAppUpdateStatusValue;
currentVersion: string;
latestVersion?: string;
release?: ElectronBridgeAppUpdateRelease;
progress?: ElectronBridgeAppUpdateProgress;
error?: string;
supportedSelfUpdate: boolean;
manualDownloadUrl: string;
}
export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
export interface ElectronBridgeAppUpdateReleaseNotesRequest {
version?: string;
direction?: ElectronBridgeAppUpdateReleaseNotesDirection;
}
export interface ElectronBridgeAppUpdateReleaseNotes {
version: string;
tagName: string;
releaseName?: string | null;
publishedAt?: string | null;
bodyMarkdown: string;
htmlUrl: string;
hasPrevious: boolean;
hasNext: boolean;
}
export interface ElectronBridgeAiSettings { export interface ElectronBridgeAiSettings {
aiProvider: string; aiProvider: string;
aiModelName: string; aiModelName: string;
@@ -452,6 +509,16 @@ export interface ElectronBridgeApi {
) => () => void; ) => () => void;
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
platform: string; platform: string;
getAppUpdateStatus: () => Promise<ElectronBridgeAppUpdateStatus>;
checkForAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
downloadAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
installAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
getAppUpdateReleaseNotes: (
request?: ElectronBridgeAppUpdateReleaseNotesRequest
) => Promise<ElectronBridgeAppUpdateReleaseNotes>;
onAppUpdateStatusChange: (
callback: (status: ElectronBridgeAppUpdateStatus) => void
) => () => void;
minimizeWindow: () => Promise<void>; minimizeWindow: () => Promise<void>;
toggleMaximizeWindow: () => Promise<ElectronBridgeWindowState>; toggleMaximizeWindow: () => Promise<ElectronBridgeWindowState>;
closeWindow: () => Promise<void>; closeWindow: () => Promise<void>;
@@ -28,6 +28,14 @@ export const VIEW_ADD_PLAYLIST = 'VIEW:PLAYLISTS';
export const AUTO_UPDATE_PLAYLISTS = 'AUTO_UPDATE'; export const AUTO_UPDATE_PLAYLISTS = 'AUTO_UPDATE';
export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE'; export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE';
// Application updates
export const APP_UPDATE_GET_STATUS = 'APP_UPDATE:GET_STATUS';
export const APP_UPDATE_CHECK = 'APP_UPDATE:CHECK';
export const APP_UPDATE_DOWNLOAD = 'APP_UPDATE:DOWNLOAD';
export const APP_UPDATE_INSTALL = 'APP_UPDATE:INSTALL';
export const APP_UPDATE_GET_RELEASE_NOTES = 'APP_UPDATE:GET_RELEASE_NOTES';
export const APP_UPDATE_STATUS_CHANGED = 'APP_UPDATE:STATUS_CHANGED';
// Experimental // Experimental
export const OPEN_MPV_PLAYER = 'OPEN_MPV_PLAYER'; export const OPEN_MPV_PLAYER = 'OPEN_MPV_PLAYER';
export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH'; export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH';
+2
View File
@@ -95,11 +95,13 @@
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"electron-conf": "1.3.0", "electron-conf": "1.3.0",
"electron-dl": "4.0.0", "electron-dl": "4.0.0",
"electron-updater": "6.8.9",
"epg-parser": "^0.1.6", "epg-parser": "^0.1.6",
"fix-path": "5.0.0", "fix-path": "5.0.0",
"hls.js": "1.6.13", "hls.js": "1.6.13",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
"lodash": "4.18.1", "lodash": "4.18.1",
"marked": "18.0.5",
"mpegts.js": "1.8.0", "mpegts.js": "1.8.0",
"ngx-indexed-db": "21.0.0", "ngx-indexed-db": "21.0.0",
"ngx-skeleton-loader": "11.3.0", "ngx-skeleton-loader": "11.3.0",
+53 -13
View File
@@ -119,6 +119,9 @@ importers:
electron-dl: electron-dl:
specifier: 4.0.0 specifier: 4.0.0
version: 4.0.0 version: 4.0.0
electron-updater:
specifier: 6.8.9
version: 6.8.9
epg-parser: epg-parser:
specifier: ^0.1.6 specifier: ^0.1.6
version: 0.1.6 version: 0.1.6
@@ -134,6 +137,9 @@ importers:
lodash: lodash:
specifier: 4.18.1 specifier: 4.18.1
version: 4.18.1 version: 4.18.1
marked:
specifier: 18.0.5
version: 18.0.5
mpegts.js: mpegts.js:
specifier: 1.8.0 specifier: 1.8.0
version: 1.8.0 version: 1.8.0
@@ -5501,6 +5507,10 @@ packages:
resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==} resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
builder-util-runtime@9.7.0:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
builder-util@26.0.11: builder-util@26.0.11:
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==} resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
@@ -6520,6 +6530,9 @@ packages:
electron-to-chromium@1.5.353: electron-to-chromium@1.5.353:
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
electron-updater@6.8.9:
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
electron-winstaller@5.4.0: electron-winstaller@5.4.0:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -8268,9 +8281,6 @@ packages:
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jsonparse@1.3.1: jsonparse@1.3.1:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
engines: {'0': node >= 0.2.0} engines: {'0': node >= 0.2.0}
@@ -8402,6 +8412,10 @@ packages:
lodash.escaperegexp@4.1.2: lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6: lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -8527,6 +8541,11 @@ packages:
engines: {node: '>= 18'} engines: {node: '>= 18'}
hasBin: true hasBin: true
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
material-design-icons-iconfont@6.7.0: material-design-icons-iconfont@6.7.0:
resolution: {integrity: sha512-lSj71DgVv20kO0kGbs42icDzbRot61gEDBLQACzkUuznRQBUYmbxzEkGU6dNBb5fRWHMaScYlAXX96HQ4/cJWA==} resolution: {integrity: sha512-lSj71DgVv20kO0kGbs42icDzbRot61gEDBLQACzkUuznRQBUYmbxzEkGU6dNBb5fRWHMaScYlAXX96HQ4/cJWA==}
@@ -11143,6 +11162,9 @@ packages:
tiny-inflate@1.0.3: tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinyexec@1.0.2: tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -13787,7 +13809,7 @@ snapshots:
'@malept/cross-spawn-promise': 2.0.0 '@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3 debug: 4.4.3
dir-compare: 4.2.0 dir-compare: 4.2.0
fs-extra: 11.3.3 fs-extra: 11.3.5
minimatch: 9.0.5 minimatch: 9.0.5
plist: 3.1.0 plist: 3.1.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -18089,6 +18111,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
builder-util-runtime@9.7.0:
dependencies:
debug: 4.4.3
sax: 1.6.0
transitivePeerDependencies:
- supports-color
builder-util@26.0.11: builder-util@26.0.11:
dependencies: dependencies:
7zip-bin: 5.2.0 7zip-bin: 5.2.0
@@ -19084,6 +19113,19 @@ snapshots:
electron-to-chromium@1.5.353: {} electron-to-chromium@1.5.353: {}
electron-updater@6.8.9:
dependencies:
builder-util-runtime: 9.7.0
fs-extra: 10.1.0
js-yaml: 4.1.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.3
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
electron-winstaller@5.4.0: electron-winstaller@5.4.0:
dependencies: dependencies:
'@electron/asar': 3.4.1 '@electron/asar': 3.4.1
@@ -20002,9 +20044,8 @@ snapshots:
fs-extra@11.3.5: fs-extra@11.3.5:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonfile: 6.2.1 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
optional: true
fs-extra@7.0.1: fs-extra@7.0.1:
dependencies: dependencies:
@@ -21471,13 +21512,6 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
optional: true
jsonparse@1.3.1: {} jsonparse@1.3.1: {}
keycode@2.2.1: {} keycode@2.2.1: {}
@@ -21633,6 +21667,8 @@ snapshots:
lodash.escaperegexp@4.1.2: {} lodash.escaperegexp@4.1.2: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {} lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {} lodash.isstring@4.0.1: {}
@@ -21796,6 +21832,8 @@ snapshots:
marked@15.0.12: {} marked@15.0.12: {}
marked@18.0.5: {}
material-design-icons-iconfont@6.7.0: {} material-design-icons-iconfont@6.7.0: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -25043,6 +25081,8 @@ snapshots:
tiny-inflate@1.0.3: {} tiny-inflate@1.0.3: {}
tiny-typed-emitter@2.1.0: {}
tinyexec@1.0.2: {} tinyexec@1.0.2: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
@@ -114,6 +114,68 @@ test('Linux package identity does not expose the internal Electron backend proje
); );
}); });
test('GitHub Releases auto-update metadata is generated and uploaded', () => {
const releaseFiles = buildAndMakeWorkflow.match(
/files: \|\n([\s\S]*?)\n\s+env:/
)?.[1];
assert.ok(releaseFiles, 'release upload files block must exist');
assert.ok(
packageMetadata.dependencies?.['electron-updater'],
'electron-updater must be a runtime dependency because the packaged app imports it'
);
assert.deepEqual(electronBuilderConfig.publish, [
{
provider: 'github',
owner: '4gray',
repo: 'iptvnator',
},
]);
assert.deepEqual(electronBuilderConfig.mac?.target, [
{
target: 'dmg',
arch: ['x64', 'arm64'],
},
{
target: 'zip',
arch: ['x64', 'arm64'],
},
]);
assert.match(buildAndMakeWorkflow, /dist\/executables\/\*\*\/latest\.yml/);
assert.match(
buildAndMakeWorkflow,
/dist\/executables\/\*\*\/latest-mac\.yml/
);
assert.match(
buildAndMakeWorkflow,
/dist\/executables\/\*\*\/latest-linux\*\.yml/
);
assert.match(buildAndMakeWorkflow, /dist\/executables\/\*\*\/\*\.blockmap/);
assert.match(buildAndMakeWorkflow, /Merge macOS updater metadata/);
assert.match(buildAndMakeWorkflow, /artifacts\/latest-mac\.yml/);
assert.doesNotMatch(
releaseFiles,
/artifacts\/macos-(?:x64|arm64)-artifacts\/latest-mac\.yml/
);
assert.match(
buildAndMakeWorkflow,
/artifacts\/linux-artifacts\/latest-linux\*\.yml/
);
assert.match(
buildAndMakeWorkflow,
/artifacts\/windows-artifacts\/latest\.yml/
);
const makeCommands = [
...buildAndMakeWorkflow.matchAll(/run: (pnpm run make:app[^\n]*)/g),
].map((match) => match[1].trim());
assert.ok(makeCommands.length >= 2, 'workflow must package Electron apps');
assert.deepEqual(
[...new Set(makeCommands)],
['pnpm run make:app -- --publishPolicy=never']
);
});
test('generated Electron package metadata mirrors the root package identity', async () => { test('generated Electron package metadata mirrors the root package identity', async () => {
const { buildElectronBuilderMetadata, buildElectronPackageMetadata } = const { buildElectronBuilderMetadata, buildElectronPackageMetadata } =
await import('./generate-electron-builder-metadata.mjs'); await import('./generate-electron-builder-metadata.mjs');
+3
View File
@@ -84,6 +84,9 @@
"@iptvnator/shared/interfaces": [ "@iptvnator/shared/interfaces": [
"libs/shared/interfaces/src/index.ts" "libs/shared/interfaces/src/index.ts"
], ],
"@iptvnator/shared/interfaces/ipc-commands": [
"libs/shared/interfaces/src/lib/ipc-commands.ts"
],
"@iptvnator/ui/shared-portals": [ "@iptvnator/ui/shared-portals": [
"libs/ui/shared-portals/src/index.ts" "libs/ui/shared-portals/src/index.ts"
], ],