diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 098790b..9e88428 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,16 +61,38 @@ jobs: libdbus-1-dev \ pkg-config \ xvfb \ - libtinfo5 + libtinfo5 \ + rpm \ + cpio \ + desktop-file-utils \ + xdg-utils - run: npm ci - name: Provision locked engines if: runner.os != 'macOS' run: node scripts/provision-engines.js --target ${{ matrix.target }} - name: Build package + if: runner.os != 'Linux' run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} env: APPIMAGE_EXTRACT_AND_RUN: 1 - FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }} + - name: Build Linux native packages + if: runner.os == 'Linux' + run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm + env: + APPIMAGE_EXTRACT_AND_RUN: 1 + - name: Verify and preserve Linux native packages + if: runner.os == 'Linux' + run: | + node scripts/verify-linux-packages.js --target ${{ matrix.target }} + mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm" + cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/" + cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/" + - name: Build Linux AppImage + if: runner.os == 'Linux' + run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage + env: + APPIMAGE_EXTRACT_AND_RUN: 1 + FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1' - name: Install pinned appimagetool (Linux only) if: runner.os == 'Linux' env: @@ -128,6 +150,20 @@ jobs: path: ${{ matrix.artifact }} if-no-files-found: error retention-days: 3 + - uses: actions/upload-artifact@v7 + if: runner.os == 'Linux' + with: + name: Firelink-Linux-x64-Deb-${{ github.ref_name }} + path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb + if-no-files-found: error + retention-days: 3 + - uses: actions/upload-artifact@v7 + if: runner.os == 'Linux' + with: + name: Firelink-Linux-x64-RPM-${{ github.ref_name }} + path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm + if-no-files-found: error + retention-days: 3 publish: name: Publish GitHub release @@ -169,6 +205,8 @@ jobs: rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg" rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage" + rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb" + rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm" rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe" - name: Generate checksums run: | diff --git a/README.md b/README.md index 71cae23..149ddd0 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,12 @@ Download desktop builds from [GitHub Releases](https://github.com/nimbold/Fireli | --- | --- | --- | | **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. | | **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. | -| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. | +| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the portable fallback. AppImage may need executable permission. | Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager. +The native packages use the distribution's normal desktop runtime dependencies, while AppImage remains the portable installation option. + ## Browser Extension
@@ -91,7 +93,7 @@ The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-
| --- | --- |
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
-| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
+| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
## Development
diff --git a/RELEASE.md b/RELEASE.md
index 27590e4..b813b18 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -5,6 +5,8 @@ Targets:
- macOS arm64 DMG
- Windows x64 NSIS installer
- Linux x64 AppImage
+- Linux x64 Debian package
+- Linux x64 RPM package
## Distribution policy
@@ -22,6 +24,8 @@ Firelink never falls back to system-installed media tools.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
+Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
+
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
## Version update
diff --git a/scripts/verify-linux-packages.js b/scripts/verify-linux-packages.js
new file mode 100644
index 0000000..b502cb0
--- /dev/null
+++ b/scripts/verify-linux-packages.js
@@ -0,0 +1,241 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+
+function argValue(name) {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
+
+function fail(message) {
+ console.error(`[FAIL] ${message}`);
+ process.exit(1);
+}
+
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd ?? repoRoot,
+ env: { ...process.env, ...options.env },
+ stdio: options.stdio ?? 'inherit',
+ encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
+ });
+
+ if (result.error) {
+ fail(`Failed to run ${command}: ${result.error.message}`);
+ }
+ if (result.status !== 0) {
+ if (options.stdio === 'pipe') {
+ if (result.stdout) process.stdout.write(result.stdout);
+ if (result.stderr) process.stderr.write(result.stderr);
+ }
+ fail(`${command} exited with status ${result.status}`);
+ }
+ return result;
+}
+
+function findSingle(directory, extension, label) {
+ if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
+ fail(`${label} directory does not exist: ${directory}`);
+ }
+
+ const matches = fs.readdirSync(directory)
+ .filter(name => name.endsWith(extension))
+ .map(name => path.join(directory, name));
+ if (matches.length !== 1) {
+ fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
+ }
+ return matches[0];
+}
+
+function assertPackageListing(packageFile, packageType, expectedPath) {
+ const result = packageType === 'deb'
+ ? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
+ : run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
+ const listing = result.stdout ?? '';
+ assertSafePackageListing(listing, packageType);
+ if (!listing.includes(expectedPath)) {
+ fail(`${packageType} package is missing ${expectedPath}`);
+ }
+ if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
+ fail(`${packageType} package is missing its desktop entry`);
+ }
+}
+
+function assertPackageRecommendations(packageFile, packageType) {
+ const result = packageType === 'deb'
+ ? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
+ : run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
+ const recommendations = result.stdout ?? '';
+ const dependencyNames = packageType === 'deb'
+ ? recommendations
+ .split(/[,|]/)
+ .map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
+ : recommendations
+ .split('\n')
+ .map(value => value.trim().split(/\s+/, 1)[0]);
+ for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
+ if (!dependencyNames.includes(dependency)) {
+ fail(`${packageType} package is missing its ${dependency} recommendation`);
+ }
+ }
+}
+
+function assertSafePackageListing(listing, packageType) {
+ const lines = listing.split('\n').filter(Boolean);
+ const paths = packageType === 'deb'
+ ? lines.map(line => {
+ const marker = line.indexOf('./');
+ if (marker < 0) fail(`Could not parse a Debian package path: ${line}`);
+ return line.slice(marker + 2);
+ })
+ : lines.map(line => line.replace(/^\/+/, ''));
+
+ for (const packagePath of paths) {
+ const parts = packagePath.split('/');
+ if (parts.includes('..') || (parts[0] !== 'usr' && packagePath !== 'usr')) {
+ fail(`${packageType} package contains an unsafe path: ${packagePath}`);
+ }
+ }
+}
+
+function extractDeb(packageFile, destination) {
+ fs.mkdirSync(destination, { recursive: true });
+ run('dpkg-deb', ['--extract', packageFile, destination]);
+}
+
+function extractRpm(packageFile, destination) {
+ fs.mkdirSync(destination, { recursive: true });
+ run('bash', ['-o', 'pipefail', '-c', 'rpm2cpio "$1" | (cd "$2" && cpio --no-absolute-filenames -idm --quiet)', '--', packageFile, destination]);
+}
+
+function readPayloadManifest(root, label) {
+ const manifestPath = path.join(root, 'payload-manifest.json');
+ if (!fs.existsSync(manifestPath)) {
+ fail(`${label} payload manifest is missing`);
+ }
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ } catch (error) {
+ fail(`${label} payload manifest is invalid: ${error.message}`);
+ }
+ return manifest;
+}
+
+function findPayloadRoot(root, target, label) {
+ const expectedBinary = `yt-dlp-${target}`;
+ const matches = [];
+ const walk = directory => {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const candidate = path.join(directory, entry.name);
+ if (!entry.isDirectory()) continue;
+ if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
+ matches.push(candidate);
+ }
+ walk(candidate);
+ }
+ };
+ walk(root);
+ if (matches.length !== 1) {
+ fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
+ }
+ return matches[0];
+}
+
+function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
+ const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
+ if (sourceManifest.target !== target) {
+ fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
+ }
+
+ const packagedManifest = readPayloadManifest(packagedRoot, label);
+ if (packagedManifest.target !== target) {
+ fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
+ }
+
+ const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
+ const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
+ .map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
+ .sort();
+ if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
+ fail(`${label} payload files differ from the provisioned engine manifest`);
+ }
+
+ for (const relative of expectedFiles) {
+ const packagedFile = path.join(packagedRoot, relative);
+ if (sha256(packagedFile) !== sourceManifest.files[relative]) {
+ fail(`${label} payload checksum mismatch: ${relative}`);
+ }
+ }
+}
+
+function findExecutable(root) {
+ const candidates = [
+ path.join(root, 'usr', 'bin', 'firelink'),
+ path.join(root, 'usr', 'bin', 'Firelink'),
+ ];
+ const executable = candidates.find(candidate => {
+ if (!fs.existsSync(candidate)) return false;
+ const stat = fs.lstatSync(candidate);
+ return stat.isFile() && !stat.isSymbolicLink();
+ });
+ if (!executable) {
+ fail(`Packaged Firelink executable was not found under ${root}`);
+ }
+ return executable;
+}
+
+function verifyExtractedPackage(packageType, packageFile, target, root) {
+ const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
+ const packagedRoot = findPayloadRoot(root, target, packageType);
+ assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
+ run(process.execPath, [
+ path.join(repoRoot, 'scripts', 'verify-binaries.js'),
+ '--search-root',
+ root,
+ '--target',
+ target,
+ ]);
+
+ const executable = findExecutable(root);
+ run('xvfb-run', [
+ '-a',
+ process.execPath,
+ path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
+ '--executable',
+ executable,
+ ], { env: { APPDIR: root } });
+}
+
+const target = argValue('--target');
+if (!target) fail('Pass --target