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
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 18:47:38 +02:00
1 parent 7d1503fd31
commit 15c2ac1f39
8 files changed
+246 -98

No files matched your search

@@ -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.
+9
View File
@@ -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.
+9
View File
@@ -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.
+31
View File
@@ -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
+10 -5
View File
@@ -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",
@@ -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: '<div data-test-id="portal-channels-list-stub"></div>',
})
export class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly revealRequest = input<unknown>(null);
readonly filteredChannels = signal<unknown[]>([]);
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
}
@Component({
selector: 'app-grid-list',
standalone: true,
template: '<div data-test-id="grid-list-stub"></div>',
})
export class StubGridListComponent {
readonly items = input<unknown[]>([]);
readonly isLoading = input(false);
readonly isAppending = input(false);
readonly appendError = input(false);
readonly searchTerm = input('');
readonly variant = input<'poster' | 'logo'>('poster');
readonly type = input<string>();
readonly itemClicked = output<unknown>();
readonly retryLoadMore = output<void>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
export class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
// 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: `
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
`,
})
export class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('');
readonly offsetMinutes = input(0);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class StubResizableDirective {}
@@ -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: '<div data-test-id="portal-channels-list-stub"></div>',
})
class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly revealRequest = input<unknown>(null);
readonly filteredChannels = signal<unknown[]>([]);
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
}
@Component({
selector: 'app-grid-list',
standalone: true,
template: '<div data-test-id="grid-list-stub"></div>',
})
class StubGridListComponent {
readonly items = input<unknown[]>([]);
readonly isLoading = input(false);
readonly isAppending = input(false);
readonly appendError = input(false);
readonly searchTerm = input('');
readonly variant = input<'poster' | 'logo'>('poster');
readonly type = input<string>();
readonly itemClicked = output<unknown>();
readonly retryLoadMore = output<void>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
// 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: `
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
`,
})
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('');
readonly offsetMinutes = input(0);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
class StubResizableDirective {}
describe('LiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
@@ -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.