From 44014f6b3859eff9974d4abbc251ad3149202d60 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 28 Jun 2026 16:58:14 +0200 Subject: [PATCH] fix(updater): address release review feedback --- .github/workflows/build-and-make.yaml | 4 +-- .../src/app/api/main.preload.ts | 16 ++++++---- .../app/services/app-update.service.spec.ts | 32 +++++++++++++++++-- .../src/app/services/app-update.service.ts | 25 +++++++++------ .../app/settings/settings.component.spec.ts | 28 +++++++++++++++- .../src/app/settings/settings.component.ts | 29 ++++++++++++++++- .../electron-package-identity.test.mjs | 14 +++++--- tsconfig.base.json | 3 ++ 8 files changed, 123 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 18a14cfe0..080d84938 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -476,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 -- --publish never - name: Verify signed macOS app if: matrix.os == 'macos' && github.event_name != 'pull_request' @@ -535,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 -- --publish never - name: Verify packaged worker layout shell: bash diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 84e7fcc37..6e8b3415b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,12 +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, - ElectronBridgeAppUpdateStatus, ElectronBridgeAppUpdateReleaseNotesRequest, + ElectronBridgeAppUpdateStatus, ElectronBridgeDbOperationEvent, ElectronBridgeDownloadStartPayload, ElectronBridgeEpgLookupOptions, @@ -49,12 +57,6 @@ 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< ( 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 index da9d7ae3b..5266d8a62 100644 --- a/apps/electron-backend/src/app/services/app-update.service.spec.ts +++ b/apps/electron-backend/src/app/services/app-update.service.spec.ts @@ -35,7 +35,8 @@ const githubReleases = [ { body: 'beta notes', draft: false, - html_url: 'https://github.com/4gray/iptvnator/releases/tag/v0.22.5-beta', + 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', @@ -106,7 +107,8 @@ describe('AppUpdateService', () => { expect(service.getStatus()).toEqual({ currentVersion: '0.22.0', - manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest', + manualDownloadUrl: + 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, supportedSelfUpdate: false, }); @@ -159,6 +161,7 @@ describe('AppUpdateService', () => { expect.objectContaining({ headers: expect.objectContaining({ Accept: 'application/vnd.github+json', + 'User-Agent': 'iptvnator/0.22.0', }), }) ); @@ -261,6 +264,27 @@ describe('AppUpdateService', () => { }); }); + 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' }); @@ -269,7 +293,9 @@ describe('AppUpdateService', () => { const status: ElectronBridgeAppUpdateStatus = service.getStatus(); service.installUpdate(); - expect(status.status).toBe(ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded); + 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 index dbfd9de86..5912b808e 100644 --- a/apps/electron-backend/src/app/services/app-update.service.ts +++ b/apps/electron-backend/src/app/services/app-update.service.ts @@ -48,7 +48,10 @@ interface AppUpdaterAdapter { 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-available', + listener: (info: AppUpdateInfo) => void + ): this; on( event: 'update-not-available', listener: (info: AppUpdateInfo) => void @@ -209,9 +212,9 @@ function toReleaseNotes( function isGitHubRelease(value: unknown): value is GitHubReleaseResponse { return Boolean( value && - typeof value === 'object' && - 'tag_name' in value && - typeof (value as GitHubReleaseResponse).tag_name === 'string' + typeof value === 'object' && + 'tag_name' in value && + typeof (value as GitHubReleaseResponse).tag_name === 'string' ); } @@ -335,10 +338,7 @@ export class AppUpdateService { } if ( - this.status.status !== - ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available && - this.status.status !== - ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading + this.status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ) { return this.getStatus(); } @@ -360,7 +360,8 @@ export class AppUpdateService { installUpdate(): ElectronBridgeAppUpdateStatus { if ( this.supportedSelfUpdate && - this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded + this.status.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded ) { this.options.updater.quitAndInstall(); } @@ -501,6 +502,7 @@ export class AppUpdateService { const response = await this.releaseFetcher(url, { headers: { Accept: 'application/vnd.github+json', + 'User-Agent': `iptvnator/${this.currentVersion}`, }, }); @@ -579,6 +581,9 @@ export class AppUpdateService { return; } - mainWindow.webContents.send(APP_UPDATE_STATUS_CHANGED, this.getStatus()); + mainWindow.webContents.send( + APP_UPDATE_STATUS_CHANGED, + this.getStatus() + ); } } diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 4aff248a9..f77917018 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -163,8 +163,10 @@ interface SettingsSectionScrollDirectiveTestApi { } interface SettingsComponentPrivateTestApi { + loadAppUpdateStatus(): Promise; matDialog: MatDialog; waitForUiFeedbackFrame(): Promise; + waitForAppUpdateStatusRetry(): Promise; } describe('SettingsComponent', () => { @@ -392,10 +394,34 @@ describe('SettingsComponent', () => { statusHandler(pushedStatus); expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1); - expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(1); + expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes( + 1 + ); expect(component.appUpdateStatus()).toEqual(pushedStatus); }); + it('retries the initial app update status load when IPC handlers are still starting', async () => { + const retriedStatus: ElectronBridgeAppUpdateStatus = { + ...DEFAULT_APP_UPDATE_STATUS, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + supportedSelfUpdate: false, + }; + await fixture.whenStable(); + (window.electron.getAppUpdateStatus as jest.Mock) + .mockReset() + .mockRejectedValueOnce(new Error('No handler registered')) + .mockResolvedValueOnce(retriedStatus); + jest.spyOn( + privateApi(component), + 'waitForAppUpdateStatusRetry' + ).mockResolvedValue(undefined); + + await privateApi(component).loadAppUpdateStatus(); + + expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(2); + expect(component.appUpdateStatus()).toEqual(retriedStatus); + }); + it('forwards app update actions to the desktop bridge', async () => { await component.checkForAppUpdate(); await component.downloadAppUpdate(); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 524514328..dcc563037 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -85,6 +85,9 @@ import { import { SettingsSnackbarService } from './settings-snackbar.service'; import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; +const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 5; +const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 100; + @Component({ templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'], @@ -321,7 +324,31 @@ export class SettingsComponent implements OnInit, OnDestroy { return; } - this.appUpdateStatus.set(await window.electron.getAppUpdateStatus()); + for ( + let attempt = 1; + attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS; + attempt += 1 + ) { + try { + this.appUpdateStatus.set( + await window.electron.getAppUpdateStatus() + ); + return; + } catch (error) { + if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) { + console.warn('Failed to load app update status:', error); + return; + } + + await this.waitForAppUpdateStatusRetry(); + } + } + } + + private async waitForAppUpdateStatusRetry(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS); + }); } async checkForAppUpdate(): Promise { diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index d78074f5d..bc02a5c38 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -152,10 +152,7 @@ test('GitHub Releases auto-update metadata is generated and uploaded', () => { ); assert.match(buildAndMakeWorkflow, /dist\/executables\/\*\*\/\*\.blockmap/); assert.match(buildAndMakeWorkflow, /Merge macOS updater metadata/); - assert.match( - buildAndMakeWorkflow, - /artifacts\/latest-mac\.yml/ - ); + assert.match(buildAndMakeWorkflow, /artifacts\/latest-mac\.yml/); assert.doesNotMatch( releaseFiles, /artifacts\/macos-(?:x64|arm64)-artifacts\/latest-mac\.yml/ @@ -168,6 +165,15 @@ test('GitHub Releases auto-update metadata is generated and uploaded', () => { buildAndMakeWorkflow, /artifacts\/windows-artifacts\/latest\.yml/ ); + + const makeCommands = [ + ...buildAndMakeWorkflow.matchAll(/run: (pnpm run make:app[^\n]*)/g), + ].map((match) => match[1].trim()); + assert.ok(makeCommands.length >= 2, 'workflow must package Electron apps'); + assert.deepEqual( + [...new Set(makeCommands)], + ['pnpm run make:app -- --publish never'] + ); }); test('generated Electron package metadata mirrors the root package identity', async () => { diff --git a/tsconfig.base.json b/tsconfig.base.json index 8f5882ce8..2e8cc3c8c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -84,6 +84,9 @@ "@iptvnator/shared/interfaces": [ "libs/shared/interfaces/src/index.ts" ], + "@iptvnator/shared/interfaces/ipc-commands": [ + "libs/shared/interfaces/src/lib/ipc-commands.ts" + ], "@iptvnator/ui/shared-portals": [ "libs/ui/shared-portals/src/index.ts" ],