From 81f970d2819b2da3edf61d1f1bca0f8378b8d021 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 17 Jul 2026 17:56:37 +0200 Subject: [PATCH] feat(packaging): define Linux frame-copy profiles --- electron-builder.json | 9 ++ tools/packaging/linux-frame-copy-profile.cjs | 96 ++++++++++++ .../linux-frame-copy-profile.test.mjs | 146 ++++++++++++++++++ tools/packaging/project.json | 4 +- 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 tools/packaging/linux-frame-copy-profile.cjs create mode 100644 tools/packaging/linux-frame-copy-profile.test.mjs diff --git a/electron-builder.json b/electron-builder.json index a874298dd..4e0306951 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -98,6 +98,15 @@ "artifactName": "${name}-${version}-${os}-${arch}.${ext}", "icon": "apps/web/src/assets/icons" }, + "deb": { + "fpm": ["--depends=libmpv2"] + }, + "rpm": { + "fpm": ["--depends=mpv-libs"] + }, + "pacman": { + "fpm": ["--depends=mpv"] + }, "flatpak": { "branch": "stable", "runtime": "org.freedesktop.Platform", diff --git a/tools/packaging/linux-frame-copy-profile.cjs b/tools/packaging/linux-frame-copy-profile.cjs new file mode 100644 index 000000000..6f00a0fab --- /dev/null +++ b/tools/packaging/linux-frame-copy-profile.cjs @@ -0,0 +1,96 @@ +'use strict'; + +function createProfile(name, runtimeMode, targets, manifestOrigin) { + return Object.freeze({ + name, + runtimeMode, + targets: Object.freeze([...targets]), + manifestOrigin, + }); +} + +const LINUX_FRAME_COPY_PROFILES = Object.freeze({ + system: createProfile( + 'system', + 'system', + ['deb', 'rpm', 'pacman'], + 'system-libmpv-frame-copy' + ), + portable: createProfile( + 'portable', + 'bundled', + ['appimage', 'snap'], + 'bundled-lgpl-frame-copy' + ), + flatpak: createProfile( + 'flatpak', + 'bundled', + ['flatpak'], + 'bundled-lgpl-frame-copy' + ), +}); +const SUPPORTED_PROFILE_NAMES = Object.freeze( + Object.keys(LINUX_FRAME_COPY_PROFILES) +); + +const LINUX_SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ + deb: 'libmpv2', + rpm: 'mpv-libs', + pacman: 'mpv', +}); + +function expectedProfileNames() { + return SUPPORTED_PROFILE_NAMES.map((name) => `"${name}"`).join(', '); +} + +function findLinuxFrameCopyProfile(value) { + if (value == null || (typeof value === 'string' && value.trim() === '')) { + throw new Error( + `Linux frame-copy profile is required. Expected one of: ${expectedProfileNames()}.` + ); + } + + const name = typeof value === 'string' ? value.trim() : String(value); + const profile = LINUX_FRAME_COPY_PROFILES[name]; + if (!profile) { + throw new Error( + `Unsupported Linux frame-copy profile "${name}". Expected one of: ${expectedProfileNames()}.` + ); + } + return profile; +} + +function resolveLinuxFrameCopyProfile(value) { + const profile = findLinuxFrameCopyProfile(value); + return createProfile( + profile.name, + profile.runtimeMode, + profile.targets, + profile.manifestOrigin + ); +} + +function validateLinuxProfileTargets(profileName, targetNames) { + const profile = findLinuxFrameCopyProfile(profileName); + if (!Array.isArray(targetNames)) { + throw new TypeError('Linux frame-copy targets must be an array.'); + } + + const allowedTargets = new Set(profile.targets); + return targetNames.flatMap((targetName) => { + const normalizedTarget = String(targetName).trim().toLowerCase(); + if (allowedTargets.has(normalizedTarget)) { + return []; + } + return [ + `Linux frame-copy profile "${profile.name}" cannot build target "${normalizedTarget}".`, + ]; + }); +} + +module.exports = { + LINUX_FRAME_COPY_PROFILES, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +}; diff --git a/tools/packaging/linux-frame-copy-profile.test.mjs b/tools/packaging/linux-frame-copy-profile.test.mjs new file mode 100644 index 000000000..17af18438 --- /dev/null +++ b/tools/packaging/linux-frame-copy-profile.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { + LINUX_FRAME_COPY_PROFILES, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); +const electronBuilderConfig = JSON.parse( + fs.readFileSync( + join(currentDir, '..', '..', 'electron-builder.json'), + 'utf8' + ) +); + +test('defines the exact immutable Linux frame-copy profile matrix', () => { + assert.deepEqual(LINUX_FRAME_COPY_PROFILES, { + system: { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }, + portable: { + name: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }, + flatpak: { + name: 'flatpak', + runtimeMode: 'bundled', + targets: ['flatpak'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }, + }); + assert.equal(Object.isFrozen(LINUX_FRAME_COPY_PROFILES), true); + for (const profile of Object.values(LINUX_FRAME_COPY_PROFILES)) { + assert.equal(Object.isFrozen(profile), true); + assert.equal(Object.isFrozen(profile.targets), true); + } +}); + +test('defines immutable system-package libmpv dependencies', () => { + assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { + deb: 'libmpv2', + rpm: 'mpv-libs', + pacman: 'mpv', + }); + assert.equal(Object.isFrozen(LINUX_SYSTEM_PACKAGE_DEPENDENCIES), true); +}); + +test('resolves each supported profile as an immutable defensive value', () => { + const firstSystemProfile = resolveLinuxFrameCopyProfile('system'); + const secondSystemProfile = resolveLinuxFrameCopyProfile('system'); + + assert.deepEqual(firstSystemProfile, { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }); + assert.deepEqual(resolveLinuxFrameCopyProfile('portable'), { + name: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }); + assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak'), { + name: 'flatpak', + runtimeMode: 'bundled', + targets: ['flatpak'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }); + assert.notEqual(firstSystemProfile, LINUX_FRAME_COPY_PROFILES.system); + assert.notEqual(firstSystemProfile, secondSystemProfile); + assert.notEqual( + firstSystemProfile.targets, + LINUX_FRAME_COPY_PROFILES.system.targets + ); + assert.notEqual(firstSystemProfile.targets, secondSystemProfile.targets); + assert.equal(Object.isFrozen(firstSystemProfile), true); + assert.equal(Object.isFrozen(firstSystemProfile.targets), true); + assert.throws(() => firstSystemProfile.targets.push('appimage'), TypeError); + assert.deepEqual(resolveLinuxFrameCopyProfile('system').targets, [ + 'deb', + 'rpm', + 'pacman', + ]); +}); + +test('rejects missing and unsupported profile names with clear errors', () => { + for (const value of [undefined, null, '', ' ']) { + assert.throws( + () => resolveLinuxFrameCopyProfile(value), + /Linux frame-copy profile is required/ + ); + } + assert.throws( + () => resolveLinuxFrameCopyProfile('standard'), + /Unsupported Linux frame-copy profile "standard"/ + ); +}); + +test('validates profile targets case-insensitively with deterministic errors', () => { + const targets = ['DEB', 'AppImage', 'RPM']; + + assert.deepEqual(validateLinuxProfileTargets('system', targets), [ + 'Linux frame-copy profile "system" cannot build target "appimage".', + ]); + assert.deepEqual(targets, ['DEB', 'AppImage', 'RPM']); + assert.deepEqual( + validateLinuxProfileTargets('portable', ['APPIMAGE', 'sNaP']), + [] + ); + assert.deepEqual(validateLinuxProfileTargets('flatpak', ['FlatPak']), []); +}); + +test('rejects a non-array profile target list', () => { + assert.throws( + () => validateLinuxProfileTargets('system', 'deb'), + /Linux frame-copy targets must be an array/ + ); +}); + +test('adds only libmpv-specific package dependencies without replacing electron-builder defaults', () => { + for (const [target, dependency] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + assert.equal( + electronBuilderConfig[target]?.depends, + undefined, + `${target}.depends must remain unset so electron-builder keeps its defaults` + ); + assert.deepEqual(electronBuilderConfig[target]?.fpm, [ + `--depends=${dependency}`, + ]); + } +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 25e5aa53f..0dfe9dddb 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -21,11 +21,13 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", + "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs", "cwd": "{workspaceRoot}" } },