mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
* fix(settings): make the update-channel check honest and keep release notes fresh Settings → About mixed two commit models: the channel select applied on Save while "Check again" ran immediately against the still-saved channel, so picking Nightly and checking reported "latest version" for Stable under a select reading Nightly. The status now carries a badge naming the channel the verdict describes; while the select shows an unsaved other channel the verdict is dimmed, a hint names both channels, and the check button becomes "Save and check for <channel> updates", which submits the form — the main process already re-checks when the saved channel changes. A download in flight or finished belongs to the previous channel and keeps the plain check. "What's new" for a nightly published after the app started failed with a raw IPC error: each release catalog is a process-lifetime snapshot and a fully paged list never re-read GitHub. findIndex now reloads the catalog once when a version is missing, and a newly found update drops every catalog. The dialog recognises the not-found rejection through the shared marker text, explains it with the version, and links to the channel's release list; other failures keep their reason under a localized headline. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(updater): serialize readers of one release catalog findIndex may rebuild the shared release array while another reader of the same catalog still holds an index into the old one and dereferences it after paging further. Every reader now runs through the catalog's runExclusive queue (getReleaseNotes and the manual-update check), so a reload can no longer pull the list out from under a navigation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): attribute a kept download to the channel it was found on setChannel keeps a download that is running or finished when the saved channel changes, but status.channel already names the new channel, so the About badge attributed a Stable download to Nightly and the pending hint vanished. Every check now stamps status.verdictChannel with the channel it ran on and setChannel leaves it alone; the badge names that channel, and while it differs from the saved one a hint says the shown update came from the other channel and the saved one has not been checked yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(updater): move release-notes reads out of AppUpdateService AppUpdateReleaseCatalogs (app-update-release-notes.ts) now owns the per-channel catalogs and both reads the updater performs on them: release notes with previous/next paging and the newest release for the manual-install fallback. The service only delegates, shrinking from 610 to 508 lines instead of growing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(updater): name verdictChannel as the badge's source The About paragraph still said the badge reads status.channel while the paragraph below it and the code use status.verdictChannel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): show the release list, not the earlier release, after a paging failure A failed Previous/Next keeps the earlier notes for navigation while the body shows the error, so the dialog's action offered the earlier release instead of the channel release list. The error is now checked first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(updater): do not reload the catalog before falling back to latest A read that falls back to the newest release on a miss (the installed version's notes, e.g. an unpublished local build) paged the whole list twice: findIndex reloaded on the miss before index 0 was selected. The reload is now opt-in per call and off on that path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
/**
|
|
* Desktop update channel. Electron only.
|
|
*
|
|
* `stable` follows the tagged releases of `4gray/iptvnator`; `nightly`
|
|
* follows the prereleases that every master merge publishes to
|
|
* `4gray/iptvnator-nightly`. The value is persisted with the other settings
|
|
* in the renderer and mirrored into the main-process config by the
|
|
* `SETTINGS_UPDATE` handler, because the startup update check runs before
|
|
* any renderer exists to ask.
|
|
*
|
|
* Channel switches are forward-only on purpose: a nightly build stays
|
|
* installed until the next stable version is newer than it. A downgrade
|
|
* could land on a release that does not understand the database schema a
|
|
* nightly migration already applied.
|
|
*/
|
|
export type AppUpdateChannel = 'stable' | 'nightly';
|
|
|
|
export const APP_UPDATE_CHANNELS: readonly AppUpdateChannel[] = [
|
|
'stable',
|
|
'nightly',
|
|
];
|
|
|
|
export const DEFAULT_APP_UPDATE_CHANNEL: AppUpdateChannel = 'stable';
|
|
|
|
/**
|
|
* Prerelease identifier every nightly version carries
|
|
* (`0.23.1-nightly.20260915.1234`). The nightly build sets the same name as
|
|
* electron-builder's publish channel, which names the updater metadata
|
|
* (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`), and
|
|
* electron-updater matches release tags on the same identifier.
|
|
*/
|
|
export const NIGHTLY_PRERELEASE_TAG = 'nightly';
|
|
|
|
/** Collapses anything that is not a known channel to `stable`. */
|
|
export function normalizeAppUpdateChannel(value: unknown): AppUpdateChannel {
|
|
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
|
|
? (value as AppUpdateChannel)
|
|
: DEFAULT_APP_UPDATE_CHANNEL;
|
|
}
|
|
|
|
interface AppUpdateRepository {
|
|
owner: string;
|
|
repo: string;
|
|
}
|
|
|
|
const APP_UPDATE_REPOSITORIES: Record<AppUpdateChannel, AppUpdateRepository> = {
|
|
stable: { owner: '4gray', repo: 'iptvnator' },
|
|
nightly: { owner: '4gray', repo: 'iptvnator-nightly' },
|
|
};
|
|
|
|
export function appUpdateRepository(
|
|
channel: AppUpdateChannel
|
|
): AppUpdateRepository {
|
|
return { ...APP_UPDATE_REPOSITORIES[channel] };
|
|
}
|
|
|
|
/**
|
|
* Page a user can open to install by hand. `/releases/latest` never resolves
|
|
* to a prerelease, and the nightly repository holds nothing else, so that
|
|
* channel links to the release list instead.
|
|
*/
|
|
export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string {
|
|
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
|
const base = `https://github.com/${owner}/${repo}/releases`;
|
|
|
|
return channel === 'stable' ? `${base}/latest` : base;
|
|
}
|
|
|
|
/** The channel's release list; the page to browse when one version is missing. */
|
|
export function appUpdateReleasesListUrl(channel: AppUpdateChannel): string {
|
|
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
|
|
|
return `https://github.com/${owner}/${repo}/releases`;
|
|
}
|
|
|
|
export function appUpdateReleasesApiUrl(channel: AppUpdateChannel): string {
|
|
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
|
|
|
return `https://api.github.com/repos/${owner}/${repo}/releases`;
|
|
}
|
|
|
|
const VERSION_PATTERN =
|
|
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
|
|
interface ParsedAppVersion {
|
|
core: [number, number, number];
|
|
prerelease: string[];
|
|
}
|
|
|
|
export function parseAppVersion(
|
|
value: string | null | undefined
|
|
): ParsedAppVersion | null {
|
|
const match = (value ?? '').trim().match(VERSION_PATTERN);
|
|
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
prerelease: match[4] ? match[4].split('.') : [],
|
|
};
|
|
}
|
|
|
|
/** The channel a build belongs to, read from its version string. */
|
|
export function appVersionChannel(
|
|
version: string | null | undefined
|
|
): AppUpdateChannel {
|
|
return parseAppVersion(version)?.prerelease[0] === NIGHTLY_PRERELEASE_TAG
|
|
? 'nightly'
|
|
: 'stable';
|
|
}
|
|
|
|
function compareIdentifiers(left: string, right: string): number {
|
|
const leftNumeric = /^\d+$/.test(left);
|
|
const rightNumeric = /^\d+$/.test(right);
|
|
|
|
if (leftNumeric && rightNumeric) {
|
|
return Math.sign(Number(left) - Number(right));
|
|
}
|
|
|
|
// Semver: numeric identifiers always have lower precedence.
|
|
if (leftNumeric !== rightNumeric) {
|
|
return leftNumeric ? -1 : 1;
|
|
}
|
|
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* Semver precedence for the version strings the updater sees: core
|
|
* numbers first, then a release outranks its own prereleases, then the
|
|
* prerelease identifiers left to right. Unparseable input compares as
|
|
* equal so a junk version can never look like an update.
|
|
*/
|
|
export function compareAppVersions(left: string, right: string): number {
|
|
const parsedLeft = parseAppVersion(left);
|
|
const parsedRight = parseAppVersion(right);
|
|
|
|
if (!parsedLeft || !parsedRight) {
|
|
return 0;
|
|
}
|
|
|
|
for (let index = 0; index < 3; index += 1) {
|
|
const difference = parsedLeft.core[index] - parsedRight.core[index];
|
|
|
|
if (difference !== 0) {
|
|
return Math.sign(difference);
|
|
}
|
|
}
|
|
|
|
if (parsedLeft.prerelease.length === 0) {
|
|
return parsedRight.prerelease.length === 0 ? 0 : 1;
|
|
}
|
|
|
|
if (parsedRight.prerelease.length === 0) {
|
|
return -1;
|
|
}
|
|
|
|
const length = Math.max(
|
|
parsedLeft.prerelease.length,
|
|
parsedRight.prerelease.length
|
|
);
|
|
|
|
for (let index = 0; index < length; index += 1) {
|
|
const leftIdentifier = parsedLeft.prerelease[index];
|
|
const rightIdentifier = parsedRight.prerelease[index];
|
|
|
|
if (leftIdentifier === undefined) {
|
|
return -1;
|
|
}
|
|
|
|
if (rightIdentifier === undefined) {
|
|
return 1;
|
|
}
|
|
|
|
const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
|
|
|
|
if (difference !== 0) {
|
|
return difference;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Marker the main process puts into the release-notes rejection when a
|
|
* version has no published GitHub release. `ipcRenderer.invoke` strips every
|
|
* custom property off a rejection and wraps the message, so the renderer can
|
|
* only recognise the case by this text.
|
|
*/
|
|
export const APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER =
|
|
'Release notes were not found for';
|
|
|
|
export function buildAppUpdateReleaseNotesNotFoundMessage(
|
|
version: string | undefined
|
|
): string {
|
|
return `${APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER} ${version ?? 'latest release'}`;
|
|
}
|
|
|
|
export function isAppUpdateReleaseNotesNotFoundMessage(
|
|
message: string | null | undefined
|
|
): boolean {
|
|
return Boolean(
|
|
message?.includes(APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER)
|
|
);
|
|
}
|