mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat(updater): nightly builds and a stable/nightly update channel
Every master push publishes its artifacts as a prerelease of 4gray/iptvnator-nightly instead of the rolling test-master draft, with a version of <next patch>-nightly.<commit date>.<run number> applied in every build job. Settings → About gains an Update channel switch; AppUpdateService re-points electron-updater per check (feed repository, allowPrerelease, channel name, allowDowngrade reset) and reads release notes from the repository the requested version belongs to. Channel switches are forward-only: a nightly build stays until a newer stable release exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f724494b9
commit
c9272f5635
53 files changed
+2024
-223
No files matched your search
@@ -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.
|
||||
@@ -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: every build job rewrites the
|
||||
# package.json version to <next patch>-nightly.<date>.<run number>
|
||||
# (tools/release/nightly-version.mjs) 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:
|
||||
@@ -445,6 +453,16 @@ 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. Every job of
|
||||
# this run derives the same value from the commit date and the
|
||||
# run number, so nothing has to be handed between jobs.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator'
|
||||
run: node tools/release/nightly-version.mjs --apply
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm nx build web --skip-nx-cache
|
||||
|
||||
@@ -1188,6 +1206,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 +1231,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,6 +1254,7 @@ jobs:
|
||||
dist/executables/**/*.msi
|
||||
dist/executables/**/*.zip
|
||||
dist/executables/**/latest.yml
|
||||
dist/executables/**/nightly.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
@@ -1285,6 +1306,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 +1333,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 +1444,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 +1459,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 +1537,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 +1560,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 +1607,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 +1650,183 @@ 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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="$(node tools/release/nightly-version.mjs)"
|
||||
{
|
||||
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/.*<!-- iptvnator-commit: \([0-9a-f]\{40\}\) -->.*/\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 '<!-- iptvnator-commit: %s -->\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. 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
|
||||
)
|
||||
|
||||
if gh release view "${TAG}" --repo "${NIGHTLY_REPOSITORY}" > /dev/null 2>&1; then
|
||||
echo "Release ${TAG} already exists (re-run); replacing it."
|
||||
gh release delete "${TAG}" --repo "${NIGHTLY_REPOSITORY}" --cleanup-tag --yes
|
||||
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
|
||||
@@ -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 `"<version> (<short-sha>)"`. 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 `"<version> (<short-sha>)"`. 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. Each build job rewrites the `package.json` version to `<next patch>-nightly.<commit date>.<run number>` (`tools/release/nightly-version.mjs --apply`, deterministic per run), 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ type SettingsUpdateHandler = (
|
||||
|
||||
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
|
||||
const STORE_KEYS = {
|
||||
APP_UPDATE_CHANNEL: 'APP_UPDATE_CHANNEL',
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT',
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS',
|
||||
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
|
||||
@@ -50,6 +51,7 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
APP_UPDATE_CHANNEL: STORE_KEYS.APP_UPDATE_CHANNEL,
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
|
||||
@@ -219,6 +221,37 @@ describe('SETTINGS_UPDATE', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('mirrors the update channel into the main-process store and notifies the updater', async () => {
|
||||
const channelModule = await import('../services/app-update-channel');
|
||||
const listener = jest.fn();
|
||||
channelModule.onAppUpdateChannelChange(listener);
|
||||
|
||||
settingsUpdateHandler({}, { updateChannel: 'nightly' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.APP_UPDATE_CHANNEL, 'nightly'],
|
||||
]);
|
||||
expect(listener).toHaveBeenCalledWith('nightly');
|
||||
});
|
||||
|
||||
it('normalizes an unknown update channel to stable and stays quiet when nothing changed', async () => {
|
||||
const channelModule = await import('../services/app-update-channel');
|
||||
const listener = jest.fn();
|
||||
channelModule.onAppUpdateChannelChange(listener);
|
||||
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{
|
||||
updateChannel: 'canary' as unknown as 'stable',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.APP_UPDATE_CHANNEL, 'stable'],
|
||||
]);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconciles an enabled remote-control update with the stored port', () => {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown =>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../services/store.service';
|
||||
import { httpServer } from '../server/http-server';
|
||||
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
|
||||
import { persistAppUpdateChannel } from '../services/app-update-channel';
|
||||
|
||||
export default class SettingsEvents {
|
||||
static bootstrapSettingsEvents(): Electron.IpcMain {
|
||||
@@ -91,6 +92,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
|
||||
}
|
||||
|
||||
// Mirrored for the startup update check, which runs before the renderer
|
||||
// exists; a change re-points the running updater immediately.
|
||||
if (arg.updateChannel !== undefined) {
|
||||
persistAppUpdateChannel(arg.updateChannel);
|
||||
}
|
||||
|
||||
// Handle remote control settings
|
||||
if (
|
||||
arg.remoteControl !== undefined ||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
normalizeAppUpdateChannel,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { APP_UPDATE_CHANNEL, store } from './store.service';
|
||||
|
||||
type AppUpdateChannelListener = (channel: AppUpdateChannel) => void;
|
||||
|
||||
const listeners = new Set<AppUpdateChannelListener>();
|
||||
|
||||
/**
|
||||
* The persisted update channel, read synchronously from the main-process
|
||||
* config so the startup update check can use it before any renderer exists.
|
||||
* Junk in the config file collapses to `stable`.
|
||||
*/
|
||||
export function readStoredAppUpdateChannel(): AppUpdateChannel {
|
||||
return normalizeAppUpdateChannel(store.get(APP_UPDATE_CHANNEL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a saved `Settings.updateChannel` into the config file and tells
|
||||
* the updater. Listeners only run on an actual change, so a Save that keeps
|
||||
* the channel cannot restart an update check.
|
||||
*/
|
||||
export function persistAppUpdateChannel(value: unknown): AppUpdateChannel {
|
||||
const channel = normalizeAppUpdateChannel(value);
|
||||
const previous = readStoredAppUpdateChannel();
|
||||
|
||||
store.set(APP_UPDATE_CHANNEL, channel);
|
||||
|
||||
if (channel !== previous) {
|
||||
for (const listener of listeners) {
|
||||
listener(channel);
|
||||
}
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function onAppUpdateChannelChange(
|
||||
listener: AppUpdateChannelListener
|
||||
): () => void {
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
appUpdateRepository,
|
||||
NIGHTLY_PRERELEASE_TAG,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface AppUpdateGitHubFeedOptions {
|
||||
provider: 'github';
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of electron-updater's `AppUpdater` the channel switch touches.
|
||||
* Kept structural so the service spec can drive it with a fake.
|
||||
*/
|
||||
export interface AppUpdateFeedTarget {
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
channel: string | null;
|
||||
setFeedURL(options: AppUpdateGitHubFeedOptions): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Points electron-updater at the repository behind `channel` right before a
|
||||
* check. Applied on every check rather than once, because the channel can
|
||||
* change while the app runs.
|
||||
*
|
||||
* Two electron-updater quirks decide the shape of this function:
|
||||
*
|
||||
* - The `channel` setter throws once a string channel is replaced by `null`,
|
||||
* so stable is written as the explicit `latest` (the default channel
|
||||
* file name) instead of being cleared.
|
||||
* - Assigning `channel` silently flips `allowDowngrade` to `true`, and the
|
||||
* constructor turns `allowPrerelease` on for any prerelease build. Both
|
||||
* are reset afterwards: a nightly build on the stable channel must wait
|
||||
* for the next stable release rather than downgrade onto one that may
|
||||
* not understand its database schema.
|
||||
*/
|
||||
export function applyAppUpdateChannel(
|
||||
updater: AppUpdateFeedTarget,
|
||||
channel: AppUpdateChannel
|
||||
): void {
|
||||
updater.setFeedURL({ provider: 'github', ...appUpdateRepository(channel) });
|
||||
updater.allowPrerelease = channel === 'nightly';
|
||||
updater.channel = channel === 'nightly' ? NIGHTLY_PRERELEASE_TAG : 'latest';
|
||||
updater.allowDowngrade = false;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
appUpdateReleasesApiUrl,
|
||||
appUpdateReleasesPageUrl,
|
||||
appVersionChannel,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const GITHUB_RELEASES_PER_PAGE = 10;
|
||||
|
||||
interface GitHubReleaseResponse {
|
||||
body?: string | null;
|
||||
draft?: boolean;
|
||||
html_url?: string;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface CachedGitHubRelease {
|
||||
bodyMarkdown: string;
|
||||
draft: boolean;
|
||||
htmlUrl: string;
|
||||
prerelease: boolean;
|
||||
publishedAt?: string | null;
|
||||
releaseName?: string | null;
|
||||
tagName: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ReleaseFetchResponse {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export type ReleaseFetcher = (
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string> }
|
||||
) => Promise<ReleaseFetchResponse>;
|
||||
|
||||
export function normalizeVersion(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'tag_name' in value &&
|
||||
typeof (value as GitHubReleaseResponse).tag_name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily paged, newest-first list of one channel's published GitHub
|
||||
* releases. Drafts are never listed. The stable catalog also drops
|
||||
* prereleases (a beta tag must not become "the latest version"), while the
|
||||
* nightly catalog keeps only releases whose version carries the nightly
|
||||
* identifier, so a stray tag in that repository cannot be offered either.
|
||||
*/
|
||||
export class AppUpdateReleaseCatalog {
|
||||
readonly releases: CachedGitHubRelease[] = [];
|
||||
loadedReleasePages = 0;
|
||||
loadedAllReleases = false;
|
||||
|
||||
constructor(
|
||||
readonly channel: AppUpdateChannel,
|
||||
private readonly fetcher: ReleaseFetcher,
|
||||
private readonly userAgent: string
|
||||
) {}
|
||||
|
||||
get latest(): CachedGitHubRelease | undefined {
|
||||
return this.releases[0];
|
||||
}
|
||||
|
||||
async ensureFirstReleaseLoaded(): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.releases.length === 0) {
|
||||
await this.ensurePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async ensurePageLoaded(page: number): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
|
||||
await this.loadPage(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Index of `version` (or its tag), paging further until it is found. */
|
||||
async findIndex(version: string | undefined): Promise<number> {
|
||||
if (!version) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const normalizedVersion = normalizeVersion(version);
|
||||
|
||||
while (true) {
|
||||
const index = this.releases.findIndex(
|
||||
(release) =>
|
||||
normalizeVersion(release.version) === normalizedVersion ||
|
||||
normalizeVersion(release.tagName) === normalizedVersion
|
||||
);
|
||||
|
||||
if (index !== -1 || this.loadedAllReleases) {
|
||||
return index;
|
||||
}
|
||||
|
||||
await this.ensurePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private belongsToChannel(release: GitHubReleaseResponse): boolean {
|
||||
if (release.draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.channel === 'nightly') {
|
||||
return appVersionChannel(release.tag_name) === 'nightly';
|
||||
}
|
||||
|
||||
return !release.prerelease;
|
||||
}
|
||||
|
||||
private async loadPage(page: number): Promise<void> {
|
||||
const url = `${appUpdateReleasesApiUrl(this.channel)}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
|
||||
const response = await this.fetcher(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': this.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub releases request failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('GitHub releases response was not an array');
|
||||
}
|
||||
|
||||
const releasesPageUrl = appUpdateReleasesPageUrl(this.channel).replace(
|
||||
/\/latest$/,
|
||||
''
|
||||
);
|
||||
|
||||
for (const release of payload) {
|
||||
if (!isGitHubRelease(release) || !this.belongsToChannel(release)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = release.tag_name;
|
||||
|
||||
if (this.releases.some((cached) => cached.tagName === tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.releases.push({
|
||||
bodyMarkdown: release.body ?? '',
|
||||
draft: Boolean(release.draft),
|
||||
htmlUrl:
|
||||
release.html_url ?? `${releasesPageUrl}/tag/${tagName}`,
|
||||
prerelease: Boolean(release.prerelease),
|
||||
publishedAt: release.published_at,
|
||||
releaseName: release.name ?? tagName,
|
||||
tagName,
|
||||
version: normalizeVersion(tagName),
|
||||
});
|
||||
}
|
||||
|
||||
this.loadedReleasePages = page;
|
||||
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -8,11 +9,66 @@ import { AppUpdateService } from './app-update.service';
|
||||
class FakeUpdater extends EventEmitter {
|
||||
autoDownload = true;
|
||||
autoInstallOnAppQuit = true;
|
||||
// electron-updater turns these on by itself for prerelease builds and
|
||||
// channel assignments; the service must reset them explicitly.
|
||||
allowPrerelease = true;
|
||||
allowDowngrade = true;
|
||||
channel: string | null = null;
|
||||
setFeedURL = jest.fn();
|
||||
checkForUpdates = jest.fn().mockResolvedValue(null);
|
||||
downloadUpdate = jest.fn().mockResolvedValue([]);
|
||||
quitAndInstall = jest.fn();
|
||||
}
|
||||
|
||||
const nightlyReleases = [
|
||||
{
|
||||
body: '## Nightly\n\nNewest master build\n\n<!-- iptvnator-commit: abc -->',
|
||||
draft: false,
|
||||
html_url:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260915.7',
|
||||
name: 'Nightly 0.23.1-nightly.20260915.7',
|
||||
prerelease: true,
|
||||
published_at: '2026-09-15T00:00:00.000Z',
|
||||
tag_name: 'v0.23.1-nightly.20260915.7',
|
||||
},
|
||||
{
|
||||
body: 'not a nightly',
|
||||
draft: false,
|
||||
html_url:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases/tag/v9.9.9',
|
||||
name: 'stray tag',
|
||||
prerelease: false,
|
||||
published_at: '2026-09-14T12:00:00.000Z',
|
||||
tag_name: 'v9.9.9',
|
||||
},
|
||||
{
|
||||
body: '## Nightly\n\nOlder master build',
|
||||
draft: false,
|
||||
html_url:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260914.5',
|
||||
name: 'Nightly 0.23.1-nightly.20260914.5',
|
||||
prerelease: true,
|
||||
published_at: '2026-09-14T00:00:00.000Z',
|
||||
tag_name: 'v0.23.1-nightly.20260914.5',
|
||||
},
|
||||
];
|
||||
|
||||
/** Answers the stable and nightly repositories with their own lists. */
|
||||
function createChannelReleaseFetcher() {
|
||||
return jest.fn(async (url: string) => {
|
||||
const releases = url.includes('/iptvnator-nightly/')
|
||||
? nightlyReleases
|
||||
: githubReleases;
|
||||
|
||||
return {
|
||||
json: jest.fn().mockResolvedValue(releases),
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const githubReleases = [
|
||||
{
|
||||
body: '## New\n\nFresh build',
|
||||
@@ -84,6 +140,8 @@ function createService(
|
||||
env?: NodeJS.ProcessEnv;
|
||||
prepareQuit?: () => void;
|
||||
cancelPreparedQuit?: () => void;
|
||||
channel?: AppUpdateChannel;
|
||||
appVersion?: string;
|
||||
} = {}
|
||||
) {
|
||||
const updater = new FakeUpdater();
|
||||
@@ -93,6 +151,8 @@ function createService(
|
||||
getVersion: () => '0.22.0',
|
||||
isPackaged: overrides.isPackaged ?? true,
|
||||
},
|
||||
appVersion: overrides.appVersion,
|
||||
channel: overrides.channel,
|
||||
getMainWindow: () => win,
|
||||
cancelPreparedQuit: overrides.cancelPreparedQuit,
|
||||
platform: overrides.platform ?? 'darwin',
|
||||
@@ -115,6 +175,8 @@ describe('AppUpdateService', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
expect(updater.autoDownload).toBe(true);
|
||||
});
|
||||
@@ -136,6 +198,8 @@ describe('AppUpdateService', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
|
||||
await service.checkForUpdatesOnStartup();
|
||||
@@ -160,6 +224,8 @@ describe('AppUpdateService', () => {
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +347,8 @@ describe('AppUpdateService', () => {
|
||||
},
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,6 +380,8 @@ describe('AppUpdateService', () => {
|
||||
latestVersion: '0.24.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: false,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,4 +604,186 @@ describe('AppUpdateService', () => {
|
||||
|
||||
expect(cancelPreparedQuit).not.toHaveBeenCalled();
|
||||
});
|
||||
it('points electron-updater at the stable repository before every check', async () => {
|
||||
const { service, updater } = createService();
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(updater.setFeedURL).toHaveBeenCalledWith({
|
||||
provider: 'github',
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator',
|
||||
});
|
||||
expect(updater.allowPrerelease).toBe(false);
|
||||
expect(updater.channel).toBe('latest');
|
||||
expect(updater.allowDowngrade).toBe(false);
|
||||
expect(updater.setFeedURL.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updater.checkForUpdates.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the nightly repository and its prerelease channel when configured', async () => {
|
||||
const { service, updater } = createService({ channel: 'nightly' });
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
channel: 'nightly',
|
||||
installedChannel: 'stable',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases',
|
||||
});
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(updater.setFeedURL).toHaveBeenCalledWith({
|
||||
provider: 'github',
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator-nightly',
|
||||
});
|
||||
expect(updater.allowPrerelease).toBe(true);
|
||||
expect(updater.channel).toBe('nightly');
|
||||
expect(updater.allowDowngrade).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the installed channel from the running version', () => {
|
||||
const { service } = createService({
|
||||
appVersion: '0.23.1-nightly.20260915.7',
|
||||
});
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
currentVersion: '0.23.1-nightly.20260915.7',
|
||||
channel: 'stable',
|
||||
installedChannel: 'nightly',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-checks on an idle updater when the channel changes and forgets the old verdict', async () => {
|
||||
const { service, updater, win } = createService();
|
||||
await service.checkForUpdates();
|
||||
service.handleUpdateNotAvailable({ version: '0.22.0' });
|
||||
updater.checkForUpdates.mockClear();
|
||||
|
||||
service.setChannel('stable');
|
||||
expect(updater.checkForUpdates).not.toHaveBeenCalled();
|
||||
|
||||
service.setChannel('nightly');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
expect(updater.setFeedURL).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ repo: 'iptvnator-nightly' })
|
||||
);
|
||||
const pushed = (
|
||||
win.webContents.send.mock.calls as [
|
||||
string,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
][]
|
||||
).map(([, status]) => status);
|
||||
expect(
|
||||
pushed.some(
|
||||
(status) =>
|
||||
status.channel === 'nightly' &&
|
||||
status.latestVersion === undefined &&
|
||||
status.release === undefined
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a running download on the old channel but reports the new one', async () => {
|
||||
const { service, updater } = createService();
|
||||
service.handleUpdateAvailable({ version: '0.23.0' });
|
||||
updater.downloadUpdate.mockImplementationOnce(
|
||||
() => new Promise(() => undefined)
|
||||
);
|
||||
void service.downloadUpdate();
|
||||
updater.checkForUpdates.mockClear();
|
||||
|
||||
service.setChannel('nightly');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(updater.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
channel: 'nightly',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator-nightly/releases',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers newer nightlies through the manual fallback using prerelease-aware ordering', async () => {
|
||||
const fetcher = createChannelReleaseFetcher();
|
||||
const { service, updater } = createService({
|
||||
appVersion: '0.23.1-nightly.20260914.5',
|
||||
channel: 'nightly',
|
||||
env: {},
|
||||
fetcher,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(updater.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/4gray/iptvnator-nightly/releases?per_page=10&page=1',
|
||||
expect.any(Object)
|
||||
);
|
||||
// v9.9.9 is not a nightly and must not win the list.
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
latestVersion: '0.23.1-nightly.20260915.7',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
installedChannel: 'nightly',
|
||||
});
|
||||
});
|
||||
|
||||
it('never downgrades a nightly build that follows the stable channel', async () => {
|
||||
const fetcher = createChannelReleaseFetcher();
|
||||
const { service } = createService({
|
||||
appVersion: '0.24.1-nightly.20260915.7',
|
||||
env: {},
|
||||
fetcher,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
await service.checkForUpdates();
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
latestVersion: '0.24.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
|
||||
installedChannel: 'nightly',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads release notes from the repository the requested version belongs to', async () => {
|
||||
const fetcher = createChannelReleaseFetcher();
|
||||
const { service } = createService({ fetcher });
|
||||
|
||||
const nightlyNotes = await service.getReleaseNotes({
|
||||
version: '0.23.1-nightly.20260915.7',
|
||||
});
|
||||
expect(nightlyNotes).toMatchObject({
|
||||
tagName: 'v0.23.1-nightly.20260915.7',
|
||||
hasNext: false,
|
||||
hasPrevious: true,
|
||||
});
|
||||
|
||||
const previous = await service.getReleaseNotes({
|
||||
version: nightlyNotes.tagName,
|
||||
direction: 'previous',
|
||||
});
|
||||
expect(previous.tagName).toBe('v0.23.1-nightly.20260914.5');
|
||||
|
||||
const stableNotes = await service.getReleaseNotes({
|
||||
version: '0.23.0',
|
||||
});
|
||||
expect(stableNotes.tagName).toBe('v0.23.0');
|
||||
|
||||
const latestOnStableChannel = await service.getReleaseNotes();
|
||||
expect(latestOnStableChannel.tagName).toBe('v0.24.0');
|
||||
expect(
|
||||
fetcher.mock.calls.map(([url]) => new URL(url).pathname)
|
||||
).toEqual([
|
||||
'/repos/4gray/iptvnator-nightly/releases',
|
||||
'/repos/4gray/iptvnator/releases',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,28 @@
|
||||
import {
|
||||
APP_UPDATE_STATUS_CHANGED,
|
||||
AppUpdateChannel,
|
||||
appUpdateReleasesPageUrl,
|
||||
appVersionChannel,
|
||||
compareAppVersions,
|
||||
DEFAULT_APP_UPDATE_CHANNEL,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateRelease,
|
||||
ElectronBridgeAppUpdateReleaseNotes,
|
||||
ElectronBridgeAppUpdateReleaseNotesRequest,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateFeedTarget, applyAppUpdateChannel } from './app-update-feed';
|
||||
import {
|
||||
AppUpdateReleaseCatalog,
|
||||
CachedGitHubRelease,
|
||||
normalizeVersion,
|
||||
ReleaseFetcher,
|
||||
ReleaseFetchResponse,
|
||||
} from './app-update-release-catalog';
|
||||
|
||||
export const APP_UPDATE_MANUAL_DOWNLOAD_URL =
|
||||
'https://github.com/4gray/iptvnator/releases/latest';
|
||||
const GITHUB_RELEASES_API_URL =
|
||||
'https://api.github.com/repos/4gray/iptvnator/releases';
|
||||
const GITHUB_RELEASES_PER_PAGE = 10;
|
||||
export const APP_UPDATE_MANUAL_DOWNLOAD_URL = appUpdateReleasesPageUrl(
|
||||
DEFAULT_APP_UPDATE_CHANNEL
|
||||
);
|
||||
|
||||
interface AppUpdateAppAdapter {
|
||||
getVersion(): string;
|
||||
@@ -41,7 +52,7 @@ interface AppUpdateProgressInfo {
|
||||
transferred?: number;
|
||||
}
|
||||
|
||||
interface AppUpdaterAdapter {
|
||||
interface AppUpdaterAdapter extends AppUpdateFeedTarget {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit?: boolean;
|
||||
checkForUpdates(): Promise<unknown>;
|
||||
@@ -69,39 +80,6 @@ interface AppUpdaterAdapter {
|
||||
|
||||
type AppUpdaterAdapterProvider = AppUpdaterAdapter | (() => AppUpdaterAdapter);
|
||||
|
||||
interface GitHubReleaseResponse {
|
||||
body?: string | null;
|
||||
draft?: boolean;
|
||||
html_url?: string;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
interface CachedGitHubRelease {
|
||||
bodyMarkdown: string;
|
||||
draft: boolean;
|
||||
htmlUrl: string;
|
||||
prerelease: boolean;
|
||||
publishedAt?: string | null;
|
||||
releaseName?: string | null;
|
||||
tagName: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface ReleaseFetchResponse {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
type ReleaseFetcher = (
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string> }
|
||||
) => Promise<ReleaseFetchResponse>;
|
||||
|
||||
export interface AppUpdateServiceOptions {
|
||||
app: AppUpdateAppAdapter;
|
||||
appVersion?: string;
|
||||
@@ -110,6 +88,8 @@ export interface AppUpdateServiceOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
processEnv?: NodeJS.ProcessEnv;
|
||||
releaseFetcher?: ReleaseFetcher;
|
||||
/** Persisted update channel at startup; `setChannel` follows later saves. */
|
||||
channel?: AppUpdateChannel;
|
||||
/**
|
||||
* Runs right before `quitAndInstall()`. Lets the unsaved-settings close
|
||||
* guard stand down for the updater-driven window close, which on macOS
|
||||
@@ -165,37 +145,6 @@ function normalizeError(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeVersion(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function parseVersionParts(value: string): [number, number, number] | null {
|
||||
const match = normalizeVersion(value).match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function isVersionGreaterThan(candidate: string, current: string): boolean {
|
||||
const candidateParts = parseVersionParts(candidate);
|
||||
const currentParts = parseVersionParts(current);
|
||||
|
||||
if (!candidateParts || !currentParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < candidateParts.length; index += 1) {
|
||||
if (candidateParts[index] !== currentParts[index]) {
|
||||
return candidateParts[index] > currentParts[index];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveCurrentVersion(
|
||||
app: AppUpdateAppAdapter,
|
||||
appVersion: string | undefined
|
||||
@@ -217,8 +166,7 @@ function toReleaseInfo(
|
||||
function toReleaseNotes(
|
||||
release: CachedGitHubRelease,
|
||||
index: number,
|
||||
releaseCount: number,
|
||||
loadedAllReleases: boolean
|
||||
catalog: AppUpdateReleaseCatalog
|
||||
): ElectronBridgeAppUpdateReleaseNotes {
|
||||
return {
|
||||
version: release.version,
|
||||
@@ -228,28 +176,22 @@ function toReleaseNotes(
|
||||
bodyMarkdown: release.bodyMarkdown,
|
||||
htmlUrl: release.htmlUrl,
|
||||
hasNext: index > 0,
|
||||
hasPrevious: index < releaseCount - 1 || !loadedAllReleases,
|
||||
hasPrevious:
|
||||
index < catalog.releases.length - 1 || !catalog.loadedAllReleases,
|
||||
};
|
||||
}
|
||||
|
||||
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'tag_name' in value &&
|
||||
typeof (value as GitHubReleaseResponse).tag_name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class AppUpdateService {
|
||||
private readonly currentVersion: string;
|
||||
private readonly isPackaged: boolean;
|
||||
private readonly supportedSelfUpdate: boolean;
|
||||
private readonly releaseFetcher: ReleaseFetcher;
|
||||
private readonly releases: CachedGitHubRelease[] = [];
|
||||
private readonly catalogs = new Map<
|
||||
AppUpdateChannel,
|
||||
AppUpdateReleaseCatalog
|
||||
>();
|
||||
private readonly updater: AppUpdaterAdapter | null = null;
|
||||
private loadedReleasePages = 0;
|
||||
private loadedAllReleases = false;
|
||||
private channel: AppUpdateChannel;
|
||||
private checkForUpdatesPromise: Promise<ElectronBridgeAppUpdateStatus> | null =
|
||||
null;
|
||||
private status: ElectronBridgeAppUpdateStatus;
|
||||
@@ -262,6 +204,7 @@ export class AppUpdateService {
|
||||
options.appVersion
|
||||
);
|
||||
this.isPackaged = options.app.isPackaged;
|
||||
this.channel = options.channel ?? DEFAULT_APP_UPDATE_CHANNEL;
|
||||
this.supportedSelfUpdate = isSelfUpdateSupported(
|
||||
options.app.isPackaged,
|
||||
options.platform ?? process.platform,
|
||||
@@ -269,12 +212,15 @@ export class AppUpdateService {
|
||||
);
|
||||
this.releaseFetcher =
|
||||
options.releaseFetcher ??
|
||||
((url, init) => fetch(url, init) as Promise<ReleaseFetchResponse>);
|
||||
((url, init) =>
|
||||
fetch(url, init) as Promise<ReleaseFetchResponse>);
|
||||
this.status = {
|
||||
currentVersion: this.currentVersion,
|
||||
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
|
||||
manualDownloadUrl: appUpdateReleasesPageUrl(this.channel),
|
||||
status: getInitialStatus(this.supportedSelfUpdate),
|
||||
supportedSelfUpdate: this.supportedSelfUpdate,
|
||||
channel: this.channel,
|
||||
installedChannel: appVersionChannel(this.currentVersion),
|
||||
};
|
||||
|
||||
if (this.supportedSelfUpdate) {
|
||||
@@ -289,6 +235,33 @@ export class AppUpdateService {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a saved channel change. A download already running or
|
||||
* finished belongs to the previous channel and is left alone — the
|
||||
* user can still install it — so only an idle updater re-checks.
|
||||
*/
|
||||
setChannel(channel: AppUpdateChannel): void {
|
||||
if (channel === this.channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel = channel;
|
||||
|
||||
const busy =
|
||||
this.status.status ===
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
|
||||
this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded;
|
||||
|
||||
if (busy || !this.isPackaged) {
|
||||
this.setStatus({});
|
||||
return;
|
||||
}
|
||||
|
||||
// The last verdict described the other channel's releases.
|
||||
this.setStatus({ latestVersion: undefined, release: undefined });
|
||||
void this.checkForUpdates();
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus> {
|
||||
if (this.checkForUpdatesPromise) {
|
||||
return this.checkForUpdatesPromise;
|
||||
@@ -311,11 +284,14 @@ export class AppUpdateService {
|
||||
|
||||
this.setStatus({
|
||||
error: undefined,
|
||||
latestVersion: undefined,
|
||||
release: undefined,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.updater) {
|
||||
applyAppUpdateChannel(this.updater, this.channel);
|
||||
await this.updater.checkForUpdates();
|
||||
} else {
|
||||
await this.checkGitHubReleaseForManualUpdate();
|
||||
@@ -335,19 +311,28 @@ export class AppUpdateService {
|
||||
return this.checkForUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release notes come from the catalog of the channel the requested
|
||||
* version belongs to, not the configured one: a nightly build on the
|
||||
* stable channel still reads its own notes, and paging from a nightly
|
||||
* tag stays inside the nightly list.
|
||||
*/
|
||||
async getReleaseNotes(
|
||||
request: ElectronBridgeAppUpdateReleaseNotesRequest = {}
|
||||
): Promise<ElectronBridgeAppUpdateReleaseNotes> {
|
||||
const catalog = this.catalogFor(
|
||||
request.version ? appVersionChannel(request.version) : this.channel
|
||||
);
|
||||
const canFallbackToLatest =
|
||||
!request.direction && (!request.version || request.fallbackToLatest);
|
||||
|
||||
if (canFallbackToLatest) {
|
||||
await this.ensureFirstStableReleaseLoaded();
|
||||
await catalog.ensureFirstReleaseLoaded();
|
||||
} else {
|
||||
await this.ensureReleasePageLoaded(1);
|
||||
await catalog.ensurePageLoaded(1);
|
||||
}
|
||||
|
||||
let index = await this.findReleaseIndex(request.version);
|
||||
let index = await catalog.findIndex(request.version);
|
||||
|
||||
if (index === -1 && canFallbackToLatest) {
|
||||
index = 0;
|
||||
@@ -361,25 +346,20 @@ export class AppUpdateService {
|
||||
|
||||
if (request.direction === 'previous') {
|
||||
index += 1;
|
||||
while (index >= this.releases.length && !this.loadedAllReleases) {
|
||||
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
|
||||
while (index >= catalog.releases.length && !catalog.loadedAllReleases) {
|
||||
await catalog.ensurePageLoaded(catalog.loadedReleasePages + 1);
|
||||
}
|
||||
} else if (request.direction === 'next') {
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
const release = this.releases[index];
|
||||
const release = catalog.releases[index];
|
||||
|
||||
if (!release) {
|
||||
throw new Error('No release notes are available in that direction');
|
||||
}
|
||||
|
||||
return toReleaseNotes(
|
||||
release,
|
||||
index,
|
||||
this.releases.length,
|
||||
this.loadedAllReleases
|
||||
);
|
||||
return toReleaseNotes(release, index, catalog);
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<ElectronBridgeAppUpdateStatus> {
|
||||
@@ -489,6 +469,21 @@ export class AppUpdateService {
|
||||
});
|
||||
}
|
||||
|
||||
private catalogFor(channel: AppUpdateChannel): AppUpdateReleaseCatalog {
|
||||
let catalog = this.catalogs.get(channel);
|
||||
|
||||
if (!catalog) {
|
||||
catalog = new AppUpdateReleaseCatalog(
|
||||
channel,
|
||||
this.releaseFetcher,
|
||||
`iptvnator/${this.currentVersion}`
|
||||
);
|
||||
this.catalogs.set(channel, catalog);
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private resolveUpdater(
|
||||
updater: AppUpdaterAdapterProvider
|
||||
): AppUpdaterAdapter {
|
||||
@@ -517,9 +512,15 @@ export class AppUpdateService {
|
||||
updater.on('error', (error) => this.handleError(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Packaged builds without self-update (Linux deb/rpm/snap/…) only learn
|
||||
* whether a newer version exists on the configured channel and point
|
||||
* the user at its release page.
|
||||
*/
|
||||
private async checkGitHubReleaseForManualUpdate(): Promise<void> {
|
||||
await this.ensureFirstStableReleaseLoaded();
|
||||
const latestRelease = this.releases[0];
|
||||
const catalog = this.catalogFor(this.channel);
|
||||
await catalog.ensureFirstReleaseLoaded();
|
||||
const latestRelease = catalog.latest;
|
||||
|
||||
if (!latestRelease) {
|
||||
this.setStatus({
|
||||
@@ -528,124 +529,28 @@ export class AppUpdateService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVersionGreaterThan(latestRelease.version, this.currentVersion)) {
|
||||
this.setStatus({
|
||||
error: undefined,
|
||||
latestVersion: latestRelease.version,
|
||||
release: toReleaseInfo(latestRelease),
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isNewer =
|
||||
compareAppVersions(latestRelease.version, this.currentVersion) > 0;
|
||||
|
||||
this.setStatus({
|
||||
error: undefined,
|
||||
latestVersion: latestRelease.version,
|
||||
release: toReleaseInfo(latestRelease),
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
|
||||
status: isNewer
|
||||
? ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
|
||||
: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureFirstStableReleaseLoaded(): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.releases.length === 0) {
|
||||
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async findReleaseIndex(
|
||||
version: string | undefined
|
||||
): Promise<number> {
|
||||
if (!version) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const normalizedVersion = normalizeVersion(version);
|
||||
|
||||
while (true) {
|
||||
const index = this.releases.findIndex(
|
||||
(release) =>
|
||||
normalizeVersion(release.version) === normalizedVersion ||
|
||||
normalizeVersion(release.tagName) === normalizedVersion
|
||||
);
|
||||
|
||||
if (index !== -1 || this.loadedAllReleases) {
|
||||
return index;
|
||||
}
|
||||
|
||||
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureReleasePageLoaded(page: number): Promise<void> {
|
||||
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
|
||||
await this.loadReleasePage(this.loadedReleasePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadReleasePage(page: number): Promise<void> {
|
||||
const url = `${GITHUB_RELEASES_API_URL}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
|
||||
const response = await this.releaseFetcher(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': `iptvnator/${this.currentVersion}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub releases request failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('GitHub releases response was not an array');
|
||||
}
|
||||
|
||||
for (const release of payload) {
|
||||
if (!isGitHubRelease(release)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (release.draft || release.prerelease) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = release.tag_name;
|
||||
const version = normalizeVersion(tagName);
|
||||
|
||||
if (
|
||||
this.releases.some(
|
||||
(cachedRelease) => cachedRelease.tagName === tagName
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.releases.push({
|
||||
bodyMarkdown: release.body ?? '',
|
||||
draft: Boolean(release.draft),
|
||||
htmlUrl:
|
||||
release.html_url ??
|
||||
`${APP_UPDATE_MANUAL_DOWNLOAD_URL.replace('/latest', '')}/tag/${tagName}`,
|
||||
prerelease: Boolean(release.prerelease),
|
||||
publishedAt: release.published_at,
|
||||
releaseName: release.name ?? tagName,
|
||||
tagName,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
this.loadedReleasePages = page;
|
||||
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
|
||||
}
|
||||
|
||||
private setStatus(
|
||||
update: Partial<
|
||||
Omit<
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
'currentVersion' | 'manualDownloadUrl' | 'supportedSelfUpdate'
|
||||
| 'currentVersion'
|
||||
| 'manualDownloadUrl'
|
||||
| 'supportedSelfUpdate'
|
||||
| 'channel'
|
||||
| 'installedChannel'
|
||||
>
|
||||
>
|
||||
): void {
|
||||
@@ -653,8 +558,10 @@ export class AppUpdateService {
|
||||
...this.status,
|
||||
...update,
|
||||
currentVersion: this.currentVersion,
|
||||
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
|
||||
manualDownloadUrl: appUpdateReleasesPageUrl(this.channel),
|
||||
supportedSelfUpdate: this.supportedSelfUpdate,
|
||||
channel: this.channel,
|
||||
installedChannel: this.status.installedChannel,
|
||||
};
|
||||
this.emitStatus();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Conf } from 'electron-conf/main';
|
||||
import { getElectronConfigDirectory } from '@iptvnator/shared/database';
|
||||
import type { StartupWindowMode } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
AppUpdateChannel,
|
||||
StartupWindowMode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
|
||||
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
|
||||
@@ -28,6 +31,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
|
||||
/** Desktop portal request cooldown; absent means enabled. */
|
||||
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
|
||||
|
||||
/**
|
||||
* Update channel (`stable` / `nightly`). Mirrored here from the renderer's
|
||||
* settings by the SETTINGS_UPDATE handler because the startup update check
|
||||
* runs before the renderer's IndexedDB settings are reachable; absent means
|
||||
* `stable`. Read and written only through `app-update-channel.ts`.
|
||||
*/
|
||||
export const APP_UPDATE_CHANNEL = 'APP_UPDATE_CHANNEL';
|
||||
|
||||
/**
|
||||
* Local XMLTV files the EPG importer may read: every path the native picker
|
||||
* returned plus every hand-typed path the user allowed in the main-process
|
||||
@@ -64,6 +75,7 @@ export type StoreType = {
|
||||
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
|
||||
[STARTUP_WINDOW_MODE]: StartupWindowMode;
|
||||
[PORTAL_CONNECTIVITY_GUARD]: boolean;
|
||||
[APP_UPDATE_CHANNEL]: AppUpdateChannel;
|
||||
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ import StalkerEvents from './app/events/stalker.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
|
||||
import { AppUpdateService } from './app/services/app-update.service';
|
||||
import {
|
||||
onAppUpdateChannelChange,
|
||||
readStoredAppUpdateChannel,
|
||||
} from './app/services/app-update-channel';
|
||||
import { databaseWorkerClient } from './app/services/database-worker-client';
|
||||
import WindowEvents from './app/events/window.events';
|
||||
import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service';
|
||||
@@ -141,6 +145,7 @@ export default class Main {
|
||||
const appUpdateService = new AppUpdateService({
|
||||
app,
|
||||
appVersion: environment.version,
|
||||
channel: readStoredAppUpdateChannel(),
|
||||
getMainWindow: () => App.mainWindow,
|
||||
updater: () => autoUpdater,
|
||||
// quitAndInstall() closes the windows before 'before-quit' fires
|
||||
@@ -150,6 +155,9 @@ export default class Main {
|
||||
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
|
||||
});
|
||||
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
|
||||
onAppUpdateChannelChange((channel) =>
|
||||
appUpdateService.setChannel(channel)
|
||||
);
|
||||
|
||||
registerStaticHeaderShims();
|
||||
ElectronEvents.bootstrapElectronEvents();
|
||||
|
||||
@@ -63,6 +63,65 @@
|
||||
></progress>
|
||||
}
|
||||
</div>
|
||||
@if (canSelectUpdateChannel()) {
|
||||
<div
|
||||
class="app-update-channel"
|
||||
[formGroup]="form()!"
|
||||
data-test-id="app-update-channel"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="updateChannel"
|
||||
data-test-id="select-update-channel"
|
||||
>
|
||||
@for (
|
||||
option of updateChannelOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'update-channel-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<small class="app-update-channel__note">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
@if (form()!.get('updateChannel')?.value === 'nightly') {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-channel-nightly-warning"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
}
|
||||
@if (isNightlyBuildOnStableChannel()) {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-nightly-on-stable-hint"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="app-update-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SETTINGS_UPDATE_CHANNEL_OPTIONS } from './settings-options';
|
||||
|
||||
function getButton(fixture: ComponentFixture<SettingsAboutSectionComponent>, id: string) {
|
||||
return fixture.nativeElement.querySelector(
|
||||
@@ -187,3 +189,86 @@ describe('SettingsAboutSectionComponent version display', () => {
|
||||
expect(getCommitMarker()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsAboutSectionComponent update channel', () => {
|
||||
let fixture: ComponentFixture<SettingsAboutSectionComponent>;
|
||||
|
||||
const query = (id: string) =>
|
||||
fixture.nativeElement.querySelector(
|
||||
`[data-test-id="${id}"]`
|
||||
) as HTMLElement | null;
|
||||
|
||||
const configure = (
|
||||
status: Partial<ElectronBridgeAppUpdateStatus>,
|
||||
form: FormGroup | null
|
||||
) => {
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.componentRef.setInput(
|
||||
'updateChannelOptions',
|
||||
SETTINGS_UPDATE_CHANNEL_OPTIONS
|
||||
);
|
||||
fixture.componentRef.setInput('appUpdateStatus', {
|
||||
currentVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
...status,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SettingsAboutSectionComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsAboutSectionComponent);
|
||||
});
|
||||
|
||||
it('binds the channel select to the shared settings form', () => {
|
||||
const form = new FormGroup({
|
||||
updateChannel: new FormControl('stable'),
|
||||
});
|
||||
configure({}, form);
|
||||
|
||||
expect(query('select-update-channel')).not.toBeNull();
|
||||
expect(query('app-update-channel-nightly-warning')).toBeNull();
|
||||
|
||||
form.controls.updateChannel.setValue('nightly');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('app-update-channel-nightly-warning')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders no channel control without a form to bind to', () => {
|
||||
configure({}, null);
|
||||
|
||||
expect(query('select-update-channel')).toBeNull();
|
||||
});
|
||||
|
||||
it('explains that a nightly build on the stable channel waits for the next release', () => {
|
||||
const form = new FormGroup({
|
||||
updateChannel: new FormControl('stable'),
|
||||
});
|
||||
configure(
|
||||
{
|
||||
currentVersion: '0.23.1-nightly.20260915.7',
|
||||
installedChannel: 'nightly',
|
||||
},
|
||||
form
|
||||
);
|
||||
|
||||
expect(query('app-update-nightly-on-stable-hint')).not.toBeNull();
|
||||
|
||||
configure({ channel: 'nightly', installedChannel: 'nightly' }, form);
|
||||
|
||||
expect(query('app-update-nightly-on-stable-hint')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,28 @@ import {
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { UpdateChannelOption } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [MatButtonModule, MatIconModule, TranslateModule],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
@@ -24,6 +35,9 @@ import {
|
||||
styles: [
|
||||
':host { display: contents; }',
|
||||
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
|
||||
'.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; }',
|
||||
],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
@@ -34,6 +48,13 @@ export class SettingsAboutSectionComponent {
|
||||
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(
|
||||
null
|
||||
);
|
||||
/**
|
||||
* The shared settings form; the channel select lives in it so a change
|
||||
* goes through the same Save and unsaved-changes flow as every other
|
||||
* setting. Absent in hosts that only render the version block.
|
||||
*/
|
||||
readonly form = input<FormGroup | null>(null);
|
||||
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
|
||||
|
||||
readonly buildCommitShort = computed(() => {
|
||||
const commit = this.buildCommit()?.trim();
|
||||
@@ -47,6 +68,23 @@ export class SettingsAboutSectionComponent {
|
||||
readonly openManualAppUpdate = output<void>();
|
||||
readonly openAppUpdateReleaseNotes = output<void>();
|
||||
|
||||
readonly canSelectUpdateChannel = computed(
|
||||
() =>
|
||||
this.isDesktop() &&
|
||||
this.form() !== null &&
|
||||
this.updateChannelOptions().length > 0
|
||||
);
|
||||
|
||||
/** A nightly build on the stable channel waits for the next stable release. */
|
||||
readonly isNightlyBuildOnStableChannel = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return (
|
||||
status?.installedChannel === 'nightly' &&
|
||||
status.channel === 'stable'
|
||||
);
|
||||
});
|
||||
|
||||
readonly isAppUpdateBusy = computed(() => {
|
||||
const status = this.appUpdateStatus()?.status;
|
||||
|
||||
|
||||
@@ -82,6 +82,38 @@ describe('settings form utils — startup window mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings form utils — update channel', () => {
|
||||
const formBuilder = new FormBuilder();
|
||||
|
||||
it('defaults the form control to the stable channel', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
|
||||
expect(form.getRawValue().updateChannel).toBe('stable');
|
||||
});
|
||||
|
||||
it('carries the chosen channel into the settings object', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ updateChannel: 'nightly' });
|
||||
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).updateChannel
|
||||
).toBe('nightly');
|
||||
});
|
||||
|
||||
it('collapses a missing or unknown channel to stable', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ updateChannel: null as unknown as 'stable' });
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).updateChannel
|
||||
).toBe('stable');
|
||||
|
||||
form.patchValue({ updateChannel: 'canary' as unknown as 'stable' });
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).updateChannel
|
||||
).toBe('stable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings form utils — EPG display offset', () => {
|
||||
const formBuilder = new FormBuilder();
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
CoverSize,
|
||||
DEFAULT_APP_UPDATE_CHANNEL,
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
DEFAULT_TMDB_SETTINGS,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
normalizeAppUpdateChannel,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeEmbeddedMpvExtraOptions,
|
||||
normalizeExternalPlayerArguments,
|
||||
@@ -89,6 +92,7 @@ export function createSettingsForm(
|
||||
}),
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal' as StartupWindowMode,
|
||||
updateChannel: DEFAULT_APP_UPDATE_CHANNEL as AppUpdateChannel,
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -182,6 +186,7 @@ export function createSettingsFromFormValue(
|
||||
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
|
||||
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
|
||||
startupWindowMode: normalizeStartupWindowMode(value.startupWindowMode),
|
||||
updateChannel: normalizeAppUpdateChannel(value.updateChannel),
|
||||
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
|
||||
stripCountryPrefix: value.stripCountryPrefix ?? false,
|
||||
theme: value.theme ?? Theme.SystemTheme,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
StartupBehaviorOption,
|
||||
StartupWindowModeOption,
|
||||
ThemeOption,
|
||||
UpdateChannelOption,
|
||||
} from './settings.models';
|
||||
|
||||
export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [
|
||||
@@ -90,6 +91,17 @@ export const SETTINGS_STARTUP_WINDOW_MODE_OPTIONS: StartupWindowModeOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_UPDATE_CHANNEL_OPTIONS: UpdateChannelOption[] = [
|
||||
{
|
||||
value: 'stable',
|
||||
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL_STABLE',
|
||||
},
|
||||
{
|
||||
value: 'nightly',
|
||||
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
@case ('about') {
|
||||
<app-settings-about-section
|
||||
[isDesktop]="isDesktop"
|
||||
[form]="settingsForm"
|
||||
[updateChannelOptions]="updateChannelOptions"
|
||||
[version]="appUpdate.version()"
|
||||
[buildCommit]="buildCommit"
|
||||
[updateMessage]="appUpdate.updateMessage()"
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
|
||||
SETTINGS_UPDATE_CHANNEL_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
@@ -169,6 +170,7 @@ export class SettingsComponent
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
readonly startupWindowModeOptions = SETTINGS_STARTUP_WINDOW_MODE_OPTIONS;
|
||||
readonly updateChannelOptions = SETTINGS_UPDATE_CHANNEL_OPTIONS;
|
||||
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
||||
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
@@ -30,6 +31,11 @@ export interface StartupWindowModeOption {
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface UpdateChannelOption {
|
||||
value: AppUpdateChannel;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
|
||||
@@ -97,6 +97,7 @@ export const DEFAULT_SETTINGS = {
|
||||
showDashboard: true,
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal',
|
||||
updateChannel: 'stable',
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -128,6 +129,8 @@ export const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
};
|
||||
|
||||
export class MatSnackBarStub {
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "إعادة تشغيل التطبيق",
|
||||
"APP_UPDATE_OPEN_RELEASE": "فتح إصدار GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "ما الجديد",
|
||||
"APP_UPDATE_CHANNEL": "قناة التحديث",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "تتبع Stable الإصدارات المنشورة. تتبع Nightly كل دمج في master وتثبّت الإصدارات التجريبية تلقائيًا. يُطبَّق عند الحفظ.",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "إصدارات Nightly هي لقطات غير مختبرة من master. قد تتعطل، وتغييراتها في قاعدة البيانات دائمة: عند العودة إلى Stable يبقى هذا الإصدار مثبتًا حتى يصدر إصدار مستقر أحدث. أنشئ نسخة احتياطية من قوائم التشغيل أولًا.",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "أنت تستخدم إصدار Nightly. ستقترح قناة Stable الإصدار المستقر التالي بمجرد أن يصبح أحدث من هذا الإصدار.",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "عاود شعل التطبيق",
|
||||
"APP_UPDATE_OPEN_RELEASE": "حل إصدار GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "شنو الجديد",
|
||||
"APP_UPDATE_CHANNEL": "قناة التحديث",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable كتبع الإصدارات المنشورة. Nightly كتبع كل دمج ف master وكتثبّت الإصدارات التجريبية أوتوماتيكيا. كيتطبّق ملي تحفظ.",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "إصدارات Nightly هي لقطات من master ما مجرّباش. يمكن يطيحو، والتغييرات ديالهم ف قاعدة البيانات دائمة: ملي ترجع ل Stable هاد الإصدار كيبقى مثبّت حتى يخرج إصدار مستقر جديد. دير نسخة احتياطية ديال قوائم التشغيل قبل.",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "راك كتستعمل إصدار Nightly. قناة Stable غادي تقترح الإصدار المستقر الجاي ملي يولي جديد كثر من هاد الإصدار.",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Перазапусціць праграму",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Адкрыць рэліз GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Што новага",
|
||||
"APP_UPDATE_CHANNEL": "Канал абнаўленняў",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable ідзе за выпушчанымі рэлізамі. Nightly ідзе за кожным зліццём у master і аўтаматычна ўсталёўвае папярэднія зборкі. Ужываецца пасля захавання.",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-зборкі — неправераныя здымкі master. Яны могуць працаваць нестабільна, а іх змены базы даных незваротныя: пасля вяртання на Stable гэтая зборка застанецца ўсталяванай, пакуль не выйдзе больш новы стабільны рэліз. Спачатку зрабіце рэзервовую копію плэйлістоў.",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Вы выкарыстоўваеце nightly-зборку. Канал Stable прапануе наступны стабільны рэліз, як толькі ён стане навейшым за гэтую зборку.",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Anwendung neu starten",
|
||||
"APP_UPDATE_OPEN_RELEASE": "GitHub-Release öffnen",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Was ist neu",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Επανεκκίνηση εφαρμογής",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Άνοιγμα GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Τι νέο υπάρχει",
|
||||
"APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Το Stable ακολουθεί τις δημοσιευμένες εκδόσεις. Το Nightly ακολουθεί κάθε συγχώνευση στο master και εγκαθιστά αυτόματα προκαταρκτικές εκδόσεις. Εφαρμόζεται κατά την αποθήκευση.",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Οι εκδόσεις nightly είναι μη δοκιμασμένα στιγμιότυπα του master. Μπορεί να παρουσιάζουν σφάλματα και οι αλλαγές τους στη βάση δεδομένων είναι μόνιμες: αν επιστρέψετε στο Stable, αυτή η έκδοση παραμένει εγκατεστημένη μέχρι να υπάρξει νεότερη σταθερή έκδοση. Δημιουργήστε πρώτα αντίγραφο ασφαλείας των λιστών αναπαραγωγής.",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Χρησιμοποιείτε έκδοση nightly. Το κανάλι Stable θα προσφέρει την επόμενη σταθερή έκδοση μόλις είναι νεότερη από αυτήν.",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Reiniciar aplicación",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Abrir release de GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Novedades",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Redémarrer l’application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Ouvrir la release GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Nouveautés",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"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_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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Riavvia applicazione",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Apri release GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Novità",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "アプリを再起動",
|
||||
"APP_UPDATE_OPEN_RELEASE": "GitHub リリースを開く",
|
||||
"APP_UPDATE_RELEASE_NOTES": "更新内容",
|
||||
"APP_UPDATE_CHANNEL": "更新チャンネル",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable は公開されたリリースに追従します。Nightly は master への各マージに追従し、プレリリースビルドを自動的にインストールします。保存時に適用されます。",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly ビルドは master の未検証スナップショットです。動作しない場合があり、データベースの変更は元に戻せません。Stable に戻しても、より新しい安定版がリリースされるまでこのビルドが残ります。先にプレイリストをバックアップしてください。",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly ビルドを使用中です。Stable チャンネルは、このビルドより新しい安定版がリリースされ次第、それを提案します。",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "애플리케이션 다시 시작",
|
||||
"APP_UPDATE_OPEN_RELEASE": "GitHub 릴리스 열기",
|
||||
"APP_UPDATE_RELEASE_NOTES": "새로운 내용",
|
||||
"APP_UPDATE_CHANNEL": "업데이트 채널",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable은 정식 릴리스를 따릅니다. Nightly는 master에 병합될 때마다 따라가며 프리릴리스 빌드를 자동으로 설치합니다. 저장 시 적용됩니다.",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 빌드는 테스트되지 않은 master의 스냅샷입니다. 오류가 있을 수 있으며 데이터베이스 변경은 되돌릴 수 없습니다. Stable로 되돌려도 더 새로운 안정 릴리스가 나올 때까지 이 빌드가 유지됩니다. 먼저 재생목록을 백업하세요.",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly 빌드를 사용 중입니다. Stable 채널은 이 빌드보다 새로운 안정 릴리스가 나오면 그것을 제안합니다.",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Applicatie opnieuw starten",
|
||||
"APP_UPDATE_OPEN_RELEASE": "GitHub-release openen",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Wat is nieuw",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Uruchom aplikację ponownie",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Otwórz wydanie w GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Co nowego",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Reiniciar aplicativo",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Abrir release no GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Novidades",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Перезапустить приложение",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Открыть релиз на GitHub",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Что нового",
|
||||
"APP_UPDATE_CHANNEL": "Канал обновлений",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable следует за выпущенными релизами. Nightly следует за каждым слиянием в master и автоматически устанавливает предварительные сборки. Применяется после сохранения.",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-сборки — непроверенные снимки master. Они могут работать нестабильно, а их изменения базы данных необратимы: после возврата на Stable эта сборка останется установленной, пока не выйдет более новый стабильный релиз. Сначала сделайте резервную копию плейлистов.",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Вы используете nightly-сборку. Канал Stable предложит следующий стабильный релиз, как только он станет новее этой сборки.",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "Uygulamayı yeniden başlat",
|
||||
"APP_UPDATE_OPEN_RELEASE": "GitHub sürümünü aç",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Yenilikler",
|
||||
"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_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)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "重启应用",
|
||||
"APP_UPDATE_OPEN_RELEASE": "打开 GitHub 发布页",
|
||||
"APP_UPDATE_RELEASE_NOTES": "更新内容",
|
||||
"APP_UPDATE_CHANNEL": "更新频道",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable 跟随正式发布的版本。Nightly 跟随每次合并到 master 的更改,并自动安装预发布构建。保存后生效。",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 构建是 master 的未经测试快照。它们可能出错,且其数据库更改不可撤销:切换回 Stable 后,此构建会一直保留,直到出现更新的稳定版本。请先备份您的播放列表。",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "您正在使用 nightly 构建。Stable 频道会在下一个稳定版本比此构建更新时提供该版本。",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。",
|
||||
|
||||
@@ -525,6 +525,12 @@
|
||||
"APP_UPDATE_INSTALL": "重新啟動應用程式",
|
||||
"APP_UPDATE_OPEN_RELEASE": "開啟 GitHub 發布頁",
|
||||
"APP_UPDATE_RELEASE_NOTES": "更新內容",
|
||||
"APP_UPDATE_CHANNEL": "更新頻道",
|
||||
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable 跟隨正式發布的版本。Nightly 跟隨每次合併到 master 的變更,並自動安裝預先發布的建置版本。儲存後生效。",
|
||||
"APP_UPDATE_CHANNEL_STABLE": "Stable",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
|
||||
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 建置是 master 未經測試的快照。它們可能出錯,且其資料庫變更無法復原:切換回 Stable 後,此建置會一直保留,直到出現更新的穩定版本。請先備份您的播放清單。",
|
||||
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "您正在使用 nightly 建置。Stable 頻道會在下一個穩定版本比此建置更新時提供該版本。",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。",
|
||||
|
||||
@@ -276,6 +276,85 @@ References: [AppImage desktop keys](https://docs.appimage.org/reference/desktop-
|
||||
[AppManager desktop parser](https://github.com/kem-a/AppManager/blob/v3.8.0/src/core/desktop_entry.vala),
|
||||
[AppManager updater](https://github.com/kem-a/AppManager/blob/v3.8.0/src/core/updater.vala).
|
||||
|
||||
## Nightly channel
|
||||
|
||||
Every push to `master` of `4gray/iptvnator` is also a nightly. The same
|
||||
`build-and-make.yaml` run that builds the matrix publishes its artifacts as a
|
||||
**prerelease of `4gray/iptvnator-nightly`** instead of the rolling
|
||||
`test-master` draft: a draft is invisible to anyone without write access and
|
||||
to electron-updater, while a published prerelease is what the desktop app's
|
||||
**Nightly** update channel installs. PR builds keep their `test-pr-<n>`
|
||||
drafts; tag builds are unaffected.
|
||||
|
||||
**Version.** Each build job rewrites the `package.json` version before the
|
||||
frontend and backend builds and before electron-builder reads it
|
||||
(`tools/release/nightly-version.mjs --apply`):
|
||||
|
||||
```
|
||||
0.23.0 → 0.23.1-nightly.20260915.1234
|
||||
└ next patch ┘ └ commit date ┘ └ run number ┘
|
||||
```
|
||||
|
||||
- Greater than the released `0.23.0`, so a stable user who switches channels
|
||||
is offered it; smaller than `0.23.1` and `0.24.0`, so the next stable
|
||||
release is offered to nightly users on either channel.
|
||||
- The run number only grows, so nightlies order correctly within a day.
|
||||
- electron-builder derives the updater channel files from the prerelease
|
||||
tag: `nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`. The artifact
|
||||
upload globs and the macOS metadata merge accept both names.
|
||||
- Every job derives the same value from the same inputs (commit date + run
|
||||
number), so nothing is handed between jobs. The release job recomputes it
|
||||
to name the tag `v<version>`.
|
||||
- The root `package.json` is an Nx `sharedGlobals` input, so the rewritten
|
||||
version reaches the `web` and `electron-backend` bundles (which embed it)
|
||||
instead of a cache hit built from the released version.
|
||||
- The base is the released version in `package.json`. In the short window
|
||||
between a version bump commit and its tag, a nightly is numbered above the
|
||||
upcoming release; the next nightly after the tag corrects that.
|
||||
|
||||
**Publication** (steps at the end of the `create-release` job):
|
||||
|
||||
1. `NIGHTLY_RELEASE_TOKEN` — a fine-grained PAT with *Contents: read/write*
|
||||
on `4gray/iptvnator-nightly` — is required. Without it the run only warns;
|
||||
`GITHUB_TOKEN` cannot write to another repository. The nightly repository
|
||||
needs one commit on its default branch, because `gh release create`
|
||||
creates the release tag there.
|
||||
2. The notes list the master commits since the previous nightly. That
|
||||
nightly's source commit is read back from the `<!-- iptvnator-commit: … -->`
|
||||
marker its own notes carry, then `compare` on the main repository (with
|
||||
`GITHUB_TOKEN`) lists the range. A missing marker or a rewritten history
|
||||
only drops the list.
|
||||
3. The release is created as a draft, assets are uploaded, then it is
|
||||
published in one edit, so electron-updater never sees a release whose
|
||||
channel file is still missing. Missing `nightly-mac.yml`,
|
||||
`nightly.yml` or `nightly-linux.yml` fails the step instead.
|
||||
4. The release job is serialized per ref, but two master runs can finish out
|
||||
of order. electron-updater takes the newest feed entry, so a nightly older
|
||||
than the newest published one is dropped rather than published.
|
||||
5. Only the newest `NIGHTLY_KEEP_RELEASES` (20) nightlies are kept; older
|
||||
ones are deleted together with their tags.
|
||||
|
||||
**In the app.** `Settings.updateChannel` (`stable` / `nightly`, default
|
||||
`stable`) is a normal renderer setting mirrored into the main-process config
|
||||
by `SETTINGS_UPDATE` (`APP_UPDATE_CHANNEL`), because the startup check runs
|
||||
before the renderer exists. `AppUpdateService` re-points electron-updater on
|
||||
every check (`app-update-feed.ts`): the GitHub feed URL of the channel's
|
||||
repository, `allowPrerelease` only for nightly, the channel name (`nightly`,
|
||||
or the explicit `latest` for stable — the setter refuses `null` once set),
|
||||
and `allowDowngrade = false` reset afterwards, since assigning a channel
|
||||
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.
|
||||
|
||||
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
|
||||
that does not understand the database schema a nightly migration already
|
||||
applied. Nightly users therefore accept that everything merged into master
|
||||
is de facto shipped — a migration on master can only be followed by another
|
||||
migration, never reworked.
|
||||
|
||||
## After verification
|
||||
|
||||
Publishing the GitHub release is manual. That publication automatically
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
StreamFormat,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
normalizeAppUpdateChannel,
|
||||
normalizeEpgOffsetMinutes,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeStartupWindowMode,
|
||||
@@ -49,6 +50,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
showDashboard: true,
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal',
|
||||
updateChannel: 'stable',
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -288,6 +290,9 @@ export const SettingsStore = signalStore(
|
||||
startupWindowMode: normalizeStartupWindowMode(
|
||||
store.startupWindowMode?.()
|
||||
),
|
||||
updateChannel: normalizeAppUpdateChannel(
|
||||
store.updateChannel?.()
|
||||
),
|
||||
showExternalPlaybackBar:
|
||||
store.showExternalPlaybackBar?.() ??
|
||||
DEFAULT_SETTINGS.showExternalPlaybackBar,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/app-update-channel.util';
|
||||
export * from './lib/channel-drm.interface';
|
||||
export * from './lib/channel.interface';
|
||||
export * from './lib/channel.model';
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
appUpdateReleasesApiUrl,
|
||||
appUpdateReleasesPageUrl,
|
||||
appUpdateRepository,
|
||||
appVersionChannel,
|
||||
compareAppVersions,
|
||||
normalizeAppUpdateChannel,
|
||||
parseAppVersion,
|
||||
} from './app-update-channel.util';
|
||||
|
||||
describe('app update channel util', () => {
|
||||
it('collapses unknown channels to stable', () => {
|
||||
expect(normalizeAppUpdateChannel('nightly')).toBe('nightly');
|
||||
expect(normalizeAppUpdateChannel('stable')).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel('canary')).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel(undefined)).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel(null)).toBe('stable');
|
||||
});
|
||||
|
||||
it('maps each channel to its repository and pages', () => {
|
||||
expect(appUpdateRepository('stable')).toEqual({
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator',
|
||||
});
|
||||
expect(appUpdateRepository('nightly')).toEqual({
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator-nightly',
|
||||
});
|
||||
// /releases/latest never resolves to a prerelease.
|
||||
expect(appUpdateReleasesPageUrl('stable')).toBe(
|
||||
'https://github.com/4gray/iptvnator/releases/latest'
|
||||
);
|
||||
expect(appUpdateReleasesPageUrl('nightly')).toBe(
|
||||
'https://github.com/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
expect(appUpdateReleasesApiUrl('nightly')).toBe(
|
||||
'https://api.github.com/repos/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the channel a build belongs to off its version', () => {
|
||||
expect(appVersionChannel('0.23.0')).toBe('stable');
|
||||
expect(appVersionChannel('v0.23.0')).toBe('stable');
|
||||
expect(appVersionChannel('0.23.1-nightly.20260915.1234')).toBe(
|
||||
'nightly'
|
||||
);
|
||||
expect(appVersionChannel('v0.23.1-nightly.20260915.1234')).toBe(
|
||||
'nightly'
|
||||
);
|
||||
expect(appVersionChannel('0.23.1-beta.1')).toBe('stable');
|
||||
expect(appVersionChannel('junk')).toBe('stable');
|
||||
expect(appVersionChannel(undefined)).toBe('stable');
|
||||
});
|
||||
|
||||
it('parses core and prerelease identifiers', () => {
|
||||
expect(parseAppVersion('v0.23.1-nightly.20260915.7+abc')).toEqual({
|
||||
core: [0, 23, 1],
|
||||
prerelease: ['nightly', '20260915', '7'],
|
||||
});
|
||||
expect(parseAppVersion('0.23')).toBeNull();
|
||||
});
|
||||
|
||||
it('orders versions by semver precedence', () => {
|
||||
expect(compareAppVersions('0.24.0', '0.23.0')).toBe(1);
|
||||
expect(compareAppVersions('0.23.0', '0.23.0')).toBe(0);
|
||||
// A nightly sits between the released version and the next patch.
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.0')).toBe(
|
||||
1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.1')).toBe(
|
||||
-1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.24.0')).toBe(
|
||||
-1
|
||||
);
|
||||
// Same day: the run number decides numerically, not lexically.
|
||||
expect(
|
||||
compareAppVersions(
|
||||
'0.23.1-nightly.20260915.1000',
|
||||
'0.23.1-nightly.20260915.999'
|
||||
)
|
||||
).toBe(1);
|
||||
expect(
|
||||
compareAppVersions(
|
||||
'0.23.1-nightly.20260916.1',
|
||||
'0.23.1-nightly.20260915.999'
|
||||
)
|
||||
).toBe(1);
|
||||
// Fewer identifiers rank lower; numeric identifiers rank below words.
|
||||
expect(compareAppVersions('0.23.1-nightly', '0.23.1-nightly.1')).toBe(
|
||||
-1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-1', '0.23.1-alpha')).toBe(-1);
|
||||
// Junk never looks like an update.
|
||||
expect(compareAppVersions('junk', '0.23.0')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Desktop update channel. Electron only.
|
||||
*
|
||||
* `stable` follows the tagged releases of `4gray/iptvnator`; `nightly`
|
||||
* follows the prereleases that every master merge publishes to
|
||||
* `4gray/iptvnator-nightly`. The value is persisted with the other settings
|
||||
* in the renderer and mirrored into the main-process config by the
|
||||
* `SETTINGS_UPDATE` handler, because the startup update check runs before
|
||||
* any renderer exists to ask.
|
||||
*
|
||||
* Channel switches are forward-only on purpose: a nightly build stays
|
||||
* installed until the next stable version is newer than it. A downgrade
|
||||
* could land on a release that does not understand the database schema a
|
||||
* nightly migration already applied.
|
||||
*/
|
||||
export type AppUpdateChannel = 'stable' | 'nightly';
|
||||
|
||||
export const APP_UPDATE_CHANNELS: readonly AppUpdateChannel[] = [
|
||||
'stable',
|
||||
'nightly',
|
||||
];
|
||||
|
||||
export const DEFAULT_APP_UPDATE_CHANNEL: AppUpdateChannel = 'stable';
|
||||
|
||||
/**
|
||||
* Prerelease identifier every nightly version carries
|
||||
* (`0.23.1-nightly.20260915.1234`). electron-builder derives the updater
|
||||
* channel file (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`) from
|
||||
* it, and electron-updater matches release tags on the same identifier.
|
||||
*/
|
||||
export const NIGHTLY_PRERELEASE_TAG = 'nightly';
|
||||
|
||||
/** Collapses anything that is not a known channel to `stable`. */
|
||||
export function normalizeAppUpdateChannel(value: unknown): AppUpdateChannel {
|
||||
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
|
||||
? (value as AppUpdateChannel)
|
||||
: DEFAULT_APP_UPDATE_CHANNEL;
|
||||
}
|
||||
|
||||
interface AppUpdateRepository {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
const APP_UPDATE_REPOSITORIES: Record<AppUpdateChannel, AppUpdateRepository> = {
|
||||
stable: { owner: '4gray', repo: 'iptvnator' },
|
||||
nightly: { owner: '4gray', repo: 'iptvnator-nightly' },
|
||||
};
|
||||
|
||||
export function appUpdateRepository(
|
||||
channel: AppUpdateChannel
|
||||
): AppUpdateRepository {
|
||||
return { ...APP_UPDATE_REPOSITORIES[channel] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Page a user can open to install by hand. `/releases/latest` never resolves
|
||||
* to a prerelease, and the nightly repository holds nothing else, so that
|
||||
* channel links to the release list instead.
|
||||
*/
|
||||
export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
const base = `https://github.com/${owner}/${repo}/releases`;
|
||||
|
||||
return channel === 'stable' ? `${base}/latest` : base;
|
||||
}
|
||||
|
||||
export function appUpdateReleasesApiUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
|
||||
return `https://api.github.com/repos/${owner}/${repo}/releases`;
|
||||
}
|
||||
|
||||
const VERSION_PATTERN =
|
||||
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
interface ParsedAppVersion {
|
||||
core: [number, number, number];
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
export function parseAppVersion(
|
||||
value: string | null | undefined
|
||||
): ParsedAppVersion | null {
|
||||
const match = (value ?? '').trim().match(VERSION_PATTERN);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
||||
prerelease: match[4] ? match[4].split('.') : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The channel a build belongs to, read from its version string. */
|
||||
export function appVersionChannel(
|
||||
version: string | null | undefined
|
||||
): AppUpdateChannel {
|
||||
return parseAppVersion(version)?.prerelease[0] === NIGHTLY_PRERELEASE_TAG
|
||||
? 'nightly'
|
||||
: 'stable';
|
||||
}
|
||||
|
||||
function compareIdentifiers(left: string, right: string): number {
|
||||
const leftNumeric = /^\d+$/.test(left);
|
||||
const rightNumeric = /^\d+$/.test(right);
|
||||
|
||||
if (leftNumeric && rightNumeric) {
|
||||
return Math.sign(Number(left) - Number(right));
|
||||
}
|
||||
|
||||
// Semver: numeric identifiers always have lower precedence.
|
||||
if (leftNumeric !== rightNumeric) {
|
||||
return leftNumeric ? -1 : 1;
|
||||
}
|
||||
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver precedence for the version strings the updater sees: core
|
||||
* numbers first, then a release outranks its own prereleases, then the
|
||||
* prerelease identifiers left to right. Unparseable input compares as
|
||||
* equal so a junk version can never look like an update.
|
||||
*/
|
||||
export function compareAppVersions(left: string, right: string): number {
|
||||
const parsedLeft = parseAppVersion(left);
|
||||
const parsedRight = parseAppVersion(right);
|
||||
|
||||
if (!parsedLeft || !parsedRight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const difference = parsedLeft.core[index] - parsedRight.core[index];
|
||||
|
||||
if (difference !== 0) {
|
||||
return Math.sign(difference);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedLeft.prerelease.length === 0) {
|
||||
return parsedRight.prerelease.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
if (parsedRight.prerelease.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const length = Math.max(
|
||||
parsedLeft.prerelease.length,
|
||||
parsedRight.prerelease.length
|
||||
);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftIdentifier = parsedLeft.prerelease[index];
|
||||
const rightIdentifier = parsedRight.prerelease[index];
|
||||
|
||||
if (leftIdentifier === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (rightIdentifier === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
|
||||
|
||||
if (difference !== 0) {
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import type { SourceProbeContext, SourceHealthResult } from './source-health';
|
||||
import type { XtreamConnectionFailure } from './xtream-connection-test';
|
||||
import type {
|
||||
@@ -263,7 +264,12 @@ export interface ElectronBridgeAppUpdateStatus {
|
||||
progress?: ElectronBridgeAppUpdateProgress;
|
||||
error?: string;
|
||||
supportedSelfUpdate: boolean;
|
||||
/** Release page for the configured channel; opened for manual installs. */
|
||||
manualDownloadUrl: string;
|
||||
/** Channel the updater checks (`Settings.updateChannel`, mirrored). */
|
||||
channel: AppUpdateChannel;
|
||||
/** Channel the running build came from, read off its version string. */
|
||||
installedChannel: AppUpdateChannel;
|
||||
}
|
||||
|
||||
export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import { Language } from './language.enum';
|
||||
import { StreamFormat } from './stream-format.enum';
|
||||
import { Theme } from './theme.enum';
|
||||
@@ -189,6 +190,15 @@ export interface Settings {
|
||||
* value means `'normal'`. F11 leaves or re-enters fullscreen.
|
||||
*/
|
||||
startupWindowMode?: StartupWindowMode;
|
||||
/**
|
||||
* Desktop update channel: `stable` (tagged releases) or `nightly`
|
||||
* (prereleases published from every master merge). Electron only;
|
||||
* a missing value means `stable`. Mirrored into the main-process config
|
||||
* on Save so the startup update check already uses it. Forward-only:
|
||||
* switching back to stable keeps the installed nightly until a newer
|
||||
* stable release exists.
|
||||
*/
|
||||
updateChannel?: AppUpdateChannel;
|
||||
/** Show the desktop footer bar for external playback status */
|
||||
showExternalPlaybackBar?: boolean;
|
||||
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"!{projectRoot}/src/test-setup.[jt]s",
|
||||
"!{projectRoot}/test-setup.[jt]s"
|
||||
],
|
||||
"sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"]
|
||||
"sharedGlobals": [
|
||||
"{workspaceRoot}/.github/workflows/ci.yml",
|
||||
"{workspaceRoot}/package.json"
|
||||
]
|
||||
},
|
||||
"targetDefaults": {
|
||||
"@angular/build:application": {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Nightly build version for master merges.
|
||||
*
|
||||
* Every push to master publishes a prerelease to `4gray/iptvnator-nightly`
|
||||
* that the desktop updater's nightly channel can install. electron-updater
|
||||
* only offers a version that is semver-greater than the running one, and the
|
||||
* released version in `package.json` is what stable users run, so a nightly
|
||||
* bumps the patch and adds a prerelease tag:
|
||||
*
|
||||
* 0.23.0 → 0.23.1-nightly.20260915.1234
|
||||
*
|
||||
* - `0.23.1-nightly.*` is greater than the stable `0.23.0`, so a stable user
|
||||
* who switches to nightly is offered it.
|
||||
* - It is smaller than both `0.23.1` and `0.24.0`, so the next stable
|
||||
* release is offered to nightly users on either channel.
|
||||
* - The date is the commit's UTC date and the last identifier is the
|
||||
* workflow run number, which only ever grows, so nightlies order
|
||||
* correctly across days and within one day.
|
||||
* - electron-builder derives the updater channel file names from the
|
||||
* prerelease tag (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`).
|
||||
*
|
||||
* Every build job of one workflow run computes the same value from the same
|
||||
* inputs, so no job needs to hand the version to another.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/release/nightly-version.mjs # print the version
|
||||
* node tools/release/nightly-version.mjs --apply # also write package.json
|
||||
*
|
||||
* Inputs default to the repository `package.json`, the HEAD commit date, and
|
||||
* `GITHUB_RUN_NUMBER`; `--base`, `--date` and `--run-number` override them.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const NIGHTLY_TAG = 'nightly';
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const DATE_PATTERN = /^\d{8}$/;
|
||||
const PACKAGE_VERSION_LINE = /^(\s*"version":\s*")([^"]+)(")/m;
|
||||
const VALUE_FLAGS = {
|
||||
'--base': 'base',
|
||||
'--date': 'date',
|
||||
'--run-number': 'runNumber',
|
||||
};
|
||||
|
||||
export function buildNightlyVersion({ baseVersion, date, runNumber }) {
|
||||
const match = STABLE_VERSION_PATTERN.exec(String(baseVersion ?? '').trim());
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Base version must be a released X.Y.Z version, got "${baseVersion}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!DATE_PATTERN.test(String(date ?? ''))) {
|
||||
throw new Error(`Date must be YYYYMMDD, got "${date}".`);
|
||||
}
|
||||
|
||||
const run = Number(runNumber);
|
||||
|
||||
if (!Number.isInteger(run) || run <= 0) {
|
||||
throw new Error(
|
||||
`Run number must be a positive integer, got "${runNumber}".`
|
||||
);
|
||||
}
|
||||
|
||||
const [, major, minor, patch] = match;
|
||||
|
||||
return `${major}.${minor}.${Number(patch) + 1}-${NIGHTLY_TAG}.${date}.${run}`;
|
||||
}
|
||||
|
||||
/** Replaces the `version` line in `package.json` text, formatting intact. */
|
||||
export function applyNightlyVersion(packageJsonText, version) {
|
||||
if (!PACKAGE_VERSION_LINE.test(packageJsonText)) {
|
||||
throw new Error('package.json has no "version" line to replace.');
|
||||
}
|
||||
|
||||
return packageJsonText.replace(PACKAGE_VERSION_LINE, `$1${version}$3`);
|
||||
}
|
||||
|
||||
export function parseArguments(argv) {
|
||||
const options = { apply: false };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
|
||||
if (argument === '--') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument === '--apply') {
|
||||
options.apply = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const optionName = VALUE_FLAGS[argument];
|
||||
|
||||
if (optionName) {
|
||||
const value = argv[index + 1];
|
||||
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
options[optionName] = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function readCommitDate() {
|
||||
const isoDate = execFileSync('git', ['show', '-s', '--format=%cI', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const commitDate = new Date(isoDate);
|
||||
|
||||
if (Number.isNaN(commitDate.getTime())) {
|
||||
throw new Error(`Cannot parse HEAD commit date "${isoDate}".`);
|
||||
}
|
||||
|
||||
return commitDate.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArguments(argv);
|
||||
|
||||
if (!options) {
|
||||
console.error(
|
||||
'Usage: node tools/release/nightly-version.mjs [--apply] [--base X.Y.Z] [--date YYYYMMDD] [--run-number N]'
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
||||
const packageJsonText = readFileSync(packageJsonPath, 'utf8');
|
||||
const version = buildNightlyVersion({
|
||||
baseVersion: options.base ?? JSON.parse(packageJsonText).version,
|
||||
date: options.date ?? readCommitDate(),
|
||||
runNumber: options.runNumber ?? process.env.GITHUB_RUN_NUMBER,
|
||||
});
|
||||
|
||||
if (options.apply) {
|
||||
writeFileSync(
|
||||
packageJsonPath,
|
||||
applyNightlyVersion(packageJsonText, version)
|
||||
);
|
||||
console.error(`Applied nightly version ${version} to package.json.`);
|
||||
}
|
||||
|
||||
console.log(version);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
applyNightlyVersion,
|
||||
buildNightlyVersion,
|
||||
parseArguments,
|
||||
} from './nightly-version.mjs';
|
||||
|
||||
describe('buildNightlyVersion', () => {
|
||||
it('bumps the patch and appends the nightly identifiers', () => {
|
||||
assert.equal(
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: '1234',
|
||||
}),
|
||||
'0.23.1-nightly.20260915.1234'
|
||||
);
|
||||
});
|
||||
|
||||
it('orders newer nightlies above older ones and below the next stable', () => {
|
||||
const semverOrder = (left, right) => {
|
||||
// Mirror semver precedence closely enough for these shapes:
|
||||
// numeric identifiers compare numerically, a release outranks
|
||||
// its prereleases.
|
||||
const parse = (value) => {
|
||||
const [core, prerelease = ''] = value.split('-');
|
||||
return {
|
||||
core: core.split('.').map(Number),
|
||||
prerelease: prerelease ? prerelease.split('.') : [],
|
||||
};
|
||||
};
|
||||
const a = parse(left);
|
||||
const b = parse(right);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a.core[index] !== b.core[index]) {
|
||||
return a.core[index] - b.core[index];
|
||||
}
|
||||
}
|
||||
if (a.prerelease.length === 0 || b.prerelease.length === 0) {
|
||||
return b.prerelease.length - a.prerelease.length;
|
||||
}
|
||||
for (let index = 0; index < a.prerelease.length; index += 1) {
|
||||
const x = a.prerelease[index];
|
||||
const y = b.prerelease[index];
|
||||
if (x === y) continue;
|
||||
return /^\d+$/.test(x) && /^\d+$/.test(y)
|
||||
? Number(x) - Number(y)
|
||||
: x < y
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const older = buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: 1234,
|
||||
});
|
||||
const sameDayLater = buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: 1240,
|
||||
});
|
||||
const nextDay = buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '20260916',
|
||||
runNumber: 1241,
|
||||
});
|
||||
|
||||
assert.ok(semverOrder(older, '0.23.0') > 0);
|
||||
assert.ok(semverOrder(sameDayLater, older) > 0);
|
||||
assert.ok(semverOrder(nextDay, sameDayLater) > 0);
|
||||
assert.ok(semverOrder(nextDay, '0.23.1') < 0);
|
||||
assert.ok(semverOrder(nextDay, '0.24.0') < 0);
|
||||
});
|
||||
|
||||
it('rejects a base version that is already a prerelease', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.1-nightly.20260915.1',
|
||||
date: '20260915',
|
||||
runNumber: 2,
|
||||
}),
|
||||
/released X\.Y\.Z version/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed dates and run numbers', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '2026-09-15',
|
||||
runNumber: 1,
|
||||
}),
|
||||
/YYYYMMDD/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: undefined,
|
||||
}),
|
||||
/positive integer/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: '0',
|
||||
}),
|
||||
/positive integer/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyNightlyVersion', () => {
|
||||
it('replaces only the version line and keeps the file formatting', () => {
|
||||
const source =
|
||||
'{\n "name": "iptvnator",\n "version": "0.23.0",\n "engines": {\n "node": "22"\n }\n}\n';
|
||||
|
||||
assert.equal(
|
||||
applyNightlyVersion(source, '0.23.1-nightly.20260915.7'),
|
||||
'{\n "name": "iptvnator",\n "version": "0.23.1-nightly.20260915.7",\n "engines": {\n "node": "22"\n }\n}\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when the version line is missing', () => {
|
||||
assert.throws(
|
||||
() => applyNightlyVersion('{ "name": "x" }', '1.0.1-nightly.1.1'),
|
||||
/no "version" line/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
it('reads the flags and ignores a bare separator', () => {
|
||||
assert.deepEqual(
|
||||
parseArguments([
|
||||
'--',
|
||||
'--apply',
|
||||
'--base',
|
||||
'0.23.0',
|
||||
'--date',
|
||||
'20260915',
|
||||
'--run-number',
|
||||
'12',
|
||||
]),
|
||||
{
|
||||
apply: true,
|
||||
base: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: '12',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('reports usage for unknown flags and missing values', () => {
|
||||
assert.equal(parseArguments(['--unknown']), null);
|
||||
assert.equal(parseArguments(['--base']), null);
|
||||
assert.equal(parseArguments(['--base', '--apply']), null);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
"{workspaceRoot}/tools/release/extract-changelog-section.mjs",
|
||||
"{workspaceRoot}/tools/release/check-release-note-gate.mjs",
|
||||
"{workspaceRoot}/tools/release/build-release-notes.mjs",
|
||||
"{workspaceRoot}/tools/release/nightly-version.mjs",
|
||||
"{workspaceRoot}/tools/release/screenshot-guards.mjs",
|
||||
"{workspaceRoot}/tools/release/screenshots.manifest.json",
|
||||
"{workspaceRoot}/.github/workflows/build-and-make.yaml",
|
||||
@@ -27,10 +28,11 @@
|
||||
"{workspaceRoot}/tools/release/release-announcements.test.mjs",
|
||||
"{workspaceRoot}/tools/release/verify-draft-release.test.mjs",
|
||||
"{workspaceRoot}/tools/release/highlight-cards.test.mjs",
|
||||
"{workspaceRoot}/tools/release/screenshot-guards.test.mjs"
|
||||
"{workspaceRoot}/tools/release/screenshot-guards.test.mjs",
|
||||
"{workspaceRoot}/tools/release/nightly-version.test.mjs"
|
||||
],
|
||||
"options": {
|
||||
"command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs",
|
||||
"command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs tools/release/nightly-version.test.mjs",
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user