mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
3cb228d931
commit
b1119189e2
54 files changed
+3569
-134
No files matched your search
@@ -360,7 +360,12 @@ jobs:
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
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));
|
||||
"
|
||||
|
||||
@@ -471,7 +476,7 @@ jobs:
|
||||
# TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
|
||||
# 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' }}
|
||||
run: pnpm run make:app
|
||||
run: pnpm run make:app -- --publishPolicy=never
|
||||
|
||||
- name: Verify signed macOS app
|
||||
if: matrix.os == 'macos' && github.event_name != 'pull_request'
|
||||
@@ -530,7 +535,7 @@ jobs:
|
||||
# TEMPORARY PR TEST: change this back to '0' after manually
|
||||
# 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' }}
|
||||
run: pnpm run make:app
|
||||
run: pnpm run make:app -- --publishPolicy=never
|
||||
|
||||
- name: Verify packaged worker layout
|
||||
shell: bash
|
||||
@@ -588,6 +593,8 @@ jobs:
|
||||
path: |
|
||||
dist/executables/**/*.dmg
|
||||
dist/executables/**/*.zip
|
||||
dist/executables/**/latest-mac.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts (Linux)
|
||||
@@ -602,6 +609,8 @@ jobs:
|
||||
dist/executables/**/*.AppImage
|
||||
dist/executables/**/*.tar.gz
|
||||
dist/executables/**/*.pacman
|
||||
dist/executables/**/latest-linux*.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts (Flatpak)
|
||||
@@ -622,6 +631,8 @@ jobs:
|
||||
dist/executables/**/*.exe
|
||||
dist/executables/**/*.msi
|
||||
dist/executables/**/*.zip
|
||||
dist/executables/**/latest.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
create-release:
|
||||
@@ -644,6 +655,119 @@ jobs:
|
||||
- name: Display structure of downloaded files
|
||||
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
|
||||
id: package-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
@@ -659,18 +783,25 @@ jobs:
|
||||
files: |
|
||||
artifacts/macos-x64-artifacts/*-x64.dmg
|
||||
artifacts/macos-x64-artifacts/*-x64.zip
|
||||
artifacts/macos-x64-artifacts/*.blockmap
|
||||
artifacts/macos-arm64-artifacts/*-arm64.dmg
|
||||
artifacts/macos-arm64-artifacts/*-arm64.zip
|
||||
artifacts/macos-arm64-artifacts/*.blockmap
|
||||
artifacts/latest-mac.yml
|
||||
artifacts/linux-artifacts/*.AppImage
|
||||
artifacts/linux-artifacts/*.deb
|
||||
artifacts/linux-artifacts/*.rpm
|
||||
artifacts/linux-artifacts/*.snap
|
||||
artifacts/linux-artifacts/*.tar.gz
|
||||
artifacts/linux-artifacts/*.pacman
|
||||
artifacts/linux-artifacts/latest-linux*.yml
|
||||
artifacts/linux-artifacts/*.blockmap
|
||||
artifacts/linux-flatpak-artifacts/*.flatpak
|
||||
artifacts/windows-artifacts/*-setup.exe
|
||||
artifacts/windows-artifacts/*.msi
|
||||
artifacts/windows-artifacts/*.zip
|
||||
artifacts/windows-artifacts/latest.yml
|
||||
artifacts/windows-artifacts/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ jobs:
|
||||
electron-e2e-tests:
|
||||
name: Electron E2E on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1'
|
||||
NX_SKIP_NX_CACHE: true
|
||||
|
||||
@@ -40,6 +40,10 @@ export const defaultStalkerPortalName = 'Mock Stalker Portal';
|
||||
export const defaultXtreamUsername = 'user1';
|
||||
export const defaultXtreamPassword = 'pass1';
|
||||
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';
|
||||
|
||||
@@ -153,6 +157,7 @@ export async function launchElectronApp(
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
});
|
||||
attachElectronProcessDiagnostics(electronApp);
|
||||
|
||||
const mainWindow = await findMainWindow(electronApp);
|
||||
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(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
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) {
|
||||
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 {
|
||||
if (!existsSync(packagedRendererIndexPath)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -41,6 +41,30 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
`;
|
||||
|
||||
test.describe('Electron Settings', () => {
|
||||
test('@settings @electron shows manual app update fallback when self-update is unavailable', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
|
||||
await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible();
|
||||
await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('app-update-open-release')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('app-update-download')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('app-update-install')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @electron gates external MPV playback behind double-click when enabled', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -2,6 +2,14 @@ import {
|
||||
DB_WORKER_OPERATIONS,
|
||||
DbWorkerOperation,
|
||||
} from '../workers/database-worker.types';
|
||||
import {
|
||||
APP_UPDATE_CHECK,
|
||||
APP_UPDATE_DOWNLOAD,
|
||||
APP_UPDATE_GET_RELEASE_NOTES,
|
||||
APP_UPDATE_GET_STATUS,
|
||||
APP_UPDATE_INSTALL,
|
||||
APP_UPDATE_STATUS_CHANGED,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
dbPreloadCases,
|
||||
@@ -130,6 +138,58 @@ describe('main preload DB IPC contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes app update commands and status events through the typed bridge', async () => {
|
||||
const api = getExposedApi();
|
||||
const callback = jest.fn();
|
||||
const status = {
|
||||
currentVersion: '0.22.0',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: 'available',
|
||||
supportedSelfUpdate: true,
|
||||
};
|
||||
|
||||
await api.getAppUpdateStatus();
|
||||
await api.checkForAppUpdate();
|
||||
await api.downloadAppUpdate();
|
||||
await api.installAppUpdate();
|
||||
await api.getAppUpdateReleaseNotes({
|
||||
direction: 'previous',
|
||||
version: 'v0.23.0',
|
||||
});
|
||||
const unsubscribe = api.onAppUpdateStatusChange(callback);
|
||||
const handler = mockIpcRenderer.on.mock.calls.at(-1)?.[1];
|
||||
|
||||
handler({}, status);
|
||||
unsubscribe();
|
||||
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
|
||||
APP_UPDATE_GET_STATUS
|
||||
);
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(APP_UPDATE_CHECK);
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
|
||||
APP_UPDATE_DOWNLOAD
|
||||
);
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(APP_UPDATE_INSTALL);
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
|
||||
APP_UPDATE_GET_RELEASE_NOTES,
|
||||
{
|
||||
direction: 'previous',
|
||||
version: 'v0.23.0',
|
||||
}
|
||||
);
|
||||
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
|
||||
APP_UPDATE_STATUS_CHANGED,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith(status);
|
||||
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
|
||||
APP_UPDATE_STATUS_CHANGED,
|
||||
handler
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards request-scoped DB operation events and unregisters the listener', () => {
|
||||
const api = getExposedApi();
|
||||
const callback = jest.fn();
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
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 {
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvRecordingStartOptions,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
ElectronBridgeApi,
|
||||
ElectronBridgeAppUpdateReleaseNotesRequest,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
ElectronBridgeDbOperationEvent,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
ElectronBridgeEpgLookupOptions,
|
||||
@@ -292,6 +302,23 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
platform: process.platform,
|
||||
getAppUpdateStatus: () => ipcRenderer.invoke(APP_UPDATE_GET_STATUS),
|
||||
checkForAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_CHECK),
|
||||
downloadAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_DOWNLOAD),
|
||||
installAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_INSTALL),
|
||||
getAppUpdateReleaseNotes: (
|
||||
request?: ElectronBridgeAppUpdateReleaseNotesRequest
|
||||
) => ipcRenderer.invoke(APP_UPDATE_GET_RELEASE_NOTES, request),
|
||||
onAppUpdateStatusChange: (
|
||||
callback: (status: ElectronBridgeAppUpdateStatus) => void
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
status: ElectronBridgeAppUpdateStatus
|
||||
) => callback(status);
|
||||
ipcRenderer.on(APP_UPDATE_STATUS_CHANGED, handler);
|
||||
return () => ipcRenderer.off(APP_UPDATE_STATUS_CHANGED, handler);
|
||||
},
|
||||
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
|
||||
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
|
||||
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
|
||||
@@ -4,6 +4,8 @@ jest.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: jest.fn(() => '/tmp'),
|
||||
isPackaged: false,
|
||||
isReady: jest.fn(() => false),
|
||||
on: jest.fn(),
|
||||
},
|
||||
BrowserWindow: jest.fn(),
|
||||
Menu: {
|
||||
@@ -37,28 +39,49 @@ import {
|
||||
isTrustedRendererNavigationUrl,
|
||||
} 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 = {
|
||||
center: jest.Mock<void, []>;
|
||||
getNormalBounds: jest.Mock<object, []>;
|
||||
loadFile: 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: {
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
openDevTools: jest.Mock<void, []>;
|
||||
setWindowOpenHandler: jest.Mock<void, [unknown]>;
|
||||
};
|
||||
};
|
||||
|
||||
function createMockMainWindow(): MockMainWindow {
|
||||
return {
|
||||
center: jest.fn<void, []>(),
|
||||
getNormalBounds: jest.fn<object, []>().mockReturnValue({}),
|
||||
loadFile: 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: {
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
openDevTools: jest.fn<void, []>(),
|
||||
setWindowOpenHandler: jest.fn<void, [unknown]>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AppInternals = {
|
||||
mainWindow: MockMainWindow;
|
||||
loadedMainWindow: MockMainWindow | null;
|
||||
mainWindow: MockMainWindow | null;
|
||||
mainWindowLoadPromise: Promise<void> | null;
|
||||
onReady: () => void;
|
||||
rendererLoadingEnabled: boolean;
|
||||
loadMainWindow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -70,7 +93,16 @@ describe('Electron app security helpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
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;
|
||||
(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', () => {
|
||||
@@ -175,6 +207,47 @@ describe('Electron app security helpers', () => {
|
||||
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 () => {
|
||||
await clearElectronServiceWorkerStorage();
|
||||
|
||||
|
||||
@@ -198,9 +198,12 @@ function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
|
||||
export default class App {
|
||||
// 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.
|
||||
static mainWindow: Electron.BrowserWindow;
|
||||
static mainWindow: Electron.BrowserWindow | null = null;
|
||||
static application: Electron.App;
|
||||
static BrowserWindow;
|
||||
private static loadedMainWindow: Electron.BrowserWindow | null = null;
|
||||
private static mainWindowLoadPromise: Promise<void> | null = null;
|
||||
private static rendererLoadingEnabled = false;
|
||||
|
||||
private static shouldOpenDevTools() {
|
||||
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
|
||||
@@ -230,15 +233,21 @@ export default class App {
|
||||
App.mainWindow = null;
|
||||
}
|
||||
|
||||
private static startMainWindowLoad(): void {
|
||||
void App.loadMainWindow().catch((error) => {
|
||||
console.error('Failed to load main window:', error);
|
||||
});
|
||||
}
|
||||
|
||||
private static onReady() {
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
if (rendererAppName) {
|
||||
App.initMainWindow();
|
||||
void App.loadMainWindow().catch((error) => {
|
||||
console.error('Failed to load main window:', error);
|
||||
});
|
||||
if (App.rendererLoadingEnabled) {
|
||||
App.startMainWindowLoad();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +257,9 @@ export default class App {
|
||||
if (App.mainWindow === null) {
|
||||
App.onReady();
|
||||
}
|
||||
if (App.rendererLoadingEnabled) {
|
||||
App.startMainWindowLoad();
|
||||
}
|
||||
}
|
||||
|
||||
private static handleRendererNavigation(
|
||||
@@ -363,6 +375,8 @@ export default class App {
|
||||
// in an array if your app supports multi windows, this is the time
|
||||
// when you should delete the corresponding element.
|
||||
App.mainWindow = null;
|
||||
App.loadedMainWindow = null;
|
||||
App.mainWindowLoadPromise = null;
|
||||
});
|
||||
|
||||
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.
|
||||
if (App.isDevelopmentMode()) {
|
||||
const loadPromise = App.mainWindow.loadURL(
|
||||
const loadPromise = mainWindow.loadURL(
|
||||
`http://localhost:${rendererAppPort}`
|
||||
);
|
||||
if (App.shouldOpenDevTools()) {
|
||||
App.mainWindow.webContents.openDevTools();
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
await loadPromise;
|
||||
} else {
|
||||
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) {
|
||||
// we pass the Electron.App object and the
|
||||
// Electron.BrowserWindow into this function
|
||||
@@ -435,7 +476,11 @@ export default class App {
|
||||
App.application = app;
|
||||
|
||||
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('before-quit', () => {
|
||||
if (App.mainWindow)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const rendererAppPort = 4200;
|
||||
export const rendererAppName = 'web'; // options.name.split('-')[0] + '-web'
|
||||
export const electronAppName = 'iptvnator';
|
||||
export const updateServerUrl = 'https://deployment-server-url.com'; // TODO: insert your update server url here
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
APP_UPDATE_CHECK,
|
||||
APP_UPDATE_DOWNLOAD,
|
||||
APP_UPDATE_GET_RELEASE_NOTES,
|
||||
APP_UPDATE_GET_STATUS,
|
||||
APP_UPDATE_INSTALL,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const mockHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(
|
||||
(channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
mockHandlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AppUpdateEvents', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockHandlers.clear();
|
||||
});
|
||||
|
||||
it('registers updater IPC handlers against the provided service', async () => {
|
||||
const status: ElectronBridgeAppUpdateStatus = {
|
||||
currentVersion: '0.22.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
};
|
||||
const service = {
|
||||
checkForUpdates: jest.fn().mockResolvedValue({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
|
||||
}),
|
||||
downloadUpdate: jest.fn().mockResolvedValue({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
|
||||
}),
|
||||
getStatus: jest.fn(() => status),
|
||||
getReleaseNotes: jest.fn().mockResolvedValue({
|
||||
bodyMarkdown: '## Release notes',
|
||||
hasNext: false,
|
||||
hasPrevious: true,
|
||||
htmlUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/tag/v0.23.0',
|
||||
publishedAt: '2026-06-28T00:00:00.000Z',
|
||||
releaseName: 'v0.23.0',
|
||||
tagName: 'v0.23.0',
|
||||
version: '0.23.0',
|
||||
}),
|
||||
installUpdate: jest.fn(() => ({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
|
||||
})),
|
||||
};
|
||||
const { default: AppUpdateEvents } = await import('./app-update.events');
|
||||
|
||||
AppUpdateEvents.bootstrapAppUpdateEvents(service);
|
||||
|
||||
expect([...mockHandlers.keys()].sort()).toEqual([
|
||||
APP_UPDATE_CHECK,
|
||||
APP_UPDATE_DOWNLOAD,
|
||||
APP_UPDATE_GET_RELEASE_NOTES,
|
||||
APP_UPDATE_GET_STATUS,
|
||||
APP_UPDATE_INSTALL,
|
||||
]);
|
||||
expect(await mockHandlers.get(APP_UPDATE_GET_STATUS)?.()).toEqual({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
});
|
||||
expect(await mockHandlers.get(APP_UPDATE_CHECK)?.()).toEqual({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
|
||||
});
|
||||
expect(await mockHandlers.get(APP_UPDATE_DOWNLOAD)?.()).toEqual({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
|
||||
});
|
||||
expect(await mockHandlers.get(APP_UPDATE_INSTALL)?.()).toEqual({
|
||||
...status,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
|
||||
});
|
||||
expect(
|
||||
await mockHandlers.get(APP_UPDATE_GET_RELEASE_NOTES)?.(undefined, {
|
||||
version: '0.23.0',
|
||||
})
|
||||
).toMatchObject({
|
||||
bodyMarkdown: '## Release notes',
|
||||
tagName: 'v0.23.0',
|
||||
});
|
||||
expect(service.getReleaseNotes).toHaveBeenCalledWith({
|
||||
version: '0.23.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
APP_UPDATE_CHECK,
|
||||
APP_UPDATE_DOWNLOAD,
|
||||
APP_UPDATE_GET_RELEASE_NOTES,
|
||||
APP_UPDATE_GET_STATUS,
|
||||
APP_UPDATE_INSTALL,
|
||||
ElectronBridgeAppUpdateReleaseNotes,
|
||||
ElectronBridgeAppUpdateReleaseNotesRequest,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateService } from '../services/app-update.service';
|
||||
|
||||
export interface AppUpdateServiceContract {
|
||||
getStatus(): ElectronBridgeAppUpdateStatus;
|
||||
checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus>;
|
||||
downloadUpdate(): Promise<ElectronBridgeAppUpdateStatus>;
|
||||
installUpdate(): ElectronBridgeAppUpdateStatus;
|
||||
getReleaseNotes(
|
||||
request?: ElectronBridgeAppUpdateReleaseNotesRequest
|
||||
): Promise<ElectronBridgeAppUpdateReleaseNotes>;
|
||||
}
|
||||
|
||||
export default class AppUpdateEvents {
|
||||
static bootstrapAppUpdateEvents(
|
||||
service: AppUpdateServiceContract | AppUpdateService
|
||||
): Electron.IpcMain {
|
||||
ipcMain.handle(APP_UPDATE_GET_STATUS, () => service.getStatus());
|
||||
ipcMain.handle(APP_UPDATE_CHECK, () => service.checkForUpdates());
|
||||
ipcMain.handle(APP_UPDATE_DOWNLOAD, () => service.downloadUpdate());
|
||||
ipcMain.handle(APP_UPDATE_INSTALL, () => service.installUpdate());
|
||||
ipcMain.handle(APP_UPDATE_GET_RELEASE_NOTES, (_event, request) =>
|
||||
service.getReleaseNotes(
|
||||
request as ElectronBridgeAppUpdateReleaseNotesRequest
|
||||
)
|
||||
);
|
||||
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { app, autoUpdater, dialog, MessageBoxOptions } from 'electron';
|
||||
import { platform, arch } from 'os';
|
||||
import { updateServerUrl } from '../constants';
|
||||
import App from '../app';
|
||||
|
||||
export default class UpdateEvents {
|
||||
// initialize auto update service - most be invoked only in production
|
||||
static initAutoUpdateService() {
|
||||
const platform_arch =
|
||||
platform() === 'win32' ? platform() : platform() + '_' + arch();
|
||||
const version = app.getVersion();
|
||||
const feed: Electron.FeedURLOptions = {
|
||||
url: `${updateServerUrl}/update/${platform_arch}/${version}`,
|
||||
};
|
||||
|
||||
if (!App.isDevelopmentMode()) {
|
||||
console.log('Initializing auto update service...\n');
|
||||
|
||||
autoUpdater.setFeedURL(feed);
|
||||
UpdateEvents.checkForUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
// check for updates - most be invoked after initAutoUpdateService() and only in production
|
||||
static checkForUpdates() {
|
||||
if (!App.isDevelopmentMode() && autoUpdater.getFeedURL() !== '') {
|
||||
autoUpdater.checkForUpdates();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autoUpdater.on(
|
||||
'update-downloaded',
|
||||
(event, releaseNotes, releaseName, releaseDate) => {
|
||||
const dialogOpts: MessageBoxOptions = {
|
||||
type: 'info' as const,
|
||||
buttons: ['Restart', 'Later'],
|
||||
title: 'Application Update',
|
||||
message: process.platform === 'win32' ? releaseNotes : releaseName,
|
||||
detail:
|
||||
'A new version has been downloaded. Restart the application to apply the updates.',
|
||||
};
|
||||
|
||||
dialog.showMessageBox(dialogOpts).then((returnValue) => {
|
||||
if (returnValue.response === 0) autoUpdater.quitAndInstall();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
console.log('Checking for updates...\n');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
console.log('New update available!\n');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('Up to date!\n');
|
||||
});
|
||||
|
||||
autoUpdater.on('before-quit-for-update', () => {
|
||||
console.log('Application update is about to begin...\n');
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (message) => {
|
||||
console.error('There was a problem updating the application');
|
||||
console.error(message, '\n');
|
||||
});
|
||||
@@ -0,0 +1,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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { getElectronUserDataPath } from '@iptvnator/shared/database';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import fixPath from 'fix-path';
|
||||
import App from './app/app';
|
||||
import { initDatabase } from './app/database/connection';
|
||||
@@ -13,6 +14,7 @@ import EmbeddedMpvEvents, {
|
||||
shutdownEmbeddedMpv,
|
||||
} from './app/events/embedded-mpv.events';
|
||||
import EpgEvents from './app/events/epg.events';
|
||||
import AppUpdateEvents from './app/events/app-update.events';
|
||||
import { shutdownMpvSession } from './app/events/mpv-session.service';
|
||||
import PlayerEvents from './app/events/player.events';
|
||||
import { shutdownVlcSession } from './app/events/vlc-session.service';
|
||||
@@ -23,6 +25,7 @@ import SharedEvents from './app/events/shared.events';
|
||||
import SquirrelEvents from './app/events/squirrel.events';
|
||||
import StalkerEvents from './app/events/stalker.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import { AppUpdateService } from './app/services/app-update.service';
|
||||
import { databaseWorkerClient } from './app/services/database-worker-client';
|
||||
import WindowEvents from './app/events/window.events';
|
||||
import XtreamEvents from './app/events/xtream.events';
|
||||
@@ -83,12 +86,12 @@ export default class Main {
|
||||
trace('startup', 'bootstrap-events:start');
|
||||
}
|
||||
|
||||
// Initialize database before other events
|
||||
await initDatabase();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'init-database:done');
|
||||
}
|
||||
const appUpdateService = new AppUpdateService({
|
||||
app,
|
||||
getMainWindow: () => App.mainWindow,
|
||||
updater: () => autoUpdater,
|
||||
});
|
||||
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
|
||||
|
||||
ElectronEvents.bootstrapElectronEvents();
|
||||
WindowEvents.bootstrapWindowEvents();
|
||||
@@ -107,17 +110,29 @@ export default class Main {
|
||||
if (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();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'reset-stale-downloads:done');
|
||||
}
|
||||
|
||||
// initialize auto updater service
|
||||
if (!App.isDevelopmentMode()) {
|
||||
// UpdateEvents.initAutoUpdateService();
|
||||
}
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-events:done');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,6 +2,8 @@
|
||||
|
||||
<app-epg-progress-panel />
|
||||
|
||||
<app-update-notification-panel />
|
||||
|
||||
@if (usesCustomWindowControls) {
|
||||
<app-window-controls />
|
||||
}
|
||||
@@ -28,13 +28,19 @@ import {
|
||||
createDevLogger,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
|
||||
const debugAppComponent = createDevLogger('AppComponent');
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent],
|
||||
imports: [
|
||||
AppUpdateNotificationPanelComponent,
|
||||
EpgProgressPanelComponent,
|
||||
RouterOutlet,
|
||||
WindowControlsComponent,
|
||||
],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
@HostBinding('class.macos-platform') get isMacOS() {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
|
||||
|
||||
const releaseNotes = {
|
||||
bodyMarkdown: '## v0.23.0\n\n- Added **desktop updater**',
|
||||
hasNext: false,
|
||||
hasPrevious: true,
|
||||
htmlUrl: 'https://github.com/4gray/iptvnator/releases/tag/v0.23.0',
|
||||
publishedAt: '2026-06-28T00:00:00.000Z',
|
||||
releaseName: 'v0.23.0',
|
||||
tagName: 'v0.23.0',
|
||||
version: '0.23.0',
|
||||
};
|
||||
|
||||
describe('AppUpdateReleaseNotesDialogComponent', () => {
|
||||
let fixture: ComponentFixture<AppUpdateReleaseNotesDialogComponent>;
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = {
|
||||
getAppUpdateReleaseNotes: jest.fn().mockResolvedValue(releaseNotes),
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AppUpdateReleaseNotesDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: { initialVersion: '0.23.0' },
|
||||
},
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: { close: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppUpdateReleaseNotesDialogComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('loads and renders markdown release notes for the initial version', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenCalledWith({
|
||||
version: '0.23.0',
|
||||
});
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-test-id="release-notes-body"] h2')
|
||||
?.textContent
|
||||
).toContain('v0.23.0');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="release-notes-body"] strong'
|
||||
)?.textContent
|
||||
).toContain('desktop updater');
|
||||
});
|
||||
|
||||
it('loads previous notes lazily without closing the dialog', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
window.electron.getAppUpdateReleaseNotes as jest.Mock
|
||||
).mockResolvedValueOnce({
|
||||
...releaseNotes,
|
||||
bodyMarkdown: '## v0.22.0\n\nOlder release',
|
||||
hasNext: true,
|
||||
hasPrevious: false,
|
||||
tagName: 'v0.22.0',
|
||||
version: '0.22.0',
|
||||
});
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="release-notes-previous"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenLastCalledWith({
|
||||
direction: 'previous',
|
||||
version: 'v0.23.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
SecurityContext,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { marked } from 'marked';
|
||||
import {
|
||||
ElectronBridgeAppUpdateReleaseNotes,
|
||||
ElectronBridgeAppUpdateReleaseNotesDirection,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface AppUpdateReleaseNotesDialogData {
|
||||
initialVersion?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-update-release-notes-dialog',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
DatePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="release-notes-dialog mat-typography">
|
||||
<div class="release-notes-dialog__toolbar">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="loading() || !notes()?.hasPrevious"
|
||||
(click)="load('previous')"
|
||||
data-test-id="release-notes-previous"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
|
||||
<div class="release-notes-dialog__version">
|
||||
<strong>{{ notes()?.releaseName || notes()?.tagName }}</strong>
|
||||
@if (notes()?.publishedAt; as publishedAt) {
|
||||
<span>{{ publishedAt | date: 'mediumDate' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="loading() || !notes()?.hasNext"
|
||||
(click)="load('next')"
|
||||
data-test-id="release-notes-next"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="release-notes-dialog__loading">
|
||||
<mat-spinner diameter="28" />
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<p class="release-notes-dialog__error">{{ error() }}</p>
|
||||
} @else {
|
||||
<article
|
||||
class="release-notes-dialog__body"
|
||||
data-test-id="release-notes-body"
|
||||
[innerHTML]="renderedMarkdown()"
|
||||
></article>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
@if (notes()?.htmlUrl; as htmlUrl) {
|
||||
<button mat-button type="button" (click)="openRelease(htmlUrl)">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button mat-flat-button type="button" (click)="close()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.release-notes-dialog {
|
||||
min-width: min(640px, calc(100vw - 64px));
|
||||
max-width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.release-notes-dialog__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0 12px;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.release-notes-dialog__version {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.release-notes-dialog__version strong,
|
||||
.release-notes-dialog__version span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-notes-dialog__version span {
|
||||
margin-top: 2px;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.release-notes-dialog__error {
|
||||
color: var(--app-error-color, #ef4444);
|
||||
}
|
||||
|
||||
.release-notes-dialog__body {
|
||||
max-height: min(58vh, 560px);
|
||||
overflow: auto;
|
||||
padding: 12px 2px 0;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h1,
|
||||
.release-notes-dialog__body h2,
|
||||
.release-notes-dialog__body h3 {
|
||||
color: var(--app-heading-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
background: var(--app-hover-overlay);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AppUpdateReleaseNotesDialogComponent implements OnInit {
|
||||
private readonly data = inject<AppUpdateReleaseNotesDialogData>(
|
||||
MAT_DIALOG_DATA
|
||||
);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<AppUpdateReleaseNotesDialogComponent>
|
||||
);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly notes = signal<ElectronBridgeAppUpdateReleaseNotes | null>(null);
|
||||
readonly renderedMarkdown = computed(() => {
|
||||
const markdown = this.notes()?.bodyMarkdown ?? '';
|
||||
const html = marked.parse(markdown, { async: false }) as string;
|
||||
|
||||
return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load(
|
||||
direction?: ElectronBridgeAppUpdateReleaseNotesDirection
|
||||
): Promise<void> {
|
||||
if (!window.electron?.getAppUpdateReleaseNotes) {
|
||||
this.error.set('Release notes are not available in this build.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = direction
|
||||
? this.notes()?.tagName
|
||||
: this.data.initialVersion;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
this.notes.set(
|
||||
await window.electron.getAppUpdateReleaseNotes({
|
||||
direction,
|
||||
version,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
this.error.set(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
openRelease(url: string): void {
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,99 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div class="setting-item setting-item--full-control app-update-panel">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control app-update-panel__control">
|
||||
<div class="app-update-status" data-test-id="app-update-status">
|
||||
<strong>
|
||||
{{
|
||||
appUpdateStatusLabelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
updateStatus.latestVersion ||
|
||||
updateStatus.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (updateStatus.error) {
|
||||
<span>{{ updateStatus.error }}</span>
|
||||
}
|
||||
@if (updateStatus.progress; as progress) {
|
||||
<progress
|
||||
[value]="progress.percent"
|
||||
max="100"
|
||||
data-test-id="app-update-progress"
|
||||
></progress>
|
||||
}
|
||||
</div>
|
||||
<div class="app-update-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="checkForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy()"
|
||||
data-test-id="app-update-check"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
|
||||
</button>
|
||||
@if (canDownloadAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="downloadAppUpdate.emit()"
|
||||
data-test-id="app-update-download"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenAppUpdateReleaseNotes()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openAppUpdateReleaseNotes.emit()"
|
||||
data-test-id="app-update-release-notes"
|
||||
>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_RELEASE_NOTES'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
@if (canInstallAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installAppUpdate.emit()"
|
||||
data-test-id="app-update-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenManualAppUpdate()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openManualAppUpdate.emit()"
|
||||
data-test-id="app-update-open-release"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="settings-note">
|
||||
<strong>{{
|
||||
|
||||
@@ -0,0 +1,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 { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [MatIconModule, TranslateModule],
|
||||
imports: [MatButtonModule, MatIconModule, TranslateModule],
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
@@ -14,4 +25,83 @@ export class SettingsAboutSectionComponent {
|
||||
readonly isDesktop = input(false);
|
||||
readonly version = input<string | undefined>();
|
||||
readonly updateMessage = input<string | undefined>();
|
||||
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(null);
|
||||
|
||||
readonly checkForAppUpdate = output<void>();
|
||||
readonly downloadAppUpdate = output<void>();
|
||||
readonly installAppUpdate = output<void>();
|
||||
readonly openManualAppUpdate = output<void>();
|
||||
readonly openAppUpdateReleaseNotes = output<void>();
|
||||
|
||||
readonly isAppUpdateBusy = computed(() => {
|
||||
const status = this.appUpdateStatus()?.status;
|
||||
|
||||
return (
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking ||
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
|
||||
);
|
||||
});
|
||||
|
||||
readonly canDownloadAppUpdate = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return (
|
||||
status?.supportedSelfUpdate === true &&
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
|
||||
);
|
||||
});
|
||||
|
||||
readonly canInstallAppUpdate = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return (
|
||||
status?.supportedSelfUpdate === true &&
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
|
||||
readonly canOpenAppUpdateReleaseNotes = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return Boolean(
|
||||
status?.latestVersion &&
|
||||
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle &&
|
||||
status.status !==
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking &&
|
||||
status.status !==
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable &&
|
||||
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error
|
||||
);
|
||||
});
|
||||
|
||||
readonly canOpenManualAppUpdate = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return Boolean(status && !status.supportedSelfUpdate);
|
||||
});
|
||||
|
||||
readonly appUpdateStatusLabelKey = computed(() => {
|
||||
const status =
|
||||
this.appUpdateStatus()?.status ??
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle;
|
||||
|
||||
switch (status) {
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
|
||||
return 'SETTINGS.APP_UPDATE_CHECKING';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
|
||||
return 'SETTINGS.APP_UPDATE_AVAILABLE';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
|
||||
return 'SETTINGS.APP_UPDATE_NOT_AVAILABLE';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
|
||||
return 'SETTINGS.APP_UPDATE_DOWNLOADING';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
|
||||
return 'SETTINGS.APP_UPDATE_DOWNLOADED';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported:
|
||||
return 'SETTINGS.APP_UPDATE_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"
|
||||
[version]="version"
|
||||
[updateMessage]="updateMessage"
|
||||
[appUpdateStatus]="appUpdateStatus()"
|
||||
(checkForAppUpdate)="checkForAppUpdate()"
|
||||
(downloadAppUpdate)="downloadAppUpdate()"
|
||||
(installAppUpdate)="installAppUpdate()"
|
||||
(openManualAppUpdate)="openManualAppUpdate()"
|
||||
(openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()"
|
||||
/>
|
||||
|
||||
<footer class="action-bar" data-test-id="settings-action-bar">
|
||||
|
||||
@@ -203,6 +203,47 @@
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.app-update-panel__control {
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-update-status {
|
||||
min-width: min(100%, 260px);
|
||||
display: flex;
|
||||
flex: 1 1 280px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
|
||||
strong {
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: min(100%, 320px);
|
||||
height: 8px;
|
||||
accent-color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
.app-update-actions {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item--external-player-arguments {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
Language,
|
||||
PlaylistMeta,
|
||||
StartupBehavior,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
|
||||
|
||||
import { signal } from '@angular/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
@@ -103,6 +106,13 @@ const DEFAULT_SETTINGS = {
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
|
||||
const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
currentVersion: '0.22.0',
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
};
|
||||
|
||||
class MockSettingsStore {
|
||||
private _settings = signal(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -153,8 +163,10 @@ interface SettingsSectionScrollDirectiveTestApi {
|
||||
}
|
||||
|
||||
interface SettingsComponentPrivateTestApi {
|
||||
loadAppUpdateStatus(): Promise<void>;
|
||||
matDialog: MatDialog;
|
||||
waitForUiFeedbackFrame(): Promise<void>;
|
||||
waitForAppUpdateStatusRetry(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
@@ -300,9 +312,22 @@ describe('SettingsComponent', () => {
|
||||
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
|
||||
getAppUpdateStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
getChannelPrograms: jest.fn().mockResolvedValue([]),
|
||||
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
|
||||
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
|
||||
checkForAppUpdate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
downloadAppUpdate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
installAppUpdate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
onAppUpdateStatusChange: jest.fn(() => jest.fn()),
|
||||
openInMpv: jest.fn(),
|
||||
openInVlc: jest.fn(),
|
||||
platform: 'linux',
|
||||
@@ -355,6 +380,126 @@ describe('SettingsComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads the desktop app update status and subscribes to status pushes', async () => {
|
||||
const pushedStatus: ElectronBridgeAppUpdateStatus = {
|
||||
...DEFAULT_APP_UPDATE_STATUS,
|
||||
latestVersion: '0.23.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
};
|
||||
|
||||
await fixture.whenStable();
|
||||
const statusHandler = (
|
||||
window.electron.onAppUpdateStatusChange as jest.Mock
|
||||
).mock.calls[0][0] as (status: ElectronBridgeAppUpdateStatus) => void;
|
||||
statusHandler(pushedStatus);
|
||||
|
||||
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
|
||||
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(component.appUpdateStatus()).toEqual(pushedStatus);
|
||||
});
|
||||
|
||||
it('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', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
CoverSize,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
Language,
|
||||
StreamFormat,
|
||||
Theme,
|
||||
@@ -82,6 +83,10 @@ import {
|
||||
buildSettingsPlaylistDeleteSummary,
|
||||
} from './settings-playlist-summary.utils';
|
||||
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({
|
||||
templateUrl: './settings.component.html',
|
||||
@@ -186,6 +191,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Update message to show */
|
||||
updateMessage = '';
|
||||
readonly appUpdateStatus = signal<ElectronBridgeAppUpdateStatus | null>(
|
||||
null
|
||||
);
|
||||
|
||||
/** EPG availability flag */
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
@@ -228,6 +236,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.playlistDeleteSummary().total > 0
|
||||
);
|
||||
|
||||
private unsubscribeAppUpdateStatus: (() => void) | null = null;
|
||||
|
||||
readonly removeAllProgressLabel = computed(() => {
|
||||
return buildRemoveAllProgressLabel({
|
||||
isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
|
||||
@@ -251,6 +261,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.bindDashboardControlsEnabledState();
|
||||
void this.loadEmbeddedMpvSupport();
|
||||
this.checkAppVersion();
|
||||
this.bindAppUpdateStatusEvents();
|
||||
void this.loadAppUpdateStatus();
|
||||
void this.fetchLocalIpAddresses();
|
||||
|
||||
if (!this.isDialog) {
|
||||
@@ -291,9 +303,114 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeAppUpdateStatus?.();
|
||||
this.unsubscribeAppUpdateStatus = null;
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
private bindAppUpdateStatusEvents(): void {
|
||||
if (!this.isDesktop || !window.electron?.onAppUpdateStatusChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeAppUpdateStatus =
|
||||
window.electron.onAppUpdateStatusChange((status) => {
|
||||
this.appUpdateStatus.set(status);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadAppUpdateStatus(): Promise<void> {
|
||||
if (!this.isDesktop) {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Цёмная",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dunkel",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Σκούρο",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dark",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Oscuro",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sombre",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Scuro",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "ダーク",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "어둡게",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Donker",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Ciemny",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Escuro",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES": "Недавно использованные источники",
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream."
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream.",
|
||||
"APP_UPDATE_TITLE": "Обновление приложения",
|
||||
"APP_UPDATE_DESCRIPTION": "Проверка обновлений desktop-приложения через GitHub Releases.",
|
||||
"APP_UPDATE_IDLE": "Можно проверить обновления",
|
||||
"APP_UPDATE_CHECKING": "Проверяем обновления…",
|
||||
"APP_UPDATE_AVAILABLE": "Доступна версия {{version}}",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "Вы используете последнюю версию",
|
||||
"APP_UPDATE_DOWNLOADING": "Загружается версия {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Версия {{version}} готова к установке",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Версия {{version}} доступна для ручной установки",
|
||||
"APP_UPDATE_ERROR": "Не удалось проверить обновление",
|
||||
"APP_UPDATE_CHECK": "Проверить снова",
|
||||
"APP_UPDATE_DOWNLOAD": "Обновить",
|
||||
"APP_UPDATE_INSTALL": "Перезагрузить приложение",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Тёмная",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Koyu",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The Angular shell defines a baseline CSP in `apps/web/src/index.html`.
|
||||
|
||||
+17
-4
@@ -2,6 +2,13 @@
|
||||
"appId": "com.fourgray.iptvnator",
|
||||
"productName": "IPTVnator",
|
||||
"artifactName": "${name}-${version}-${os}-${arch}.${ext}",
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "4gray",
|
||||
"repo": "iptvnator"
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "dist/apps",
|
||||
"output": "dist/executables"
|
||||
@@ -34,10 +41,16 @@
|
||||
},
|
||||
"extraResources": ["dist/apps/electron-backend/workers"],
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.video",
|
||||
"forceCodeSigning": true,
|
||||
"gatekeeperAssess": false,
|
||||
|
||||
@@ -123,6 +123,20 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
|
||||
export type ElectronBridgeDownloadStatus =
|
||||
(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 = {
|
||||
All: 'all',
|
||||
Series: 'series',
|
||||
@@ -154,6 +168,49 @@ export interface ElectronBridgeWindowState {
|
||||
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 {
|
||||
aiProvider: string;
|
||||
aiModelName: string;
|
||||
@@ -452,6 +509,16 @@ export interface ElectronBridgeApi {
|
||||
) => () => void;
|
||||
getAppVersion: () => Promise<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>;
|
||||
toggleMaximizeWindow: () => Promise<ElectronBridgeWindowState>;
|
||||
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_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
|
||||
export const OPEN_MPV_PLAYER = 'OPEN_MPV_PLAYER';
|
||||
export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH';
|
||||
|
||||
@@ -95,11 +95,13 @@
|
||||
"drizzle-orm": "0.45.2",
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-dl": "4.0.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"epg-parser": "^0.1.6",
|
||||
"fix-path": "5.0.0",
|
||||
"hls.js": "1.6.13",
|
||||
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
|
||||
"lodash": "4.18.1",
|
||||
"marked": "18.0.5",
|
||||
"mpegts.js": "1.8.0",
|
||||
"ngx-indexed-db": "21.0.0",
|
||||
"ngx-skeleton-loader": "11.3.0",
|
||||
|
||||
Generated
+53
-13
@@ -119,6 +119,9 @@ importers:
|
||||
electron-dl:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
electron-updater:
|
||||
specifier: 6.8.9
|
||||
version: 6.8.9
|
||||
epg-parser:
|
||||
specifier: ^0.1.6
|
||||
version: 0.1.6
|
||||
@@ -134,6 +137,9 @@ importers:
|
||||
lodash:
|
||||
specifier: 4.18.1
|
||||
version: 4.18.1
|
||||
marked:
|
||||
specifier: 18.0.5
|
||||
version: 18.0.5
|
||||
mpegts.js:
|
||||
specifier: 1.8.0
|
||||
version: 1.8.0
|
||||
@@ -5501,6 +5507,10 @@ packages:
|
||||
resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
|
||||
|
||||
@@ -6520,6 +6530,9 @@ packages:
|
||||
electron-to-chromium@1.5.353:
|
||||
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
|
||||
|
||||
electron-updater@6.8.9:
|
||||
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
|
||||
|
||||
electron-winstaller@5.4.0:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -8268,9 +8281,6 @@ packages:
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
jsonfile@6.2.1:
|
||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||
|
||||
jsonparse@1.3.1:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
@@ -8402,6 +8412,10 @@ packages:
|
||||
lodash.escaperegexp@4.1.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
@@ -8527,6 +8541,11 @@ packages:
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
marked@18.0.5:
|
||||
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
material-design-icons-iconfont@6.7.0:
|
||||
resolution: {integrity: sha512-lSj71DgVv20kO0kGbs42icDzbRot61gEDBLQACzkUuznRQBUYmbxzEkGU6dNBb5fRWHMaScYlAXX96HQ4/cJWA==}
|
||||
|
||||
@@ -11143,6 +11162,9 @@ packages:
|
||||
tiny-inflate@1.0.3:
|
||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||
|
||||
tiny-typed-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -13787,7 +13809,7 @@ snapshots:
|
||||
'@malept/cross-spawn-promise': 2.0.0
|
||||
debug: 4.4.3
|
||||
dir-compare: 4.2.0
|
||||
fs-extra: 11.3.3
|
||||
fs-extra: 11.3.5
|
||||
minimatch: 9.0.5
|
||||
plist: 3.1.0
|
||||
transitivePeerDependencies:
|
||||
@@ -18089,6 +18111,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
dependencies:
|
||||
7zip-bin: 5.2.0
|
||||
@@ -19084,6 +19113,19 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
'@electron/asar': 3.4.1
|
||||
@@ -20002,9 +20044,8 @@ snapshots:
|
||||
fs-extra@11.3.5:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
optional: true
|
||||
|
||||
fs-extra@7.0.1:
|
||||
dependencies:
|
||||
@@ -21471,13 +21512,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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: {}
|
||||
|
||||
keycode@2.2.1: {}
|
||||
@@ -21633,6 +21667,8 @@ snapshots:
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
@@ -21796,6 +21832,8 @@ snapshots:
|
||||
|
||||
marked@15.0.12: {}
|
||||
|
||||
marked@18.0.5: {}
|
||||
|
||||
material-design-icons-iconfont@6.7.0: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
@@ -25043,6 +25081,8 @@ snapshots:
|
||||
|
||||
tiny-inflate@1.0.3: {}
|
||||
|
||||
tiny-typed-emitter@2.1.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
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 () => {
|
||||
const { buildElectronBuilderMetadata, buildElectronPackageMetadata } =
|
||||
await import('./generate-electron-builder-metadata.mjs');
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"@iptvnator/shared/interfaces": [
|
||||
"libs/shared/interfaces/src/index.ts"
|
||||
],
|
||||
"@iptvnator/shared/interfaces/ipc-commands": [
|
||||
"libs/shared/interfaces/src/lib/ipc-commands.ts"
|
||||
],
|
||||
"@iptvnator/ui/shared-portals": [
|
||||
"libs/ui/shared-portals/src/index.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user