diff --git a/.changes/updater-nightly-channel.md b/.changes/updater-nightly-channel.md new file mode 100644 index 000000000..3041a169a --- /dev/null +++ b/.changes/updater-nightly-channel.md @@ -0,0 +1,7 @@ +--- +type: feature +area: updater +highlight: Nightly update channel +--- + +Settings → About now has an Update channel switch. Nightly follows every merge into master: the desktop updater offers each new build automatically, and About shows the build's commit for bug reports. Stable stays the default; a nightly build remains installed until the next stable release is newer. diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 1cd46c20d..7a91c9b8d 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -13,6 +13,14 @@ name: Build and Make Electron App # cleanup-pr-draft.yml deletes the draft when the PR closes, so the stale # window is visible and bounded; refreshing drafts on skipped runs is not # worth a separate workflow. +# +# Master pushes are the nightly channel: the nightly-version job computes +# one -nightly.. version for the whole run +# (tools/release/nightly-version.mjs), every build job writes it into +# package.json so electron-updater treats the build as newer than the +# released version, and the release job publishes the artifacts as a +# prerelease of 4gray/iptvnator-nightly instead of the rolling test-master +# draft. Contract: docs/architecture/release-pipeline.md ("Nightly channel"). on: push: branches: @@ -42,6 +50,39 @@ permissions: contents: read jobs: + # One version for the whole run. Computed here rather than in each build + # job because the rule depends on whether the base tag exists on origin: + # a tag pushed while the matrix runs would otherwise give one run two + # different versions. Empty output means "not a nightly build". + nightly-version: + name: Resolve nightly version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.resolve.outputs.version }} + steps: + - name: Checkout code + if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' + uses: actions/checkout@v7 + + - name: Resolve nightly version + id: resolve + shell: bash + env: + NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }} + run: | + set -euo pipefail + + if [ "${NIGHTLY}" != "true" ]; then + echo "version=" >> "${GITHUB_OUTPUT}" + echo "Not a master push; no nightly version." + exit 0 + fi + + VERSION="$(node tools/release/nightly-version.mjs)" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "Nightly version: ${VERSION}" + linux-embedded-mpv-runtime: name: Build pinned Linux Embedded MPV runtime runs-on: ubuntu-22.04 @@ -340,6 +381,7 @@ jobs: build-cross-platform: name: Build on ${{ matrix.os }} ${{ matrix.arch }} + needs: nightly-version runs-on: ${{ matrix.runner }} timeout-minutes: 120 concurrency: @@ -445,6 +487,18 @@ jobs: BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} run: node tools/build/inject-build-commit.mjs + - name: Apply nightly version + # Master merges feed the nightly update channel. The version + # must be greater than the released one for electron-updater to + # offer it, and it must be in package.json before the frontend + # and backend builds and electron-builder read it. The value + # comes from the nightly-version job so every job of this run + # builds the same version. + if: needs.nightly-version.outputs.version != '' + env: + NIGHTLY_VERSION: ${{ needs.nightly-version.outputs.version }} + run: node tools/release/nightly-version.mjs --apply --version "${NIGHTLY_VERSION}" + - name: Build frontend run: pnpm nx build web --skip-nx-cache @@ -1188,6 +1242,7 @@ jobs: dist/executables/**/*.dmg dist/executables/**/*.zip dist/executables/**/latest-mac.yml + dist/executables/**/nightly-mac.yml dist/executables/**/*.blockmap retention-days: 7 @@ -1212,6 +1267,7 @@ jobs: dist/executables/*.AppImage dist/executables/*.snap dist/executables/**/latest-linux*.yml + dist/executables/**/nightly-linux*.yml dist/executables/**/*.blockmap retention-days: 7 @@ -1234,12 +1290,15 @@ jobs: dist/executables/**/*.msi dist/executables/**/*.zip dist/executables/**/latest.yml + dist/executables/**/nightly.yml dist/executables/**/*.blockmap retention-days: 7 build-linux: name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }}) - needs: linux-embedded-mpv-runtime + needs: + - linux-embedded-mpv-runtime + - nightly-version runs-on: ${{ matrix.runner }} timeout-minutes: 120 concurrency: @@ -1276,6 +1335,7 @@ jobs: create-release: name: Create Draft Release needs: + - nightly-version - build-cross-platform - build-linux if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} @@ -1285,6 +1345,12 @@ jobs: cancel-in-progress: false permissions: contents: write + env: + # Master pushes publish to the nightly repository (steps at the + # end of this job) instead of the rolling draft. + NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }} + NIGHTLY_REPOSITORY: 4gray/iptvnator-nightly + NIGHTLY_KEEP_RELEASES: '20' steps: - name: Checkout code @@ -1306,10 +1372,20 @@ jobs: node <<'NODE' const fs = require('fs'); - const candidates = [ - 'artifacts/macos-x64-artifacts/latest-mac.yml', - 'artifacts/macos-arm64-artifacts/latest-mac.yml', - ].filter((filePath) => fs.existsSync(filePath)); + // electron-builder names the updater metadata after the + // channel: latest-mac.yml for releases, nightly-mac.yml for + // the prerelease versions master builds carry. + const channelFile = ['latest-mac.yml', 'nightly-mac.yml'].find( + (name) => + fs.existsSync(`artifacts/macos-x64-artifacts/${name}`) || + fs.existsSync(`artifacts/macos-arm64-artifacts/${name}`) + ); + const candidates = channelFile + ? [ + `artifacts/macos-x64-artifacts/${channelFile}`, + `artifacts/macos-arm64-artifacts/${channelFile}`, + ].filter((filePath) => fs.existsSync(filePath)) + : []; if (candidates.length === 0) { console.log('No macOS update metadata found; skipping merge.'); @@ -1407,8 +1483,8 @@ jobs: mergedEntries ); - fs.writeFileSync('artifacts/latest-mac.yml', merged); - console.log(`Merged ${candidates.length} macOS update metadata files.`); + fs.writeFileSync(`artifacts/${channelFile}`, merged); + console.log(`Merged ${candidates.length} macOS update metadata files into ${channelFile}.`); NODE - name: Get version from package.json @@ -1422,6 +1498,7 @@ jobs: # for every push. PR builds must not use github.sha here: that is the # ephemeral merge-commit SHA, which resolves to nothing in the repo. - name: Compose release metadata + if: env.NIGHTLY != 'true' id: release-meta shell: bash env: @@ -1499,7 +1576,7 @@ jobs: # full current set right after. Only drafts are pruned; published # releases are never touched. - name: Prune stale draft assets - if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open' + if: env.NIGHTLY != 'true' && (github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ steps.release-meta.outputs.tag }} @@ -1522,7 +1599,7 @@ jobs: - name: Create Draft Release id: draft-release - if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open' + if: env.NIGHTLY != 'true' && (github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open') uses: softprops/action-gh-release@v3 with: draft: true @@ -1569,7 +1646,7 @@ jobs: # body is left as the action set it and only title/commitish are # re-asserted. - name: Ensure draft metadata is current - if: steps.draft-release.outputs.id != '' + if: env.NIGHTLY != 'true' && steps.draft-release.outputs.id != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_ID: ${{ steps.draft-release.outputs.id }} @@ -1612,3 +1689,196 @@ jobs: --arg body "${FULL_BODY}" \ '{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' | gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null + + # ── Nightly channel ────────────────────────────────────────── + # Drafts are invisible to anyone without write access and to + # electron-updater, so master builds are published as prereleases + # of the nightly repository, which the desktop app's Nightly update + # channel follows. The repository needs one commit on its default + # branch (gh creates the release tag there) and a fine-grained PAT + # with Contents: read/write on it, stored as NIGHTLY_RELEASE_TOKEN. + # Without the token the build still succeeds and only warns. + - name: Resolve nightly release metadata + if: env.NIGHTLY == 'true' + id: nightly-meta + shell: bash + env: + NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }} + VERSION: ${{ needs.nightly-version.outputs.version }} + run: | + set -euo pipefail + + if [ -z "${VERSION}" ]; then + echo "::error::The nightly-version job produced no version for this master push." + exit 1 + fi + { + echo "version=${VERSION}" + echo "tag=v${VERSION}" + } >> "${GITHUB_OUTPUT}" + + if [ -z "${NIGHTLY_RELEASE_TOKEN}" ]; then + echo "::warning::NIGHTLY_RELEASE_TOKEN is not configured; the nightly release for ${VERSION} is skipped." + echo "publish=false" >> "${GITHUB_OUTPUT}" + else + echo "publish=true" >> "${GITHUB_OUTPUT}" + fi + + # The notes list the master commits since the previous nightly. + # That nightly's source commit is read back from the marker its + # own notes carry, because the nightly repository has no copy of + # the app history to compare against. The main-repository compare + # uses GITHUB_TOKEN; only the nightly repository is read with the + # PAT. + - name: Compose nightly release notes + if: steps.nightly-meta.outputs.publish == 'true' + id: nightly-notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }} + VERSION: ${{ steps.nightly-meta.outputs.version }} + HEAD_SHA: ${{ github.sha }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + SHORT_SHA="${HEAD_SHA:0:7}" + NOTES_FILE="${RUNNER_TEMP}/nightly-notes.md" + + PREVIOUS_TAG="$(GH_TOKEN="${NIGHTLY_RELEASE_TOKEN}" gh release list \ + --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 1 \ + --json tagName --jq '.[0].tagName // ""')" + PREVIOUS_SHA="" + if [ -n "${PREVIOUS_TAG}" ]; then + PREVIOUS_SHA="$(GH_TOKEN="${NIGHTLY_RELEASE_TOKEN}" gh release view "${PREVIOUS_TAG}" \ + --repo "${NIGHTLY_REPOSITORY}" --json body --jq '.body // ""' | + sed -n 's/.*.*/\1/p' | head -n 1)" + fi + + COMMITS="" + if [ -n "${PREVIOUS_SHA}" ] && [ "${PREVIOUS_SHA}" != "${HEAD_SHA}" ]; then + # A rewritten history makes the compare fail; the notes + # then just omit the list rather than failing the release. + COMMITS="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PREVIOUS_SHA}...${HEAD_SHA}" \ + --jq '.commits[] | "- [`\(.sha[0:7])`](\(.html_url)) \(.commit.message | split("\n")[0])"' || true)" + fi + + { + printf '🌙 Nightly build from `master` — commit [`%s`](%s/commit/%s) · [workflow run](%s)\n\n' \ + "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}" + printf 'Untested snapshot of master for the desktop app'"'"'s **Nightly** update channel (Settings → About → Update channel). Nightly builds may break, and their database changes are permanent: switching back to Stable keeps this build installed until the next stable release is newer. Back up your playlists first.\n\n' + if [ -n "${COMMITS}" ]; then + printf '## Changes since %s\n\n%s\n\n' "${PREVIOUS_TAG}" "${COMMITS}" + else + printf 'See the [commit history](%s/commits/master) for what changed.\n\n' "${REPO_URL}" + fi + printf '\n' "${HEAD_SHA}" + } > "${NOTES_FILE}" + + echo "notes-file=${NOTES_FILE}" >> "${GITHUB_OUTPUT}" + + # Created as a draft, assets uploaded, then published in one edit, + # so electron-updater never sees a release whose channel file is + # still missing. A published release is never deleted here: a + # rerun after a successful publish is a no-op, and only a draft + # left behind by a failed run is replaced. The release job is + # serialized per ref but two master runs can still finish out of + # order, so a nightly that is older than the newest published one + # is dropped instead of becoming the feed's newest entry. + - name: Publish nightly release + if: steps.nightly-meta.outputs.publish == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }} + TAG: ${{ steps.nightly-meta.outputs.tag }} + VERSION: ${{ steps.nightly-meta.outputs.version }} + HEAD_SHA: ${{ github.sha }} + NOTES_FILE: ${{ steps.nightly-notes.outputs.notes-file }} + run: | + set -euo pipefail + shopt -s nullglob + + NEWEST_PUBLISHED="$(gh release list --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 200 \ + --json tagName --jq '.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\."))' | + sort -V | tail -n 1)" + if [ -n "${NEWEST_PUBLISHED}" ] && [ "${NEWEST_PUBLISHED}" != "${TAG}" ] && + [ "$(printf '%s\n%s\n' "${NEWEST_PUBLISHED}" "${TAG}" | sort -V | tail -n 1)" != "${TAG}" ]; then + echo "::notice::${NEWEST_PUBLISHED} is already published and newer than ${TAG}; not publishing this superseded build." + exit 0 + fi + + for required in \ + artifacts/nightly-mac.yml \ + artifacts/windows-artifacts/nightly.yml \ + artifacts/linux-portable-artifacts/nightly-linux.yml; do + if [ ! -f "${required}" ]; then + echo "::error::Missing updater metadata ${required}; the nightly channel would be unable to install this build." + exit 1 + fi + done + + assets=( + artifacts/macos-x64-artifacts/*-x64.dmg + artifacts/macos-x64-artifacts/*-x64.zip + artifacts/macos-x64-artifacts/*.blockmap + artifacts/macos-arm64-artifacts/*-arm64.dmg + artifacts/macos-arm64-artifacts/*-arm64.zip + artifacts/macos-arm64-artifacts/*.blockmap + artifacts/nightly-mac.yml + artifacts/linux-system-artifacts/*.deb + artifacts/linux-system-artifacts/*.rpm + artifacts/linux-system-artifacts/*.pacman + artifacts/linux-system-artifacts/*.pkg.tar.* + artifacts/linux-portable-artifacts/*.AppImage + artifacts/linux-portable-artifacts/*.snap + artifacts/linux-portable-artifacts/nightly-linux*.yml + artifacts/linux-portable-artifacts/*.blockmap + artifacts/linux-flatpak-artifacts/*.flatpak + artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz + artifacts/windows-artifacts/*-setup.exe + artifacts/windows-artifacts/*.msi + artifacts/windows-artifacts/*.zip + artifacts/windows-artifacts/nightly.yml + artifacts/windows-artifacts/*.blockmap + ) + + EXISTING="$(gh api "repos/${NIGHTLY_REPOSITORY}/releases?per_page=100" --paginate | + jq -c --arg tag "${TAG}" 'map(select(.tag_name == $tag)) | first // empty')" + if [ -n "${EXISTING}" ]; then + if [ "$(jq -r '.draft' <<< "${EXISTING}")" != "true" ]; then + echo "::notice::${TAG} is already published in ${NIGHTLY_REPOSITORY}; nothing to do for this re-run." + exit 0 + fi + DRAFT_ID="$(jq -r '.id' <<< "${EXISTING}")" + echo "Removing the draft ${TAG} (id ${DRAFT_ID}) a failed run left behind." + gh api -X DELETE "repos/${NIGHTLY_REPOSITORY}/releases/${DRAFT_ID}" + fi + + gh release create "${TAG}" "${assets[@]}" \ + --repo "${NIGHTLY_REPOSITORY}" \ + --draft \ + --prerelease \ + --title "Nightly ${VERSION} (${HEAD_SHA:0:7})" \ + --notes-file "${NOTES_FILE}" + gh release edit "${TAG}" --repo "${NIGHTLY_REPOSITORY}" --draft=false --prerelease + + echo "Published ${TAG} to ${NIGHTLY_REPOSITORY} with ${#assets[@]} assets." + + - name: Prune old nightly releases + if: steps.nightly-meta.outputs.publish == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }} + run: | + set -euo pipefail + + gh release list --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 200 \ + --json tagName --jq '.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\."))' | + sort -V -r | tail -n "+$((NIGHTLY_KEEP_RELEASES + 1))" | + while read -r tag; do + [ -n "${tag}" ] || continue + echo "Deleting nightly ${tag} (keeping the newest ${NIGHTLY_KEEP_RELEASES})." + gh release delete "${tag}" --repo "${NIGHTLY_REPOSITORY}" --cleanup-tag --yes + done diff --git a/CLAUDE.md b/CLAUDE.md index 60da49c6a..7d1012edf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1712,7 +1712,10 @@ Build configurations in `apps/web/project.json`: The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations. **Build Commit In About**: -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 deliberately stays untouched — 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. +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` (`--apply --version`), 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. electron-builder names the updater metadata after the prerelease tag (`nightly-mac.yml` / `nightly.yml` / `nightly-linux.yml`), so upload globs and 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"). ### Testing Strategy diff --git a/README.md b/README.md index 89869f174..eaea6e8ae 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,18 @@ Download the latest version of the application for macOS, Windows, and Linux fro Alternatively, you can install the application using one of the following package managers: +### Nightly builds + +Every merge into `master` is published as a prerelease in +[4gray/iptvnator-nightly](https://github.com/4gray/iptvnator-nightly). The +desktop app can follow them: **Settings → About → Update channel → Nightly** +offers each new build through the built-in updater (macOS, Windows, and the +Linux AppImage). Nightly builds are untested snapshots — they may break, and +their database changes are permanent, so switching back to **Stable** keeps +the installed nightly until the next stable release is newer. Back up your +playlists first, and mention the version and commit shown in *About* when +reporting a bug from a nightly. + ### Homebrew ```shell diff --git a/apps/electron-backend/src/app/events/app-update.events.spec.ts b/apps/electron-backend/src/app/events/app-update.events.spec.ts index d0e80b40f..799893e1c 100644 --- a/apps/electron-backend/src/app/events/app-update.events.spec.ts +++ b/apps/electron-backend/src/app/events/app-update.events.spec.ts @@ -14,8 +14,9 @@ jest.mock('electron', () => ({ ipcMain: { handle: jest.fn( (channel: string, handler: (...args: unknown[]) => unknown) => { - mockHandlers.set(channel, handler); - }), + mockHandlers.set(channel, handler); + } + ), }, })); @@ -32,6 +33,8 @@ describe('AppUpdateEvents', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle, supportedSelfUpdate: true, + channel: 'stable', + installedChannel: 'stable', }; const service = { checkForUpdates: jest.fn().mockResolvedValue({ @@ -59,7 +62,8 @@ describe('AppUpdateEvents', () => { status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded, })), }; - const { default: AppUpdateEvents } = await import('./app-update.events'); + const { default: AppUpdateEvents } = + await import('./app-update.events'); AppUpdateEvents.bootstrapAppUpdateEvents(service); diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index 65c7f459d..a5cd5c03a 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -22,6 +22,7 @@ type SettingsUpdateHandler = ( const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; const STORE_KEYS = { + APP_UPDATE_CHANNEL: 'APP_UPDATE_CHANNEL', EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT', EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS', EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY', @@ -50,6 +51,7 @@ jest.mock('electron', () => ({ })); jest.mock('../services/store.service', () => ({ + APP_UPDATE_CHANNEL: STORE_KEYS.APP_UPDATE_CHANNEL, EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS, EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, @@ -219,6 +221,37 @@ describe('SETTINGS_UPDATE', () => { ]); }); + it('mirrors the update channel into the main-process store and notifies the updater', async () => { + const channelModule = await import('../services/app-update-channel'); + const listener = jest.fn(); + channelModule.onAppUpdateChannelChange(listener); + + settingsUpdateHandler({}, { updateChannel: 'nightly' }); + + expect(mockStoreSet.mock.calls).toEqual([ + [STORE_KEYS.APP_UPDATE_CHANNEL, 'nightly'], + ]); + expect(listener).toHaveBeenCalledWith('nightly'); + }); + + it('normalizes an unknown update channel to stable and stays quiet when nothing changed', async () => { + const channelModule = await import('../services/app-update-channel'); + const listener = jest.fn(); + channelModule.onAppUpdateChannelChange(listener); + + settingsUpdateHandler( + {}, + { + updateChannel: 'canary' as unknown as 'stable', + } + ); + + expect(mockStoreSet.mock.calls).toEqual([ + [STORE_KEYS.APP_UPDATE_CHANNEL, 'stable'], + ]); + expect(listener).not.toHaveBeenCalled(); + }); + it('reconciles an enabled remote-control update with the stored port', () => { mockStoreGet.mockImplementation( (key: string, fallbackValue: unknown): unknown => diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index bb32607f5..c289739c2 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -19,6 +19,7 @@ import { } from '../services/store.service'; import { httpServer } from '../server/http-server'; import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard'; +import { persistAppUpdateChannel } from '../services/app-update-channel'; export default class SettingsEvents { static bootstrapSettingsEvents(): Electron.IpcMain { @@ -91,6 +92,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance); } + // Mirrored for the startup update check, which runs before the renderer + // exists; a change re-points the running updater immediately. + if (arg.updateChannel !== undefined) { + persistAppUpdateChannel(arg.updateChannel); + } + // Handle remote control settings if ( arg.remoteControl !== undefined || diff --git a/apps/electron-backend/src/app/services/app-update-channel.ts b/apps/electron-backend/src/app/services/app-update-channel.ts new file mode 100644 index 000000000..755eb3d53 --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update-channel.ts @@ -0,0 +1,48 @@ +import { + AppUpdateChannel, + normalizeAppUpdateChannel, +} from '@iptvnator/shared/interfaces'; +import { APP_UPDATE_CHANNEL, store } from './store.service'; + +type AppUpdateChannelListener = (channel: AppUpdateChannel) => void; + +const listeners = new Set(); + +/** + * The persisted update channel, read synchronously from the main-process + * config so the startup update check can use it before any renderer exists. + * Junk in the config file collapses to `stable`. + */ +export function readStoredAppUpdateChannel(): AppUpdateChannel { + return normalizeAppUpdateChannel(store.get(APP_UPDATE_CHANNEL)); +} + +/** + * Mirrors a saved `Settings.updateChannel` into the config file and tells + * the updater. Listeners only run on an actual change, so a Save that keeps + * the channel cannot restart an update check. + */ +export function persistAppUpdateChannel(value: unknown): AppUpdateChannel { + const channel = normalizeAppUpdateChannel(value); + const previous = readStoredAppUpdateChannel(); + + store.set(APP_UPDATE_CHANNEL, channel); + + if (channel !== previous) { + for (const listener of listeners) { + listener(channel); + } + } + + return channel; +} + +export function onAppUpdateChannelChange( + listener: AppUpdateChannelListener +): () => void { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} diff --git a/apps/electron-backend/src/app/services/app-update-feed.ts b/apps/electron-backend/src/app/services/app-update-feed.ts new file mode 100644 index 000000000..0966e7cf2 --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update-feed.ts @@ -0,0 +1,48 @@ +import { + AppUpdateChannel, + appUpdateRepository, + NIGHTLY_PRERELEASE_TAG, +} from '@iptvnator/shared/interfaces'; + +export interface AppUpdateGitHubFeedOptions { + provider: 'github'; + owner: string; + repo: string; +} + +/** + * The slice of electron-updater's `AppUpdater` the channel switch touches. + * Kept structural so the service spec can drive it with a fake. + */ +export interface AppUpdateFeedTarget { + allowPrerelease: boolean; + allowDowngrade: boolean; + channel: string | null; + setFeedURL(options: AppUpdateGitHubFeedOptions): void; +} + +/** + * Points electron-updater at the repository behind `channel` right before a + * check. Applied on every check rather than once, because the channel can + * change while the app runs. + * + * Two electron-updater quirks decide the shape of this function: + * + * - The `channel` setter throws once a string channel is replaced by `null`, + * so stable is written as the explicit `latest` (the default channel + * file name) instead of being cleared. + * - Assigning `channel` silently flips `allowDowngrade` to `true`, and the + * constructor turns `allowPrerelease` on for any prerelease build. Both + * are reset afterwards: a nightly build on the stable channel must wait + * for the next stable release rather than downgrade onto one that may + * not understand its database schema. + */ +export function applyAppUpdateChannel( + updater: AppUpdateFeedTarget, + channel: AppUpdateChannel +): void { + updater.setFeedURL({ provider: 'github', ...appUpdateRepository(channel) }); + updater.allowPrerelease = channel === 'nightly'; + updater.channel = channel === 'nightly' ? NIGHTLY_PRERELEASE_TAG : 'latest'; + updater.allowDowngrade = false; +} 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 new file mode 100644 index 000000000..e629a83be --- /dev/null +++ b/apps/electron-backend/src/app/services/app-update-release-catalog.ts @@ -0,0 +1,178 @@ +import { + AppUpdateChannel, + appUpdateReleasesApiUrl, + appUpdateReleasesPageUrl, + appVersionChannel, +} from '@iptvnator/shared/interfaces'; + +const GITHUB_RELEASES_PER_PAGE = 10; + +interface GitHubReleaseResponse { + body?: string | null; + draft?: boolean; + html_url?: string; + name?: string | null; + prerelease?: boolean; + published_at?: string | null; + tag_name?: string; +} + +export interface CachedGitHubRelease { + bodyMarkdown: string; + draft: boolean; + htmlUrl: string; + prerelease: boolean; + publishedAt?: string | null; + releaseName?: string | null; + tagName: string; + version: string; +} + +export interface ReleaseFetchResponse { + json(): Promise; + ok: boolean; + status: number; + statusText: string; +} + +export type ReleaseFetcher = ( + url: string, + init?: { headers?: Record } +) => Promise; + +export function normalizeVersion(value: string | null | undefined): string { + return (value ?? '').trim().replace(/^v/i, ''); +} + +function isGitHubRelease(value: unknown): value is GitHubReleaseResponse { + return Boolean( + value && + typeof value === 'object' && + 'tag_name' in value && + typeof (value as GitHubReleaseResponse).tag_name === 'string' + ); +} + +/** + * Lazily paged, newest-first list of one channel's published GitHub + * releases. Drafts are never listed. The stable catalog also drops + * prereleases (a beta tag must not become "the latest version"), while the + * nightly catalog keeps only releases whose version carries the nightly + * identifier, so a stray tag in that repository cannot be offered either. + */ +export class AppUpdateReleaseCatalog { + readonly releases: CachedGitHubRelease[] = []; + loadedReleasePages = 0; + loadedAllReleases = false; + + constructor( + readonly channel: AppUpdateChannel, + private readonly fetcher: ReleaseFetcher, + private readonly userAgent: string + ) {} + + get latest(): CachedGitHubRelease | undefined { + return this.releases[0]; + } + + async ensureFirstReleaseLoaded(): Promise { + while (!this.loadedAllReleases && this.releases.length === 0) { + await this.ensurePageLoaded(this.loadedReleasePages + 1); + } + } + + async ensurePageLoaded(page: number): Promise { + while (!this.loadedAllReleases && this.loadedReleasePages < page) { + await this.loadPage(this.loadedReleasePages + 1); + } + } + + /** Index of `version` (or its tag), paging further until it is found. */ + async findIndex(version: string | undefined): Promise { + if (!version) { + return -1; + } + + const normalizedVersion = normalizeVersion(version); + + while (true) { + const index = this.releases.findIndex( + (release) => + normalizeVersion(release.version) === normalizedVersion || + normalizeVersion(release.tagName) === normalizedVersion + ); + + if (index !== -1 || this.loadedAllReleases) { + return index; + } + + await this.ensurePageLoaded(this.loadedReleasePages + 1); + } + } + + private belongsToChannel(release: GitHubReleaseResponse): boolean { + if (release.draft) { + return false; + } + + if (this.channel === 'nightly') { + return appVersionChannel(release.tag_name) === 'nightly'; + } + + return !release.prerelease; + } + + private async loadPage(page: number): Promise { + const url = `${appUpdateReleasesApiUrl(this.channel)}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`; + const response = await this.fetcher(url, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': this.userAgent, + }, + }); + + if (!response.ok) { + throw new Error( + `GitHub releases request failed: ${response.status} ${response.statusText}` + ); + } + + const payload = await response.json(); + + if (!Array.isArray(payload)) { + throw new Error('GitHub releases response was not an array'); + } + + const releasesPageUrl = appUpdateReleasesPageUrl(this.channel).replace( + /\/latest$/, + '' + ); + + for (const release of payload) { + if (!isGitHubRelease(release) || !this.belongsToChannel(release)) { + continue; + } + + const tagName = release.tag_name; + + if (this.releases.some((cached) => cached.tagName === tagName)) { + continue; + } + + this.releases.push({ + bodyMarkdown: release.body ?? '', + draft: Boolean(release.draft), + htmlUrl: + release.html_url ?? `${releasesPageUrl}/tag/${tagName}`, + prerelease: Boolean(release.prerelease), + publishedAt: release.published_at, + releaseName: release.name ?? tagName, + tagName, + version: normalizeVersion(tagName), + }); + } + + this.loadedReleasePages = page; + this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE; + } +} 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 ae266b8b6..c506ee136 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 @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import { + AppUpdateChannel, ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; @@ -8,11 +9,66 @@ import { AppUpdateService } from './app-update.service'; class FakeUpdater extends EventEmitter { autoDownload = true; autoInstallOnAppQuit = true; + // electron-updater turns these on by itself for prerelease builds and + // channel assignments; the service must reset them explicitly. + allowPrerelease = true; + allowDowngrade = true; + channel: string | null = null; + setFeedURL = jest.fn(); checkForUpdates = jest.fn().mockResolvedValue(null); downloadUpdate = jest.fn().mockResolvedValue([]); quitAndInstall = jest.fn(); } +const nightlyReleases = [ + { + body: '## Nightly\n\nNewest master build\n\n', + draft: false, + html_url: + 'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260915.7', + name: 'Nightly 0.23.1-nightly.20260915.7', + prerelease: true, + published_at: '2026-09-15T00:00:00.000Z', + tag_name: 'v0.23.1-nightly.20260915.7', + }, + { + body: 'not a nightly', + draft: false, + html_url: + 'https://github.com/4gray/iptvnator-nightly/releases/tag/v9.9.9', + name: 'stray tag', + prerelease: false, + published_at: '2026-09-14T12:00:00.000Z', + tag_name: 'v9.9.9', + }, + { + body: '## Nightly\n\nOlder master build', + draft: false, + html_url: + 'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260914.5', + name: 'Nightly 0.23.1-nightly.20260914.5', + prerelease: true, + published_at: '2026-09-14T00:00:00.000Z', + tag_name: 'v0.23.1-nightly.20260914.5', + }, +]; + +/** Answers the stable and nightly repositories with their own lists. */ +function createChannelReleaseFetcher() { + return jest.fn(async (url: string) => { + const releases = url.includes('/iptvnator-nightly/') + ? nightlyReleases + : githubReleases; + + return { + json: jest.fn().mockResolvedValue(releases), + ok: true, + status: 200, + statusText: 'OK', + }; + }); +} + const githubReleases = [ { body: '## New\n\nFresh build', @@ -84,6 +140,8 @@ function createService( env?: NodeJS.ProcessEnv; prepareQuit?: () => void; cancelPreparedQuit?: () => void; + channel?: AppUpdateChannel; + appVersion?: string; } = {} ) { const updater = new FakeUpdater(); @@ -93,6 +151,8 @@ function createService( getVersion: () => '0.22.0', isPackaged: overrides.isPackaged ?? true, }, + appVersion: overrides.appVersion, + channel: overrides.channel, getMainWindow: () => win, cancelPreparedQuit: overrides.cancelPreparedQuit, platform: overrides.platform ?? 'darwin', @@ -115,6 +175,8 @@ describe('AppUpdateService', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); expect(updater.autoDownload).toBe(true); }); @@ -136,6 +198,8 @@ describe('AppUpdateService', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); await service.checkForUpdatesOnStartup(); @@ -160,6 +224,8 @@ describe('AppUpdateService', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); }); @@ -281,6 +347,8 @@ describe('AppUpdateService', () => { }, status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); }); @@ -312,6 +380,8 @@ describe('AppUpdateService', () => { latestVersion: '0.24.0', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); }); @@ -534,4 +604,186 @@ describe('AppUpdateService', () => { expect(cancelPreparedQuit).not.toHaveBeenCalled(); }); + it('points electron-updater at the stable repository before every check', async () => { + const { service, updater } = createService(); + + await service.checkForUpdates(); + + expect(updater.setFeedURL).toHaveBeenCalledWith({ + provider: 'github', + owner: '4gray', + repo: 'iptvnator', + }); + expect(updater.allowPrerelease).toBe(false); + expect(updater.channel).toBe('latest'); + expect(updater.allowDowngrade).toBe(false); + expect(updater.setFeedURL.mock.invocationCallOrder[0]).toBeLessThan( + updater.checkForUpdates.mock.invocationCallOrder[0] + ); + }); + + it('follows the nightly repository and its prerelease channel when configured', async () => { + const { service, updater } = createService({ channel: 'nightly' }); + + expect(service.getStatus()).toMatchObject({ + channel: 'nightly', + installedChannel: 'stable', + manualDownloadUrl: + 'https://github.com/4gray/iptvnator-nightly/releases', + }); + + await service.checkForUpdates(); + + expect(updater.setFeedURL).toHaveBeenCalledWith({ + provider: 'github', + owner: '4gray', + repo: 'iptvnator-nightly', + }); + expect(updater.allowPrerelease).toBe(true); + expect(updater.channel).toBe('nightly'); + expect(updater.allowDowngrade).toBe(false); + }); + + it('reports the installed channel from the running version', () => { + const { service } = createService({ + appVersion: '0.23.1-nightly.20260915.7', + }); + + expect(service.getStatus()).toMatchObject({ + currentVersion: '0.23.1-nightly.20260915.7', + channel: 'stable', + installedChannel: 'nightly', + }); + }); + + it('re-checks on an idle updater when the channel changes and forgets the old verdict', async () => { + const { service, updater, win } = createService(); + await service.checkForUpdates(); + service.handleUpdateNotAvailable({ version: '0.22.0' }); + updater.checkForUpdates.mockClear(); + + service.setChannel('stable'); + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + + service.setChannel('nightly'); + await Promise.resolve(); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + expect(updater.setFeedURL).toHaveBeenLastCalledWith( + expect.objectContaining({ repo: 'iptvnator-nightly' }) + ); + const pushed = ( + win.webContents.send.mock.calls as [ + string, + ElectronBridgeAppUpdateStatus, + ][] + ).map(([, status]) => status); + expect( + pushed.some( + (status) => + status.channel === 'nightly' && + status.latestVersion === undefined && + status.release === undefined + ) + ).toBe(true); + }); + + it('leaves a running download on the old channel but reports the new one', async () => { + const { service, updater } = createService(); + service.handleUpdateAvailable({ version: '0.23.0' }); + updater.downloadUpdate.mockImplementationOnce( + () => new Promise(() => undefined) + ); + void service.downloadUpdate(); + updater.checkForUpdates.mockClear(); + + service.setChannel('nightly'); + await Promise.resolve(); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ + channel: 'nightly', + latestVersion: '0.23.0', + manualDownloadUrl: + 'https://github.com/4gray/iptvnator-nightly/releases', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading, + }); + }); + + it('offers newer nightlies through the manual fallback using prerelease-aware ordering', async () => { + const fetcher = createChannelReleaseFetcher(); + const { service, updater } = createService({ + appVersion: '0.23.1-nightly.20260914.5', + channel: 'nightly', + env: {}, + fetcher, + platform: 'linux', + }); + + await service.checkForUpdates(); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledWith( + 'https://api.github.com/repos/4gray/iptvnator-nightly/releases?per_page=10&page=1', + expect.any(Object) + ); + // v9.9.9 is not a nightly and must not win the list. + expect(service.getStatus()).toMatchObject({ + latestVersion: '0.23.1-nightly.20260915.7', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + installedChannel: 'nightly', + }); + }); + + it('never downgrades a nightly build that follows the stable channel', async () => { + const fetcher = createChannelReleaseFetcher(); + const { service } = createService({ + appVersion: '0.24.1-nightly.20260915.7', + env: {}, + fetcher, + platform: 'linux', + }); + + await service.checkForUpdates(); + + expect(service.getStatus()).toMatchObject({ + latestVersion: '0.24.0', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + installedChannel: 'nightly', + }); + }); + + it('reads release notes from the repository the requested version belongs to', async () => { + const fetcher = createChannelReleaseFetcher(); + const { service } = createService({ fetcher }); + + const nightlyNotes = await service.getReleaseNotes({ + version: '0.23.1-nightly.20260915.7', + }); + expect(nightlyNotes).toMatchObject({ + tagName: 'v0.23.1-nightly.20260915.7', + hasNext: false, + hasPrevious: true, + }); + + const previous = await service.getReleaseNotes({ + version: nightlyNotes.tagName, + direction: 'previous', + }); + expect(previous.tagName).toBe('v0.23.1-nightly.20260914.5'); + + const stableNotes = await service.getReleaseNotes({ + version: '0.23.0', + }); + expect(stableNotes.tagName).toBe('v0.23.0'); + + const latestOnStableChannel = await service.getReleaseNotes(); + expect(latestOnStableChannel.tagName).toBe('v0.24.0'); + expect( + fetcher.mock.calls.map(([url]) => new URL(url).pathname) + ).toEqual([ + '/repos/4gray/iptvnator-nightly/releases', + '/repos/4gray/iptvnator/releases', + ]); + }); }); 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 6fe2d9cd7..c8b61cdf5 100644 --- a/apps/electron-backend/src/app/services/app-update.service.ts +++ b/apps/electron-backend/src/app/services/app-update.service.ts @@ -1,17 +1,28 @@ import { APP_UPDATE_STATUS_CHANGED, + AppUpdateChannel, + appUpdateReleasesPageUrl, + appVersionChannel, + compareAppVersions, + DEFAULT_APP_UPDATE_CHANNEL, ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ElectronBridgeAppUpdateRelease, ElectronBridgeAppUpdateReleaseNotes, ElectronBridgeAppUpdateReleaseNotesRequest, ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; +import { AppUpdateFeedTarget, applyAppUpdateChannel } from './app-update-feed'; +import { + AppUpdateReleaseCatalog, + CachedGitHubRelease, + normalizeVersion, + ReleaseFetcher, + ReleaseFetchResponse, +} from './app-update-release-catalog'; -export const APP_UPDATE_MANUAL_DOWNLOAD_URL = - 'https://github.com/4gray/iptvnator/releases/latest'; -const GITHUB_RELEASES_API_URL = - 'https://api.github.com/repos/4gray/iptvnator/releases'; -const GITHUB_RELEASES_PER_PAGE = 10; +export const APP_UPDATE_MANUAL_DOWNLOAD_URL = appUpdateReleasesPageUrl( + DEFAULT_APP_UPDATE_CHANNEL +); interface AppUpdateAppAdapter { getVersion(): string; @@ -41,7 +52,7 @@ interface AppUpdateProgressInfo { transferred?: number; } -interface AppUpdaterAdapter { +interface AppUpdaterAdapter extends AppUpdateFeedTarget { autoDownload: boolean; autoInstallOnAppQuit?: boolean; checkForUpdates(): Promise; @@ -69,39 +80,6 @@ interface AppUpdaterAdapter { type AppUpdaterAdapterProvider = AppUpdaterAdapter | (() => AppUpdaterAdapter); -interface GitHubReleaseResponse { - body?: string | null; - draft?: boolean; - html_url?: string; - name?: string | null; - prerelease?: boolean; - published_at?: string | null; - tag_name?: string; -} - -interface CachedGitHubRelease { - bodyMarkdown: string; - draft: boolean; - htmlUrl: string; - prerelease: boolean; - publishedAt?: string | null; - releaseName?: string | null; - tagName: string; - version: string; -} - -interface ReleaseFetchResponse { - json(): Promise; - ok: boolean; - status: number; - statusText: string; -} - -type ReleaseFetcher = ( - url: string, - init?: { headers?: Record } -) => Promise; - export interface AppUpdateServiceOptions { app: AppUpdateAppAdapter; appVersion?: string; @@ -110,6 +88,8 @@ export interface AppUpdateServiceOptions { platform?: NodeJS.Platform; processEnv?: NodeJS.ProcessEnv; releaseFetcher?: ReleaseFetcher; + /** Persisted update channel at startup; `setChannel` follows later saves. */ + channel?: AppUpdateChannel; /** * Runs right before `quitAndInstall()`. Lets the unsaved-settings close * guard stand down for the updater-driven window close, which on macOS @@ -165,37 +145,6 @@ function normalizeError(error: unknown): string { return String(error); } -function normalizeVersion(value: string | null | undefined): string { - return (value ?? '').trim().replace(/^v/i, ''); -} - -function parseVersionParts(value: string): [number, number, number] | null { - const match = normalizeVersion(value).match(/^(\d+)\.(\d+)\.(\d+)/); - - if (!match) { - return null; - } - - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -function isVersionGreaterThan(candidate: string, current: string): boolean { - const candidateParts = parseVersionParts(candidate); - const currentParts = parseVersionParts(current); - - if (!candidateParts || !currentParts) { - return false; - } - - for (let index = 0; index < candidateParts.length; index += 1) { - if (candidateParts[index] !== currentParts[index]) { - return candidateParts[index] > currentParts[index]; - } - } - - return false; -} - function resolveCurrentVersion( app: AppUpdateAppAdapter, appVersion: string | undefined @@ -217,8 +166,7 @@ function toReleaseInfo( function toReleaseNotes( release: CachedGitHubRelease, index: number, - releaseCount: number, - loadedAllReleases: boolean + catalog: AppUpdateReleaseCatalog ): ElectronBridgeAppUpdateReleaseNotes { return { version: release.version, @@ -228,28 +176,22 @@ function toReleaseNotes( bodyMarkdown: release.bodyMarkdown, htmlUrl: release.htmlUrl, hasNext: index > 0, - hasPrevious: index < releaseCount - 1 || !loadedAllReleases, + hasPrevious: + index < catalog.releases.length - 1 || !catalog.loadedAllReleases, }; } -function isGitHubRelease(value: unknown): value is GitHubReleaseResponse { - return Boolean( - value && - typeof value === 'object' && - 'tag_name' in value && - typeof (value as GitHubReleaseResponse).tag_name === 'string' - ); -} - export class AppUpdateService { private readonly currentVersion: string; private readonly isPackaged: boolean; private readonly supportedSelfUpdate: boolean; private readonly releaseFetcher: ReleaseFetcher; - private readonly releases: CachedGitHubRelease[] = []; + private readonly catalogs = new Map< + AppUpdateChannel, + AppUpdateReleaseCatalog + >(); private readonly updater: AppUpdaterAdapter | null = null; - private loadedReleasePages = 0; - private loadedAllReleases = false; + private channel: AppUpdateChannel; private checkForUpdatesPromise: Promise | null = null; private status: ElectronBridgeAppUpdateStatus; @@ -262,6 +204,7 @@ export class AppUpdateService { options.appVersion ); this.isPackaged = options.app.isPackaged; + this.channel = options.channel ?? DEFAULT_APP_UPDATE_CHANNEL; this.supportedSelfUpdate = isSelfUpdateSupported( options.app.isPackaged, options.platform ?? process.platform, @@ -269,12 +212,15 @@ export class AppUpdateService { ); this.releaseFetcher = options.releaseFetcher ?? - ((url, init) => fetch(url, init) as Promise); + ((url, init) => + fetch(url, init) as Promise); this.status = { currentVersion: this.currentVersion, - manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL, + manualDownloadUrl: appUpdateReleasesPageUrl(this.channel), status: getInitialStatus(this.supportedSelfUpdate), supportedSelfUpdate: this.supportedSelfUpdate, + channel: this.channel, + installedChannel: appVersionChannel(this.currentVersion), }; if (this.supportedSelfUpdate) { @@ -289,6 +235,33 @@ export class AppUpdateService { return { ...this.status }; } + /** + * Follows a saved channel change. A download already running or + * finished belongs to the previous channel and is left alone — the + * user can still install it — so only an idle updater re-checks. + */ + setChannel(channel: AppUpdateChannel): void { + if (channel === this.channel) { + return; + } + + this.channel = channel; + + const busy = + this.status.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || + this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; + + if (busy || !this.isPackaged) { + this.setStatus({}); + return; + } + + // The last verdict described the other channel's releases. + this.setStatus({ latestVersion: undefined, release: undefined }); + void this.checkForUpdates(); + } + async checkForUpdates(): Promise { if (this.checkForUpdatesPromise) { return this.checkForUpdatesPromise; @@ -311,11 +284,14 @@ export class AppUpdateService { this.setStatus({ error: undefined, + latestVersion: undefined, + release: undefined, status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking, }); try { if (this.updater) { + applyAppUpdateChannel(this.updater, this.channel); await this.updater.checkForUpdates(); } else { await this.checkGitHubReleaseForManualUpdate(); @@ -335,19 +311,28 @@ export class AppUpdateService { return this.checkForUpdates(); } + /** + * Release notes come from the catalog of the channel the requested + * version belongs to, not the configured one: a nightly build on the + * stable channel still reads its own notes, and paging from a nightly + * tag stays inside the nightly list. + */ async getReleaseNotes( request: ElectronBridgeAppUpdateReleaseNotesRequest = {} ): Promise { + const catalog = this.catalogFor( + request.version ? appVersionChannel(request.version) : this.channel + ); const canFallbackToLatest = !request.direction && (!request.version || request.fallbackToLatest); if (canFallbackToLatest) { - await this.ensureFirstStableReleaseLoaded(); + await catalog.ensureFirstReleaseLoaded(); } else { - await this.ensureReleasePageLoaded(1); + await catalog.ensurePageLoaded(1); } - let index = await this.findReleaseIndex(request.version); + let index = await catalog.findIndex(request.version); if (index === -1 && canFallbackToLatest) { index = 0; @@ -361,25 +346,20 @@ export class AppUpdateService { if (request.direction === 'previous') { index += 1; - while (index >= this.releases.length && !this.loadedAllReleases) { - await this.ensureReleasePageLoaded(this.loadedReleasePages + 1); + while (index >= catalog.releases.length && !catalog.loadedAllReleases) { + await catalog.ensurePageLoaded(catalog.loadedReleasePages + 1); } } else if (request.direction === 'next') { index -= 1; } - const release = this.releases[index]; + const release = catalog.releases[index]; if (!release) { throw new Error('No release notes are available in that direction'); } - return toReleaseNotes( - release, - index, - this.releases.length, - this.loadedAllReleases - ); + return toReleaseNotes(release, index, catalog); } async downloadUpdate(): Promise { @@ -489,6 +469,21 @@ export class AppUpdateService { }); } + private catalogFor(channel: AppUpdateChannel): AppUpdateReleaseCatalog { + let catalog = this.catalogs.get(channel); + + if (!catalog) { + catalog = new AppUpdateReleaseCatalog( + channel, + this.releaseFetcher, + `iptvnator/${this.currentVersion}` + ); + this.catalogs.set(channel, catalog); + } + + return catalog; + } + private resolveUpdater( updater: AppUpdaterAdapterProvider ): AppUpdaterAdapter { @@ -517,9 +512,15 @@ export class AppUpdateService { updater.on('error', (error) => this.handleError(error)); } + /** + * Packaged builds without self-update (Linux deb/rpm/snap/…) only learn + * whether a newer version exists on the configured channel and point + * the user at its release page. + */ private async checkGitHubReleaseForManualUpdate(): Promise { - await this.ensureFirstStableReleaseLoaded(); - const latestRelease = this.releases[0]; + const catalog = this.catalogFor(this.channel); + await catalog.ensureFirstReleaseLoaded(); + const latestRelease = catalog.latest; if (!latestRelease) { this.setStatus({ @@ -528,124 +529,28 @@ export class AppUpdateService { return; } - if (isVersionGreaterThan(latestRelease.version, this.currentVersion)) { - this.setStatus({ - error: undefined, - latestVersion: latestRelease.version, - release: toReleaseInfo(latestRelease), - status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, - }); - return; - } + const isNewer = + compareAppVersions(latestRelease.version, this.currentVersion) > 0; this.setStatus({ error: undefined, latestVersion: latestRelease.version, release: toReleaseInfo(latestRelease), - status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + status: isNewer + ? ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available + : ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, }); } - private async ensureFirstStableReleaseLoaded(): Promise { - while (!this.loadedAllReleases && this.releases.length === 0) { - await this.ensureReleasePageLoaded(this.loadedReleasePages + 1); - } - } - - private async findReleaseIndex( - version: string | undefined - ): Promise { - if (!version) { - return -1; - } - - const normalizedVersion = normalizeVersion(version); - - while (true) { - const index = this.releases.findIndex( - (release) => - normalizeVersion(release.version) === normalizedVersion || - normalizeVersion(release.tagName) === normalizedVersion - ); - - if (index !== -1 || this.loadedAllReleases) { - return index; - } - - await this.ensureReleasePageLoaded(this.loadedReleasePages + 1); - } - } - - private async ensureReleasePageLoaded(page: number): Promise { - while (!this.loadedAllReleases && this.loadedReleasePages < page) { - await this.loadReleasePage(this.loadedReleasePages + 1); - } - } - - private async loadReleasePage(page: number): Promise { - const url = `${GITHUB_RELEASES_API_URL}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`; - const response = await this.releaseFetcher(url, { - headers: { - Accept: 'application/vnd.github+json', - 'User-Agent': `iptvnator/${this.currentVersion}`, - }, - }); - - if (!response.ok) { - throw new Error( - `GitHub releases request failed: ${response.status} ${response.statusText}` - ); - } - - const payload = await response.json(); - - if (!Array.isArray(payload)) { - throw new Error('GitHub releases response was not an array'); - } - - for (const release of payload) { - if (!isGitHubRelease(release)) { - continue; - } - - if (release.draft || release.prerelease) { - continue; - } - - const tagName = release.tag_name; - const version = normalizeVersion(tagName); - - if ( - this.releases.some( - (cachedRelease) => cachedRelease.tagName === tagName - ) - ) { - continue; - } - - this.releases.push({ - bodyMarkdown: release.body ?? '', - draft: Boolean(release.draft), - htmlUrl: - release.html_url ?? - `${APP_UPDATE_MANUAL_DOWNLOAD_URL.replace('/latest', '')}/tag/${tagName}`, - prerelease: Boolean(release.prerelease), - publishedAt: release.published_at, - releaseName: release.name ?? tagName, - tagName, - version, - }); - } - - this.loadedReleasePages = page; - this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE; - } - private setStatus( update: Partial< Omit< ElectronBridgeAppUpdateStatus, - 'currentVersion' | 'manualDownloadUrl' | 'supportedSelfUpdate' + | 'currentVersion' + | 'manualDownloadUrl' + | 'supportedSelfUpdate' + | 'channel' + | 'installedChannel' > > ): void { @@ -653,8 +558,10 @@ export class AppUpdateService { ...this.status, ...update, currentVersion: this.currentVersion, - manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL, + manualDownloadUrl: appUpdateReleasesPageUrl(this.channel), supportedSelfUpdate: this.supportedSelfUpdate, + channel: this.channel, + installedChannel: this.status.installedChannel, }; this.emitStatus(); } diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 9bdb10d30..0362b3637 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -1,6 +1,9 @@ import { Conf } from 'electron-conf/main'; import { getElectronConfigDirectory } from '@iptvnator/shared/database'; -import type { StartupWindowMode } from '@iptvnator/shared/interfaces'; +import type { + AppUpdateChannel, + StartupWindowMode, +} from '@iptvnator/shared/interfaces'; export const WINDOW_BOUNDS = 'WINDOW_BOUNDS'; export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH'; @@ -28,6 +31,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE'; /** Desktop portal request cooldown; absent means enabled. */ export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD'; +/** + * Update channel (`stable` / `nightly`). Mirrored here from the renderer's + * settings by the SETTINGS_UPDATE handler because the startup update check + * runs before the renderer's IndexedDB settings are reachable; absent means + * `stable`. Read and written only through `app-update-channel.ts`. + */ +export const APP_UPDATE_CHANNEL = 'APP_UPDATE_CHANNEL'; + /** * Local XMLTV files the EPG importer may read: every path the native picker * returned plus every hand-typed path the user allowed in the main-process @@ -64,6 +75,7 @@ export type StoreType = { [EMBEDDED_MPV_AUTO_RECONNECT]: boolean; [STARTUP_WINDOW_MODE]: StartupWindowMode; [PORTAL_CONNECTIVITY_GUARD]: boolean; + [APP_UPDATE_CHANNEL]: AppUpdateChannel; [TRUSTED_LOCAL_EPG_SOURCES]: string[]; }; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 69827f094..f3a98459d 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -33,6 +33,10 @@ import StalkerEvents from './app/events/stalker.events'; import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; import { registerStaticHeaderShims } from './app/services/request-header-overrides.service'; import { AppUpdateService } from './app/services/app-update.service'; +import { + onAppUpdateChannelChange, + readStoredAppUpdateChannel, +} from './app/services/app-update-channel'; import { databaseWorkerClient } from './app/services/database-worker-client'; import WindowEvents from './app/events/window.events'; import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service'; @@ -141,6 +145,7 @@ export default class Main { const appUpdateService = new AppUpdateService({ app, appVersion: environment.version, + channel: readStoredAppUpdateChannel(), getMainWindow: () => App.mainWindow, updater: () => autoUpdater, // quitAndInstall() closes the windows before 'before-quit' fires @@ -150,6 +155,9 @@ export default class Main { cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(), }); AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); + onAppUpdateChannelChange((channel) => + appUpdateService.setChannel(channel) + ); registerStaticHeaderShims(); ElectronEvents.bootstrapElectronEvents(); diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts index 110f07e51..f567a2573 100644 --- a/apps/web/src/app/app-update-notification-panel.component.spec.ts +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -14,6 +14,8 @@ const availableStatus: ElectronBridgeAppUpdateStatus = { manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, supportedSelfUpdate: true, + channel: 'stable', + installedChannel: 'stable', }; describe('AppUpdateNotificationPanelComponent', () => { diff --git a/apps/web/src/app/services/app-update-install.service.spec.ts b/apps/web/src/app/services/app-update-install.service.spec.ts index dd2639428..658efc39c 100644 --- a/apps/web/src/app/services/app-update-install.service.spec.ts +++ b/apps/web/src/app/services/app-update-install.service.spec.ts @@ -12,6 +12,9 @@ const BASE_STATUS: ElectronBridgeAppUpdateStatus = { currentVersion: '0.23.0', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle, supportedSelfUpdate: true, + manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest', + channel: 'stable', + installedChannel: 'stable', }; /** 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 34881ab6d..79210c5f1 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -63,6 +63,65 @@ > } + @if (canSelectUpdateChannel()) { +
+ + {{ + 'SETTINGS.APP_UPDATE_CHANNEL' | translate + }} + + @for ( + option of updateChannelOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + + + {{ + 'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION' + | translate + }} + + @if (form()!.get('updateChannel')?.value === 'nightly') { + + {{ + 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING' + | translate + }} + + } + @if (isNightlyBuildOnStableChannel()) { + + {{ + 'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT' + | translate + }} + + } +
+ }