fix(updater): address release review feedback

This commit is contained in:
4gray committed 2026-06-28 16:58:14 +02:00
1 parent b64e187a5e
commit 44014f6b38
8 files changed
+123 -28

No files matched your search

+2 -2
View File
@@ -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
@@ -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<
(
@@ -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<string[]>((resolve) => {
resolveDownload = () => resolve([]);
})
);
service.handleUpdateAvailable({ version: '0.23.0' });
const firstDownload = service.downloadUpdate();
const secondStatus = await service.downloadUpdate();
resolveDownload?.();
await firstDownload;
expect(updater.downloadUpdate).toHaveBeenCalledTimes(1);
expect(secondStatus.status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
);
});
it('marks a downloaded update ready to install and installs on request', () => {
const { service, updater } = createService();
service.handleUpdateAvailable({ version: '0.23.0' });
@@ -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);
});
});
@@ -48,7 +48,10 @@ interface AppUpdaterAdapter {
downloadUpdate(): Promise<string[]>;
quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
on(event: 'checking-for-update', listener: () => void): this;
on(event: 'update-available', listener: (info: AppUpdateInfo) => void): this;
on(
event: 'update-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()
);
}
}
@@ -163,8 +163,10 @@ interface SettingsSectionScrollDirectiveTestApi {
}
interface SettingsComponentPrivateTestApi {
loadAppUpdateStatus(): Promise<void>;
matDialog: MatDialog;
waitForUiFeedbackFrame(): Promise<void>;
waitForAppUpdateStatusRetry(): Promise<void>;
}
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();
@@ -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<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS);
});
}
async checkForAppUpdate(): Promise<void> {
@@ -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 () => {
+3
View File
@@ -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"
],