diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 7b6a80ed6..18a14cfe0 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)); " @@ -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/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..84e7fcc37 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -5,6 +5,8 @@ import type { EmbeddedMpvSession, EmbeddedMpvSupport, ElectronBridgeApi, + ElectronBridgeAppUpdateStatus, + ElectronBridgeAppUpdateReleaseNotesRequest, ElectronBridgeDbOperationEvent, ElectronBridgeDownloadStartPayload, ElectronBridgeEpgLookupOptions, @@ -47,6 +49,12 @@ const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE'; const WINDOW_CLOSE = 'WINDOW:CLOSE'; const WINDOW_GET_STATE = 'WINDOW:GET_STATE'; const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED'; +const APP_UPDATE_GET_STATUS = 'APP_UPDATE:GET_STATUS'; +const APP_UPDATE_CHECK = 'APP_UPDATE:CHECK'; +const APP_UPDATE_DOWNLOAD = 'APP_UPDATE:DOWNLOAD'; +const APP_UPDATE_INSTALL = 'APP_UPDATE:INSTALL'; +const APP_UPDATE_GET_RELEASE_NOTES = 'APP_UPDATE:GET_RELEASE_NOTES'; +const APP_UPDATE_STATUS_CHANGED = 'APP_UPDATE:STATUS_CHANGED'; const dbSaveContentProgressListeners = new Set< ( @@ -292,6 +300,23 @@ const electronApi: ElectronBridgeApi = { }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), platform: process.platform, + getAppUpdateStatus: () => ipcRenderer.invoke(APP_UPDATE_GET_STATUS), + checkForAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_CHECK), + downloadAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_DOWNLOAD), + installAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_INSTALL), + getAppUpdateReleaseNotes: ( + request?: ElectronBridgeAppUpdateReleaseNotesRequest + ) => ipcRenderer.invoke(APP_UPDATE_GET_RELEASE_NOTES, request), + onAppUpdateStatusChange: ( + callback: (status: ElectronBridgeAppUpdateStatus) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + status: ElectronBridgeAppUpdateStatus + ) => callback(status); + ipcRenderer.on(APP_UPDATE_STATUS_CHANGED, handler); + return () => ipcRenderer.off(APP_UPDATE_STATUS_CHANGED, handler); + }, minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE), toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE), closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE), 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..da9d7ae3b --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update.service.spec.ts @@ -0,0 +1,275 @@ +import { EventEmitter } from 'events'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { AppUpdateService } from './app-update.service'; + +class FakeUpdater extends EventEmitter { + autoDownload = true; + autoInstallOnAppQuit = true; + checkForUpdates = jest.fn().mockResolvedValue(null); + downloadUpdate = jest.fn().mockResolvedValue([]); + quitAndInstall = jest.fn(); +} + +const githubReleases = [ + { + body: '## New\n\nFresh build', + draft: false, + html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.24.0', + name: 'v0.24.0', + prerelease: false, + published_at: '2026-06-29T00:00:00.000Z', + tag_name: 'v0.24.0', + }, + { + body: '## Current\n\nUpdate details', + draft: false, + html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.23.0', + name: 'v0.23.0', + prerelease: false, + published_at: '2026-06-28T00:00:00.000Z', + tag_name: 'v0.23.0', + }, + { + body: 'beta notes', + draft: false, + html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.22.5-beta', + name: 'v0.22.5-beta', + prerelease: true, + published_at: '2026-06-27T00:00:00.000Z', + tag_name: 'v0.22.5-beta', + }, + { + body: '## Older\n\nBug fixes', + draft: false, + html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.22.0', + name: 'v0.22.0', + prerelease: false, + published_at: '2026-06-20T00:00:00.000Z', + tag_name: 'v0.22.0', + }, +]; + +function createReleaseFetcher(pages: unknown[][] = [githubReleases]) { + return jest.fn(async (url: string) => { + const parsedUrl = new URL(url); + const page = Number(parsedUrl.searchParams.get('page') ?? '1'); + + return { + json: jest.fn().mockResolvedValue(pages[page - 1] ?? []), + ok: true, + status: 200, + statusText: 'OK', + }; + }); +} + +function createWindow() { + return { + isDestroyed: jest.fn(() => false), + webContents: { + send: jest.fn(), + }, + }; +} + +function createService( + overrides: { + fetcher?: ReturnType; + isPackaged?: boolean; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + } = {} +) { + const updater = new FakeUpdater(); + const win = createWindow(); + const service = new AppUpdateService({ + app: { + getVersion: () => '0.22.0', + isPackaged: overrides.isPackaged ?? true, + }, + getMainWindow: () => win, + platform: overrides.platform ?? 'darwin', + processEnv: overrides.env ?? {}, + releaseFetcher: overrides.fetcher, + updater, + }); + + return { service, updater, win }; +} + +describe('AppUpdateService', () => { + it('reports unsupported status outside packaged builds', () => { + const { service, updater } = createService({ isPackaged: false }); + + expect(service.getStatus()).toEqual({ + currentVersion: '0.22.0', + manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + supportedSelfUpdate: false, + }); + expect(updater.autoDownload).toBe(false); + }); + + it('supports Linux self-update only for AppImage builds', () => { + const unsupported = createService({ + platform: 'linux', + env: {}, + }).service.getStatus(); + const appImage = createService({ + platform: 'linux', + env: { APPIMAGE: '/Applications/IPTVnator.AppImage' }, + }).service.getStatus(); + + expect(unsupported.supportedSelfUpdate).toBe(false); + expect(unsupported.status).toBe( + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported + ); + expect(appImage.supportedSelfUpdate).toBe(true); + expect(appImage.status).toBe(ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle); + }); + + it('checks for updates without downloading automatically', async () => { + const { service, updater } = createService(); + + await service.checkForUpdates(); + + expect(updater.autoDownload).toBe(false); + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + expect(service.getStatus().status).toBe( + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking + ); + }); + + it('checks packaged non-AppImage Linux builds through GitHub manual fallback', async () => { + const fetcher = createReleaseFetcher(); + const { service, updater } = createService({ + env: {}, + fetcher, + platform: 'linux', + }); + + await service.checkForUpdates(); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledWith( + 'https://api.github.com/repos/4gray/iptvnator/releases?per_page=10&page=1', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/vnd.github+json', + }), + }) + ); + expect(service.getStatus()).toMatchObject({ + latestVersion: '0.24.0', + release: { + releaseName: 'v0.24.0', + version: '0.24.0', + }, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + supportedSelfUpdate: false, + }); + }); + + it('starts a packaged startup check without downloading automatically', async () => { + const { service, updater } = createService(); + + await service.checkForUpdatesOnStartup(); + + expect(updater.autoDownload).toBe(false); + expect(updater.downloadUpdate).not.toHaveBeenCalled(); + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + }); + + it('loads release notes lazily and navigates between cached versions', async () => { + const fetcher = createReleaseFetcher(); + const { service } = createService({ fetcher }); + + const current = await service.getReleaseNotes({ version: '0.23.0' }); + const older = await service.getReleaseNotes({ + direction: 'previous', + version: current.tagName, + }); + const newer = await service.getReleaseNotes({ + direction: 'next', + version: older.tagName, + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(current).toMatchObject({ + bodyMarkdown: '## Current\n\nUpdate details', + hasNext: true, + hasPrevious: true, + tagName: 'v0.23.0', + version: '0.23.0', + }); + expect(older).toMatchObject({ + bodyMarkdown: '## Older\n\nBug fixes', + hasNext: true, + hasPrevious: false, + tagName: 'v0.22.0', + }); + expect(newer.tagName).toBe('v0.23.0'); + }); + + it('stores available release details from updater events', () => { + const { service } = createService(); + + service.handleUpdateAvailable({ + version: '0.23.0', + releaseName: 'Release v0.23.0', + releaseDate: '2026-06-28T10:00:00.000Z', + releaseNotes: 'Bug fixes', + }); + + expect(service.getStatus()).toMatchObject({ + latestVersion: '0.23.0', + release: { + releaseDate: '2026-06-28T10:00:00.000Z', + releaseName: 'Release v0.23.0', + releaseNotes: 'Bug fixes', + version: '0.23.0', + }, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + }); + }); + + it('downloads only after an update is available and exposes progress', async () => { + const { service, updater } = createService(); + service.handleUpdateAvailable({ version: '0.23.0' }); + + const downloadPromise = service.downloadUpdate(); + service.handleDownloadProgress({ + bytesPerSecond: 2048, + percent: 42.5, + total: 1000, + transferred: 425, + }); + await downloadPromise; + + expect(updater.downloadUpdate).toHaveBeenCalledTimes(1); + expect(service.getStatus()).toMatchObject({ + progress: { + bytesPerSecond: 2048, + percent: 42.5, + total: 1000, + transferred: 425, + }, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading, + }); + }); + + it('marks a downloaded update ready to install and installs on request', () => { + const { service, updater } = createService(); + service.handleUpdateAvailable({ version: '0.23.0' }); + service.handleUpdateDownloaded({ version: '0.23.0' }); + + const status: ElectronBridgeAppUpdateStatus = service.getStatus(); + service.installUpdate(); + + expect(status.status).toBe(ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded); + expect(updater.quitAndInstall).toHaveBeenCalledTimes(1); + }); +}); 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..dbfd9de86 --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update.service.ts @@ -0,0 +1,584 @@ +import { + APP_UPDATE_STATUS_CHANGED, + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateRelease, + ElectronBridgeAppUpdateReleaseNotes, + ElectronBridgeAppUpdateReleaseNotesRequest, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; + +export const APP_UPDATE_MANUAL_DOWNLOAD_URL = + 'https://github.com/4gray/iptvnator/releases/latest'; +const GITHUB_RELEASES_API_URL = + 'https://api.github.com/repos/4gray/iptvnator/releases'; +const GITHUB_RELEASES_PER_PAGE = 10; + +interface AppUpdateAppAdapter { + getVersion(): string; + isPackaged: boolean; +} + +interface AppUpdateWebContents { + send(channel: string, payload: ElectronBridgeAppUpdateStatus): void; +} + +interface AppUpdateWindow { + isDestroyed(): boolean; + webContents: AppUpdateWebContents; +} + +interface AppUpdateInfo { + version: string; + releaseDate?: string; + releaseName?: string | null; + releaseNotes?: string | unknown[] | null; +} + +interface AppUpdateProgressInfo { + bytesPerSecond?: number; + percent: number; + total?: number; + transferred?: number; +} + +interface AppUpdaterAdapter { + autoDownload: boolean; + autoInstallOnAppQuit?: boolean; + checkForUpdates(): Promise; + 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; +} + +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: AppUpdaterAdapter; + getMainWindow: () => AppUpdateWindow | null | undefined; + platform?: NodeJS.Platform; + processEnv?: NodeJS.ProcessEnv; + releaseFetcher?: ReleaseFetcher; +} + +function isSelfUpdateSupported( + isPackaged: boolean, + platform: NodeJS.Platform, + processEnv: NodeJS.ProcessEnv +): boolean { + if (!isPackaged) { + return false; + } + + if (platform === 'darwin' || platform === 'win32') { + return true; + } + + return platform === 'linux' && Boolean(processEnv.APPIMAGE); +} + +function getInitialStatus(supportedSelfUpdate: boolean) { + return supportedSelfUpdate + ? ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle + : ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported; +} + +function toRelease(info: AppUpdateInfo): ElectronBridgeAppUpdateRelease { + return { + version: info.version, + releaseDate: info.releaseDate, + releaseName: info.releaseName, + releaseNotes: + typeof info.releaseNotes === 'string' ? info.releaseNotes : null, + }; +} + +function normalizeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +function normalizeVersion(value: string | null | undefined): string { + return (value ?? '').trim().replace(/^v/i, ''); +} + +function parseVersionParts(value: string): [number, number, number] | null { + const match = normalizeVersion(value).match(/^(\d+)\.(\d+)\.(\d+)/); + + if (!match) { + return null; + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function isVersionGreaterThan(candidate: string, current: string): boolean { + const candidateParts = parseVersionParts(candidate); + const currentParts = parseVersionParts(current); + + if (!candidateParts || !currentParts) { + return false; + } + + for (let index = 0; index < candidateParts.length; index += 1) { + if (candidateParts[index] !== currentParts[index]) { + return candidateParts[index] > currentParts[index]; + } + } + + return false; +} + +function toReleaseInfo( + release: CachedGitHubRelease +): ElectronBridgeAppUpdateRelease { + return { + version: release.version, + releaseDate: release.publishedAt ?? undefined, + releaseName: release.releaseName, + releaseNotes: null, + }; +} + +function toReleaseNotes( + release: CachedGitHubRelease, + index: number, + releaseCount: number, + loadedAllReleases: boolean +): ElectronBridgeAppUpdateReleaseNotes { + return { + version: release.version, + tagName: release.tagName, + releaseName: release.releaseName, + publishedAt: release.publishedAt, + bodyMarkdown: release.bodyMarkdown, + htmlUrl: release.htmlUrl, + hasNext: index > 0, + hasPrevious: index < releaseCount - 1 || !loadedAllReleases, + }; +} + +function isGitHubRelease(value: unknown): value is GitHubReleaseResponse { + return Boolean( + value && + typeof value === 'object' && + 'tag_name' in value && + typeof (value as GitHubReleaseResponse).tag_name === 'string' + ); +} + +export class AppUpdateService { + private readonly currentVersion: string; + private readonly isPackaged: boolean; + private readonly supportedSelfUpdate: boolean; + private readonly releaseFetcher: ReleaseFetcher; + private readonly releases: CachedGitHubRelease[] = []; + private loadedReleasePages = 0; + private loadedAllReleases = false; + private status: ElectronBridgeAppUpdateStatus; + + constructor(private readonly options: AppUpdateServiceOptions) { + this.currentVersion = options.app.getVersion(); + this.isPackaged = options.app.isPackaged; + this.supportedSelfUpdate = isSelfUpdateSupported( + options.app.isPackaged, + options.platform ?? process.platform, + options.processEnv ?? process.env + ); + this.releaseFetcher = + options.releaseFetcher ?? + ((url, init) => fetch(url, init) as Promise); + this.status = { + currentVersion: this.currentVersion, + manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL, + status: getInitialStatus(this.supportedSelfUpdate), + supportedSelfUpdate: this.supportedSelfUpdate, + }; + + options.updater.autoDownload = false; + options.updater.autoInstallOnAppQuit = false; + this.attachUpdaterEvents(options.updater); + } + + getStatus(): ElectronBridgeAppUpdateStatus { + return { ...this.status }; + } + + async checkForUpdates(): Promise { + if (!this.isPackaged) { + this.setStatus({ + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + }); + return this.getStatus(); + } + + this.setStatus({ + error: undefined, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking, + }); + + try { + if (this.supportedSelfUpdate) { + await this.options.updater.checkForUpdates(); + } else { + await this.checkGitHubReleaseForManualUpdate(); + } + } catch (error) { + this.handleError(error); + } + + return this.getStatus(); + } + + async checkForUpdatesOnStartup(): Promise { + 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.supportedSelfUpdate) { + this.setStatus({ + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + }); + return this.getStatus(); + } + + if ( + this.status.status !== + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available && + this.status.status !== + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading + ) { + return this.getStatus(); + } + + this.setStatus({ + error: undefined, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading, + }); + + try { + await this.options.updater.downloadUpdate(); + } catch (error) { + this.handleError(error); + } + + return this.getStatus(); + } + + installUpdate(): ElectronBridgeAppUpdateStatus { + if ( + this.supportedSelfUpdate && + this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded + ) { + this.options.updater.quitAndInstall(); + } + + return this.getStatus(); + } + + handleUpdateAvailable(info: AppUpdateInfo): void { + const release = toRelease(info); + this.setStatus({ + error: undefined, + latestVersion: release.version, + release, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + }); + } + + handleUpdateNotAvailable(info: AppUpdateInfo): void { + this.setStatus({ + error: undefined, + latestVersion: info.version, + release: toRelease(info), + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + }); + } + + handleDownloadProgress(progress: AppUpdateProgressInfo): void { + this.setStatus({ + progress: { + bytesPerSecond: progress.bytesPerSecond, + percent: progress.percent, + total: progress.total, + transferred: progress.transferred, + }, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading, + }); + } + + handleUpdateDownloaded(info: AppUpdateInfo): void { + const release = toRelease(info); + this.setStatus({ + latestVersion: release.version, + release, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded, + }); + } + + handleError(error: unknown): void { + this.setStatus({ + error: normalizeError(error), + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error, + }); + } + + private attachUpdaterEvents(updater: AppUpdaterAdapter): void { + updater.on('checking-for-update', () => { + this.setStatus({ + error: undefined, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking, + }); + }); + updater.on('update-available', (info) => + this.handleUpdateAvailable(info) + ); + updater.on('update-not-available', (info) => + this.handleUpdateNotAvailable(info) + ); + updater.on('download-progress', (progress) => + this.handleDownloadProgress(progress) + ); + updater.on('update-downloaded', (info) => + this.handleUpdateDownloaded(info) + ); + updater.on('error', (error) => this.handleError(error)); + } + + private async checkGitHubReleaseForManualUpdate(): Promise { + await this.ensureReleasePageLoaded(1); + const latestRelease = this.releases[0]; + + if (!latestRelease) { + this.setStatus({ + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + }); + return; + } + + if (isVersionGreaterThan(latestRelease.version, this.currentVersion)) { + this.setStatus({ + error: undefined, + latestVersion: latestRelease.version, + release: toReleaseInfo(latestRelease), + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + }); + return; + } + + this.setStatus({ + error: undefined, + latestVersion: latestRelease.version, + release: toReleaseInfo(latestRelease), + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + }); + } + + private async findReleaseIndex( + version: string | undefined + ): Promise { + 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', + }, + }); + + 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..1b1fefc13 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'; @@ -102,6 +105,13 @@ export default class Main { DatabaseEvents.bootstrapDatabaseEvents(); EpgEvents.bootstrapEpgEvents(); RemoteControlEvents.bootstrapRemoteControlEvents(); + const appUpdateService = new AppUpdateService({ + app, + getMainWindow: () => App.mainWindow, + updater: autoUpdater, + }); + AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); + void appUpdateService.checkForUpdatesOnStartup(); // Set main window for downloads and reset stale downloads if (App.mainWindow) { @@ -113,11 +123,6 @@ export default class Main { trace('startup', 'reset-stale-downloads:done'); } - // initialize auto updater service - if (!App.isDevelopmentMode()) { - // UpdateEvents.initAutoUpdateService(); - } - if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:done'); } 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..17bbbd77a --- /dev/null +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -0,0 +1,106 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; +import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component'; + +const availableStatus: ElectronBridgeAppUpdateStatus = { + currentVersion: '0.22.0', + latestVersion: '0.23.0', + manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + supportedSelfUpdate: true, +}; + +describe('AppUpdateNotificationPanelComponent', () => { + let fixture: ComponentFixture; + 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); + }); +}); 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..55aae8973 --- /dev/null +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -0,0 +1,260 @@ +import { Component, computed, OnDestroy, OnInit, signal, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component'; + +@Component({ + selector: 'app-update-notification-panel', + imports: [MatButtonModule, MatIconModule, TranslatePipe], + template: ` + @if (isVisible(); as visible) { +
+
+ + 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 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..0c4fb35ac --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.spec.ts @@ -0,0 +1,101 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { SettingsAboutSectionComponent } from './settings-about-section.component'; + +function getButton(fixture: ComponentFixture, 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); + }); +}); 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..ecc9dbbea 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_MANUAL_AVAILABLE'; + 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()" />