From 15c2ac1f399e434b7229bf84c6ebf3a484e83afc Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:47:38 +0200 Subject: [PATCH] feat(packaging): add AppManager discovery metadata to AppImages (#1559) * feat(packaging): add AppManager discovery metadata to AppImages * test(xtream): restore live queue URL service mock * test(xtream): extract live layout component stubs --- .changes/packaging-appmanager-metadata.md | 6 ++ AGENTS.md | 9 ++ CLAUDE.md | 9 ++ docs/architecture/release-pipeline.md | 31 ++++++ electron-builder.json | 15 ++- .../live-stream-layout-stubs.spec-data.ts | 95 ++++++++++++++++++ .../live-stream-layout.component.spec.ts | 98 ++----------------- .../electron-package-identity.test.mjs | 81 ++++++++++++++- 8 files changed, 246 insertions(+), 98 deletions(-) create mode 100644 .changes/packaging-appmanager-metadata.md create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts diff --git a/.changes/packaging-appmanager-metadata.md b/.changes/packaging-appmanager-metadata.md new file mode 100644 index 000000000..bd052761a --- /dev/null +++ b/.changes/packaging-appmanager-metadata.md @@ -0,0 +1,6 @@ +--- +type: feature +area: packaging +--- + +AppImage builds now include an update source for AppManager, allowing it to discover new GitHub releases and download full application updates without manually configuring the repository. diff --git a/AGENTS.md b/AGENTS.md index 22937d53f..f4b87bd72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,15 @@ name or passphrase you entered is not correct`. Keep the patch until - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. +## AppImage Manager Metadata + +AppManager full-download discovery uses `appImage.desktop.entry` URL fields. +Electron Builder generates the version; `extraMetadata.desktopName=iptvnator` +preserves Linux window identity without a shared `linux.desktop.entry` object +(builder's nested merge would leak AppImage fields into Snap). This does not +enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md` +(AppImage external-manager metadata). + ## Regression Prevention And Test Updates - Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. diff --git a/CLAUDE.md b/CLAUDE.md index 28ab0a4a1..c38a9a806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`. - Final task summaries should state whether a release note was added or why it was skipped. +## AppImage Manager Metadata + +AppManager full-download discovery uses `appImage.desktop.entry` URL fields. +Electron Builder generates the version; `extraMetadata.desktopName=iptvnator` +preserves Linux window identity without a shared `linux.desktop.entry` object +(builder's nested merge would leak AppImage fields into Snap). This does not +enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md` +(AppImage external-manager metadata). + ## Regression Prevention And Test Updates - Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 190662226..1232b58b5 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -245,6 +245,37 @@ An asset no rule claims is reported as a `NOTE:` and does **not** fail the run: a new build target should surface for a human to notice, not block a release until the rules catch up. +## AppImage external-manager metadata + +`electron-builder.json` scopes `X-AppImage-Name`, `X-AppImage-Homepage` and +`X-AppImage-UpdateURL` to `appImage.desktop.entry`. The source URL is the +canonical GitHub repository. AppManager 3.8.0 reads these URL fields and can +discover GitHub releases and download complete AppImages. Its architecture +selection recognizes the existing `x86_64`, `arm64` and `armv7l` asset names. +Other Linux package formats do not inherit these AppImage-specific fields. +`extraMetadata.desktopName=iptvnator` preserves the existing window class and +desktop filename. Do not reintroduce a shared `linux.desktop.entry` object: +electron-builder 26.15.7's target merge mutates nested defaults, which would +leak the AppImage URL fields into Snap in the same portable packaging pass. + +Electron Builder generates `X-AppImage-Version` from `appInfo.buildVersion`; +do not hardcode it in the desktop entry. `X-AppImage-Arch` is intentionally +omitted: a single runner-wide value would mislabel this multi-architecture +target. Version, preserved desktop defaults and format isolation are checked +through the installed builder in `electron-package-identity.test.mjs`. + +This metadata enables AppManager's full-download workflow. It does not embed +AppImageUpdate `.upd_info`, generate `.zsync`, or change Electron's existing +`latest-linux*.yml` and embedded blockmap update path. The required release +asset set remains unchanged. Gear Lever/AppImageUpdate delta compatibility +is not implied. An older AppImage needs a first manual or built-in update, +or a manually configured AppManager source, to acquire these fields. Use one +updater at a time for a manager-owned installation. + +References: [AppImage desktop keys](https://docs.appimage.org/reference/desktop-integration.html), +[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). + ## After verification Publishing the GitHub release is manual. That publication automatically diff --git a/electron-builder.json b/electron-builder.json index 55a1728b3..860bab83c 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -54,6 +54,7 @@ "extraMetadata": { "name": "iptvnator", "productName": "IPTVnator", + "desktopName": "iptvnator", "main": "electron-backend/main.js" }, "extraResources": ["dist/apps/electron-backend/workers"], @@ -81,11 +82,6 @@ "category": "Video", "executableName": "iptvnator", "executableArgs": ["--ozone-platform=x11"], - "desktop": { - "entry": { - "StartupWMClass": "iptvnator" - } - }, "target": [ { "target": "AppImage", @@ -115,6 +111,15 @@ "artifactName": "${name}-${version}-${os}-${arch}.${ext}", "icon": "apps/web/src/assets/icons" }, + "appImage": { + "desktop": { + "entry": { + "X-AppImage-Name": "IPTVnator", + "X-AppImage-Homepage": "https://github.com/4gray/iptvnator", + "X-AppImage-UpdateURL": "https://github.com/4gray/iptvnator" + } + } + }, "flatpak": { "branch": "stable", "runtime": "org.freedesktop.Platform", diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts new file mode 100644 index 000000000..a64f26c32 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts @@ -0,0 +1,95 @@ +import { Component, Directive, input, output, signal } from '@angular/core'; +import { + EpgProgram, + RecordingStartMetadata, + RecordingStoppedEvent, +} from '@iptvnator/shared/interfaces'; +import { + EpgProgramActivationEvent, + EpgTimelineSummary, +} from '@iptvnator/ui/epg'; +import { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; + +@Component({ + selector: 'app-portal-channels-list', + standalone: true, + template: '
', +}) +export class StubPortalChannelsListComponent { + readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server'); + readonly channelsOverride = input(null); + readonly searchTermInput = input(''); + readonly revealRequest = input(null); + readonly filteredChannels = signal([]); + readonly playClicked = output(); + readonly playbackRequested = output(); +} + +@Component({ + selector: 'app-grid-list', + standalone: true, + template: '
', +}) +export class StubGridListComponent { + readonly items = input([]); + readonly isLoading = input(false); + readonly isAppending = input(false); + readonly appendError = input(false); + readonly searchTerm = input(''); + readonly variant = input<'poster' | 'logo'>('poster'); + readonly type = input(); + readonly itemClicked = output(); + readonly retryLoadMore = output(); +} + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '', +}) +export class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); + readonly streamUrl = input(''); + readonly title = input(''); + readonly playback = input(null); + readonly recordingMetadata = input(null); + readonly externalFallbackRequested = output(); + readonly recordingStopped = output(); +} + +// Matches both live-panel selectors so the host's timeline ↔ list swap can be +// asserted by tag name; both branches share the identical contract. +@Component({ + selector: 'app-epg-timeline, app-epg-list-view', + standalone: true, + template: ` +
{{ summaryLabelKey() }}
+
{{ summary()?.title }}
+ `, +}) +export class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly sourceLabel = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(true); + readonly loading = input(false); + readonly selectedDate = input(null); + readonly collapsed = input(false); + readonly summary = input(null); + readonly summaryLabelKey = input(''); + readonly offsetMinutes = input(0); + readonly programActivated = output(); + readonly returnToLive = output(); + readonly selectedDateChange = output(); + readonly collapsedChange = output(); +} + +@Directive({ + selector: '[appResizable]', + standalone: true, +}) +export class StubResizableDirective {} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 24e4d3b96..634130699 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -1,4 +1,4 @@ -import { Directive, Component, input, output, signal } from '@angular/core'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -25,9 +25,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { EpgListViewComponent, - EpgProgramActivationEvent, EpgTimelineComponent, - EpgTimelineSummary, } from '@iptvnator/ui/epg'; import { type PlaybackFallbackRequest, @@ -36,8 +34,6 @@ import { import { EpgItem, EpgProgram, - RecordingStartMetadata, - RecordingStoppedEvent, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { GridListComponent } from '@iptvnator/portal/shared/ui'; @@ -46,92 +42,16 @@ import { LiveStreamLayoutComponent } from './live-stream-layout.component'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { + StubEpgTimelineComponent, + StubGridListComponent, + StubPortalChannelsListComponent, + StubResizableDirective, + StubWebPlayerViewComponent, +} from './live-stream-layout-stubs.spec-data'; + const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; -@Component({ - selector: 'app-portal-channels-list', - standalone: true, - template: '
', -}) -class StubPortalChannelsListComponent { - readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server'); - readonly channelsOverride = input(null); - readonly searchTermInput = input(''); - readonly revealRequest = input(null); - readonly filteredChannels = signal([]); - readonly playClicked = output(); - readonly playbackRequested = output(); -} - -@Component({ - selector: 'app-grid-list', - standalone: true, - template: '
', -}) -class StubGridListComponent { - readonly items = input([]); - readonly isLoading = input(false); - readonly isAppending = input(false); - readonly appendError = input(false); - readonly searchTerm = input(''); - readonly variant = input<'poster' | 'logo'>('poster'); - readonly type = input(); - readonly itemClicked = output(); - readonly retryLoadMore = output(); -} - -@Component({ - selector: 'app-web-player-view', - standalone: true, - template: '', -}) -class StubWebPlayerViewComponent { - readonly playbackSessionKey = input.required(); - readonly streamUrl = input(''); - readonly title = input(''); - readonly playback = input(null); - readonly recordingMetadata = input(null); - readonly externalFallbackRequested = output(); - readonly recordingStopped = output(); -} - -// Matches both live-panel selectors so the host's timeline ↔ list swap can be -// asserted by tag name; both branches share the identical contract. -@Component({ - selector: 'app-epg-timeline, app-epg-list-view', - standalone: true, - template: ` -
{{ summaryLabelKey() }}
-
{{ summary()?.title }}
- `, -}) -class StubEpgTimelineComponent { - readonly programs = input([]); - readonly channelName = input(''); - readonly channelLogo = input(''); - readonly sourceLabel = input(''); - readonly archivePlaybackAvailable = input(false); - readonly archiveDays = input(0); - readonly activeProgram = input(null); - readonly isLivePlayback = input(true); - readonly loading = input(false); - readonly selectedDate = input(null); - readonly collapsed = input(false); - readonly summary = input(null); - readonly summaryLabelKey = input(''); - readonly offsetMinutes = input(0); - readonly programActivated = output(); - readonly returnToLive = output(); - readonly selectedDateChange = output(); - readonly collapsedChange = output(); -} - -@Directive({ - selector: '[appResizable]', - standalone: true, -}) -class StubResizableDirective {} - describe('LiveStreamLayoutComponent', () => { let fixture: ComponentFixture; let component: LiveStreamLayoutComponent; diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index c28344434..6630c4a7f 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -155,10 +155,7 @@ test('Linux package identity does not expose the internal Electron backend proje assert.equal(electronBuilderConfig.extraMetadata?.name, 'iptvnator'); assert.equal(electronBuilderConfig.extraMetadata?.productName, 'IPTVnator'); assert.equal(electronBuilderConfig.linux?.executableName, 'iptvnator'); - assert.equal( - electronBuilderConfig.linux?.desktop?.entry?.StartupWMClass, - 'iptvnator' - ); + assert.equal(electronBuilderConfig.extraMetadata?.desktopName, 'iptvnator'); assert.ok( electronBuilderConfig.linux?.executableArgs?.includes( '--ozone-platform=x11' @@ -166,6 +163,82 @@ test('Linux package identity does not expose the internal Electron backend proje ); }); +test('AppImage desktop metadata supports AppManager without changing other Linux packages', async () => { + // Exercise the installed builder: target-specific desktop fields must merge + // with Linux defaults, and the builder must keep generating the version. + const builderRequire = createRequire(require.resolve('electron-builder')); + const { default: AppImageTarget } = builderRequire( + 'app-builder-lib/out/targets/appimage/AppImageTarget' + ); + const { LinuxTargetHelper } = builderRequire( + 'app-builder-lib/out/targets/LinuxTargetHelper' + ); + for (const version of [ + packageMetadata.version, + '0.24.1', + '0.25.0-beta.1', + ]) { + const config = structuredClone(electronBuilderConfig); + const packager = { + config, + platformSpecificBuildOptions: config.linux, + executableName: config.linux.executableName, + fileAssociations: config.fileAssociations, + info: { metadata: { ...packageMetadata, ...config.extraMetadata } }, + appInfo: { + productName: config.productName, + description: packageMetadata.description, + buildVersion: version, + }, + }; + const helper = new LinuxTargetHelper(packager); + const target = new AppImageTarget( + 'AppImage', + packager, + helper, + os.tmpdir() + ); + const desktop = await target.desktopEntry.value; + const fields = Object.fromEntries( + desktop + .split('\n') + .filter((line) => line.includes('=')) + .map((line) => { + const separator = line.indexOf('='); + return [ + line.slice(0, separator), + line.slice(separator + 1), + ]; + }) + ); + assert.equal(fields['X-AppImage-Name'], 'IPTVnator'); + assert.equal(fields['X-AppImage-Version'], version); + assert.equal( + fields['X-AppImage-Homepage'], + 'https://github.com/4gray/iptvnator' + ); + assert.equal( + fields['X-AppImage-UpdateURL'], + 'https://github.com/4gray/iptvnator' + ); + assert.equal(fields.StartupWMClass, 'iptvnator'); + assert.equal(fields.Exec, 'AppRun --ozone-platform=x11 %U'); + assert.equal( + fields.MimeType, + 'audio/x-mpegurl;application/vnd.apple.mpegurl;' + ); + // A runner-wide architecture override would mislabel the ARM builds. + assert.equal(fields['X-AppImage-Arch'], undefined); + const otherDesktop = await helper.computeDesktopEntry( + config.linux, + 'iptvnator %U' + ); + assert.doesNotMatch(otherDesktop, /^X-AppImage-/m); + assert.match(otherDesktop, /^StartupWMClass=iptvnator$/m); + assert.equal(helper.getDesktopFileName(), 'iptvnator'); + } +}); + test('playlist file associations are registered with the operating system', () => { // Without these the OS never offers IPTVnator as a handler, so every // runtime path for an OS-supplied playlist is unreachable by double-click.