diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index e178f22ec..4576a5ee1 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -228,6 +228,21 @@ jobs: PACKAGE_ARCH: ${{ matrix.arch || '' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" + - name: Smoke test packaged Flatpak launcher + if: matrix.os == 'linux' + shell: bash + run: | + set -euo pipefail + + FLATPAK_BUNDLE="$(find dist/executables -maxdepth 1 -name '*.flatpak' | head -n 1)" + if [ -z "${FLATPAK_BUNDLE}" ]; then + echo "::error::Flatpak bundle not found in dist/executables" + exit 1 + fi + + flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}" + flatpak run --command=sh com.fourgray.iptvnator -c '/app/bin/iptvnator --version' + - name: Upload artifacts (macOS) if: matrix.os == 'macos' uses: actions/upload-artifact@v4 diff --git a/electron-builder.json b/electron-builder.json index 78eafb4b7..32ba5457f 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -70,7 +70,7 @@ "artifactName": "${name}-${version}-${os}-${arch}.${ext}", "icon": "apps/web/src/assets/icons" }, - "afterPack": "electron-builder-sandbox-fix", + "afterPack": "./tools/packaging/linux-after-pack.cjs", "snap": { "confinement": "strict", "grade": "stable", diff --git a/tools/packaging/linux-after-pack.cjs b/tools/packaging/linux-after-pack.cjs new file mode 100644 index 000000000..93e6b8cb4 --- /dev/null +++ b/tools/packaging/linux-after-pack.cjs @@ -0,0 +1,72 @@ +const fs = require('fs/promises'); +const path = require('path'); + +function log(message) { + console.log(` - ${message}`); +} + +function createLoaderScript({ executableName, productName }) { + return `#!/usr/bin/env bash +set -u + +UNPRIVILEGED_USERNS_ENABLED=$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null) +RESTRICT_UNPRIVILEGED_USERNS=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null) + +SCRIPT_PATH="\${BASH_SOURCE[0]}" +if command -v readlink >/dev/null 2>&1; then + RESOLVED_SCRIPT_PATH=$(readlink -f "$SCRIPT_PATH" 2>/dev/null || true) + if [ -n "$RESOLVED_SCRIPT_PATH" ]; then + SCRIPT_PATH="$RESOLVED_SCRIPT_PATH" + fi +fi + +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + +APPLY_NO_SANDBOX_FLAG=0 +if [ "$UNPRIVILEGED_USERNS_ENABLED" != 1 ] || [ "$RESTRICT_UNPRIVILEGED_USERNS" = 1 ]; then + APPLY_NO_SANDBOX_FLAG=1 +fi + +if [ "$SCRIPT_DIR" = "/usr/bin" ]; then + SCRIPT_DIR="/opt/${productName}" +fi + +EXEC_ARGS=() +if [ "$APPLY_NO_SANDBOX_FLAG" = 1 ]; then + echo "Note: Running with --no-sandbox since unprivileged_userns_clone is disabled or apparmor_restrict_unprivileged_userns is enabled." + EXEC_ARGS+=(--no-sandbox) +fi + +exec "$SCRIPT_DIR/${executableName}.bin" "\${EXEC_ARGS[@]}" "$@" +`; +} + +async function afterPackHook(params) { + if (params.electronPlatformName !== 'linux') { + return; + } + + log('applying Linux launcher sandbox fix'); + + const executable = path.join(params.appOutDir, params.packager.executableName); + + try { + await fs.rename(executable, `${executable}.bin`); + await fs.writeFile( + executable, + createLoaderScript({ + executableName: params.packager.executableName, + productName: params.packager.appInfo.productName, + }) + ); + await fs.chmod(executable, 0o755); + } catch (error) { + log(`failed to create launcher wrapper: ${error.message}`); + throw new Error('Failed to create launcher wrapper'); + } + + log('Linux launcher sandbox fix applied'); +} + +module.exports = afterPackHook; +module.exports.createLoaderScript = createLoaderScript; diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 8526578d3..80f0295ac 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -14,6 +14,12 @@ if (!platform) { const workspaceRoot = process.cwd(); const executablesRoot = path.join(workspaceRoot, 'dist', 'executables'); +const packageJsonPath = path.join(workspaceRoot, 'package.json'); +const electronBuilderConfigPath = path.join(workspaceRoot, 'electron-builder.json'); +const packageMetadata = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +const electronBuilderConfig = JSON.parse( + fs.readFileSync(electronBuilderConfigPath, 'utf8') +); const workerRelativeDir = path.join( 'dist', 'apps', @@ -32,6 +38,7 @@ const nativeModuleRelativeDirs = [ 'node_modules' ), ]; +const linuxExecutableName = getLinuxExecutableName(); function directoryExists(directoryPath) { return fs.existsSync(directoryPath) && fs.statSync(directoryPath).isDirectory(); @@ -102,6 +109,62 @@ function getResourceDirs() { } } +function sanitizeExecutableName(value) { + return value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, ''); +} + +function getLinuxExecutableName() { + const configuredExecutableName = + electronBuilderConfig.linux?.executableName ?? + electronBuilderConfig.executableName; + + if (configuredExecutableName) { + return sanitizeExecutableName(configuredExecutableName); + } + + return packageMetadata.name.toLowerCase(); +} + +function verifyLinuxLauncher(resourceDir, errors) { + const appDir = path.dirname(resourceDir); + const launcherPath = path.join(appDir, linuxExecutableName); + const launcherBinaryPath = `${launcherPath}.bin`; + + if (!fileExists(launcherBinaryPath)) { + errors.push( + `Missing Linux launcher binary in ${appDir}: ${path.basename(launcherBinaryPath)}` + ); + return; + } + + if (!fileExists(launcherPath)) { + errors.push( + `Missing Linux launcher wrapper in ${appDir}: ${path.basename(launcherPath)}` + ); + return; + } + + const launcherScript = fs.readFileSync(launcherPath, 'utf8'); + const requiredMarkers = [ + 'SCRIPT_PATH="${BASH_SOURCE[0]}"', + 'readlink -f "$SCRIPT_PATH"', + `exec "$SCRIPT_DIR/${linuxExecutableName}.bin"`, + ]; + const missingMarkers = requiredMarkers.filter( + (marker) => !launcherScript.includes(marker) + ); + + if (missingMarkers.length > 0) { + errors.push( + [ + `Linux launcher wrapper is missing symlink-safe logic in ${launcherPath}.`, + 'Missing markers:', + ...missingMarkers.map((marker) => `- ${marker}`), + ].join('\n') + ); + } +} + function verifyResourceDir(resourceDir) { const missingWorkers = workerFiles.filter( (workerFile) => @@ -133,6 +196,10 @@ function verifyResourceDir(resourceDir) { ); } + if (platform === 'linux') { + verifyLinuxLauncher(resourceDir, errors); + } + return { resourceDir, errors,