From b1119189e2cbe1e9b500acaa257d9f4420ab04db Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:21:20 +0200 Subject: [PATCH] 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. --- .github/workflows/build-and-make.yaml | 137 +++- .github/workflows/e2e-tests.yaml | 1 + .../src/electron-test-fixtures.ts | 74 ++- apps/electron-backend-e2e/src/settings.e2e.ts | 24 + .../src/app/api/main.preload.spec.ts | 60 ++ .../src/app/api/main.preload.ts | 27 + apps/electron-backend/src/app/app.spec.ts | 77 ++- apps/electron-backend/src/app/app.ts | 63 +- apps/electron-backend/src/app/constants.ts | 1 - .../src/app/events/app-update.events.spec.ts | 101 +++ .../src/app/events/app-update.events.ts | 40 ++ .../src/app/events/update.events.ts | 69 -- .../app/services/app-update.service.spec.ts | 416 ++++++++++++ .../src/app/services/app-update.service.ts | 621 ++++++++++++++++++ apps/electron-backend/src/main.ts | 37 +- ...pdate-notification-panel.component.spec.ts | 140 ++++ ...app-update-notification-panel.component.ts | 281 ++++++++ apps/web/src/app/app.component.html | 2 + apps/web/src/app/app.component.ts | 8 +- ...ate-release-notes-dialog.component.spec.ts | 97 +++ ...p-update-release-notes-dialog.component.ts | 240 +++++++ .../settings-about-section.component.html | 93 +++ .../settings-about-section.component.spec.ts | 116 ++++ .../settings-about-section.component.ts | 94 ++- .../src/app/settings/settings.component.html | 6 + .../src/app/settings/settings.component.scss | 41 ++ .../app/settings/settings.component.spec.ts | 145 ++++ .../src/app/settings/settings.component.ts | 117 ++++ apps/web/src/assets/i18n/ar.json | 17 +- apps/web/src/assets/i18n/ary.json | 17 +- apps/web/src/assets/i18n/by.json | 17 +- apps/web/src/assets/i18n/de.json | 17 +- apps/web/src/assets/i18n/el.json | 17 +- apps/web/src/assets/i18n/en.json | 17 +- apps/web/src/assets/i18n/es.json | 17 +- apps/web/src/assets/i18n/fr.json | 17 +- apps/web/src/assets/i18n/it.json | 17 +- apps/web/src/assets/i18n/ja.json | 17 +- apps/web/src/assets/i18n/ko.json | 17 +- apps/web/src/assets/i18n/nl.json | 17 +- apps/web/src/assets/i18n/pl.json | 17 +- apps/web/src/assets/i18n/pt.json | 17 +- apps/web/src/assets/i18n/ru.json | 17 +- apps/web/src/assets/i18n/tr.json | 17 +- apps/web/src/assets/i18n/zh.json | 17 +- apps/web/src/assets/i18n/zhtw.json | 17 +- docs/architecture/electron-security.md | 40 ++ electron-builder.json | 21 +- .../src/lib/electron-api.interface.ts | 67 ++ .../shared/interfaces/src/lib/ipc-commands.ts | 8 + package.json | 2 + pnpm-lock.yaml | 66 +- .../electron-package-identity.test.mjs | 62 ++ tsconfig.base.json | 3 + 54 files changed, 3569 insertions(+), 134 deletions(-) create mode 100644 apps/electron-backend/src/app/events/app-update.events.spec.ts create mode 100644 apps/electron-backend/src/app/events/app-update.events.ts delete mode 100644 apps/electron-backend/src/app/events/update.events.ts create mode 100644 apps/electron-backend/src/app/services/app-update.service.spec.ts create mode 100644 apps/electron-backend/src/app/services/app-update.service.ts create mode 100644 apps/web/src/app/app-update-notification-panel.component.spec.ts create mode 100644 apps/web/src/app/app-update-notification-panel.component.ts create mode 100644 apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts create mode 100644 apps/web/src/app/settings/app-update-release-notes-dialog.component.ts create mode 100644 apps/web/src/app/settings/settings-about-section.component.spec.ts diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 7b6a80ed6..894a3bbca 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -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 }} diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 7190a00bd..fe56b10ce 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -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 diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index c1f8e57bf..48038d7d2 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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 ?? ''} signal=${ + signal ?? '' + }` + ); + }); +} + export async function closeElectronApp( app: LaunchedElectronApp ): Promise { 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, + timeoutMs: number +): Promise { + let timeoutId: NodeJS.Timeout | undefined; + + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolvePromise) => { + timeoutId = setTimeout( + () => resolvePromise(false), + timeoutMs + ); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + function assertPackagedRendererBuildIsElectronSafe(): void { if (!existsSync(packagedRendererIndexPath)) { throw new Error( diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index f0d834b90..ecb32572b 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -41,6 +41,30 @@ const epgFixtureXml = ` `; 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, }) => { diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 06fcca47e..aaf54a94e 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -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(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 23dc47e75..6e8b3415b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 3afd14d84..41484888b 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -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; + getNormalBounds: jest.Mock; loadFile: jest.Mock, [string]>; loadURL: jest.Mock, [string]>; + on: jest.Mock void]>; + once: jest.Mock void]>; + setMenu: jest.Mock; + show: jest.Mock; webContents: { + on: jest.Mock void]>; openDevTools: jest.Mock; + setWindowOpenHandler: jest.Mock; }; }; function createMockMainWindow(): MockMainWindow { return { + center: jest.fn(), + getNormalBounds: jest.fn().mockReturnValue({}), loadFile: jest.fn, [string]>().mockResolvedValue(), loadURL: jest.fn, [string]>().mockResolvedValue(), + on: jest.fn void]>(), + once: jest.fn void]>(), + setMenu: jest.fn(), + show: jest.fn(), webContents: { + on: jest.fn void]>(), openDevTools: jest.fn(), + setWindowOpenHandler: jest.fn(), }, }; } type AppInternals = { - mainWindow: MockMainWindow; + loadedMainWindow: MockMainWindow | null; + mainWindow: MockMainWindow | null; + mainWindowLoadPromise: Promise | null; + onReady: () => void; + rendererLoadingEnabled: boolean; loadMainWindow: () => Promise; }; @@ -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(); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 57c70cbad..e46865396 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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 | 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 { + private static async loadMainWindowContent( + mainWindow: Electron.BrowserWindow + ): Promise { // 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 { + 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) diff --git a/apps/electron-backend/src/app/constants.ts b/apps/electron-backend/src/app/constants.ts index 2be5eb634..206377676 100644 --- a/apps/electron-backend/src/app/constants.ts +++ b/apps/electron-backend/src/app/constants.ts @@ -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 diff --git a/apps/electron-backend/src/app/events/app-update.events.spec.ts b/apps/electron-backend/src/app/events/app-update.events.spec.ts new file mode 100644 index 000000000..d0e80b40f --- /dev/null +++ b/apps/electron-backend/src/app/events/app-update.events.spec.ts @@ -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 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', + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/app-update.events.ts b/apps/electron-backend/src/app/events/app-update.events.ts new file mode 100644 index 000000000..75ca8c517 --- /dev/null +++ b/apps/electron-backend/src/app/events/app-update.events.ts @@ -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; + downloadUpdate(): Promise; + installUpdate(): ElectronBridgeAppUpdateStatus; + getReleaseNotes( + request?: ElectronBridgeAppUpdateReleaseNotesRequest + ): Promise; +} + +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; + } +} diff --git a/apps/electron-backend/src/app/events/update.events.ts b/apps/electron-backend/src/app/events/update.events.ts deleted file mode 100644 index e31496ef1..000000000 --- a/apps/electron-backend/src/app/events/update.events.ts +++ /dev/null @@ -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'); -}); diff --git a/apps/electron-backend/src/app/services/app-update.service.spec.ts b/apps/electron-backend/src/app/services/app-update.service.spec.ts new file mode 100644 index 000000000..3b3adac05 --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update.service.spec.ts @@ -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; + 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((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); + }); +}); diff --git a/apps/electron-backend/src/app/services/app-update.service.ts b/apps/electron-backend/src/app/services/app-update.service.ts new file mode 100644 index 000000000..83f0f7afd --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update.service.ts @@ -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; + downloadUpdate(): Promise; + 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; + ok: boolean; + status: number; + statusText: string; +} + +type ReleaseFetcher = ( + url: string, + init?: { headers?: Record } +) => Promise; + +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 | 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); + 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 { + if (this.checkForUpdatesPromise) { + return this.checkForUpdatesPromise; + } + + this.checkForUpdatesPromise = this.runCheckForUpdates().finally(() => { + this.checkForUpdatesPromise = null; + }); + + return this.checkForUpdatesPromise; + } + + private async runCheckForUpdates(): Promise { + 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 { + if (!this.isPackaged) { + return this.getStatus(); + } + + return this.checkForUpdates(); + } + + async getReleaseNotes( + request: ElectronBridgeAppUpdateReleaseNotesRequest = {} + ): Promise { + 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 { + 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 { + 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 { + while (!this.loadedAllReleases && this.releases.length === 0) { + await this.ensureReleasePageLoaded(this.loadedReleasePages + 1); + } + } + + private async findReleaseIndex( + version: string | undefined + ): Promise { + 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 { + while (!this.loadedAllReleases && this.loadedReleasePages < page) { + await this.loadReleasePage(this.loadedReleasePages + 1); + } + } + + private async loadReleasePage(page: number): Promise { + 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() + ); + } +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 9ecc6b0d2..1ca4a3be0 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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'); } diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts new file mode 100644 index 000000000..110f07e51 --- /dev/null +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts new file mode 100644 index 000000000..a597e6d94 --- /dev/null +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -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) { +
+
+ + system_update + {{ 'SETTINGS.APP_UPDATE_TITLE' | translate }} + + +
+ +
+ + {{ + labelKey() + | translate + : { + version: + status()?.latestVersion || + status()?.currentVersion, + } + }} + + @if (status()?.progress; as progress) { + + } +
+ +
+ + + @if (status()?.status === appUpdateStatuses.Downloaded) { + + } @else { + + } +
+
+ } + `, + 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(null); + readonly dismissedVersion = signal(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 { + 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 { + if (!window.electron?.installAppUpdate) { + return; + } + + this.status.set(await window.electron.installAppUpdate()); + } + + dismiss(): void { + this.dismissedVersion.set(this.status()?.latestVersion ?? null); + } +} diff --git a/apps/web/src/app/app.component.html b/apps/web/src/app/app.component.html index da17c53a0..81e10494d 100644 --- a/apps/web/src/app/app.component.html +++ b/apps/web/src/app/app.component.html @@ -2,6 +2,8 @@ + + @if (usesCustomWindowControls) { } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index ff34bc4e6..11a918152 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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() { diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts new file mode 100644 index 000000000..9d2fda13f --- /dev/null +++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts @@ -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; + 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', + }); + }); +}); diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts new file mode 100644 index 000000000..88d23a68d --- /dev/null +++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts @@ -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: ` +

+ {{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }} +

+ + +
+ + +
+ {{ notes()?.releaseName || notes()?.tagName }} + @if (notes()?.publishedAt; as publishedAt) { + {{ publishedAt | date: 'mediumDate' }} + } +
+ + +
+ + @if (loading()) { +
+ +
+ } @else if (error()) { +

{{ error() }}

+ } @else { +
+ } +
+ + + @if (notes()?.htmlUrl; as htmlUrl) { + + } + + + `, + 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( + MAT_DIALOG_DATA + ); + private readonly dialogRef = inject( + MatDialogRef + ); + private readonly sanitizer = inject(DomSanitizer); + + readonly loading = signal(false); + readonly error = signal(null); + readonly notes = signal(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 { + 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(); + } +} diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index b0887b15f..bd349491c 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -24,6 +24,99 @@ + @if (isDesktop() && appUpdateStatus(); as updateStatus) { +
+
+

{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}

+

{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}

+
+
+
+ + {{ + appUpdateStatusLabelKey() + | translate + : { + version: + updateStatus.latestVersion || + updateStatus.currentVersion, + } + }} + + @if (updateStatus.error) { + {{ updateStatus.error }} + } + @if (updateStatus.progress; as progress) { + + } +
+
+ + @if (canDownloadAppUpdate()) { + + } + @if (canOpenAppUpdateReleaseNotes()) { + + } + @if (canInstallAppUpdate()) { + + } + @if (canOpenManualAppUpdate()) { + + } +
+
+
+ } + @if (isDesktop()) {
{{ diff --git a/apps/web/src/app/settings/settings-about-section.component.spec.ts b/apps/web/src/app/settings/settings-about-section.component.spec.ts new file mode 100644 index 000000000..b74b3d016 --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.spec.ts @@ -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, id: string) { + return fixture.nativeElement.querySelector( + `[data-test-id="${id}"]` + ) as HTMLButtonElement | null; +} + +function configureComponent( + fixture: ComponentFixture, + 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; + + 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(); + }); +}); diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 0c2be99a9..0c67a5783 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -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(); readonly updateMessage = input(); + readonly appUpdateStatus = input(null); + + readonly checkForAppUpdate = output(); + readonly downloadAppUpdate = output(); + readonly installAppUpdate = output(); + readonly openManualAppUpdate = output(); + readonly openAppUpdateReleaseNotes = output(); + + 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'; + } + }); } diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 9f1205ca4..55cf815ad 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -105,6 +105,12 @@ [isDesktop]="isDesktop" [version]="version" [updateMessage]="updateMessage" + [appUpdateStatus]="appUpdateStatus()" + (checkForAppUpdate)="checkForAppUpdate()" + (downloadAppUpdate)="downloadAppUpdate()" + (installAppUpdate)="installAppUpdate()" + (openManualAppUpdate)="openManualAppUpdate()" + (openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()" />