mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(updater): nightly builds and a stable/nightly update channel (#1608)
* feat(updater): nightly builds and a stable/nightly update channel Every master push publishes its artifacts as a prerelease of 4gray/iptvnator-nightly instead of the rolling test-master draft, with a version of <next patch>-nightly.<commit date>.<run number> applied in every build job. Settings → About gains an Update channel switch; AppUpdateService re-points electron-updater per check (feed repository, allowPrerelease, channel name, allowDowngrade reset) and reads release notes from the repository the requested version belongs to. Channel switches are forward-only: a nightly build stays until a newer stable release exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(updater): compute the nightly version once and keep re-runs safe Review follow-ups: the nightly version is resolved by a leading job and handed to every build job, and the patch is bumped only when the base tag already exists so the release-cut window stays below the imminent release. A re-run never deletes a published nightly; only a draft left by a failed run is replaced. Typed update-status literals in the remaining specs carry the new channel fields. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(packaging): expect the nightly-version prerequisite in the build workflow graph Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f724494b9
commit
6f7973a9fb
57 files changed
+2249
-229
No files matched your search
@@ -14,8 +14,9 @@ jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(
|
||||
(channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
mockHandlers.set(channel, handler);
|
||||
}),
|
||||
mockHandlers.set(channel, handler);
|
||||
}
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -32,6 +33,8 @@ describe('AppUpdateEvents', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
};
|
||||
const service = {
|
||||
checkForUpdates: jest.fn().mockResolvedValue({
|
||||
@@ -59,7 +62,8 @@ describe('AppUpdateEvents', () => {
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
|
||||
})),
|
||||
};
|
||||
const { default: AppUpdateEvents } = await import('./app-update.events');
|
||||
const { default: AppUpdateEvents } =
|
||||
await import('./app-update.events');
|
||||
|
||||
AppUpdateEvents.bootstrapAppUpdateEvents(service);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type SettingsUpdateHandler = (
|
||||
|
||||
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
|
||||
const STORE_KEYS = {
|
||||
APP_UPDATE_CHANNEL: 'APP_UPDATE_CHANNEL',
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT',
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS',
|
||||
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
|
||||
@@ -50,6 +51,7 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
APP_UPDATE_CHANNEL: STORE_KEYS.APP_UPDATE_CHANNEL,
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
|
||||
@@ -219,6 +221,37 @@ describe('SETTINGS_UPDATE', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('mirrors the update channel into the main-process store and notifies the updater', async () => {
|
||||
const channelModule = await import('../services/app-update-channel');
|
||||
const listener = jest.fn();
|
||||
channelModule.onAppUpdateChannelChange(listener);
|
||||
|
||||
settingsUpdateHandler({}, { updateChannel: 'nightly' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.APP_UPDATE_CHANNEL, 'nightly'],
|
||||
]);
|
||||
expect(listener).toHaveBeenCalledWith('nightly');
|
||||
});
|
||||
|
||||
it('normalizes an unknown update channel to stable and stays quiet when nothing changed', async () => {
|
||||
const channelModule = await import('../services/app-update-channel');
|
||||
const listener = jest.fn();
|
||||
channelModule.onAppUpdateChannelChange(listener);
|
||||
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{
|
||||
updateChannel: 'canary' as unknown as 'stable',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.APP_UPDATE_CHANNEL, 'stable'],
|
||||
]);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconciles an enabled remote-control update with the stored port', () => {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown =>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../services/store.service';
|
||||
import { httpServer } from '../server/http-server';
|
||||
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
|
||||
import { persistAppUpdateChannel } from '../services/app-update-channel';
|
||||
|
||||
export default class SettingsEvents {
|
||||
static bootstrapSettingsEvents(): Electron.IpcMain {
|
||||
@@ -91,6 +92,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
|
||||
}
|
||||
|
||||
// Mirrored for the startup update check, which runs before the renderer
|
||||
// exists; a change re-points the running updater immediately.
|
||||
if (arg.updateChannel !== undefined) {
|
||||
persistAppUpdateChannel(arg.updateChannel);
|
||||
}
|
||||
|
||||
// Handle remote control settings
|
||||
if (
|
||||
arg.remoteControl !== undefined ||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
normalizeAppUpdateChannel,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { APP_UPDATE_CHANNEL, store } from './store.service';
|
||||
|
||||
type AppUpdateChannelListener = (channel: AppUpdateChannel) => void;
|
||||
|
||||
const listeners = new Set<AppUpdateChannelListener>();
|
||||
|
||||
/**
|
||||
* The persisted update channel, read synchronously from the main-process
|
||||
* config so the startup update check can use it before any renderer exists.
|
||||
* Junk in the config file collapses to `stable`.
|
||||
*/
|
||||
export function readStoredAppUpdateChannel(): AppUpdateChannel {
|
||||
return normalizeAppUpdateChannel(store.get(APP_UPDATE_CHANNEL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a saved `Settings.updateChannel` into the config file and tells
|
||||
* the updater. Listeners only run on an actual change, so a Save that keeps
|
||||
* the channel cannot restart an update check.
|
||||
*/
|
||||
export function persistAppUpdateChannel(value: unknown): AppUpdateChannel {
|
||||
const channel = normalizeAppUpdateChannel(value);
|
||||
const previous = readStoredAppUpdateChannel();
|
||||
|
||||
store.set(APP_UPDATE_CHANNEL, channel);
|
||||
|
||||
if (channel !== previous) {
|
||||
for (const listener of listeners) {
|
||||
listener(channel);
|
||||
}
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function onAppUpdateChannelChange(
|
||||
listener: AppUpdateChannelListener
|
||||
): () => void {
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
appUpdateRepository,
|
||||
NIGHTLY_PRERELEASE_TAG,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface AppUpdateGitHubFeedOptions {
|
||||
provider: 'github';
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of electron-updater's `AppUpdater` the channel switch touches.
|
||||
* Kept structural so the service spec can drive it with a fake.
|
||||
*/
|
||||
export interface AppUpdateFeedTarget {
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
channel: string | null;
|
||||
setFeedURL(options: AppUpdateGitHubFeedOptions): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Points electron-updater at the repository behind `channel` right before a
|
||||
* check. Applied on every check rather than once, because the channel can
|
||||
* change while the app runs.
|
||||
*
|
||||
* Two electron-updater quirks decide the shape of this function:
|
||||
*
|
||||
* - The `channel` setter throws once a string channel is replaced by `null`,
|
||||
* so stable is written as the explicit `latest` (the default channel
|
||||
* file name) instead of being cleared.
|
||||
* - Assigning `channel` silently flips `allowDowngrade` to `true`, and the
|
||||
* constructor turns `allowPrerelease` on for any prerelease build. Both
|
||||
* are reset afterwards: a nightly build on the stable channel must wait
|
||||
* for the next stable release rather than downgrade onto one that may
|
||||
* not understand its database schema.
|
||||
*/
|
||||
export function applyAppUpdateChannel(
|
||||
updater: AppUpdateFeedTarget,
|
||||
channel: AppUpdateChannel
|
||||
): void {
|
||||
updater.setFeedURL({ provider: 'github', ...appUpdateRepository(channel) });
|
||||
updater.allowPrerelease = channel === 'nightly';
|
||||
updater.channel = channel === 'nightly' ? NIGHTLY_PRERELEASE_TAG : 'latest';
|
||||
updater.allowDowngrade = false;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
appUpdateReleasesApiUrl,
|
||||
appUpdateReleasesPageUrl,
|
||||
appVersionChannel,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const GITHUB_RELEASES_PER_PAGE = 10;
|
||||
|
||||
interface GitHubReleaseResponse {
|
||||
body?: string | null;
|
||||
draft?: boolean;
|
||||
html_url?: string;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface CachedGitHubRelease {
|
||||
bodyMarkdown: string;
|
||||
draft: boolean;
|
||||
htmlUrl: string;
|
||||
prerelease: boolean;
|
||||
publishedAt?: string | null;
|
||||
releaseName?: string | null;
|
||||
tagName: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ReleaseFetchResponse {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export type ReleaseFetcher = (
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string> }
|
||||
) => Promise<ReleaseFetchResponse>;
|
||||
|
||||
export function normalizeVersion(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'tag_name' in value &&
|
||||
typeof (value as GitHubReleaseResponse).tag_name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily paged, newest-first list of one channel's published GitHub
|
||||
* releases. Drafts are never listed. The stable catalog also drops
|
||||
* prereleases (a beta tag must not become "the latest version"), while the
|
||||
* nightly catalog keeps only releases whose version carries the nightly
|
||||
* identifier, so a stray tag in that repository cannot be offered either.
|
||||
*/
|
||||
export class AppUpdateReleaseCatalog {
|
||||
readonly releases: CachedGitHubRelease[] = [];
|
||||
loadedReleasePages = 0;
|
||||
loadedAllReleases = false;
|
||||
|
||||
constructor(
|
||||
readonly channel: AppUpdateChannel,
|
||||
private readonly fetcher: ReleaseFetcher,
|
||||
private readonly userAgent: string
|
||||
) {}
|
||||
|
||||
get latest(): CachedGitHubRelease | undefined {
|
||||
return this.releases[0];
|
||||
}
|
||||
|
||||
async ensureFirstReleaseLoaded(): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.releases.length === 0) {
|
||||
await this.ensurePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async ensurePageLoaded(page: number): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
|
||||
await this.loadPage(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Index of `version` (or its tag), paging further until it is found. */
|
||||
async findIndex(version: string | undefined): Promise<number> {
|
||||
if (!version) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const normalizedVersion = normalizeVersion(version);
|
||||
|
||||
while (true) {
|
||||
const index = this.releases.findIndex(
|
||||
(release) =>
|
||||
normalizeVersion(release.version) === normalizedVersion ||
|
||||
normalizeVersion(release.tagName) === normalizedVersion
|
||||
);
|
||||
|
||||
if (index !== -1 || this.loadedAllReleases) {
|
||||
return index;
|
||||
}
|
||||
|
||||
await this.ensurePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private belongsToChannel(release: GitHubReleaseResponse): boolean {
|
||||
if (release.draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.channel === 'nightly') {
|
||||
return appVersionChannel(release.tag_name) === 'nightly';
|
||||
}
|
||||
|
||||
return !release.prerelease;
|
||||
}
|
||||
|
||||
private async loadPage(page: number): Promise<void> {
|
||||
const url = `${appUpdateReleasesApiUrl(this.channel)}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
|
||||
const response = await this.fetcher(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': this.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const releasesPageUrl = appUpdateReleasesPageUrl(this.channel).replace(
|
||||
/\/latest$/,
|
||||
''
|
||||
);
|
||||
|
||||
for (const release of payload) {
|
||||
if (!isGitHubRelease(release) || !this.belongsToChannel(release)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = release.tag_name;
|
||||
|
||||
if (this.releases.some((cached) => cached.tagName === tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.releases.push({
|
||||
bodyMarkdown: release.body ?? '',
|
||||
draft: Boolean(release.draft),
|
||||
htmlUrl:
|
||||
release.html_url ?? `${releasesPageUrl}/tag/${tagName}`,
|
||||
prerelease: Boolean(release.prerelease),
|
||||
publishedAt: release.published_at,
|
||||
releaseName: release.name ?? tagName,
|
||||
tagName,
|
||||
version: normalizeVersion(tagName),
|
||||
});
|
||||
}
|
||||
|
||||
this.loadedReleasePages = page;
|
||||
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -8,11 +9,66 @@ import { AppUpdateService } from './app-update.service';
|
||||
class FakeUpdater extends EventEmitter {
|
||||
autoDownload = true;
|
||||
autoInstallOnAppQuit = true;
|
||||
// electron-updater turns these on by itself for prerelease builds and
|
||||
// channel assignments; the service must reset them explicitly.
|
||||
allowPrerelease = true;
|
||||
allowDowngrade = true;
|
||||
channel: string | null = null;
|
||||
setFeedURL = jest.fn();
|
||||
checkForUpdates = jest.fn().mockResolvedValue(null);
|
||||
downloadUpdate = jest.fn().mockResolvedValue([]);
|
||||
quitAndInstall = jest.fn();
|
||||
}
|
||||
|
||||
const nightlyReleases = [
|
||||
{
|
||||
body: '## Nightly\n\nNewest master build\n\n<!-- iptvnator-commit: abc -->',
|
||||
draft: false,
|
||||
html_url:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260915.7',
|
||||
name: 'Nightly 0.23.1-nightly.20260915.7',
|
||||
prerelease: true,
|
||||
published_at: '2026-09-15T00:00:00.000Z',
|
||||
tag_name: 'v0.23.1-nightly.20260915.7',
|
||||
},
|
||||
{
|
||||
body: 'not a nightly',
|
||||
draft: false,
|
||||
html_url:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases/tag/v9.9.9',
|
||||
name: 'stray tag',
|
||||
prerelease: false,
|
||||
published_at: '2026-09-14T12:00:00.000Z',
|
||||
tag_name: 'v9.9.9',
|
||||
},
|
||||
{
|
||||
body: '## Nightly\n\nOlder master build',
|
||||
draft: false,
|
||||
html_url:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260914.5',
|
||||
name: 'Nightly 0.23.1-nightly.20260914.5',
|
||||
prerelease: true,
|
||||
published_at: '2026-09-14T00:00:00.000Z',
|
||||
tag_name: 'v0.23.1-nightly.20260914.5',
|
||||
},
|
||||
];
|
||||
|
||||
/** Answers the stable and nightly repositories with their own lists. */
|
||||
function createChannelReleaseFetcher() {
|
||||
return jest.fn(async (url: string) => {
|
||||
const releases = url.includes('/iptvnator-nightly/')
|
||||
? nightlyReleases
|
||||
: githubReleases;
|
||||
|
||||
return {
|
||||
json: jest.fn().mockResolvedValue(releases),
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const githubReleases = [
|
||||
{
|
||||
body: '## New\n\nFresh build',
|
||||
@@ -84,6 +140,8 @@ function createService(
|
||||
env?: NodeJS.ProcessEnv;
|
||||
prepareQuit?: () => void;
|
||||
cancelPreparedQuit?: () => void;
|
||||
channel?: AppUpdateChannel;
|
||||
appVersion?: string;
|
||||
} = {}
|
||||
) {
|
||||
const updater = new FakeUpdater();
|
||||
@@ -93,6 +151,8 @@ function createService(
|
||||
getVersion: () => '0.22.0',
|
||||
isPackaged: overrides.isPackaged ?? true,
|
||||
},
|
||||
appVersion: overrides.appVersion,
|
||||
channel: overrides.channel,
|
||||
getMainWindow: () => win,
|
||||
cancelPreparedQuit: overrides.cancelPreparedQuit,
|
||||
platform: overrides.platform ?? 'darwin',
|
||||
@@ -115,6 +175,8 @@ describe('AppUpdateService', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
expect(updater.autoDownload).toBe(true);
|
||||
});
|
||||
@@ -136,6 +198,8 @@ describe('AppUpdateService', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
|
||||
await service.checkForUpdatesOnStartup();
|
||||
@@ -160,6 +224,8 @@ describe('AppUpdateService', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +347,8 @@ describe('AppUpdateService', () => {
|
||||
},
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,6 +380,8 @@ describe('AppUpdateService', () => {
|
||||
latestVersion: '0.24.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,4 +604,186 @@ describe('AppUpdateService', () => {
|
||||
|
||||
expect(cancelPreparedQuit).not.toHaveBeenCalled();
|
||||
});
|
||||
it('points electron-updater at the stable repository before every check', async () => {
|
||||
const { service, updater } = createService();
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(updater.setFeedURL).toHaveBeenCalledWith({
|
||||
provider: 'github',
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator',
|
||||
});
|
||||
expect(updater.allowPrerelease).toBe(false);
|
||||
expect(updater.channel).toBe('latest');
|
||||
expect(updater.allowDowngrade).toBe(false);
|
||||
expect(updater.setFeedURL.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updater.checkForUpdates.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the nightly repository and its prerelease channel when configured', async () => {
|
||||
const { service, updater } = createService({ channel: 'nightly' });
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
channel: 'nightly',
|
||||
installedChannel: 'stable',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases',
|
||||
});
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(updater.setFeedURL).toHaveBeenCalledWith({
|
||||
provider: 'github',
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator-nightly',
|
||||
});
|
||||
expect(updater.allowPrerelease).toBe(true);
|
||||
expect(updater.channel).toBe('nightly');
|
||||
expect(updater.allowDowngrade).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the installed channel from the running version', () => {
|
||||
const { service } = createService({
|
||||
appVersion: '0.23.1-nightly.20260915.7',
|
||||
});
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
currentVersion: '0.23.1-nightly.20260915.7',
|
||||
channel: 'stable',
|
||||
installedChannel: 'nightly',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-checks on an idle updater when the channel changes and forgets the old verdict', async () => {
|
||||
const { service, updater, win } = createService();
|
||||
await service.checkForUpdates();
|
||||
service.handleUpdateNotAvailable({ version: '0.22.0' });
|
||||
updater.checkForUpdates.mockClear();
|
||||
|
||||
service.setChannel('stable');
|
||||
expect(updater.checkForUpdates).not.toHaveBeenCalled();
|
||||
|
||||
service.setChannel('nightly');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
expect(updater.setFeedURL).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ repo: 'iptvnator-nightly' })
|
||||
);
|
||||
const pushed = (
|
||||
win.webContents.send.mock.calls as [
|
||||
string,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
][]
|
||||
).map(([, status]) => status);
|
||||
expect(
|
||||
pushed.some(
|
||||
(status) =>
|
||||
status.channel === 'nightly' &&
|
||||
status.latestVersion === undefined &&
|
||||
status.release === undefined
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a running download on the old channel but reports the new one', async () => {
|
||||
const { service, updater } = createService();
|
||||
service.handleUpdateAvailable({ version: '0.23.0' });
|
||||
updater.downloadUpdate.mockImplementationOnce(
|
||||
() => new Promise(() => undefined)
|
||||
);
|
||||
void service.downloadUpdate();
|
||||
updater.checkForUpdates.mockClear();
|
||||
|
||||
service.setChannel('nightly');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(updater.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
channel: 'nightly',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers newer nightlies through the manual fallback using prerelease-aware ordering', async () => {
|
||||
const fetcher = createChannelReleaseFetcher();
|
||||
const { service, updater } = createService({
|
||||
appVersion: '0.23.1-nightly.20260914.5',
|
||||
channel: 'nightly',
|
||||
env: {},
|
||||
fetcher,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(updater.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/4gray/iptvnator-nightly/releases?per_page=10&page=1',
|
||||
expect.any(Object)
|
||||
);
|
||||
// v9.9.9 is not a nightly and must not win the list.
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
latestVersion: '0.23.1-nightly.20260915.7',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
installedChannel: 'nightly',
|
||||
});
|
||||
});
|
||||
|
||||
it('never downgrades a nightly build that follows the stable channel', async () => {
|
||||
const fetcher = createChannelReleaseFetcher();
|
||||
const { service } = createService({
|
||||
appVersion: '0.24.1-nightly.20260915.7',
|
||||
env: {},
|
||||
fetcher,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
latestVersion: '0.24.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
|
||||
installedChannel: 'nightly',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads release notes from the repository the requested version belongs to', async () => {
|
||||
const fetcher = createChannelReleaseFetcher();
|
||||
const { service } = createService({ fetcher });
|
||||
|
||||
const nightlyNotes = await service.getReleaseNotes({
|
||||
version: '0.23.1-nightly.20260915.7',
|
||||
});
|
||||
expect(nightlyNotes).toMatchObject({
|
||||
tagName: 'v0.23.1-nightly.20260915.7',
|
||||
hasNext: false,
|
||||
hasPrevious: true,
|
||||
});
|
||||
|
||||
const previous = await service.getReleaseNotes({
|
||||
version: nightlyNotes.tagName,
|
||||
direction: 'previous',
|
||||
});
|
||||
expect(previous.tagName).toBe('v0.23.1-nightly.20260914.5');
|
||||
|
||||
const stableNotes = await service.getReleaseNotes({
|
||||
version: '0.23.0',
|
||||
});
|
||||
expect(stableNotes.tagName).toBe('v0.23.0');
|
||||
|
||||
const latestOnStableChannel = await service.getReleaseNotes();
|
||||
expect(latestOnStableChannel.tagName).toBe('v0.24.0');
|
||||
expect(
|
||||
fetcher.mock.calls.map(([url]) => new URL(url).pathname)
|
||||
).toEqual([
|
||||
'/repos/4gray/iptvnator-nightly/releases',
|
||||
'/repos/4gray/iptvnator/releases',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,28 @@
|
||||
import {
|
||||
APP_UPDATE_STATUS_CHANGED,
|
||||
AppUpdateChannel,
|
||||
appUpdateReleasesPageUrl,
|
||||
appVersionChannel,
|
||||
compareAppVersions,
|
||||
DEFAULT_APP_UPDATE_CHANNEL,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateRelease,
|
||||
ElectronBridgeAppUpdateReleaseNotes,
|
||||
ElectronBridgeAppUpdateReleaseNotesRequest,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateFeedTarget, applyAppUpdateChannel } from './app-update-feed';
|
||||
import {
|
||||
AppUpdateReleaseCatalog,
|
||||
CachedGitHubRelease,
|
||||
normalizeVersion,
|
||||
ReleaseFetcher,
|
||||
ReleaseFetchResponse,
|
||||
} from './app-update-release-catalog';
|
||||
|
||||
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;
|
||||
export const APP_UPDATE_MANUAL_DOWNLOAD_URL = appUpdateReleasesPageUrl(
|
||||
DEFAULT_APP_UPDATE_CHANNEL
|
||||
);
|
||||
|
||||
interface AppUpdateAppAdapter {
|
||||
getVersion(): string;
|
||||
@@ -41,7 +52,7 @@ interface AppUpdateProgressInfo {
|
||||
transferred?: number;
|
||||
}
|
||||
|
||||
interface AppUpdaterAdapter {
|
||||
interface AppUpdaterAdapter extends AppUpdateFeedTarget {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit?: boolean;
|
||||
checkForUpdates(): Promise<unknown>;
|
||||
@@ -69,39 +80,6 @@ interface AppUpdaterAdapter {
|
||||
|
||||
type AppUpdaterAdapterProvider = AppUpdaterAdapter | (() => AppUpdaterAdapter);
|
||||
|
||||
interface GitHubReleaseResponse {
|
||||
body?: string | null;
|
||||
draft?: boolean;
|
||||
html_url?: string;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
interface CachedGitHubRelease {
|
||||
bodyMarkdown: string;
|
||||
draft: boolean;
|
||||
htmlUrl: string;
|
||||
prerelease: boolean;
|
||||
publishedAt?: string | null;
|
||||
releaseName?: string | null;
|
||||
tagName: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface ReleaseFetchResponse {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
type ReleaseFetcher = (
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string> }
|
||||
) => Promise<ReleaseFetchResponse>;
|
||||
|
||||
export interface AppUpdateServiceOptions {
|
||||
app: AppUpdateAppAdapter;
|
||||
appVersion?: string;
|
||||
@@ -110,6 +88,8 @@ export interface AppUpdateServiceOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
processEnv?: NodeJS.ProcessEnv;
|
||||
releaseFetcher?: ReleaseFetcher;
|
||||
/** Persisted update channel at startup; `setChannel` follows later saves. */
|
||||
channel?: AppUpdateChannel;
|
||||
/**
|
||||
* Runs right before `quitAndInstall()`. Lets the unsaved-settings close
|
||||
* guard stand down for the updater-driven window close, which on macOS
|
||||
@@ -165,37 +145,6 @@ function normalizeError(error: unknown): string {
|
||||
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 resolveCurrentVersion(
|
||||
app: AppUpdateAppAdapter,
|
||||
appVersion: string | undefined
|
||||
@@ -217,8 +166,7 @@ function toReleaseInfo(
|
||||
function toReleaseNotes(
|
||||
release: CachedGitHubRelease,
|
||||
index: number,
|
||||
releaseCount: number,
|
||||
loadedAllReleases: boolean
|
||||
catalog: AppUpdateReleaseCatalog
|
||||
): ElectronBridgeAppUpdateReleaseNotes {
|
||||
return {
|
||||
version: release.version,
|
||||
@@ -228,28 +176,22 @@ function toReleaseNotes(
|
||||
bodyMarkdown: release.bodyMarkdown,
|
||||
htmlUrl: release.htmlUrl,
|
||||
hasNext: index > 0,
|
||||
hasPrevious: index < releaseCount - 1 || !loadedAllReleases,
|
||||
hasPrevious:
|
||||
index < catalog.releases.length - 1 || !catalog.loadedAllReleases,
|
||||
};
|
||||
}
|
||||
|
||||
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'tag_name' in value &&
|
||||
typeof (value as GitHubReleaseResponse).tag_name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class AppUpdateService {
|
||||
private readonly currentVersion: string;
|
||||
private readonly isPackaged: boolean;
|
||||
private readonly supportedSelfUpdate: boolean;
|
||||
private readonly releaseFetcher: ReleaseFetcher;
|
||||
private readonly releases: CachedGitHubRelease[] = [];
|
||||
private readonly catalogs = new Map<
|
||||
AppUpdateChannel,
|
||||
AppUpdateReleaseCatalog
|
||||
>();
|
||||
private readonly updater: AppUpdaterAdapter | null = null;
|
||||
private loadedReleasePages = 0;
|
||||
private loadedAllReleases = false;
|
||||
private channel: AppUpdateChannel;
|
||||
private checkForUpdatesPromise: Promise<ElectronBridgeAppUpdateStatus> | null =
|
||||
null;
|
||||
private status: ElectronBridgeAppUpdateStatus;
|
||||
@@ -262,6 +204,7 @@ export class AppUpdateService {
|
||||
options.appVersion
|
||||
);
|
||||
this.isPackaged = options.app.isPackaged;
|
||||
this.channel = options.channel ?? DEFAULT_APP_UPDATE_CHANNEL;
|
||||
this.supportedSelfUpdate = isSelfUpdateSupported(
|
||||
options.app.isPackaged,
|
||||
options.platform ?? process.platform,
|
||||
@@ -269,12 +212,15 @@ export class AppUpdateService {
|
||||
);
|
||||
this.releaseFetcher =
|
||||
options.releaseFetcher ??
|
||||
((url, init) => fetch(url, init) as Promise<ReleaseFetchResponse>);
|
||||
((url, init) =>
|
||||
fetch(url, init) as Promise<ReleaseFetchResponse>);
|
||||
this.status = {
|
||||
currentVersion: this.currentVersion,
|
||||
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
|
||||
manualDownloadUrl: appUpdateReleasesPageUrl(this.channel),
|
||||
status: getInitialStatus(this.supportedSelfUpdate),
|
||||
supportedSelfUpdate: this.supportedSelfUpdate,
|
||||
channel: this.channel,
|
||||
installedChannel: appVersionChannel(this.currentVersion),
|
||||
};
|
||||
|
||||
if (this.supportedSelfUpdate) {
|
||||
@@ -289,6 +235,33 @@ export class AppUpdateService {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a saved channel change. A download already running or
|
||||
* finished belongs to the previous channel and is left alone — the
|
||||
* user can still install it — so only an idle updater re-checks.
|
||||
*/
|
||||
setChannel(channel: AppUpdateChannel): void {
|
||||
if (channel === this.channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel = channel;
|
||||
|
||||
const busy =
|
||||
this.status.status ===
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
|
||||
this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded;
|
||||
|
||||
if (busy || !this.isPackaged) {
|
||||
this.setStatus({});
|
||||
return;
|
||||
}
|
||||
|
||||
// The last verdict described the other channel's releases.
|
||||
this.setStatus({ latestVersion: undefined, release: undefined });
|
||||
void this.checkForUpdates();
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus> {
|
||||
if (this.checkForUpdatesPromise) {
|
||||
return this.checkForUpdatesPromise;
|
||||
@@ -311,11 +284,14 @@ export class AppUpdateService {
|
||||
|
||||
this.setStatus({
|
||||
error: undefined,
|
||||
latestVersion: undefined,
|
||||
release: undefined,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.updater) {
|
||||
applyAppUpdateChannel(this.updater, this.channel);
|
||||
await this.updater.checkForUpdates();
|
||||
} else {
|
||||
await this.checkGitHubReleaseForManualUpdate();
|
||||
@@ -335,19 +311,28 @@ export class AppUpdateService {
|
||||
return this.checkForUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release notes come from the catalog of the channel the requested
|
||||
* version belongs to, not the configured one: a nightly build on the
|
||||
* stable channel still reads its own notes, and paging from a nightly
|
||||
* tag stays inside the nightly list.
|
||||
*/
|
||||
async getReleaseNotes(
|
||||
request: ElectronBridgeAppUpdateReleaseNotesRequest = {}
|
||||
): Promise<ElectronBridgeAppUpdateReleaseNotes> {
|
||||
const catalog = this.catalogFor(
|
||||
request.version ? appVersionChannel(request.version) : this.channel
|
||||
);
|
||||
const canFallbackToLatest =
|
||||
!request.direction && (!request.version || request.fallbackToLatest);
|
||||
|
||||
if (canFallbackToLatest) {
|
||||
await this.ensureFirstStableReleaseLoaded();
|
||||
await catalog.ensureFirstReleaseLoaded();
|
||||
} else {
|
||||
await this.ensureReleasePageLoaded(1);
|
||||
await catalog.ensurePageLoaded(1);
|
||||
}
|
||||
|
||||
let index = await this.findReleaseIndex(request.version);
|
||||
let index = await catalog.findIndex(request.version);
|
||||
|
||||
if (index === -1 && canFallbackToLatest) {
|
||||
index = 0;
|
||||
@@ -361,25 +346,20 @@ export class AppUpdateService {
|
||||
|
||||
if (request.direction === 'previous') {
|
||||
index += 1;
|
||||
while (index >= this.releases.length && !this.loadedAllReleases) {
|
||||
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
|
||||
while (index >= catalog.releases.length && !catalog.loadedAllReleases) {
|
||||
await catalog.ensurePageLoaded(catalog.loadedReleasePages + 1);
|
||||
}
|
||||
} else if (request.direction === 'next') {
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
const release = this.releases[index];
|
||||
const release = catalog.releases[index];
|
||||
|
||||
if (!release) {
|
||||
throw new Error('No release notes are available in that direction');
|
||||
}
|
||||
|
||||
return toReleaseNotes(
|
||||
release,
|
||||
index,
|
||||
this.releases.length,
|
||||
this.loadedAllReleases
|
||||
);
|
||||
return toReleaseNotes(release, index, catalog);
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<ElectronBridgeAppUpdateStatus> {
|
||||
@@ -489,6 +469,21 @@ export class AppUpdateService {
|
||||
});
|
||||
}
|
||||
|
||||
private catalogFor(channel: AppUpdateChannel): AppUpdateReleaseCatalog {
|
||||
let catalog = this.catalogs.get(channel);
|
||||
|
||||
if (!catalog) {
|
||||
catalog = new AppUpdateReleaseCatalog(
|
||||
channel,
|
||||
this.releaseFetcher,
|
||||
`iptvnator/${this.currentVersion}`
|
||||
);
|
||||
this.catalogs.set(channel, catalog);
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private resolveUpdater(
|
||||
updater: AppUpdaterAdapterProvider
|
||||
): AppUpdaterAdapter {
|
||||
@@ -517,9 +512,15 @@ export class AppUpdateService {
|
||||
updater.on('error', (error) => this.handleError(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Packaged builds without self-update (Linux deb/rpm/snap/…) only learn
|
||||
* whether a newer version exists on the configured channel and point
|
||||
* the user at its release page.
|
||||
*/
|
||||
private async checkGitHubReleaseForManualUpdate(): Promise<void> {
|
||||
await this.ensureFirstStableReleaseLoaded();
|
||||
const latestRelease = this.releases[0];
|
||||
const catalog = this.catalogFor(this.channel);
|
||||
await catalog.ensureFirstReleaseLoaded();
|
||||
const latestRelease = catalog.latest;
|
||||
|
||||
if (!latestRelease) {
|
||||
this.setStatus({
|
||||
@@ -528,124 +529,28 @@ export class AppUpdateService {
|
||||
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;
|
||||
}
|
||||
const isNewer =
|
||||
compareAppVersions(latestRelease.version, this.currentVersion) > 0;
|
||||
|
||||
this.setStatus({
|
||||
error: undefined,
|
||||
latestVersion: latestRelease.version,
|
||||
release: toReleaseInfo(latestRelease),
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
|
||||
status: isNewer
|
||||
? ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
|
||||
: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureFirstStableReleaseLoaded(): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.releases.length === 0) {
|
||||
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async findReleaseIndex(
|
||||
version: string | undefined
|
||||
): Promise<number> {
|
||||
if (!version) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const normalizedVersion = normalizeVersion(version);
|
||||
|
||||
while (true) {
|
||||
const index = this.releases.findIndex(
|
||||
(release) =>
|
||||
normalizeVersion(release.version) === normalizedVersion ||
|
||||
normalizeVersion(release.tagName) === normalizedVersion
|
||||
);
|
||||
|
||||
if (index !== -1 || this.loadedAllReleases) {
|
||||
return index;
|
||||
}
|
||||
|
||||
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureReleasePageLoaded(page: number): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
|
||||
await this.loadReleasePage(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadReleasePage(page: number): Promise<void> {
|
||||
const url = `${GITHUB_RELEASES_API_URL}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
|
||||
const response = await this.releaseFetcher(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': `iptvnator/${this.currentVersion}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub releases request failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('GitHub releases response was not an array');
|
||||
}
|
||||
|
||||
for (const release of payload) {
|
||||
if (!isGitHubRelease(release)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (release.draft || release.prerelease) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = release.tag_name;
|
||||
const version = normalizeVersion(tagName);
|
||||
|
||||
if (
|
||||
this.releases.some(
|
||||
(cachedRelease) => cachedRelease.tagName === tagName
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.releases.push({
|
||||
bodyMarkdown: release.body ?? '',
|
||||
draft: Boolean(release.draft),
|
||||
htmlUrl:
|
||||
release.html_url ??
|
||||
`${APP_UPDATE_MANUAL_DOWNLOAD_URL.replace('/latest', '')}/tag/${tagName}`,
|
||||
prerelease: Boolean(release.prerelease),
|
||||
publishedAt: release.published_at,
|
||||
releaseName: release.name ?? tagName,
|
||||
tagName,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
this.loadedReleasePages = page;
|
||||
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
|
||||
}
|
||||
|
||||
private setStatus(
|
||||
update: Partial<
|
||||
Omit<
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
'currentVersion' | 'manualDownloadUrl' | 'supportedSelfUpdate'
|
||||
| 'currentVersion'
|
||||
| 'manualDownloadUrl'
|
||||
| 'supportedSelfUpdate'
|
||||
| 'channel'
|
||||
| 'installedChannel'
|
||||
>
|
||||
>
|
||||
): void {
|
||||
@@ -653,8 +558,10 @@ export class AppUpdateService {
|
||||
...this.status,
|
||||
...update,
|
||||
currentVersion: this.currentVersion,
|
||||
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
|
||||
manualDownloadUrl: appUpdateReleasesPageUrl(this.channel),
|
||||
supportedSelfUpdate: this.supportedSelfUpdate,
|
||||
channel: this.channel,
|
||||
installedChannel: this.status.installedChannel,
|
||||
};
|
||||
this.emitStatus();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Conf } from 'electron-conf/main';
|
||||
import { getElectronConfigDirectory } from '@iptvnator/shared/database';
|
||||
import type { StartupWindowMode } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
AppUpdateChannel,
|
||||
StartupWindowMode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
|
||||
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
|
||||
@@ -28,6 +31,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
|
||||
/** Desktop portal request cooldown; absent means enabled. */
|
||||
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
|
||||
|
||||
/**
|
||||
* Update channel (`stable` / `nightly`). Mirrored here from the renderer's
|
||||
* settings by the SETTINGS_UPDATE handler because the startup update check
|
||||
* runs before the renderer's IndexedDB settings are reachable; absent means
|
||||
* `stable`. Read and written only through `app-update-channel.ts`.
|
||||
*/
|
||||
export const APP_UPDATE_CHANNEL = 'APP_UPDATE_CHANNEL';
|
||||
|
||||
/**
|
||||
* Local XMLTV files the EPG importer may read: every path the native picker
|
||||
* returned plus every hand-typed path the user allowed in the main-process
|
||||
@@ -64,6 +75,7 @@ export type StoreType = {
|
||||
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
|
||||
[STARTUP_WINDOW_MODE]: StartupWindowMode;
|
||||
[PORTAL_CONNECTIVITY_GUARD]: boolean;
|
||||
[APP_UPDATE_CHANNEL]: AppUpdateChannel;
|
||||
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ import StalkerEvents from './app/events/stalker.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
|
||||
import { AppUpdateService } from './app/services/app-update.service';
|
||||
import {
|
||||
onAppUpdateChannelChange,
|
||||
readStoredAppUpdateChannel,
|
||||
} from './app/services/app-update-channel';
|
||||
import { databaseWorkerClient } from './app/services/database-worker-client';
|
||||
import WindowEvents from './app/events/window.events';
|
||||
import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service';
|
||||
@@ -141,6 +145,7 @@ export default class Main {
|
||||
const appUpdateService = new AppUpdateService({
|
||||
app,
|
||||
appVersion: environment.version,
|
||||
channel: readStoredAppUpdateChannel(),
|
||||
getMainWindow: () => App.mainWindow,
|
||||
updater: () => autoUpdater,
|
||||
// quitAndInstall() closes the windows before 'before-quit' fires
|
||||
@@ -150,6 +155,9 @@ export default class Main {
|
||||
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
|
||||
});
|
||||
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
|
||||
onAppUpdateChannelChange((channel) =>
|
||||
appUpdateService.setChannel(channel)
|
||||
);
|
||||
|
||||
registerStaticHeaderShims();
|
||||
ElectronEvents.bootstrapElectronEvents();
|
||||
|
||||
Reference in new issue
Block a user