diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a384da..5ce23fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,12 +93,11 @@ jobs: 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 + - name: Build Linux AppImage from existing binary if: runner.os == 'Linux' - run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage + run: node scripts/build-linux-appimage.js --target ${{ matrix.target }} 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: diff --git a/scripts/before-tauri-build.js b/scripts/before-tauri-build.js index ecdcc14..a6b22f7 100755 --- a/scripts/before-tauri-build.js +++ b/scripts/before-tauri-build.js @@ -1,5 +1,4 @@ #!/usr/bin/env node -import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -53,11 +52,4 @@ process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}` run(process.execPath, ['scripts/stage-engines.js']); run(process.execPath, ['scripts/verify-binaries.js', '--staged']); -if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') { - const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist'); - fs.rmSync(engineDist, { recursive: true, force: true }); - fs.mkdirSync(engineDist, { recursive: true }); - console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.'); -} - runNpmScript('build'); diff --git a/scripts/build-linux-appimage.js b/scripts/build-linux-appimage.js new file mode 100644 index 0000000..cd41ff1 --- /dev/null +++ b/scripts/build-linux-appimage.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +let activeChild; +let receivedSignal; + +export const APPIMAGE_CONFIG = JSON.stringify({ + bundle: { + resources: { + 'engine-dist/': null, + }, + }, +}); + +function argValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function signalExitCode(signal) { + return { + SIGHUP: 129, + SIGINT: 130, + SIGTERM: 143, + }[signal] ?? 1; +} + +function npmInvocation(args) { + if (process.env.npm_execpath) { + return [process.execPath, [process.env.npm_execpath, ...args]]; + } + + if (process.platform === 'win32') { + return ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]]; + } + + return ['npm', args]; +} + +export function appImageBundleArguments(target) { + return [ + 'run', + 'tauri', + '--', + 'bundle', + '-vv', + '--target', + target, + '--bundles', + 'appimage', + '--config', + APPIMAGE_CONFIG, + ]; +} + +function run(command, args, options = {}) { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: { ...process.env, ...options.env }, + stdio: options.stdio ?? 'inherit', + windowsHide: true, + detached: process.platform !== 'win32', + }); + + activeChild = child; + + return new Promise((resolve, reject) => { + let settled = false; + + child.once('error', error => { + if (activeChild === child) activeChild = undefined; + if (!settled) { + settled = true; + reject(error); + } + }); + + child.once('close', (code, signal) => { + if (activeChild === child) activeChild = undefined; + if (!settled) { + settled = true; + resolve({ code, signal }); + } + }); + }); +} + +async function runChecked(command, args, label = command) { + let result; + try { + result = await run(command, args); + } catch (error) { + throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error }); + } + + if (result.signal) { + const error = new Error(`${label} was terminated by ${result.signal}.`); + error.exitCode = signalExitCode(result.signal); + throw error; + } + + if (result.code !== 0) { + const error = new Error(`${label} exited with status ${result.code}.`); + error.exitCode = result.code ?? 1; + throw error; + } + + return result; +} + +function assertSafeTarget(target) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) { + throw new Error(`Invalid target triple: ${target}`); + } +} + +async function main() { + const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE; + if (!target) { + throw new Error('Pass --target .'); + } + assertSafeTarget(target); + + // The native-package build has already staged and verified the engines. + // Verify once more before creating the AppImage so a failed preparation + // cannot produce an artifact that later appears valid only because its + // payload is absent. + await runChecked( + process.execPath, + ['scripts/verify-binaries.js', '--staged', '--target', target], + 'staged engine verification' + ); + + if (receivedSignal) { + const error = new Error(`Build interrupted by ${receivedSignal}.`); + error.exitCode = signalExitCode(receivedSignal); + throw error; + } + + const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target)); + await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling'); + + if (receivedSignal) { + const error = new Error(`Build interrupted by ${receivedSignal}.`); + error.exitCode = signalExitCode(receivedSignal); + throw error; + } + + console.log(`Built the Linux AppImage from the existing ${target} release binary.`); +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const handleSignal = signal => { + receivedSignal ??= signal; + + // npm and Tauri can have Rust/packaging descendants. On POSIX, the child + // is a detached process-group leader, so signal the whole group instead of + // leaving descendants running after the wrapper exits. + if (activeChild && activeChild.exitCode === null && activeChild.signalCode === null) { + try { + if (process.platform === 'win32' || !activeChild.pid) { + activeChild.kill(signal); + } else { + process.kill(-activeChild.pid, signal); + } + } catch (error) { + if (error.code !== 'ESRCH') { + console.error(`[WARN] Could not terminate the AppImage build process group: ${error.message}`); + } + } + } else { + process.exitCode = signalExitCode(signal); + } + }; + + for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) { + process.once(signal, () => handleSignal(signal)); + } + + main() + .catch(error => { + console.error(`[FAIL] ${error.message}`); + process.exitCode = error.exitCode ?? 1; + }) + .finally(() => { + for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) { + process.removeAllListeners(signal); + } + }); +} diff --git a/scripts/build-linux-appimage.node-test.js b/scripts/build-linux-appimage.node-test.js new file mode 100644 index 0000000..0f1c69f --- /dev/null +++ b/scripts/build-linux-appimage.node-test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js'; + +test('AppImage config removes only engine-dist from Tauri resources', () => { + assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), { + bundle: { + resources: { + 'engine-dist/': null, + }, + }, + }); +}); + +test('AppImage bundling uses the existing binary instead of tauri build', () => { + const args = appImageBundleArguments('x86_64-unknown-linux-gnu'); + assert.equal(args[3], 'bundle'); + assert.equal(args.includes('build'), false); + assert.deepEqual(args.slice(-2), ['--config', APPIMAGE_CONFIG]); +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43cd004..2c39b3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5334,6 +5334,7 @@ async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<() static NEXT_DOCK_BADGE_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +#[cfg(target_os = "macos")] fn should_apply_dock_badge_update( current_session: u64, current_generation: u64, @@ -8041,6 +8042,7 @@ mod tests { assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video")); } + #[cfg(target_os = "macos")] #[test] fn dock_badge_updates_reject_stale_sessions_and_generations() { assert!(should_apply_dock_badge_update(1, 99, 2, 1));