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.