From 7522c7689f54d461f64b37102f20274b759f1a16 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:09:24 +0200 Subject: [PATCH] fix(settings): honest update-channel check and fresh release notes (#1631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5.1 --- .../settings-update-channel-save-and-check.md | 6 + .../updater-release-notes-fresh-catalog.md | 6 + CLAUDE.md | 2 +- .../services/app-update-release-catalog.ts | 63 ++++++- .../app/services/app-update-release-notes.ts | 158 ++++++++++++++++ .../app/services/app-update.service.spec.ts | 169 +++++++++++++++++ .../src/app/services/app-update.service.ts | 113 ++--------- ...ate-release-notes-dialog.component.spec.ts | 131 ++++++++++++- ...p-update-release-notes-dialog.component.ts | 108 ++++++++++- .../settings-about-section.component.html | 103 ++++++++-- .../settings-about-section.component.spec.ts | 178 ++++++++++++++++-- .../settings-about-section.component.ts | 85 +++++++++ .../src/app/settings/settings.component.html | 1 + apps/web/src/assets/i18n/ar.json | 6 + apps/web/src/assets/i18n/ary.json | 6 + apps/web/src/assets/i18n/by.json | 6 + apps/web/src/assets/i18n/de.json | 6 + apps/web/src/assets/i18n/el.json | 6 + apps/web/src/assets/i18n/en.json | 6 + apps/web/src/assets/i18n/es.json | 6 + apps/web/src/assets/i18n/fr.json | 6 + apps/web/src/assets/i18n/hu.json | 6 + apps/web/src/assets/i18n/it.json | 6 + apps/web/src/assets/i18n/ja.json | 6 + apps/web/src/assets/i18n/ko.json | 6 + apps/web/src/assets/i18n/nl.json | 6 + apps/web/src/assets/i18n/pl.json | 6 + apps/web/src/assets/i18n/pt.json | 6 + apps/web/src/assets/i18n/ru.json | 6 + apps/web/src/assets/i18n/tr.json | 6 + apps/web/src/assets/i18n/zh.json | 6 + apps/web/src/assets/i18n/zhtw.json | 6 + docs/architecture/release-pipeline.md | 42 ++++- .../src/lib/app-update-channel.util.spec.ts | 29 +++ .../src/lib/app-update-channel.util.ts | 30 +++ .../src/lib/electron-api.interface.ts | 8 + 36 files changed, 1198 insertions(+), 148 deletions(-) create mode 100644 .changes/settings-update-channel-save-and-check.md create mode 100644 .changes/updater-release-notes-fresh-catalog.md create mode 100644 apps/electron-backend/src/app/services/app-update-release-notes.ts diff --git a/.changes/settings-update-channel-save-and-check.md b/.changes/settings-update-channel-save-and-check.md new file mode 100644 index 000000000..5b6ea86d2 --- /dev/null +++ b/.changes/settings-update-channel-save-and-check.md @@ -0,0 +1,6 @@ +--- +type: fix +area: settings +--- + +The update status in Settings → About now names the channel it describes, and after picking another channel the check button becomes "Save and check": one click saves the choice and reports what that channel offers. Previously "Check again" silently re-checked the still-saved channel, so a fresh nightly could look like "no update". diff --git a/.changes/updater-release-notes-fresh-catalog.md b/.changes/updater-release-notes-fresh-catalog.md new file mode 100644 index 000000000..897bcacb5 --- /dev/null +++ b/.changes/updater-release-notes-fresh-catalog.md @@ -0,0 +1,6 @@ +--- +type: fix +area: updater +--- + +"What's new" no longer fails with a raw error for a build published after the app started: the release list is re-read from GitHub when the requested version is missing, so a freshly offered nightly shows its notes. When a version really has no release, the dialog now says so in plain words and links to the channel's releases page. diff --git a/CLAUDE.md b/CLAUDE.md index 51ac9b058..a1dc06536 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1725,7 +1725,7 @@ The factory pattern ensures a single codebase works in both environments without CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `" ()"`. The semver version itself stays untouched on PR and tag builds — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version. **Nightly Builds And Update Channel**: -Master pushes are the nightly channel. The leading `nightly-version` job computes one `-nightly..` version per run (`tools/release/nightly-version.mjs`; the patch is bumped only when `v` already exists on origin, so the release-cut window stays below the imminent release), every build job writes it into `package.json` and sets `publish[0].channel: nightly` in `electron-builder.json` (`--apply --version`; electron-builder does not derive the channel from the prerelease tag for the GitHub provider), and the `create-release` job publishes the artifacts as a prerelease of `4gray/iptvnator-nightly` (secret `NIGHTLY_RELEASE_TOKEN`; missing token only warns) instead of the rolling `test-master` draft, keeping the newest 20. the explicit publish channel names the updater metadata `nightly-mac.yml` / `nightly.yml` / `nightly-linux.yml`, and upload globs plus the macOS merge accept both names. `Settings.updateChannel` (Settings → About, Electron only, default `stable`) is mirrored into the main-process config (`APP_UPDATE_CHANNEL`, `app-update-channel.ts`) for the startup check; `AppUpdateService` applies the channel to electron-updater before every check (`app-update-feed.ts`: feed repository, `allowPrerelease`, channel name, and `allowDowngrade` forced back to `false` — assigning a channel silently enables downgrades) and reads release notes from the repository the requested version belongs to (`app-update-release-catalog.ts`). Switching back to stable is forward-only: the nightly stays until a newer stable release exists, because a downgrade could hit a database schema a nightly migration already applied. Contract: `docs/architecture/release-pipeline.md` ("Nightly channel"). +Master pushes are the nightly channel. The leading `nightly-version` job computes one `-nightly..` version per run (`tools/release/nightly-version.mjs`; the patch is bumped only when `v` already exists on origin, so the release-cut window stays below the imminent release), every build job writes it into `package.json` and sets `publish[0].channel: nightly` in `electron-builder.json` (`--apply --version`; electron-builder does not derive the channel from the prerelease tag for the GitHub provider), and the `create-release` job publishes the artifacts as a prerelease of `4gray/iptvnator-nightly` (secret `NIGHTLY_RELEASE_TOKEN`; missing token only warns) instead of the rolling `test-master` draft, keeping the newest 20. the explicit publish channel names the updater metadata `nightly-mac.yml` / `nightly.yml` / `nightly-linux.yml`, and upload globs plus the macOS merge accept both names. `Settings.updateChannel` (Settings → About, Electron only, default `stable`) is mirrored into the main-process config (`APP_UPDATE_CHANNEL`, `app-update-channel.ts`) for the startup check; `AppUpdateService` applies the channel to electron-updater before every check (`app-update-feed.ts`: feed repository, `allowPrerelease`, channel name, and `allowDowngrade` forced back to `false` — assigning a channel silently enables downgrades) and reads release notes from the repository the requested version belongs to (`AppUpdateReleaseCatalogs` in `app-update-release-notes.ts` over `app-update-release-catalog.ts`; a catalog is a process-lifetime snapshot, so a version missing from a fully paged list reloads it once, readers of one catalog are serialized through `runExclusive` because that reload rebuilds the array under an index another reader holds, and a newly found update drops every catalog — otherwise a nightly published after startup had "no release notes"; the dialog recognises that rejection by the shared `APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER` text and links to the channel's release list). Switching back to stable is forward-only: the nightly stays until a newer stable release exists, because a downgrade could hit a database schema a nightly migration already applied. The About section's status badge names the channel the verdict describes (`status.verdictChannel`, stamped by every check and kept across a channel change saved mid-download, since that download still belongs to the old channel), and while the select shows an unsaved other channel the plain "Check again" becomes "Save and check for updates" (submits the form; `setChannel` re-checks on its own) — a check against an unsaved channel is deliberately not offered. Contract: `docs/architecture/release-pipeline.md` ("Nightly channel"). ### Testing Strategy diff --git a/apps/electron-backend/src/app/services/app-update-release-catalog.ts b/apps/electron-backend/src/app/services/app-update-release-catalog.ts index e629a83be..1ee8ce54a 100644 --- a/apps/electron-backend/src/app/services/app-update-release-catalog.ts +++ b/apps/electron-backend/src/app/services/app-update-release-catalog.ts @@ -1,7 +1,7 @@ import { AppUpdateChannel, appUpdateReleasesApiUrl, - appUpdateReleasesPageUrl, + appUpdateReleasesListUrl, appVersionChannel, } from '@iptvnator/shared/interfaces'; @@ -59,11 +59,18 @@ function isGitHubRelease(value: unknown): value is GitHubReleaseResponse { * 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. + * + * The list is a snapshot of GitHub at load time and the service keeps it + * for the whole process, so a release published later is invisible to it + * until something reloads it — `findIndex` does, once, when a version is + * missing from a fully paged list (a nightly the updater just offered had + * "no release notes" for exactly this reason). */ export class AppUpdateReleaseCatalog { - readonly releases: CachedGitHubRelease[] = []; + releases: CachedGitHubRelease[] = []; loadedReleasePages = 0; loadedAllReleases = false; + private queue: Promise = Promise.resolve(); constructor( readonly channel: AppUpdateChannel, @@ -87,13 +94,45 @@ export class AppUpdateReleaseCatalog { } } - /** Index of `version` (or its tag), paging further until it is found. */ - async findIndex(version: string | undefined): Promise { + /** + * Runs `work` after every earlier caller's work has settled. A read + * computes an index into `releases` and dereferences it after awaiting + * more pages, and `findIndex` may rebuild the whole list in between, + * so two overlapping readers of one catalog must never interleave. + */ + runExclusive(work: () => Promise): Promise { + const run = this.queue.then(work, work); + + this.queue = run.catch(() => undefined); + + return run; + } + + /** Forgets every loaded page so the next read starts from GitHub again. */ + reset(): void { + this.releases = []; + this.loadedReleasePages = 0; + this.loadedAllReleases = false; + } + + /** + * Index of `version` (or its tag), paging further until it is found. + * With `reloadOnMiss`, a miss on a completely paged list reloads the + * list once before answering -1: the snapshot may simply predate the + * release. Callers that fall back to the newest release on a miss pass + * false — an unpublished local build would otherwise page the whole + * list twice for an answer the first pass already had. + */ + async findIndex( + version: string | undefined, + reloadOnMiss = true + ): Promise { if (!version) { return -1; } const normalizedVersion = normalizeVersion(version); + let reloaded = !reloadOnMiss; while (true) { const index = this.releases.findIndex( @@ -102,10 +141,19 @@ export class AppUpdateReleaseCatalog { normalizeVersion(release.tagName) === normalizedVersion ); - if (index !== -1 || this.loadedAllReleases) { + if (index !== -1) { return index; } + if (this.loadedAllReleases) { + if (reloaded) { + return -1; + } + + reloaded = true; + this.reset(); + } + await this.ensurePageLoaded(this.loadedReleasePages + 1); } } @@ -143,10 +191,7 @@ export class AppUpdateReleaseCatalog { throw new Error('GitHub releases response was not an array'); } - const releasesPageUrl = appUpdateReleasesPageUrl(this.channel).replace( - /\/latest$/, - '' - ); + const releasesPageUrl = appUpdateReleasesListUrl(this.channel); for (const release of payload) { if (!isGitHubRelease(release) || !this.belongsToChannel(release)) { diff --git a/apps/electron-backend/src/app/services/app-update-release-notes.ts b/apps/electron-backend/src/app/services/app-update-release-notes.ts new file mode 100644 index 000000000..fa8cf171d --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update-release-notes.ts @@ -0,0 +1,158 @@ +import { + AppUpdateChannel, + appVersionChannel, + buildAppUpdateReleaseNotesNotFoundMessage, + ElectronBridgeAppUpdateReleaseNotes, + ElectronBridgeAppUpdateReleaseNotesRequest, +} from '@iptvnator/shared/interfaces'; +import { + AppUpdateReleaseCatalog, + CachedGitHubRelease, + ReleaseFetcher, +} from './app-update-release-catalog'; + +function toReleaseNotes( + release: CachedGitHubRelease, + index: number, + catalog: AppUpdateReleaseCatalog +): 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 < catalog.releases.length - 1 || !catalog.loadedAllReleases, + }; +} + +/** + * One lazily created release catalog per channel, plus the two reads the + * updater performs on them: release notes for a version (with previous / + * next paging) and the newest release for the manual-install fallback. + * Every read runs inside the catalog's exclusive queue. + */ +export class AppUpdateReleaseCatalogs { + private readonly catalogs = new Map< + AppUpdateChannel, + AppUpdateReleaseCatalog + >(); + + constructor( + private readonly fetcher: ReleaseFetcher, + private readonly userAgent: string + ) {} + + /** + * Forgets every loaded catalog. Called when the updater finds a new + * release: it is newer than anything the catalogs were loaded with, so + * their snapshots are stale by definition, and forgetting them keeps + * the "latest" fallback honest (a version lookup reloads on its own). + */ + clear(): void { + this.catalogs.clear(); + } + + /** The newest release of `channel`, loading the first page if needed. */ + latestRelease( + channel: AppUpdateChannel + ): Promise { + const catalog = this.catalogFor(channel); + + return catalog.runExclusive(async () => { + await catalog.ensureFirstReleaseLoaded(); + + return catalog.latest; + }); + } + + /** + * 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. + */ + getReleaseNotes( + configuredChannel: AppUpdateChannel, + request: ElectronBridgeAppUpdateReleaseNotesRequest + ): Promise { + const catalog = this.catalogFor( + request.version + ? appVersionChannel(request.version) + : configuredChannel + ); + + return catalog.runExclusive(() => + this.readReleaseNotes(catalog, request) + ); + } + + private catalogFor(channel: AppUpdateChannel): AppUpdateReleaseCatalog { + let catalog = this.catalogs.get(channel); + + if (!catalog) { + catalog = new AppUpdateReleaseCatalog( + channel, + this.fetcher, + this.userAgent + ); + this.catalogs.set(channel, catalog); + } + + return catalog; + } + + /** The exclusive section of `getReleaseNotes`: one reader per catalog. */ + private async readReleaseNotes( + catalog: AppUpdateReleaseCatalog, + request: ElectronBridgeAppUpdateReleaseNotesRequest + ): Promise { + const canFallbackToLatest = + !request.direction && + (!request.version || request.fallbackToLatest); + + if (canFallbackToLatest) { + await catalog.ensureFirstReleaseLoaded(); + } else { + await catalog.ensurePageLoaded(1); + } + + let index = await catalog.findIndex( + request.version, + !canFallbackToLatest + ); + + if (index === -1 && canFallbackToLatest) { + index = 0; + } + + if (index === -1) { + throw new Error( + buildAppUpdateReleaseNotesNotFoundMessage(request.version) + ); + } + + if (request.direction === 'previous') { + index += 1; + while ( + index >= catalog.releases.length && + !catalog.loadedAllReleases + ) { + await catalog.ensurePageLoaded(catalog.loadedReleasePages + 1); + } + } else if (request.direction === 'next') { + index -= 1; + } + + const release = catalog.releases[index]; + + if (!release) { + throw new Error('No release notes are available in that direction'); + } + + return toReleaseNotes(release, index, catalog); + } +} 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 c506ee136..ed02bb20e 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 @@ -4,6 +4,7 @@ import { ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; +import { AppUpdateReleaseCatalog } from './app-update-release-catalog'; import { AppUpdateService } from './app-update.service'; class FakeUpdater extends EventEmitter { @@ -440,6 +441,9 @@ describe('AppUpdateService', () => { tagName: 'v0.24.0', version: '0.24.0', }); + // The miss must not reload the list before falling back: the + // first pass already holds the answer. + expect(fetcher).toHaveBeenCalledTimes(1); }); it('stores available release details from updater events', () => { @@ -690,6 +694,7 @@ describe('AppUpdateService', () => { it('leaves a running download on the old channel but reports the new one', async () => { const { service, updater } = createService(); + await service.checkForUpdates(); service.handleUpdateAvailable({ version: '0.23.0' }); updater.downloadUpdate.mockImplementationOnce( () => new Promise(() => undefined) @@ -707,6 +712,26 @@ describe('AppUpdateService', () => { manualDownloadUrl: 'https://github.com/4gray/iptvnator-nightly/releases', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading, + // The retained download still belongs to the channel it was found on. + verdictChannel: 'stable', + }); + }); + + it('stamps every verdict with the channel it was checked on', async () => { + const { service } = createService(); + + expect(service.getStatus().verdictChannel).toBeUndefined(); + + await service.checkForUpdates(); + expect(service.getStatus().verdictChannel).toBe('stable'); + + service.handleUpdateNotAvailable({ version: '0.22.0' }); + service.setChannel('nightly'); + await Promise.resolve(); + + expect(service.getStatus()).toMatchObject({ + channel: 'nightly', + verdictChannel: 'nightly', }); }); @@ -753,6 +778,150 @@ describe('AppUpdateService', () => { }); }); + it('reloads a fully paged catalog when a version the updater just offered is missing from it', async () => { + // A snapshot taken before the newest nightly was published: only the + // older one exists, and it fits in one page, so the catalog believes + // it has read everything. + const published: unknown[] = [nightlyReleases[2]]; + const fetcher = jest.fn(async (_url: string) => ({ + json: jest.fn().mockResolvedValue([...published]), + ok: true, + status: 200, + statusText: 'OK', + })); + const { service } = createService({ fetcher }); + + const older = await service.getReleaseNotes({ + version: '0.23.1-nightly.20260914.5', + }); + expect(older.tagName).toBe('v0.23.1-nightly.20260914.5'); + expect(fetcher).toHaveBeenCalledTimes(1); + + published.unshift(nightlyReleases[0]); + + const newer = await service.getReleaseNotes({ + version: '0.23.1-nightly.20260915.7', + }); + expect(newer).toMatchObject({ + tagName: 'v0.23.1-nightly.20260915.7', + hasNext: false, + hasPrevious: true, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + + // A version GitHub really does not have reloads once and then stops. + await expect( + service.getReleaseNotes({ version: '0.23.1-nightly.20260916.9' }) + ).rejects.toThrow( + 'Release notes were not found for 0.23.1-nightly.20260916.9' + ); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('serializes overlapping readers so a reload cannot pull the list out from under a navigation', async () => { + // Page 1 answers only once the gate opens, so both readers are in + // flight together; it is a full page (per_page is 10), so the release + // the navigation needs sits on page 2. + const pageOne = Array.from({ length: 10 }, (_, offset) => ({ + body: `release ${34 - offset}`, + draft: false, + html_url: `https://github.com/4gray/iptvnator/releases/tag/v0.${34 - offset}.0`, + name: `v0.${34 - offset}.0`, + prerelease: false, + published_at: '2026-07-01T00:00:00.000Z', + tag_name: `v0.${34 - offset}.0`, + })); + const pageTwo = githubReleases.slice(1); + let openGate: () => void = () => undefined; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + const fetcher = jest.fn(async (url: string) => { + const page = Number(new URL(url).searchParams.get('page') ?? '1'); + + if (page === 1) { + await gate; + } + + return { + json: jest + .fn() + .mockResolvedValue( + page === 1 ? pageOne : page === 2 ? pageTwo : [] + ), + ok: true, + status: 200, + statusText: 'OK', + }; + }); + const { service } = createService({ fetcher }); + + // A version GitHub does not have: this reader resets the catalog. + const missing = service.getReleaseNotes({ version: '0.99.0' }); + // A navigation that dereferences an index after paging further. + const navigation = service.getReleaseNotes({ + direction: 'previous', + version: 'v0.23.0', + }); + openGate(); + + await expect(missing).rejects.toThrow( + 'Release notes were not found for 0.99.0' + ); + await expect(navigation).resolves.toMatchObject({ + tagName: 'v0.22.0', + hasNext: true, + hasPrevious: false, + }); + }); + + it('runs catalog work one caller at a time and survives a rejected caller', async () => { + const catalog = new AppUpdateReleaseCatalog( + 'stable', + createReleaseFetcher(), + 'iptvnator/test' + ); + const order: string[] = []; + let finishFirst: () => void = () => undefined; + + const first = catalog.runExclusive(async () => { + order.push('first:start'); + await new Promise((resolve) => { + finishFirst = resolve; + }); + order.push('first:end'); + throw new Error('first failed'); + }); + const second = catalog.runExclusive(async () => { + order.push('second:start'); + + return 'second'; + }); + await Promise.resolve(); + + expect(order).toEqual(['first:start']); + + finishFirst(); + + await expect(first).rejects.toThrow('first failed'); + await expect(second).resolves.toBe('second'); + expect(order).toEqual(['first:start', 'first:end', 'second:start']); + }); + + it('forgets loaded catalogs once the updater reports a newer release', async () => { + const fetcher = createReleaseFetcher(); + const { service } = createService({ fetcher }); + + await service.getReleaseNotes(); + await service.getReleaseNotes(); + expect(fetcher).toHaveBeenCalledTimes(1); + + service.handleUpdateAvailable({ version: '0.25.0' }); + + await service.getReleaseNotes(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('reads release notes from the repository the requested version belongs to', async () => { const fetcher = createChannelReleaseFetcher(); const { service } = createService({ fetcher }); 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 c8b61cdf5..ab51a8599 100644 --- a/apps/electron-backend/src/app/services/app-update.service.ts +++ b/apps/electron-backend/src/app/services/app-update.service.ts @@ -13,12 +13,12 @@ import { } from '@iptvnator/shared/interfaces'; import { AppUpdateFeedTarget, applyAppUpdateChannel } from './app-update-feed'; import { - AppUpdateReleaseCatalog, CachedGitHubRelease, normalizeVersion, ReleaseFetcher, ReleaseFetchResponse, } from './app-update-release-catalog'; +import { AppUpdateReleaseCatalogs } from './app-update-release-notes'; export const APP_UPDATE_MANUAL_DOWNLOAD_URL = appUpdateReleasesPageUrl( DEFAULT_APP_UPDATE_CHANNEL @@ -163,33 +163,11 @@ function toReleaseInfo( }; } -function toReleaseNotes( - release: CachedGitHubRelease, - index: number, - catalog: AppUpdateReleaseCatalog -): 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 < catalog.releases.length - 1 || !catalog.loadedAllReleases, - }; -} - export class AppUpdateService { private readonly currentVersion: string; private readonly isPackaged: boolean; private readonly supportedSelfUpdate: boolean; - private readonly releaseFetcher: ReleaseFetcher; - private readonly catalogs = new Map< - AppUpdateChannel, - AppUpdateReleaseCatalog - >(); + private readonly catalogs: AppUpdateReleaseCatalogs; private readonly updater: AppUpdaterAdapter | null = null; private channel: AppUpdateChannel; private checkForUpdatesPromise: Promise | null = @@ -210,10 +188,13 @@ export class AppUpdateService { options.platform ?? process.platform, options.processEnv ?? process.env ); - this.releaseFetcher = + const releaseFetcher: ReleaseFetcher = options.releaseFetcher ?? - ((url, init) => - fetch(url, init) as Promise); + ((url, init) => fetch(url, init) as Promise); + this.catalogs = new AppUpdateReleaseCatalogs( + releaseFetcher, + `iptvnator/${this.currentVersion}` + ); this.status = { currentVersion: this.currentVersion, manualDownloadUrl: appUpdateReleasesPageUrl(this.channel), @@ -239,6 +220,8 @@ export class AppUpdateService { * 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. + * `verdictChannel` keeps naming the channel that download came from + * until a check on the new channel replaces it. */ setChannel(channel: AppUpdateChannel): void { if (channel === this.channel) { @@ -250,7 +233,8 @@ export class AppUpdateService { const busy = this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || - this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; + this.status.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; if (busy || !this.isPackaged) { this.setStatus({}); @@ -287,6 +271,7 @@ export class AppUpdateService { latestVersion: undefined, release: undefined, status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking, + verdictChannel: this.channel, }); try { @@ -311,55 +296,11 @@ 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( + /** Release notes for a version; see `AppUpdateReleaseCatalogs`. */ + getReleaseNotes( request: ElectronBridgeAppUpdateReleaseNotesRequest = {} ): Promise { - const catalog = this.catalogFor( - request.version ? appVersionChannel(request.version) : this.channel - ); - const canFallbackToLatest = - !request.direction && (!request.version || request.fallbackToLatest); - - if (canFallbackToLatest) { - await catalog.ensureFirstReleaseLoaded(); - } else { - await catalog.ensurePageLoaded(1); - } - - let index = await catalog.findIndex(request.version); - - if (index === -1 && canFallbackToLatest) { - 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 >= catalog.releases.length && !catalog.loadedAllReleases) { - await catalog.ensurePageLoaded(catalog.loadedReleasePages + 1); - } - } else if (request.direction === 'next') { - index -= 1; - } - - const release = catalog.releases[index]; - - if (!release) { - throw new Error('No release notes are available in that direction'); - } - - return toReleaseNotes(release, index, catalog); + return this.catalogs.getReleaseNotes(this.channel, request); } async downloadUpdate(): Promise { @@ -416,6 +357,9 @@ export class AppUpdateService { handleUpdateAvailable(info: AppUpdateInfo): void { const release = toRelease(info); + + this.catalogs.clear(); + this.setStatus({ error: undefined, latestVersion: release.version, @@ -469,21 +413,6 @@ 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 { @@ -518,9 +447,7 @@ export class AppUpdateService { * the user at its release page. */ private async checkGitHubReleaseForManualUpdate(): Promise { - const catalog = this.catalogFor(this.channel); - await catalog.ensureFirstReleaseLoaded(); - const latestRelease = catalog.latest; + const latestRelease = await this.catalogs.latestRelease(this.channel); if (!latestRelease) { this.setStatus({ 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 index acc5d24f1..7b19aa44d 100644 --- 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 @@ -60,8 +60,9 @@ describe('AppUpdateReleaseNotesDialogComponent', () => { version: '0.23.0', }); expect( - fixture.nativeElement.querySelector('[data-test-id="release-notes-body"] h2') - ?.textContent + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-body"] h2' + )?.textContent ).toContain('v0.23.0'); expect( fixture.nativeElement.querySelector( @@ -91,6 +92,128 @@ describe('AppUpdateReleaseNotesDialogComponent', () => { expect(image.classList).toContain('release-notes-dialog__image'); }); + it('explains a version without a GitHub release and links to the channel release list', async () => { + ( + window.electron.getAppUpdateReleaseNotes as jest.Mock + ).mockRejectedValueOnce( + new Error( + "Error invoking remote method 'APP_UPDATE:GET_RELEASE_NOTES': Error: Release notes were not found for 0.23.0" + ) + ); + const openSpy = jest + .spyOn(window, 'open') + .mockImplementation(() => null); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const error = fixture.nativeElement.querySelector( + '[data-test-id="release-notes-error"]' + ) as HTMLElement; + expect(error.textContent).toContain( + 'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND' + ); + expect(error.textContent).not.toContain('Error invoking remote method'); + expect(error.classList).not.toContain( + 'release-notes-dialog__error--failed' + ); + expect(fixture.componentInstance.error()).toMatchObject({ + kind: 'not-found', + version: '0.23.0', + }); + + ( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-open-releases"]' + ) as HTMLButtonElement + ).click(); + + expect(openSpy).toHaveBeenCalledWith( + 'https://github.com/4gray/iptvnator/releases', + '_blank', + 'noreferrer' + ); + openSpy.mockRestore(); + }); + + it('shows a generic failure with the underlying reason for other errors', async () => { + ( + window.electron.getAppUpdateReleaseNotes as jest.Mock + ).mockRejectedValueOnce( + new Error( + "Error invoking remote method 'APP_UPDATE:GET_RELEASE_NOTES': Error: GitHub releases request failed: 403 rate limit exceeded" + ) + ); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const error = fixture.nativeElement.querySelector( + '[data-test-id="release-notes-error"]' + ) as HTMLElement; + expect(error.classList).toContain( + 'release-notes-dialog__error--failed' + ); + expect(error.textContent).toContain( + 'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED' + ); + expect(error.textContent).toContain( + 'GitHub releases request failed: 403 rate limit exceeded' + ); + expect(error.textContent).not.toContain('Error invoking remote method'); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-open-releases"]' + ) + ).not.toBeNull(); + }); + + it('offers the release list, not the earlier release, when paging fails', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-open-release"]' + ) + ).not.toBeNull(); + + ( + window.electron.getAppUpdateReleaseNotes as jest.Mock + ).mockRejectedValueOnce( + new Error('No release notes are available in that direction') + ); + ( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-previous"]' + ) as HTMLButtonElement + ).click(); + await fixture.whenStable(); + fixture.detectChanges(); + + // The earlier notes stay for navigation, but the body is the error, + // so the action must match the body. + expect(fixture.componentInstance.notes()?.tagName).toBe('v0.23.0'); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-error"]' + ) + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-open-release"]' + ) + ).toBeNull(); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="release-notes-open-releases"]' + ) + ).not.toBeNull(); + }); + it('loads previous notes lazily without closing the dialog', async () => { fixture.detectChanges(); await fixture.whenStable(); @@ -114,7 +237,9 @@ describe('AppUpdateReleaseNotesDialogComponent', () => { ).click(); await fixture.whenStable(); - expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenLastCalledWith({ + 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 index 2078703f1..870e1c142 100644 --- 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 @@ -21,13 +21,27 @@ import { DomSanitizer } from '@angular/platform-browser'; import { TranslatePipe } from '@ngx-translate/core'; import { marked } from 'marked'; import { + appUpdateReleasesListUrl, + appVersionChannel, ElectronBridgeAppUpdateReleaseNotes, ElectronBridgeAppUpdateReleaseNotesDirection, ElectronBridgeAppUpdateReleaseNotesRequest, + isAppUpdateReleaseNotesNotFoundMessage, } from '@iptvnator/shared/interfaces'; const RELEASE_NOTES_IMAGE_CLASS = 'release-notes-dialog__image'; +/** `ipcRenderer.invoke` wraps a main-process rejection; the wrapper is noise. */ +const IPC_REJECTION_PREFIX = + /^Error invoking remote method '[^']*': (?:Error: )?/; + +interface ReleaseNotesLoadError { + /** `not-found`: GitHub has no release for that version; `failed`: anything else. */ + kind: 'not-found' | 'failed'; + message: string; + version?: string; +} + export interface AppUpdateReleaseNotesDialogData { initialVersion?: string; fallbackToLatest?: boolean; @@ -100,8 +114,31 @@ function decorateReleaseNotesHtml(html: string): string {
- } @else if (error()) { -

{{ error() }}

+ } @else if (error(); as loadError) { +
+ @if (loadError.kind === 'not-found') { +

+ {{ + 'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND' + | translate: { version: loadError.version } + }} +

+ } @else { +

+ {{ + 'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED' + | translate + }} +

+ {{ loadError.message }} + } +
} @else {
- @if (notes()?.htmlUrl; as htmlUrl) { - + } @else if (notes()?.htmlUrl; as htmlUrl) { + @@ -167,9 +222,25 @@ function decorateReleaseNotesHtml(html: string): string { } .release-notes-dialog__error { + padding: 16px 2px 0; + color: var(--app-body-color); + } + + .release-notes-dialog__error--failed { color: var(--app-error-color, #ef4444); } + .release-notes-dialog__error p { + margin: 0; + } + + .release-notes-dialog__error small { + display: block; + margin-top: 6px; + color: var(--app-muted-color); + overflow-wrap: anywhere; + } + .release-notes-dialog__body { max-height: min(58vh, 560px); overflow: auto; @@ -223,8 +294,18 @@ export class AppUpdateReleaseNotesDialogComponent implements OnInit { private readonly sanitizer = inject(DomSanitizer); readonly loading = signal(false); - readonly error = signal(null); + readonly error = signal(null); readonly notes = signal(null); + /** Release list of the channel the failed request was about. */ + readonly releasesPageUrl = computed(() => + appUpdateReleasesListUrl( + appVersionChannel( + this.error()?.version ?? + this.notes()?.version ?? + this.data.initialVersion + ) + ) + ); readonly renderedMarkdown = computed(() => { const markdown = this.notes()?.bodyMarkdown ?? ''; const html = marked.parse(markdown, { async: false }) as string; @@ -242,7 +323,10 @@ export class AppUpdateReleaseNotesDialogComponent implements OnInit { direction?: ElectronBridgeAppUpdateReleaseNotesDirection ): Promise { if (!window.electron?.getAppUpdateReleaseNotes) { - this.error.set('Release notes are not available in this build.'); + this.error.set({ + kind: 'failed', + message: 'Release notes are not available in this build.', + }); return; } @@ -275,9 +359,17 @@ export class AppUpdateReleaseNotesDialogComponent implements OnInit { await window.electron.getAppUpdateReleaseNotes(request) ); } catch (error) { - this.error.set( + const message = ( error instanceof Error ? error.message : String(error) - ); + ).replace(IPC_REJECTION_PREFIX, ''); + + this.error.set({ + kind: isAppUpdateReleaseNotesNotFoundMessage(message) + ? 'not-found' + : 'failed', + message, + version, + }); } finally { this.loading.set(false); } 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 79210c5f1..7542ce093 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -1,7 +1,4 @@ -
+
info @@ -40,7 +37,19 @@

{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}

-
+
+ @if (appliedChannelLabelKey(); as channelLabelKey) { + + {{ channelLabelKey | translate }} + + } {{ appUpdateStatusLabelKey() @@ -62,6 +71,41 @@ data-test-id="app-update-progress" > } + @if (hasPendingChannelChange()) { + + {{ + 'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT' + | translate + : { + current: + (channelLabelKey( + verdictChannel() + ) | translate), + next: + (channelLabelKey( + draftUpdateChannel() + ) | translate), + } + }} + + } @else if (hasRetainedOtherChannelVerdict()) { + + {{ + 'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT' + | translate + : { + current: + (channelLabelKey( + verdictChannel() + ) | translate), + next: + (channelLabelKey( + updateStatus.channel + ) | translate), + } + }} + + }
@if (canSelectUpdateChannel()) {
- @if (form()!.get('updateChannel')?.value === 'nightly') { + @if ( + form()!.get('updateChannel')?.value === 'nightly' + ) { }
- + @if (canSaveAndCheckForAppUpdate()) { + + } @else { + + } @if (canDownloadAppUpdate()) { } 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 index 5337ce108..ca8dd90a3 100644 --- a/apps/web/src/app/settings/settings-about-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-about-section.component.spec.ts @@ -8,7 +8,10 @@ import { import { SettingsAboutSectionComponent } from './settings-about-section.component'; import { SETTINGS_UPDATE_CHANNEL_OPTIONS } from './settings-options'; -function getButton(fixture: ComponentFixture, id: string) { +function getButton( + fixture: ComponentFixture, + id: string +) { return fixture.nativeElement.querySelector( `[data-test-id="${id}"]` ) as HTMLButtonElement | null; @@ -30,10 +33,7 @@ describe('SettingsAboutSectionComponent app updates', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - SettingsAboutSectionComponent, - TranslateModule.forRoot(), - ], + imports: [SettingsAboutSectionComponent, TranslateModule.forRoot()], }).compileComponents(); fixture = TestBed.createComponent(SettingsAboutSectionComponent); @@ -43,7 +43,9 @@ describe('SettingsAboutSectionComponent app updates', () => { const download = jest.fn(); fixture.componentInstance.downloadAppUpdate.subscribe(download); const openNotes = jest.fn(); - fixture.componentInstance.openAppUpdateReleaseNotes.subscribe(openNotes); + fixture.componentInstance.openAppUpdateReleaseNotes.subscribe( + openNotes + ); configureComponent(fixture, { currentVersion: '0.22.0', latestVersion: '0.23.0', @@ -84,7 +86,9 @@ describe('SettingsAboutSectionComponent app updates', () => { it('shows release notes for the current version when no update is available', () => { const openNotes = jest.fn(); - fixture.componentInstance.openAppUpdateReleaseNotes.subscribe(openNotes); + fixture.componentInstance.openAppUpdateReleaseNotes.subscribe( + openNotes + ); configureComponent(fixture, { currentVersion: '0.22.0', latestVersion: '0.22.0', @@ -139,10 +143,7 @@ describe('SettingsAboutSectionComponent version display', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - SettingsAboutSectionComponent, - TranslateModule.forRoot(), - ], + imports: [SettingsAboutSectionComponent, TranslateModule.forRoot()], }).compileComponents(); fixture = TestBed.createComponent(SettingsAboutSectionComponent); @@ -223,10 +224,7 @@ describe('SettingsAboutSectionComponent update channel', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - SettingsAboutSectionComponent, - TranslateModule.forRoot(), - ], + imports: [SettingsAboutSectionComponent, TranslateModule.forRoot()], }).compileComponents(); fixture = TestBed.createComponent(SettingsAboutSectionComponent); @@ -271,4 +269,154 @@ describe('SettingsAboutSectionComponent update channel', () => { expect(query('app-update-nightly-on-stable-hint')).toBeNull(); }); + + it('labels the verdict with the channel it describes', () => { + configure({ channel: 'stable' }, null); + + expect(query('app-update-status-channel')?.textContent?.trim()).toBe( + 'SETTINGS.APP_UPDATE_CHANNEL_STABLE' + ); + + configure({ channel: 'nightly' }, null); + + expect(query('app-update-status-channel')?.textContent?.trim()).toBe( + 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY' + ); + }); + + it('offers Save and check instead of a plain check while the select shows another channel', () => { + const check = jest.fn(); + const saveAndCheck = jest.fn(); + fixture.componentInstance.checkForAppUpdate.subscribe(check); + fixture.componentInstance.saveAndCheckForAppUpdate.subscribe( + saveAndCheck + ); + const form = new FormGroup({ + updateChannel: new FormControl('stable'), + }); + configure( + { + channel: 'stable', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + }, + form + ); + + expect(query('app-update-check')).not.toBeNull(); + expect(query('app-update-save-and-check')).toBeNull(); + expect(query('app-update-channel-pending-hint')).toBeNull(); + + form.controls.updateChannel.setValue('nightly'); + fixture.detectChanges(); + + expect(query('app-update-check')).toBeNull(); + expect(query('app-update-channel-pending-hint')).not.toBeNull(); + expect( + query('app-update-status')?.classList.contains( + 'app-update-status--stale' + ) + ).toBe(true); + + query('app-update-save-and-check')?.click(); + + expect(saveAndCheck).toHaveBeenCalledTimes(1); + expect(check).not.toHaveBeenCalled(); + }); + + it('returns to the plain check once the saved channel matches the select', () => { + const form = new FormGroup({ + updateChannel: new FormControl('nightly'), + }); + configure({ channel: 'stable' }, form); + + expect(query('app-update-save-and-check')).not.toBeNull(); + + configure( + { + channel: 'nightly', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking, + }, + form + ); + + expect(query('app-update-save-and-check')).toBeNull(); + expect(query('app-update-channel-pending-hint')).toBeNull(); + expect( + query('app-update-status')?.classList.contains( + 'app-update-status--stale' + ) + ).toBe(false); + expect((query('app-update-check') as HTMLButtonElement).disabled).toBe( + true + ); + }); + + it('keeps the plain check while a download of the previous channel is in flight or finished', () => { + const form = new FormGroup({ + updateChannel: new FormControl('nightly'), + }); + + for (const status of [ + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading, + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded, + ]) { + configure( + { channel: 'stable', latestVersion: '0.24.0', status }, + form + ); + + expect(query('app-update-save-and-check')).toBeNull(); + expect(query('app-update-check')).not.toBeNull(); + expect(query('app-update-channel-pending-hint')).not.toBeNull(); + } + }); + + it('attributes a kept download to the channel it was found on, not the saved one', () => { + const form = new FormGroup({ + updateChannel: new FormControl('nightly'), + }); + configure( + { + channel: 'nightly', + latestVersion: '0.24.0', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded, + verdictChannel: 'stable', + }, + form + ); + + expect(query('app-update-status-channel')?.textContent?.trim()).toBe( + 'SETTINGS.APP_UPDATE_CHANNEL_STABLE' + ); + expect(query('app-update-channel-pending-hint')).toBeNull(); + expect(query('app-update-channel-retained-hint')).not.toBeNull(); + expect(query('app-update-save-and-check')).toBeNull(); + + // Once the new channel has been checked the badge follows it. + configure( + { + channel: 'nightly', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + verdictChannel: 'nightly', + }, + form + ); + + expect(query('app-update-status-channel')?.textContent?.trim()).toBe( + 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY' + ); + expect(query('app-update-channel-retained-hint')).toBeNull(); + }); + + it('disables Save and check while the form cannot be saved', () => { + const form = new FormGroup({ + updateChannel: new FormControl('nightly'), + broken: new FormControl('', () => ({ invalid: true })), + }); + configure({ channel: 'stable' }, form); + + expect( + (query('app-update-save-and-check') as HTMLButtonElement).disabled + ).toBe(true); + }); }); 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 23c3508ec..0065b8b02 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -13,6 +13,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { TranslateModule } from '@ngx-translate/core'; import { + AppUpdateChannel, + APP_UPDATE_CHANNELS, ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; @@ -38,6 +40,8 @@ import { UpdateChannelOption } from './settings.models'; '.app-update-channel { margin-top: 12px; }', '.app-update-channel mat-form-field { width: 100%; max-width: 320px; }', '.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }', + '.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 650; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }', + '.app-update-status--stale strong, .app-update-status--stale .app-update-status__channel { opacity: 0.55; }', ], }) export class SettingsAboutSectionComponent { @@ -63,6 +67,13 @@ export class SettingsAboutSectionComponent { }); readonly checkForAppUpdate = output(); + /** + * Emitted instead of a plain check while the channel select shows a + * channel the updater has not been told about yet. The host saves the + * form; the main process re-checks on its own once the saved channel + * changes, so one click yields the verdict the user is looking at. + */ + readonly saveAndCheckForAppUpdate = output(); readonly downloadAppUpdate = output(); readonly installAppUpdate = output(); readonly openManualAppUpdate = output(); @@ -85,6 +96,80 @@ export class SettingsAboutSectionComponent { ); }); + /** + * The channel the select currently shows. Read off the control on every + * check rather than tracked as a signal: the section is eagerly checked + * and the sibling nightly warning already reads the control the same way. + */ + draftUpdateChannel(): AppUpdateChannel | null { + const value: unknown = this.form()?.get('updateChannel')?.value; + + return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel) + ? (value as AppUpdateChannel) + : null; + } + + /** + * True while the select shows a channel other than the one the last + * verdict describes. The verdict on screen is then about the OTHER + * channel, and a plain "check again" would silently repeat it. + */ + hasPendingChannelChange(): boolean { + const draft = this.draftUpdateChannel(); + const applied = this.appUpdateStatus()?.channel; + + return draft !== null && applied !== undefined && draft !== applied; + } + + /** + * Whether Save would make the updater re-check. A download in flight or + * finished belongs to the previous channel and is kept, so saving there + * changes the channel without a new verdict and the plain check stays. + */ + canSaveAndCheckForAppUpdate(): boolean { + const status = this.appUpdateStatus()?.status; + + return ( + this.hasPendingChannelChange() && + status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading && + status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded + ); + } + + channelLabelKey(channel: AppUpdateChannel | null | undefined): string { + return channel === 'nightly' + ? 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY' + : 'SETTINGS.APP_UPDATE_CHANNEL_STABLE'; + } + + /** + * The channel the verdict on screen belongs to. `verdictChannel` is + * stamped by every check; a channel change saved while a download was + * running or done keeps that download, so the two can differ until + * the new channel is checked. + */ + verdictChannel(): AppUpdateChannel | null { + const status = this.appUpdateStatus(); + + return status?.verdictChannel ?? status?.channel ?? null; + } + + /** The kept download was found on another channel than the saved one. */ + hasRetainedOtherChannelVerdict(): boolean { + const status = this.appUpdateStatus(); + + return ( + status?.verdictChannel !== undefined && + status.verdictChannel !== status.channel + ); + } + + readonly appliedChannelLabelKey = computed(() => { + const channel = this.verdictChannel(); + + return channel ? this.channelLabelKey(channel) : null; + }); + readonly isAppUpdateBusy = computed(() => { const status = this.appUpdateStatus()?.status; diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 11133125d..c3ee2e281 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -99,6 +99,7 @@ [updateMessage]="appUpdate.updateMessage()" [appUpdateStatus]="appUpdate.status()" (checkForAppUpdate)="appUpdate.checkForAppUpdate()" + (saveAndCheckForAppUpdate)="onSubmit()" (downloadAppUpdate)="appUpdate.downloadAppUpdate()" (installAppUpdate)="appUpdate.installAppUpdate()" (openManualAppUpdate)="appUpdate.openManualAppUpdate()" diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 41e525686..46cf09b3a 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "إعادة تشغيل التطبيق", "APP_UPDATE_OPEN_RELEASE": "فتح إصدار GitHub", "APP_UPDATE_RELEASE_NOTES": "ما الجديد", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات", "APP_UPDATE_CHANNEL": "قناة التحديث", "APP_UPDATE_CHANNEL_DESCRIPTION": "تتبع Stable الإصدارات المنشورة. تتبع Nightly كل دمج في master وتثبّت الإصدارات التجريبية تلقائيًا. يُطبَّق عند الحفظ.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "حفظ والتحقق من تحديثات {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "هذه النتيجة تخص قناة {{current}}. احفظ للتبديل إلى {{next}} ثم تحقق مرة أخرى.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "تم العثور على هذا التحديث في قناة {{current}}. لم يتم التحقق من قناة {{next}} بعد.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "إصدارات Nightly هي لقطات غير مختبرة من master. قد تتعطل، وتغييراتها في قاعدة البيانات دائمة: عند العودة إلى Stable يبقى هذا الإصدار مثبتًا حتى يصدر إصدار مستقر أحدث. أنشئ نسخة احتياطية من قوائم التشغيل أولًا.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "أنت تستخدم إصدار Nightly. ستقترح قناة Stable الإصدار المستقر التالي بمجرد أن يصبح أحدث من هذا الإصدار.", "EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c34b1280a..d70f556e7 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "عاود شعل التطبيق", "APP_UPDATE_OPEN_RELEASE": "حل إصدار GitHub", "APP_UPDATE_RELEASE_NOTES": "شنو الجديد", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات", "APP_UPDATE_CHANNEL": "قناة التحديث", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable كتبع الإصدارات المنشورة. Nightly كتبع كل دمج ف master وكتثبّت الإصدارات التجريبية أوتوماتيكيا. كيتطبّق ملي تحفظ.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "حفظ وتحقق من تحديثات {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "هاد النتيجة ديال قناة {{current}}. حفظ باش تبدل لـ {{next}} وعاود تحقق.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "هاد التحديث تلقا فقناة {{current}}. قناة {{next}} مازال ما تحققناش منها.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "إصدارات Nightly هي لقطات من master ما مجرّباش. يمكن يطيحو، والتغييرات ديالهم ف قاعدة البيانات دائمة: ملي ترجع ل Stable هاد الإصدار كيبقى مثبّت حتى يخرج إصدار مستقر جديد. دير نسخة احتياطية ديال قوائم التشغيل قبل.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "راك كتستعمل إصدار Nightly. قناة Stable غادي تقترح الإصدار المستقر الجاي ملي يولي جديد كثر من هاد الإصدار.", "EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 816cbc343..a0c5d0077 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Перазапусціць праграму", "APP_UPDATE_OPEN_RELEASE": "Адкрыць рэліз GitHub", "APP_UPDATE_RELEASE_NOTES": "Што новага", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Адкрыць старонку рэлізаў", "APP_UPDATE_CHANNEL": "Канал абнаўленняў", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable ідзе за выпушчанымі рэлізамі. Nightly ідзе за кожным зліццём у master і аўтаматычна ўсталёўвае папярэднія зборкі. Ужываецца пасля захавання.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Захаваць і праверыць абнаўленні {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Гэты вынік датычыцца канала {{current}}. Захавайце, каб пераключыцца на {{next}} і праверыць яшчэ раз.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Гэта абнаўленне знойдзена на канале {{current}}. Канал {{next}} яшчэ не правераны.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-зборкі — неправераныя здымкі master. Яны могуць працаваць нестабільна, а іх змены базы даных незваротныя: пасля вяртання на Stable гэтая зборка застанецца ўсталяванай, пакуль не выйдзе больш новы стабільны рэліз. Спачатку зрабіце рэзервовую копію плэйлістоў.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Вы выкарыстоўваеце nightly-зборку. Канал Stable прапануе наступны стабільны рэліз, як толькі ён стане навейшым за гэтую зборку.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index da25a4cd6..4378a1641 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Anwendung neu starten", "APP_UPDATE_OPEN_RELEASE": "GitHub-Release öffnen", "APP_UPDATE_RELEASE_NOTES": "Was ist neu", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Für {{version}} wurden auf GitHub keine Release-Notes gefunden. Das Release wurde möglicherweise entfernt oder ist noch nicht veröffentlicht.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Die Release-Notes konnten nicht geladen werden.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Release-Seite öffnen", "APP_UPDATE_CHANNEL": "Update-Kanal", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable folgt den veröffentlichten Releases. Nightly folgt jedem Merge in master und installiert Vorabversionen automatisch. Gilt nach dem Speichern.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Speichern und auf {{channel}}-Updates prüfen", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Dieses Ergebnis gilt für den Kanal {{current}}. Speichern, um zu {{next}} zu wechseln und erneut zu prüfen.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Dieses Update wurde im Kanal {{current}} gefunden. Der Kanal {{next}} wurde noch nicht geprüft.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-Builds sind ungetestete Momentaufnahmen von master. Sie können fehlerhaft sein, und ihre Datenbankänderungen sind dauerhaft: Nach dem Wechsel zurück zu Stable bleibt dieser Build installiert, bis ein neueres stabiles Release erscheint. Sichern Sie zuerst Ihre Playlists.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Sie verwenden einen Nightly-Build. Der Stable-Kanal bietet das nächste stabile Release an, sobald es neuer als dieser Build ist.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 06e5ba520..63da59999 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Επανεκκίνηση εφαρμογής", "APP_UPDATE_OPEN_RELEASE": "Άνοιγμα GitHub release", "APP_UPDATE_RELEASE_NOTES": "Τι νέο υπάρχει", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Άνοιγμα σελίδας εκδόσεων", "APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων", "APP_UPDATE_CHANNEL_DESCRIPTION": "Το Stable ακολουθεί τις δημοσιευμένες εκδόσεις. Το Nightly ακολουθεί κάθε συγχώνευση στο master και εγκαθιστά αυτόματα προκαταρκτικές εκδόσεις. Εφαρμόζεται κατά την αποθήκευση.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Αποθήκευση και έλεγχος για ενημερώσεις {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Αυτό το αποτέλεσμα αφορά το κανάλι {{current}}. Αποθηκεύστε για να αλλάξετε σε {{next}} και ελέγξτε ξανά.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Αυτή η ενημέρωση βρέθηκε στο κανάλι {{current}}. Το κανάλι {{next}} δεν έχει ελεγχθεί ακόμη.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Οι εκδόσεις nightly είναι μη δοκιμασμένα στιγμιότυπα του master. Μπορεί να παρουσιάζουν σφάλματα και οι αλλαγές τους στη βάση δεδομένων είναι μόνιμες: αν επιστρέψετε στο Stable, αυτή η έκδοση παραμένει εγκατεστημένη μέχρι να υπάρξει νεότερη σταθερή έκδοση. Δημιουργήστε πρώτα αντίγραφο ασφαλείας των λιστών αναπαραγωγής.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Χρησιμοποιείτε έκδοση nightly. Το κανάλι Stable θα προσφέρει την επόμενη σταθερή έκδοση μόλις είναι νεότερη από αυτήν.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index fd6aec977..6c8d3f9ff 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Restart application", "APP_UPDATE_OPEN_RELEASE": "Open GitHub release", "APP_UPDATE_RELEASE_NOTES": "What's new", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No release notes were found for {{version}} on GitHub. The release may have been removed or is not published yet.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Release notes could not be loaded.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Open releases page", "APP_UPDATE_CHANNEL": "Update channel", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable follows the tagged releases. Nightly follows every merge into master and installs prerelease builds automatically. Applies on Save.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Save and check for {{channel}} updates", + "APP_UPDATE_CHANNEL_PENDING_HINT": "This result is for the {{current}} channel. Save to switch to {{next}} and check again.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "This update was found on the {{current}} channel. The {{next}} channel has not been checked yet.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly builds are untested snapshots of master. They may break, and their database changes are permanent: switching back to Stable keeps this build until the next stable release is newer. Back up your playlists first.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "You are running a nightly build. The Stable channel will offer the next stable release once it is newer than this build.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index bb73edfc9..41ddaaaba 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Reiniciar aplicación", "APP_UPDATE_OPEN_RELEASE": "Abrir release de GitHub", "APP_UPDATE_RELEASE_NOTES": "Novedades", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No se encontraron notas de la versión {{version}} en GitHub. Es posible que la versión se haya eliminado o aún no esté publicada.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "No se pudieron cargar las notas de la versión.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versiones", "APP_UPDATE_CHANNEL": "Canal de actualizaciones", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable sigue las versiones publicadas. Nightly sigue cada fusión en master e instala compilaciones preliminares automáticamente. Se aplica al guardar.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Guardar y comprobar actualizaciones de {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Este resultado corresponde al canal {{current}}. Guarda para cambiar a {{next}} y comprobar de nuevo.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Esta actualización se encontró en el canal {{current}}. El canal {{next}} aún no se ha comprobado.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Las compilaciones nightly son instantáneas sin probar de master. Pueden fallar y sus cambios en la base de datos son permanentes: al volver a Stable, esta compilación se mantiene instalada hasta que haya una versión estable más reciente. Haz primero una copia de seguridad de tus listas.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Estás usando una compilación nightly. El canal Stable ofrecerá la próxima versión estable en cuanto sea más reciente que esta compilación.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b1fc481ee..47782117c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Redémarrer l’application", "APP_UPDATE_OPEN_RELEASE": "Ouvrir la release GitHub", "APP_UPDATE_RELEASE_NOTES": "Nouveautés", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Aucune note de version n'a été trouvée pour {{version}} sur GitHub. La version a peut-être été supprimée ou n'est pas encore publiée.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossible de charger les notes de version.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Ouvrir la page des versions", "APP_UPDATE_CHANNEL": "Canal de mise à jour", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable suit les versions publiées. Nightly suit chaque fusion dans master et installe automatiquement les préversions. Appliqué à l’enregistrement.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Enregistrer et vérifier les mises à jour {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Ce résultat concerne le canal {{current}}. Enregistrez pour passer à {{next}} et vérifier à nouveau.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Cette mise à jour a été trouvée sur le canal {{current}}. Le canal {{next}} n'a pas encore été vérifié.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Les builds nightly sont des instantanés non testés de master. Ils peuvent être instables et leurs modifications de la base de données sont définitives : en revenant à Stable, ce build reste installé jusqu’à ce qu’une version stable plus récente existe. Sauvegardez d’abord vos listes de lecture.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Vous utilisez un build nightly. Le canal Stable proposera la prochaine version stable dès qu’elle sera plus récente que ce build.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 95a678a29..a528cca10 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Alkalmazás újraindítása", "APP_UPDATE_OPEN_RELEASE": "GitHub-kiadás megnyitása", "APP_UPDATE_RELEASE_NOTES": "Újdonságok", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "A(z) {{version}} verzióhoz nem található kiadási jegyzet a GitHubon. A kiadást talán eltávolították, vagy még nem tették közzé.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "A kiadási jegyzetek betöltése nem sikerült.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Kiadások oldalának megnyitása", "APP_UPDATE_CHANNEL": "Frissítési csatorna", "APP_UPDATE_CHANNEL_DESCRIPTION": "A Stable a kiadott verziókat követi. A Nightly a master minden beolvasztását követi, és automatikusan telepíti az előzetes buildeket. Mentéskor lép érvénybe.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Mentés és {{channel}} frissítések ellenőrzése", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Ez az eredmény a(z) {{current}} csatornára vonatkozik. Mentse a(z) {{next}} csatornára váltáshoz, majd ellenőrizze újra.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Ez a frissítés a(z) {{current}} csatornán található. A(z) {{next}} csatorna még nincs ellenőrizve.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "A nightly buildek a master teszteletlen pillanatképei. Hibásak lehetnek, és adatbázis-módosításaik véglegesek: a Stable-re visszaváltva ez a build marad telepítve, amíg nem jelenik meg újabb stabil kiadás. Előbb készítsen biztonsági mentést a lejátszási listáiról.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly buildet használ. A Stable csatorna a következő stabil kiadást kínálja fel, amint az újabb lesz ennél a buildnél.", "EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 502271f85..200ccd428 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Riavvia applicazione", "APP_UPDATE_OPEN_RELEASE": "Apri release GitHub", "APP_UPDATE_RELEASE_NOTES": "Novità", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nessuna nota di rilascio trovata per {{version}} su GitHub. La versione potrebbe essere stata rimossa o non è ancora pubblicata.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossibile caricare le note di rilascio.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Apri la pagina delle versioni", "APP_UPDATE_CHANNEL": "Canale di aggiornamento", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable segue le versioni pubblicate. Nightly segue ogni merge in master e installa automaticamente le build preliminari. Si applica al salvataggio.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Salva e controlla gli aggiornamenti {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Questo risultato riguarda il canale {{current}}. Salva per passare a {{next}} e controlla di nuovo.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Questo aggiornamento è stato trovato sul canale {{current}}. Il canale {{next}} non è ancora stato controllato.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Le build nightly sono istantanee non testate di master. Possono avere problemi e le loro modifiche al database sono permanenti: tornando a Stable, questa build resta installata finché non esiste una versione stabile più recente. Esegui prima un backup delle playlist.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Stai usando una build nightly. Il canale Stable proporrà la prossima versione stabile non appena sarà più recente di questa build.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index b6e2ccf0f..e09dcfce7 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "アプリを再起動", "APP_UPDATE_OPEN_RELEASE": "GitHub リリースを開く", "APP_UPDATE_RELEASE_NOTES": "更新内容", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。", + "APP_UPDATE_OPEN_RELEASES_PAGE": "リリースページを開く", "APP_UPDATE_CHANNEL": "更新チャンネル", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable は公開されたリリースに追従します。Nightly は master への各マージに追従し、プレリリースビルドを自動的にインストールします。保存時に適用されます。", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "保存して {{channel}} の更新を確認", + "APP_UPDATE_CHANNEL_PENDING_HINT": "この結果は {{current}} チャンネルのものです。保存して {{next}} に切り替え、もう一度確認してください。", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "この更新は {{current}} チャンネルで見つかったものです。{{next}} チャンネルはまだ確認されていません。", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly ビルドは master の未検証スナップショットです。動作しない場合があり、データベースの変更は元に戻せません。Stable に戻しても、より新しい安定版がリリースされるまでこのビルドが残ります。先にプレイリストをバックアップしてください。", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly ビルドを使用中です。Stable チャンネルは、このビルドより新しい安定版がリリースされ次第、それを提案します。", "EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 40c3e8bbd..8d0f14c4b 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "애플리케이션 다시 시작", "APP_UPDATE_OPEN_RELEASE": "GitHub 릴리스 열기", "APP_UPDATE_RELEASE_NOTES": "새로운 내용", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "릴리스 페이지 열기", "APP_UPDATE_CHANNEL": "업데이트 채널", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable은 정식 릴리스를 따릅니다. Nightly는 master에 병합될 때마다 따라가며 프리릴리스 빌드를 자동으로 설치합니다. 저장 시 적용됩니다.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "저장하고 {{channel}} 업데이트 확인", + "APP_UPDATE_CHANNEL_PENDING_HINT": "이 결과는 {{current}} 채널에 대한 것입니다. 저장하여 {{next}}(으)로 전환한 뒤 다시 확인하세요.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "이 업데이트는 {{current}} 채널에서 찾은 것입니다. {{next}} 채널은 아직 확인되지 않았습니다.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 빌드는 테스트되지 않은 master의 스냅샷입니다. 오류가 있을 수 있으며 데이터베이스 변경은 되돌릴 수 없습니다. Stable로 되돌려도 더 새로운 안정 릴리스가 나올 때까지 이 빌드가 유지됩니다. 먼저 재생목록을 백업하세요.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly 빌드를 사용 중입니다. Stable 채널은 이 빌드보다 새로운 안정 릴리스가 나오면 그것을 제안합니다.", "EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 35009862c..58052df24 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Applicatie opnieuw starten", "APP_UPDATE_OPEN_RELEASE": "GitHub-release openen", "APP_UPDATE_RELEASE_NOTES": "Wat is nieuw", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Er zijn geen releasenotes voor {{version}} gevonden op GitHub. De release is mogelijk verwijderd of nog niet gepubliceerd.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "De releasenotes konden niet worden geladen.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Releasepagina openen", "APP_UPDATE_CHANNEL": "Updatekanaal", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable volgt de uitgebrachte releases. Nightly volgt elke merge in master en installeert voorlopige builds automatisch. Wordt toegepast bij opslaan.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Opslaan en controleren op {{channel}}-updates", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Dit resultaat geldt voor het kanaal {{current}}. Sla op om naar {{next}} te wisselen en controleer opnieuw.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Deze update is gevonden op het kanaal {{current}}. Het kanaal {{next}} is nog niet gecontroleerd.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-builds zijn ongeteste momentopnamen van master. Ze kunnen fouten bevatten en hun databasewijzigingen zijn permanent: na terugschakelen naar Stable blijft deze build geïnstalleerd tot er een nieuwere stabiele release is. Maak eerst een back-up van je afspeellijsten.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Je gebruikt een nightly-build. Het Stable-kanaal biedt de volgende stabiele release aan zodra die nieuwer is dan deze build.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ecdec591b..81de4a649 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Uruchom aplikację ponownie", "APP_UPDATE_OPEN_RELEASE": "Otwórz wydanie w GitHub", "APP_UPDATE_RELEASE_NOTES": "Co nowego", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nie znaleziono informacji o wydaniu {{version}} w GitHub. Wydanie mogło zostać usunięte lub nie zostało jeszcze opublikowane.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Nie udało się wczytać informacji o wydaniu.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Otwórz stronę wydań", "APP_UPDATE_CHANNEL": "Kanał aktualizacji", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable podąża za opublikowanymi wydaniami. Nightly podąża za każdym scaleniem do master i automatycznie instaluje wersje wstępne. Obowiązuje po zapisaniu.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Zapisz i sprawdź aktualizacje {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Ten wynik dotyczy kanału {{current}}. Zapisz, aby przełączyć na {{next}} i sprawdzić ponownie.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Ta aktualizacja została znaleziona na kanale {{current}}. Kanał {{next}} nie został jeszcze sprawdzony.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Kompilacje nightly to nieprzetestowane migawki master. Mogą działać niestabilnie, a ich zmiany w bazie danych są trwałe: po powrocie na Stable ta kompilacja pozostanie zainstalowana, dopóki nie pojawi się nowsze wydanie stabilne. Najpierw wykonaj kopię zapasową playlist.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Używasz kompilacji nightly. Kanał Stable zaproponuje następne wydanie stabilne, gdy tylko będzie nowsze od tej kompilacji.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 33f2181a6..c40749e33 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Reiniciar aplicativo", "APP_UPDATE_OPEN_RELEASE": "Abrir release no GitHub", "APP_UPDATE_RELEASE_NOTES": "Novidades", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nenhuma nota de versão foi encontrada para {{version}} no GitHub. A versão pode ter sido removida ou ainda não foi publicada.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Não foi possível carregar as notas da versão.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versões", "APP_UPDATE_CHANNEL": "Canal de atualização", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable segue as versões publicadas. Nightly segue cada merge em master e instala compilações preliminares automaticamente. Aplica-se ao salvar.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Salvar e verificar atualizações de {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Este resultado é do canal {{current}}. Salve para mudar para {{next}} e verificar novamente.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Esta atualização foi encontrada no canal {{current}}. O canal {{next}} ainda não foi verificado.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "As compilações nightly são instantâneos não testados de master. Podem falhar e suas alterações no banco de dados são permanentes: ao voltar para Stable, esta compilação permanece instalada até existir uma versão estável mais recente. Faça backup das suas playlists primeiro.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Você está usando uma compilação nightly. O canal Stable oferecerá a próxima versão estável assim que ela for mais recente que esta compilação.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f3ab6d1be..0b487a112 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Перезапустить приложение", "APP_UPDATE_OPEN_RELEASE": "Открыть релиз на GitHub", "APP_UPDATE_RELEASE_NOTES": "Что нового", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Открыть страницу релизов", "APP_UPDATE_CHANNEL": "Канал обновлений", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable следует за выпущенными релизами. Nightly следует за каждым слиянием в master и автоматически устанавливает предварительные сборки. Применяется после сохранения.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Сохранить и проверить обновления {{channel}}", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Этот результат относится к каналу {{current}}. Сохраните, чтобы переключиться на {{next}} и проверить снова.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Это обновление найдено на канале {{current}}. Канал {{next}} ещё не проверялся.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-сборки — непроверенные снимки master. Они могут работать нестабильно, а их изменения базы данных необратимы: после возврата на Stable эта сборка останется установленной, пока не выйдет более новый стабильный релиз. Сначала сделайте резервную копию плейлистов.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Вы используете nightly-сборку. Канал Stable предложит следующий стабильный релиз, как только он станет новее этой сборки.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index e3683ad4b..f96a89748 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "Uygulamayı yeniden başlat", "APP_UPDATE_OPEN_RELEASE": "GitHub sürümünü aç", "APP_UPDATE_RELEASE_NOTES": "Yenilikler", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub'da {{version}} için sürüm notu bulunamadı. Sürüm kaldırılmış ya da henüz yayınlanmamış olabilir.", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Sürüm notları yüklenemedi.", + "APP_UPDATE_OPEN_RELEASES_PAGE": "Sürümler sayfasını aç", "APP_UPDATE_CHANNEL": "Güncelleme kanalı", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable, yayımlanan sürümleri izler. Nightly, master’a yapılan her birleştirmeyi izler ve ön sürümleri otomatik olarak kurar. Kaydedildiğinde uygulanır.", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "Kaydet ve {{channel}} güncellemelerini denetle", + "APP_UPDATE_CHANNEL_PENDING_HINT": "Bu sonuç {{current}} kanalına aittir. {{next}} kanalına geçmek için kaydedin ve tekrar denetleyin.", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "Bu güncelleme {{current}} kanalında bulundu. {{next}} kanalı henüz denetlenmedi.", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly derlemeleri master’ın test edilmemiş anlık görüntüleridir. Bozuk olabilirler ve veritabanı değişiklikleri kalıcıdır: Stable’a geri dönüldüğünde bu derleme, daha yeni bir kararlı sürüm çıkana kadar kurulu kalır. Önce oynatma listelerinizi yedekleyin.", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Bir nightly derlemesi kullanıyorsunuz. Stable kanalı, bu derlemeden daha yeni olduğunda sonraki kararlı sürümü sunacaktır.", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 98c48e79f..0e2a2605d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "重启应用", "APP_UPDATE_OPEN_RELEASE": "打开 GitHub 发布页", "APP_UPDATE_RELEASE_NOTES": "更新内容", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。", + "APP_UPDATE_OPEN_RELEASES_PAGE": "打开版本发布页面", "APP_UPDATE_CHANNEL": "更新频道", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable 跟随正式发布的版本。Nightly 跟随每次合并到 master 的更改,并自动安装预发布构建。保存后生效。", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "保存并检查 {{channel}} 更新", + "APP_UPDATE_CHANNEL_PENDING_HINT": "此结果针对 {{current}} 频道。保存以切换到 {{next}} 并再次检查。", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "此更新来自 {{current}} 频道。{{next}} 频道尚未检查。", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 构建是 master 的未经测试快照。它们可能出错,且其数据库更改不可撤销:切换回 Stable 后,此构建会一直保留,直到出现更新的稳定版本。请先备份您的播放列表。", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "您正在使用 nightly 构建。Stable 频道会在下一个稳定版本比此构建更新时提供该版本。", "EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 19f2147b7..f74e6cc3e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -525,10 +525,16 @@ "APP_UPDATE_INSTALL": "重新啟動應用程式", "APP_UPDATE_OPEN_RELEASE": "開啟 GitHub 發布頁", "APP_UPDATE_RELEASE_NOTES": "更新內容", + "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。", + "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。", + "APP_UPDATE_OPEN_RELEASES_PAGE": "開啟版本發布頁面", "APP_UPDATE_CHANNEL": "更新頻道", "APP_UPDATE_CHANNEL_DESCRIPTION": "Stable 跟隨正式發布的版本。Nightly 跟隨每次合併到 master 的變更,並自動安裝預先發布的建置版本。儲存後生效。", "APP_UPDATE_CHANNEL_STABLE": "Stable", "APP_UPDATE_CHANNEL_NIGHTLY": "Nightly", + "APP_UPDATE_SAVE_AND_CHECK": "儲存並檢查 {{channel}} 更新", + "APP_UPDATE_CHANNEL_PENDING_HINT": "此結果針對 {{current}} 頻道。儲存以切換至 {{next}} 並再次檢查。", + "APP_UPDATE_CHANNEL_RETAINED_HINT": "此更新來自 {{current}} 頻道。{{next}} 頻道尚未檢查。", "APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 建置是 master 未經測試的快照。它們可能出錯,且其資料庫變更無法復原:切換回 Stable 後,此建置會一直保留,直到出現更新的穩定版本。請先備份您的播放清單。", "APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "您正在使用 nightly 建置。Stable 頻道會在下一個穩定版本比此建置更新時提供該版本。", "EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)", diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 5d8764db0..89140fe05 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -358,7 +358,47 @@ silently enables downgrades and the constructor enables prereleases for any prerelease build. Release notes and the manual-install fallback (Linux without AppImage) read the channel's release list; notes for a nightly version always come from the nightly repository, so a nightly build on the -stable channel still shows its own notes. +stable channel still shows its own notes. `AppUpdateReleaseCatalogs` +(`app-update-release-notes.ts`) owns one catalog per channel and both reads +the updater performs on them (release notes with paging, newest release for +the manual-install fallback); the service only delegates. Each catalog is a +snapshot of the GitHub release list kept for the whole process, so `findIndex` reloads it +once when a version is missing from a fully paged list — the updater had +offered a nightly published after the catalog was first read, and "What's +new" answered "not found" for it — and `handleUpdateAvailable` drops every +catalog, since a newly found release proves the snapshots stale. Readers +of one catalog are serialized through `runExclusive` (both +`getReleaseNotes` and the manual-update check): a read dereferences an +index into `releases` after awaiting further pages, and that reload +rebuilds the array, so two overlapping readers must never interleave. The +not-found rejection carries the shared +`APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER` text: `ipcRenderer.invoke` +strips custom properties off rejections, so the dialog recognises the case +by that text, shows localized copy with the version, and links to the +channel's release list (`appUpdateReleasesListUrl`) instead of printing the +IPC wrapper; every other failure keeps its underlying reason under a +localized headline. + +The About section keeps the select honest about that Save boundary. The +status block carries a badge naming the channel the verdict describes +(`status.verdictChannel`, see below), and while the select shows a channel +other than the saved one (`status.channel`) the +verdict is dimmed, a hint names both channels, and the plain "Check again" +button is replaced by a primary "Save and check for updates" +button that submits the settings form +(`SettingsAboutSectionComponent.saveAndCheckForAppUpdate` → +`SettingsComponent.onSubmit()`). No renderer-side check follows: the saved +channel reaches `persistAppUpdateChannel`, whose change listener calls +`AppUpdateService.setChannel`, which already re-checks an idle updater. A +download in flight or finished belongs to the previous channel and is kept +by `setChannel`, so in those states the plain check stays and only the hint +is shown. Because that kept download outlives the channel it was found on, +every check stamps `status.verdictChannel` with the channel it ran on and +`setChannel` leaves it alone: the badge names `verdictChannel`, not +`channel`, and while the two differ a hint says the shown update came from +the other channel and the saved one has not been checked yet. Checking the unsaved channel without saving was rejected on +purpose: the updater would then offer a download for a channel that is not +persisted. Switching is forward-only on purpose: a nightly build stays installed until a newer stable release exists, because a downgrade could land on a release diff --git a/libs/shared/interfaces/src/lib/app-update-channel.util.spec.ts b/libs/shared/interfaces/src/lib/app-update-channel.util.spec.ts index 628900c5f..34618912a 100644 --- a/libs/shared/interfaces/src/lib/app-update-channel.util.spec.ts +++ b/libs/shared/interfaces/src/lib/app-update-channel.util.spec.ts @@ -1,6 +1,9 @@ import { appUpdateReleasesApiUrl, + appUpdateReleasesListUrl, appUpdateReleasesPageUrl, + buildAppUpdateReleaseNotesNotFoundMessage, + isAppUpdateReleaseNotesNotFoundMessage, appUpdateRepository, appVersionChannel, compareAppVersions, @@ -36,6 +39,32 @@ describe('app update channel util', () => { expect(appUpdateReleasesApiUrl('nightly')).toBe( 'https://api.github.com/repos/4gray/iptvnator-nightly/releases' ); + expect(appUpdateReleasesListUrl('stable')).toBe( + 'https://github.com/4gray/iptvnator/releases' + ); + expect(appUpdateReleasesListUrl('nightly')).toBe( + 'https://github.com/4gray/iptvnator-nightly/releases' + ); + }); + + it('recognises the not-found rejection through the IPC wrapper', () => { + const message = buildAppUpdateReleaseNotesNotFoundMessage('0.24.0'); + + expect(message).toBe('Release notes were not found for 0.24.0'); + expect(buildAppUpdateReleaseNotesNotFoundMessage(undefined)).toBe( + 'Release notes were not found for latest release' + ); + expect( + isAppUpdateReleaseNotesNotFoundMessage( + `Error invoking remote method 'APP_UPDATE:GET_RELEASE_NOTES': Error: ${message}` + ) + ).toBe(true); + expect( + isAppUpdateReleaseNotesNotFoundMessage( + 'GitHub releases request failed: 403 Forbidden' + ) + ).toBe(false); + expect(isAppUpdateReleaseNotesNotFoundMessage(null)).toBe(false); }); it('reads the channel a build belongs to off its version', () => { diff --git a/libs/shared/interfaces/src/lib/app-update-channel.util.ts b/libs/shared/interfaces/src/lib/app-update-channel.util.ts index 6c2ca2d83..bef69a5f9 100644 --- a/libs/shared/interfaces/src/lib/app-update-channel.util.ts +++ b/libs/shared/interfaces/src/lib/app-update-channel.util.ts @@ -66,6 +66,13 @@ export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string { 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]; @@ -176,3 +183,26 @@ export function compareAppVersions(left: string, right: string): number { 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) + ); +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 31a93034d..569efd7a6 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -269,6 +269,14 @@ export interface ElectronBridgeAppUpdateStatus { manualDownloadUrl: string; /** Channel the updater checks (`Settings.updateChannel`, mirrored). */ channel: AppUpdateChannel; + /** + * Channel the current verdict — `latestVersion`, `release`, a download + * in flight or finished — was produced on. Absent until the first + * check. Differs from `channel` after a channel change saved while a + * download was running or done: the download is kept, the new channel + * has not been checked yet. + */ + verdictChannel?: AppUpdateChannel; /** Channel the running build came from, read off its version string. */ installedChannel: AppUpdateChannel; }