diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4af985..138a3a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,7 @@ jobs: libwebkit2gtk-4.1-dev \ libappindicator3-dev \ librsvg2-dev \ + curl \ patchelf \ libdbus-1-dev \ pkg-config \ @@ -68,49 +69,46 @@ jobs: - name: Provision locked engines if: runner.os != 'macOS' run: node scripts/provision-engines.js --target ${{ matrix.target }} - - name: Hide all engines from linuxdeploy (Linux only) - if: runner.os == 'Linux' - run: | - # Patch tauri.conf.json's beforeBuildCommand to empty engine-dist - # AFTER stage-engines.js verifies it, but BEFORE tauri builds the bundle. - # This hides all static binaries (ffmpeg, aria2c, etc) from linuxdeploy - # which corrupts them via patchelf. We will repack them into the AppImage later. - sed -i 's/npm run build/rm -rf src-tauri\/engine-dist\/* \&\& npm run build/' src-tauri/tauri.conf.json - name: Build package 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: Install pinned appimagetool (Linux only) + if: runner.os == 'Linux' + env: + APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + APPIMAGETOOL_SHA256: a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 + run: | + curl -fsSL "$APPIMAGETOOL_URL" -o "$RUNNER_TEMP/appimagetool" + echo "$APPIMAGETOOL_SHA256 $RUNNER_TEMP/appimagetool" | sha256sum -c - + chmod +x "$RUNNER_TEMP/appimagetool" - name: Repack AppImage with engines (Linux only) if: runner.os == 'Linux' - run: | - APPDIR="src-tauri/target/${{ matrix.target }}/release/bundle/appimage/Firelink.AppDir" - # Ensure target directory exists in AppDir - mkdir -p "$APPDIR/usr/lib/Firelink/engine-dist/${{ matrix.target }}" - # Copy everything directly from provisioned-engines into the AppDir - cp -r src-tauri/provisioned-engines/${{ matrix.target }}/* "$APPDIR/usr/lib/Firelink/engine-dist/${{ matrix.target }}/" - - # Download appimagetool - wget -qO appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage - chmod +x appimagetool - - # Find the original AppImage name to overwrite - APPIMAGE=$(ls src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage | head -n 1) - - # Repack the AppImage - env APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 ./appimagetool "$APPDIR" "$APPIMAGE" + run: node scripts/repack-linux-appimage-engines.js --target ${{ matrix.target }} --appimagetool "$RUNNER_TEMP/appimagetool" - name: Verify macOS packaged engines and launch if: runner.os == 'macOS' run: | APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app" node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }} node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink" - - name: Verify Windows installer payload + - name: Verify Windows installer payload and launch if: runner.os == 'Windows' shell: pwsh run: | $installer = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe" | Select-Object -First 1 - 7z x $installer.FullName "-o$env:RUNNER_TEMP/firelink-installer" -y - node scripts/verify-binaries.js --search-root "$env:RUNNER_TEMP/firelink-installer" --target ${{ matrix.target }} + if (-not $installer) { throw "Windows NSIS installer artifact was not produced." } + $extractRoot = "$env:RUNNER_TEMP/firelink-installer" + Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue + 7z x $installer.FullName "-o$extractRoot" -y + node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }} + $installRoot = "$env:RUNNER_TEMP\FirelinkSmoke" + Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue + & $installer.FullName /S "/D=$installRoot" + if ($LASTEXITCODE -ne 0) { throw "Silent NSIS install failed with exit code $LASTEXITCODE." } + $exe = Get-ChildItem $installRoot -Recurse -File | Where-Object { $_.Name -ieq "firelink.exe" } | Sort-Object FullName | Select-Object -First 1 + if (-not $exe) { throw "firelink.exe was not found under $installRoot after silent install." } + node scripts/smoke-packaged-app.js --executable $exe.FullName --assert-no-visible-child-windows - name: Verify Linux AppImage payload and launch if: runner.os == 'Linux' run: | @@ -124,6 +122,7 @@ jobs: name: Firelink-${{ matrix.label }}-${{ github.ref_name }} path: ${{ matrix.artifact }} if-no-files-found: error + retention-days: 3 publish: name: Publish GitHub release diff --git a/package-lock.json b/package-lock.json index cfc7bcb..1ff5f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "postcss": "^8.5.15", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", - "vite": "^8.1.0", + "vite": "^8.1.2", "vitest": "^4.1.9" }, "engines": { @@ -1974,9 +1974,9 @@ } }, "node_modules/vite": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.1.tgz", - "integrity": "sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/package.json b/package.json index 97489bd..c0a2d3e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "postcss": "^8.5.15", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", - "vite": "^8.1.0", + "vite": "^8.1.2", "vitest": "^4.1.9" } } diff --git a/scripts/before-tauri-build.js b/scripts/before-tauri-build.js new file mode 100755 index 0000000..86a64bd --- /dev/null +++ b/scripts/before-tauri-build.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); + +function run(command, args) { + const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command; + const result = spawnSync(executable, args, { + cwd: repoRoot, + stdio: 'inherit', + }); + + if (result.error) { + console.error(`Failed to run ${executable}: ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +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.'); +} + +run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build']); diff --git a/scripts/repack-linux-appimage-engines.js b/scripts/repack-linux-appimage-engines.js new file mode 100755 index 0000000..b33e323 --- /dev/null +++ b/scripts/repack-linux-appimage-engines.js @@ -0,0 +1,157 @@ +#!/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(message); + process.exit(1); +} + +function mustBeDirectory(directory, label) { + if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) { + fail(`${label} does not exist or is not a directory: ${directory}`); + } +} + +function mustBeFile(file, label) { + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + fail(`${label} does not exist or is not a file: ${file}`); + } +} + +function findSingleAppImage(directory) { + mustBeDirectory(directory, 'AppImage bundle directory'); + const appImages = fs + .readdirSync(directory) + .filter(name => name.endsWith('.AppImage')) + .map(name => path.join(directory, name)); + + if (appImages.length !== 1) { + fail(`Expected exactly one AppImage in ${directory}, found ${appImages.length}.`); + } + + return appImages[0]; +} + +function validatePayloadManifest(root, target, label) { + const manifestPath = path.join(root, 'payload-manifest.json'); + mustBeFile(manifestPath, `${label} payload manifest`); + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + fail(`${label} payload manifest is not valid JSON: ${error.message}`); + } + + if (manifest.target !== target) { + fail(`${label} payload target mismatch: expected ${target}, got ${manifest.target}`); + } + + const expectedFiles = Object.keys(manifest.files || {}).sort(); + const actualFiles = collectRegularFiles(root, { ignoredNames: ['payload-manifest.json'] }) + .map(file => path.relative(root, file).split(path.sep).join('/')) + .sort(); + + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + fail(`${label} payload files do not match payload-manifest.json.`); + } + + for (const relative of expectedFiles) { + const file = path.join(root, relative); + if (sha256(file) !== manifest.files[relative]) { + fail(`${label} payload checksum mismatch: ${relative}`); + } + } +} + +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}`); + } +} + +const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE || process.env.TAURI_ENV_TARGET_TRIPLE; +if (!target) { + fail('Pass --target .'); +} + +const bundleDirectory = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle', 'appimage'); +const appDir = path.resolve(argValue('--appdir') || path.join(bundleDirectory, 'Firelink.AppDir')); +const appImage = path.resolve(argValue('--appimage') || findSingleAppImage(bundleDirectory)); +const appImageTool = path.resolve(argValue('--appimagetool') || process.env.APPIMAGETOOL || 'appimagetool'); +const source = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target); +const destination = path.join(appDir, 'usr', 'lib', 'Firelink', 'engine-dist', target); + +mustBeDirectory(appDir, 'Firelink.AppDir'); +mustBeDirectory(source, 'Provisioned engine payload'); +mustBeFile(appImage, 'AppImage'); +mustBeFile(appImageTool, 'appimagetool'); + +validatePayloadManifest(source, target, 'Provisioned engine'); + +fs.rmSync(destination, { recursive: true, force: true }); +fs.mkdirSync(path.dirname(destination), { recursive: true }); +fs.cpSync(source, destination, { + recursive: true, + dereference: false, + preserveTimestamps: true, +}); +validatePayloadManifest(destination, target, 'AppDir engine'); + +fs.chmodSync(appImageTool, 0o755); +run(appImageTool, [appDir, appImage], { + env: { + APPIMAGE_EXTRACT_AND_RUN: '1', + ARCH: 'x86_64', + }, +}); + +const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-appimage-')); +try { + fs.chmodSync(appImage, 0o755); + run(appImage, ['--appimage-extract'], { + cwd: extractRoot, + env: { APPIMAGE_EXTRACT_AND_RUN: '1' }, + stdio: 'pipe', + }); + + const extractedPayload = path.join(extractRoot, 'squashfs-root', 'usr', 'lib', 'Firelink', 'engine-dist', target); + mustBeDirectory(extractedPayload, 'Extracted AppImage engine payload'); + validatePayloadManifest(extractedPayload, target, 'Extracted AppImage engine'); +} finally { + fs.rmSync(extractRoot, { recursive: true, force: true }); +} + +console.log(`Repacked and verified AppImage engine payload for ${target}.`); diff --git a/scripts/smoke-packaged-app.js b/scripts/smoke-packaged-app.js old mode 100644 new mode 100755 index 33c4f73..e0d08e3 --- a/scripts/smoke-packaged-app.js +++ b/scripts/smoke-packaged-app.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import path from 'node:path'; function argValue(name) { @@ -12,52 +12,127 @@ if (!executableArg) { console.error('Pass --executable .'); process.exit(1); } -const executable = path.resolve(executableArg); +const executable = path.resolve(executableArg); +const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows'); const child = spawn(executable, [], { cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(), detached: process.platform !== 'win32', - env: { - ...process.env, + env: { + ...process.env, FIRELINK_SMOKE_TEST: '1', WEBKIT_DISABLE_COMPOSITING_MODE: '1', - GDK_BACKEND: 'x11' + GDK_BACKEND: 'x11', }, - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], }); + let stderr = ''; let spawnError = null; +let readyPort = null; + child.on('error', error => { spawnError = error; }); + child.on('exit', (code, signal) => { if (readyPort === null) { console.error(`Child exited prematurely with code ${code} signal ${signal}`); } }); + child.stderr.on('data', data => { stderr += data.toString(); }); -let readyPort = null; -for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) { - for (let port = 6412; port <= 6422; port += 1) { - try { - const response = await fetch(`http://127.0.0.1:${port}/ping`); - if (response.headers.get('x-firelink-server') === '1') { - readyPort = port; - break; - } - } catch {} - } - if (readyPort === null) { - await new Promise(resolve => setTimeout(resolve, 250)); +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function findReadyPort() { + for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) { + if (spawnError) { + break; + } + + for (let port = 6412; port <= 6422; port += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/ping`); + if (response.headers.get('x-firelink-server') === '1') { + readyPort = port; + break; + } + } catch {} + } + + if (readyPort === null) { + await sleep(250); + } } } -if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); -} else { +function assertNoVisibleWindows(rootPid) { + if (process.platform !== 'win32') { + return; + } + + const script = ` +$root = ${rootPid} +$all = Get-CimInstance Win32_Process +$pending = @($root) +$descendants = @() +while ($pending.Count -gt 0) { + $parent = $pending[0] + if ($pending.Count -eq 1) { + $pending = @() + } else { + $pending = $pending[1..($pending.Count - 1)] + } + + $children = @($all | Where-Object { $_.ParentProcessId -eq $parent }) + foreach ($child in $children) { + $descendants += $child.ProcessId + $pending += $child.ProcessId + } +} + +$visible = foreach ($pid in $descendants) { + $process = Get-Process -Id $pid -ErrorAction SilentlyContinue + if ($process -and $process.MainWindowHandle -ne 0) { + "$($process.ProcessName)($pid)" + } +} + +if ($visible.Count -gt 0) { + Write-Error "Visible child process windows detected: $($visible -join ', ')" + exit 1 +} +`; + + try { + execFileSync('powershell', ['-NoProfile', '-Command', script], { + stdio: 'pipe', + windowsHide: true, + }); + } catch (error) { + const detail = [error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n').trim(); + throw new Error(detail || 'Visible child process window check failed.'); + } +} + +function terminateChild() { + if (!child.pid) { + return; + } + + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }); + return; + } + try { process.kill(-child.pid, 'SIGTERM'); } catch { @@ -65,9 +140,23 @@ if (process.platform === 'win32') { } } -if (readyPort === null) { - const detail = spawnError?.message || stderr.slice(-1000); - console.error(`Packaged Firelink did not expose extension server. ${detail}`); - process.exit(1); +await findReadyPort(); + +try { + if (readyPort === null) { + if (spawnError) { + console.error(`Packaged Firelink failed to start: ${spawnError.message}`); + } else { + console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`); + } + process.exit(1); + } + + if (assertNoVisibleChildWindows) { + assertNoVisibleWindows(child.pid); + } + + console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`); +} finally { + terminateChild(); } -console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1f9e2ce..637f2b8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "node scripts/stage-engines.js && npm run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "node scripts/stage-engines.js && node scripts/verify-binaries.js --staged && npm run build", + "beforeBuildCommand": "node scripts/before-tauri-build.js", "frontendDist": "../dist" }, "app": {