diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 301dde6..4d1eb5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,12 +118,20 @@ jobs: if: runner.os != 'macOS' run: node scripts/provision-engines.js --target ${{ matrix.target }} - name: Stage and verify engines + env: + FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist run: | node scripts/stage-engines.js --target ${{ matrix.target }} node scripts/verify-binaries.js --staged --target ${{ matrix.target }} - name: Run Torrent process smoke - run: node scripts/smoke-torrent.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} --failure-paths + env: + FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist + run: node scripts/smoke-torrent.js --failure-paths - name: Run Aria2 resolver smoke - run: node scripts/smoke-aria2-resolver.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} + env: + FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist + run: node scripts/smoke-aria2-resolver.js - name: Run Aria2 normal-transfer smoke - run: node scripts/smoke-aria2-transfers.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} + env: + FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist + run: node scripts/smoke-aria2-transfers.js diff --git a/.gitignore b/.gitignore index e26afe6..a265bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,9 +45,6 @@ lerna-debug.log* target/ src-tauri/target/ src-tauri/gen/ -src-tauri/engine-dist/ -src-tauri/engine-dist.lock -src-tauri/engine-dist.lock.* src-tauri/provisioned-engines/ # Locally provisioned native engines diff --git a/RELEASE.md b/RELEASE.md index 422df74..abc65fd 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -22,7 +22,8 @@ Firelink never falls back to system-installed media tools. - `engines.lock.json` pins current committed macOS payload hashes. - `engine-sources.lock.json` pins Windows/Linux source archives and checksums. - `scripts/provision-engines.js` downloads and verifies target archives. -- `scripts/stage-engines.js` creates one target-specific bundle payload. +- `scripts/stage-engines.js` creates one target-specific bundle payload in an + invocation-owned temporary workspace. - `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 bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries. @@ -41,8 +42,6 @@ Keep versions aligned: ```bash npm ci -node scripts/stage-engines.js --target aarch64-apple-darwin -node scripts/verify-binaries.js --staged --target aarch64-apple-darwin npm test -- --run npm run build cd src-tauri && cargo test --all-targets @@ -50,6 +49,29 @@ cd .. npm run tauri build -- --target aarch64-apple-darwin --bundles dmg ``` +`npm run tauri` owns engine staging for `dev`, `build`, and `bundle`. The +wrapper creates a private workspace, verifies the payload, and removes the +workspace after Tauri exits. To stage and verify a payload manually, provide a +private output root explicitly: + +```bash +ENGINE_OUTPUT_ROOT="$(mktemp -d -t firelink-engines)/engine-dist" +FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \ + node scripts/stage-engines.js --target aarch64-apple-darwin +FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \ + node scripts/verify-binaries.js --staged --target aarch64-apple-darwin +``` + +Do not use `src-tauri/engine-dist` or another repository-shared directory as +the manual output root. On Windows, set `FIRELINK_ENGINE_OUTPUT_ROOT` to a +private directory under `$env:TEMP` and use the PowerShell form: + +```powershell +$env:FIRELINK_ENGINE_OUTPUT_ROOT = Join-Path $env:TEMP "firelink-engines-$PID\engine-dist" +node scripts/stage-engines.js --target x86_64-pc-windows-msvc +node scripts/verify-binaries.js --staged --target x86_64-pc-windows-msvc +``` + Verify the DMG and the app it contains, then launch outside the repository working directory. The DMG bundler removes the intermediate app directory, so the post-build checks must use the mounted release artifact: diff --git a/scripts/before-tauri-bundle.js b/scripts/before-tauri-bundle.js new file mode 100644 index 0000000..6a30273 --- /dev/null +++ b/scripts/before-tauri-bundle.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveOutputRoot, resolveTargetTriple } from './engine-workspace.js'; + +if ( + process.env.FIRELINK_SKIP_ENGINE_RESOURCE === '1' + || process.env.FIRELINK_ENGINE_BUNDLE_PREPARED === '1' +) { + process.exit(0); +} + +// Staging belongs to beforeBuildCommand or the standalone-bundle wrapper. +// This hook is a read-only fence against a missing payload immediately before +// Tauri consumes the resource tree. +const target = resolveTargetTriple(); +const outputRoot = resolveOutputRoot(); +const suffix = target.includes('windows') ? '.exe' : ''; +const destination = path.join(outputRoot, target); +const expectedNames = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'] + .map(engine => `${engine}-${target}${suffix}`); + +for (const name of expectedNames) { + const candidate = path.join(destination, name); + if (!fs.existsSync(candidate) || !fs.lstatSync(candidate).isFile()) { + throw new Error(`Prepared engine payload is incomplete: ${candidate}`); + } +} + +console.log(`Prepared engine payload is present for ${target} at ${destination}`); diff --git a/scripts/build-linux-appimage.js b/scripts/build-linux-appimage.js index e7c176f..e7fe5c6 100644 --- a/scripts/build-linux-appimage.js +++ b/scripts/build-linux-appimage.js @@ -93,10 +93,10 @@ function run(command, args, options = {}) { }); } -async function runChecked(command, args, label = command) { +async function runChecked(command, args, label = command, options = {}) { let result; try { - result = await run(command, args); + result = await run(command, args, options); } catch (error) { throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error }); } @@ -130,13 +130,14 @@ async function main() { 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. + // Verify the immutable provisioned payload once more before creating the + // AppImage so a failed preparation cannot produce an artifact that later + // appears valid only because its payload is absent. + const provisionedRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target); await runChecked( process.execPath, - ['scripts/verify-binaries.js', '--staged', '--target', target], - 'staged engine verification' + ['scripts/verify-binaries.js', '--root', provisionedRoot, '--target', target], + 'provisioned engine verification' ); if (receivedSignal) { @@ -146,7 +147,9 @@ async function main() { } const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target)); - await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling'); + await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling', { + env: { FIRELINK_SKIP_ENGINE_RESOURCE: '1' }, + }); if (receivedSignal) { const error = new Error(`Build interrupted by ${receivedSignal}.`); diff --git a/scripts/engine-staging-lock.js b/scripts/engine-staging-lock.js deleted file mode 100644 index 7f5ecfb..0000000 --- a/scripts/engine-staging-lock.js +++ /dev/null @@ -1,187 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const DEFAULT_TIMEOUT_MS = 60_000; -const RETRY_DELAYS_MS = [25, 50, 100, 250, 500]; -const RETRYABLE_RENAME_ERRORS = new Set(['EACCES', 'EBUSY', 'EPERM']); - -function sleep(milliseconds) { - return new Promise(resolve => setTimeout(resolve, milliseconds)); -} - -function isProcessAlive(pid) { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error?.code === 'EPERM'; - } -} - -function readLock(lockPath) { - let contents; - try { - contents = fs.readFileSync(lockPath, 'utf8'); - } catch (error) { - if (error?.code === 'ENOENT') return null; - throw error; - } - - try { - const owner = JSON.parse(contents); - if ( - !Number.isSafeInteger(owner?.pid) - || owner.pid <= 0 - || typeof owner.token !== 'string' - || owner.token.length === 0 - ) { - return { malformed: true }; - } - return { owner }; - } catch { - return { malformed: true }; - } -} - -async function removeStaleLock(lockPath) { - const quarantinePath = `${lockPath}.stale-${process.pid}-${process.hrtime.bigint()}`; - for (let attempt = 0; ; attempt += 1) { - try { - // Rename is the compare-and-remove operation: another waiter cannot - // delete a newly acquired lock after this path has changed owners. - fs.renameSync(lockPath, quarantinePath); - break; - } catch (error) { - if (error?.code === 'ENOENT' || error?.code === 'EEXIST') return false; - if (!RETRYABLE_RENAME_ERRORS.has(error?.code) || attempt >= RETRY_DELAYS_MS.length) { - return false; - } - await sleep(RETRY_DELAYS_MS[attempt]); - } - } - - try { - fs.unlinkSync(quarantinePath); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } - return true; -} - -function releaseLock(lockPath, token) { - const currentLock = readLock(lockPath); - if (!currentLock) return; - if (currentLock.malformed) { - throw new Error(`Cannot release staging lock with an invalid owner record: ${lockPath}`); - } - const owner = currentLock.owner; - if (owner.token !== token || owner.pid !== process.pid) { - throw new Error(`Staging lock ownership changed before release: ${lockPath}`); - } - try { - fs.unlinkSync(lockPath); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } -} - -function createLock(lockPath, owner) { - const temporaryPath = `${lockPath}.owner-${owner.token}`; - let temporaryCreated = false; - try { - // Write the complete record away from the contested path, then publish it - // with a hard link. Unlike rename, link never replaces an existing lock. - fs.writeFileSync(temporaryPath, `${JSON.stringify(owner)}\n`, { - encoding: 'utf8', - flag: 'wx', - mode: 0o600, - }); - temporaryCreated = true; - fs.linkSync(temporaryPath, lockPath); - } finally { - if (temporaryCreated) { - try { - fs.unlinkSync(temporaryPath); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } - } - } -} - -export function readExclusiveFileLockOwner(lockPath) { - const currentLock = readLock(path.resolve(lockPath)); - if (!currentLock?.owner) { - throw new Error(`Staging lock owner record is unavailable: ${path.resolve(lockPath)}`); - } - return currentLock.owner; -} - -export function assertExclusiveFileLockHeld(lockPath, expectedOwner) { - if ( - !Number.isSafeInteger(expectedOwner?.pid) - || expectedOwner.pid <= 0 - || typeof expectedOwner.token !== 'string' - || expectedOwner.token.length === 0 - ) { - throw new Error(`Invalid inherited staging lock owner: ${path.resolve(lockPath)}`); - } - - const owner = readExclusiveFileLockOwner(lockPath); - if ( - owner.pid !== expectedOwner.pid - || owner.token !== expectedOwner.token - || !isProcessAlive(owner.pid) - ) { - throw new Error(`Inherited staging lock is no longer held: ${path.resolve(lockPath)}`); - } -} - -/** - * Serializes operations that replace the shared engine-dist directory. - * Returns an idempotent release function after the caller owns the lock. - */ -export async function acquireExclusiveFileLock(lockPath, options = {}) { - const resolvedPath = path.resolve(lockPath); - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const startedAt = Date.now(); - const token = `${process.pid}-${process.hrtime.bigint()}`; - const owner = { - pid: process.pid, - token, - startedAt: new Date().toISOString(), - }; - - for (let attempt = 0; ; attempt += 1) { - try { - createLock(resolvedPath, owner); - - let released = false; - return () => { - if (released) return; - releaseLock(resolvedPath, token); - released = true; - }; - } catch (error) { - if (error?.code !== 'EEXIST') throw error; - - const currentLock = readLock(resolvedPath); - if (currentLock?.malformed) { - if (await removeStaleLock(resolvedPath)) continue; - } else if (currentLock?.owner && !isProcessAlive(currentLock.owner.pid)) { - if (await removeStaleLock(resolvedPath)) continue; - } - - if (Date.now() - startedAt >= timeoutMs) { - const ownerDescription = currentLock?.owner?.pid - ? ` owned by PID ${currentLock.owner.pid}` - : ' with an unreadable owner record'; - throw new Error(`Timed out waiting for staging lock ${resolvedPath}${ownerDescription}`); - } - - const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)]; - await sleep(delay); - } - } -} diff --git a/scripts/engine-staging-lock.node-test.js b/scripts/engine-staging-lock.node-test.js deleted file mode 100644 index 1cdd41b..0000000 --- a/scripts/engine-staging-lock.node-test.js +++ /dev/null @@ -1,186 +0,0 @@ -import assert from 'node:assert/strict'; -import { once } from 'node:events'; -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import { pathToFileURL } from 'node:url'; -import { - acquireExclusiveFileLock, - assertExclusiveFileLockHeld, - readExclusiveFileLockOwner, -} from './engine-staging-lock.js'; - -const childOutputStates = new WeakMap(); - -function temporaryLockPath() { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-stage-lock-')); - return path.join(directory, 'engine-dist.lock'); -} - -function waitForOutput(child, expected) { - const state = childOutputStates.get(child); - assert.ok(state, 'child output tracking must be installed before waiting'); - if (state.output.includes(expected)) return Promise.resolve(state.output); - if (state.error) return Promise.reject(state.error); - if (state.exit) { - return Promise.reject(new Error( - `lock child exited before '${expected}' (code=${state.exit.code}, signal=${state.exit.signal})`, - )); - } - - return new Promise((resolve, reject) => { - state.waiters.push({ expected, resolve, reject }); - }); -} - -function trackChildOutput(child) { - const state = { error: null, exit: null, output: '', waiters: [] }; - childOutputStates.set(child, state); - child.stdout.setEncoding('utf8'); - child.stdout.on('data', chunk => { - state.output += chunk; - for (const waiter of state.waiters.splice(0)) { - if (state.output.includes(waiter.expected)) { - waiter.resolve(state.output); - } else { - state.waiters.push(waiter); - } - } - }); - child.once('error', error => { - state.error = error; - for (const waiter of state.waiters.splice(0)) waiter.reject(error); - }); - child.once('exit', (code, signal) => { - state.exit = { code, signal }; - const error = new Error(`lock child exited before a requested output (code=${code}, signal=${signal})`); - for (const waiter of state.waiters.splice(0)) waiter.reject(error); - }); -} - -function spawnLockChild(lockPath) { - const moduleUrl = pathToFileURL(path.resolve('scripts/engine-staging-lock.js')).href; - const source = ` -import { acquireExclusiveFileLock } from ${JSON.stringify(moduleUrl)}; -process.stdout.write('started\\n'); -const release = await acquireExclusiveFileLock(process.argv[1], { timeoutMs: 5_000 }); -process.stdout.write('acquired\\n'); -process.stdin.once('data', () => { - release(); - process.exit(0); -}); -process.stdin.resume(); -`; - const child = spawn(process.execPath, ['--input-type=module', '-e', source, lockPath], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - trackChildOutput(child); - return child; -} - -test('exclusive staging lock waits for a live owner and releases idempotently', async () => { - const lockPath = temporaryLockPath(); - const releaseFirst = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); - const secondAcquisition = acquireExclusiveFileLock(lockPath, { - timeoutMs: 1_000, - }); - - assert.equal(fs.existsSync(lockPath), true); - releaseFirst(); - releaseFirst(); - - const releaseSecond = await secondAcquisition; - assert.equal(fs.existsSync(lockPath), true); - releaseSecond(); - assert.equal(fs.existsSync(lockPath), false); -}); - -test('exclusive staging lock recovers empty and malformed legacy records', async () => { - for (const contents of ['', '{not-json']) { - const lockPath = temporaryLockPath(); - fs.writeFileSync(lockPath, contents); - - const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); - const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - assert.equal(owner.pid, process.pid); - release(); - assert.equal(fs.existsSync(lockPath), false); - } -}); - -test('exclusive staging lock serializes separate processes', async () => { - const lockPath = temporaryLockPath(); - const first = spawnLockChild(lockPath); - let second; - try { - await waitForOutput(first, 'acquired\n'); - second = spawnLockChild(lockPath); - await waitForOutput(second, 'started\n'); - - const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - assert.equal(owner.pid, first.pid); - first.stdin.write('\n'); - await once(first, 'exit'); - await waitForOutput(second, 'acquired\n'); - second.stdin.write('\n'); - await once(second, 'exit'); - } finally { - for (const child of [first, second]) { - if (child && child.exitCode === null && child.signalCode === null) child.kill(); - } - } -}); - -test('exclusive staging lock recovers after an owner is force-killed', async () => { - const lockPath = temporaryLockPath(); - const owner = spawnLockChild(lockPath); - try { - await waitForOutput(owner, 'acquired\n'); - assert.equal(owner.kill('SIGKILL'), true); - await once(owner, 'exit'); - - const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); - const replacement = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - assert.equal(replacement.pid, process.pid); - release(); - assert.equal(fs.existsSync(lockPath), false); - } finally { - if (owner.exitCode === null && owner.signalCode === null) owner.kill(); - } -}); - -test('exclusive staging lock recovers a lock owned by a dead process', async () => { - const lockPath = temporaryLockPath(); - fs.writeFileSync(lockPath, JSON.stringify({ - pid: 999_999_999, - token: 'dead-owner', - })); - - const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); - const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - assert.equal(owner.pid, process.pid); - assert.notEqual(owner.token, 'dead-owner'); - release(); - assert.equal(fs.existsSync(lockPath), false); -}); - -test('staging lock release fails closed when ownership changes', async () => { - const lockPath = temporaryLockPath(); - const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); - fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token: 'other-owner' })); - - assert.throws(() => release(), /ownership changed/); - fs.unlinkSync(lockPath); -}); - -test('inherited staging lock validation rejects a released lease', async () => { - const lockPath = temporaryLockPath(); - const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); - const owner = readExclusiveFileLockOwner(lockPath); - assert.doesNotThrow(() => assertExclusiveFileLockHeld(lockPath, owner)); - release(); - - assert.throws(() => assertExclusiveFileLockHeld(lockPath, owner), /owner record is unavailable/); -}); diff --git a/scripts/engine-workspace.js b/scripts/engine-workspace.js new file mode 100644 index 0000000..45a7fb1 --- /dev/null +++ b/scripts/engine-workspace.js @@ -0,0 +1,125 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { removePathWithRetry } from './engine-payload-promotion.js'; + +const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64' }; +const PLATFORM_MAP = { + darwin: 'apple-darwin', + win32: 'pc-windows-msvc', + linux: 'unknown-linux-gnu', +}; +const SAFE_TARGET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function argumentValue(args, name) { + const index = args.indexOf(name); + if (index >= 0) return args[index + 1]; + const prefix = `${name}=`; + const inline = args.find(argument => argument.startsWith(prefix)); + return inline?.slice(prefix.length); +} + +export function assertSafeTarget(target) { + if (typeof target !== 'string' || !SAFE_TARGET_PATTERN.test(target)) { + throw new Error(`Invalid target triple: ${target ?? ''}`); + } + return target; +} + +export function resolveTargetTriple( + args = process.argv.slice(2), + env = process.env, + platform = os.platform(), + arch = os.arch(), +) { + const hostTarget = ARCH_MAP[arch] && PLATFORM_MAP[platform] + ? `${ARCH_MAP[arch]}-${PLATFORM_MAP[platform]}` + : undefined; + const target = argumentValue(args, '--target') + || env.TAURI_ENV_TARGET_TRIPLE + || env.FIRELINK_TARGET_TRIPLE + || hostTarget; + return assertSafeTarget(target); +} + +export function resolveOutputRoot(args = process.argv.slice(2), env = process.env) { + const outputRoot = argumentValue(args, '--output-root') || env.FIRELINK_ENGINE_OUTPUT_ROOT; + if (!outputRoot) { + throw new Error( + 'No engine output workspace was provided. Run through npm run tauri or set FIRELINK_ENGINE_OUTPUT_ROOT.', + ); + } + return path.resolve(outputRoot); +} + +function canonicalPathWithMissingComponents(value) { + let cursor = path.resolve(value); + const missing = []; + + while (true) { + try { + const canonical = fs.realpathSync.native(cursor); + return path.join(canonical, ...missing.reverse()); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + const parent = path.dirname(cursor); + if (parent === cursor) throw error; + missing.push(path.basename(cursor)); + cursor = parent; + } + } +} + +function comparablePath(value) { + const normalized = path.normalize(value); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function isWithinPath(root, candidate) { + const relative = path.relative(comparablePath(root), comparablePath(candidate)); + return relative === '' + || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +export function assertSafeOutputRoot(outputRoot, forbiddenRoots = []) { + const canonicalOutputRoot = canonicalPathWithMissingComponents(outputRoot); + for (const forbiddenRoot of forbiddenRoots) { + const canonicalForbiddenRoot = canonicalPathWithMissingComponents(forbiddenRoot); + if (isWithinPath(canonicalForbiddenRoot, canonicalOutputRoot)) { + throw new Error( + `Refusing to use a repository-shared engine workspace: ${outputRoot}`, + ); + } + } + return canonicalOutputRoot; +} + +export function createEngineWorkspace(target) { + assertSafeTarget(target); + const workspace = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engine-${target}-${process.pid}-`)), + ); + const outputRoot = path.join(workspace, 'engine-dist'); + fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 }); + return { outputRoot, runtimeRoot: outputRoot, workspace }; +} + +export function engineResourceConfig(outputRoot) { + const source = `${path.resolve(outputRoot)}${path.sep}`; + return JSON.stringify({ + bundle: { + resources: { + [source]: 'engine-dist/', + }, + }, + }); +} + +export async function removeEngineWorkspace(workspace) { + const resolved = path.resolve(workspace); + const basename = path.basename(resolved); + if (!basename.startsWith('firelink-engine-')) { + throw new Error(`Refusing to remove an unexpected engine workspace: ${resolved}`); + } + await removePathWithRetry(resolved); +} diff --git a/scripts/engine-workspace.node-test.js b/scripts/engine-workspace.node-test.js new file mode 100644 index 0000000..51d9ef7 --- /dev/null +++ b/scripts/engine-workspace.node-test.js @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + assertSafeTarget, + assertSafeOutputRoot, + createEngineWorkspace, + engineResourceConfig, + removeEngineWorkspace, + resolveOutputRoot, + resolveTargetTriple, +} from './engine-workspace.js'; + +test('target resolution accepts explicit and inline target arguments', () => { + assert.equal( + resolveTargetTriple(['--target', 'x86_64-unknown-linux-gnu'], {}, 'darwin', 'arm64'), + 'x86_64-unknown-linux-gnu', + ); + assert.equal( + resolveTargetTriple(['--target=x86_64-pc-windows-msvc'], {}, 'darwin', 'arm64'), + 'x86_64-pc-windows-msvc', + ); +}); + +test('target validation rejects path traversal before filesystem use', () => { + assert.throws(() => assertSafeTarget('../outside'), /Invalid target triple/); + assert.throws( + () => resolveTargetTriple(['--target', 'x86_64/../../outside'], {}, 'darwin', 'arm64'), + /Invalid target triple/, + ); +}); + +test('engine workspaces are unique and produce an absolute Tauri resource mapping', async () => { + const first = createEngineWorkspace('aarch64-apple-darwin'); + const second = createEngineWorkspace('aarch64-apple-darwin'); + try { + assert.notEqual(first.workspace, second.workspace); + assert.equal(fs.statSync(first.outputRoot).isDirectory(), true); + const config = JSON.parse(engineResourceConfig(first.outputRoot)); + assert.equal( + config.bundle.resources[`${path.resolve(first.outputRoot)}${path.sep}`], + 'engine-dist/', + ); + } finally { + await removeEngineWorkspace(first.workspace); + await removeEngineWorkspace(second.workspace); + } +}); + +test('staging requires an explicit private output workspace', () => { + assert.throws(() => resolveOutputRoot([], {}), /No engine output workspace/); + assert.equal( + resolveOutputRoot(['--output-root', '/tmp/firelink-engine-run'], {}).endsWith( + path.join('firelink-engine-run'), + ), + true, + ); +}); + +test('shared repository output roots and descendants are rejected', () => { + const repoRoot = path.resolve('/repo'); + assert.throws( + () => assertSafeOutputRoot('/repo/src-tauri/engine-dist/target', [ + repoRoot, + path.join(repoRoot, 'src-tauri'), + path.join(repoRoot, 'src-tauri', 'engine-dist'), + ]), + /repository-shared engine workspace/, + ); +}); + +test('output roots are checked after resolving symlinked parents', () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-workspace-test-')); + const sharedRoot = path.join(temporaryRoot, 'shared'); + const linkedRoot = path.join(temporaryRoot, 'linked'); + try { + fs.mkdirSync(path.join(sharedRoot, 'src-tauri', 'engine-dist'), { recursive: true }); + fs.symlinkSync(sharedRoot, linkedRoot, 'dir'); + assert.throws( + () => assertSafeOutputRoot(path.join(linkedRoot, 'src-tauri', 'engine-dist', 'target'), [ + sharedRoot, + path.join(sharedRoot, 'src-tauri'), + path.join(sharedRoot, 'src-tauri', 'engine-dist'), + ]), + /repository-shared engine workspace/, + ); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/prepare-tauri-engines.js b/scripts/prepare-tauri-engines.js new file mode 100644 index 0000000..7d68f27 --- /dev/null +++ b/scripts/prepare-tauri-engines.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function run(script, args) { + const result = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', script), ...args], { + cwd: repoRoot, + stdio: 'inherit', + windowsHide: true, + }); + if (result.error) { + console.error(`[FAIL] Could not run ${script}: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) process.exit(result.status ?? 1); +} + +run('stage-engines.js', []); +run('verify-binaries.js', ['--staged']); diff --git a/scripts/smoke-aria2-resolver.js b/scripts/smoke-aria2-resolver.js index 2dae624..112fd5f 100644 --- a/scripts/smoke-aria2-resolver.js +++ b/scripts/smoke-aria2-resolver.js @@ -24,12 +24,18 @@ const argumentIndex = process.argv.indexOf('--binary'); const binaryPath = path.resolve( argumentIndex >= 0 ? process.argv[argumentIndex + 1] - : path.join( - repoRoot, - 'src-tauri', - 'binaries', - `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`, - ), + : process.env.FIRELINK_ENGINE_OUTPUT_ROOT + ? path.join( + process.env.FIRELINK_ENGINE_OUTPUT_ROOT, + targetTriple, + `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`, + ) + : path.join( + repoRoot, + 'src-tauri', + 'binaries', + `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`, + ), ); if (!fs.existsSync(binaryPath)) { diff --git a/scripts/smoke-aria2-transfers.js b/scripts/smoke-aria2-transfers.js index 6f66a23..8782ecf 100644 --- a/scripts/smoke-aria2-transfers.js +++ b/scripts/smoke-aria2-transfers.js @@ -17,7 +17,13 @@ const targetTriple = `${arch}-${platform}`; const argumentIndex = process.argv.indexOf('--binary'); const binaryPath = path.resolve(argumentIndex >= 0 ? process.argv[argumentIndex + 1] - : path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`)); + : process.env.FIRELINK_ENGINE_OUTPUT_ROOT + ? path.join( + process.env.FIRELINK_ENGINE_OUTPUT_ROOT, + targetTriple, + `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`, + ) + : path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`)); if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`); const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index 937a340..c4636f4 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -36,7 +36,10 @@ if (!arch || !platform) { const targetTriple = `${arch}-${platform}`; const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`; const binaryPath = path.resolve( - argumentValue('--binary') || path.join(repoRoot, 'src-tauri', 'binaries', executableName), + argumentValue('--binary') + || (process.env.FIRELINK_ENGINE_OUTPUT_ROOT + ? path.join(process.env.FIRELINK_ENGINE_OUTPUT_ROOT, targetTriple, executableName) + : path.join(repoRoot, 'src-tauri', 'binaries', executableName)), ); const runtimeAbortController = new AbortController(); diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js index bbb73dd..7c02a69 100644 --- a/scripts/stage-engines.js +++ b/scripts/stage-engines.js @@ -1,37 +1,26 @@ #!/usr/bin/env node import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js'; +import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js'; import { - acquireExclusiveFileLock, - assertExclusiveFileLockHeld, -} from './engine-staging-lock.js'; + assertSafeOutputRoot, + resolveOutputRoot, + resolveTargetTriple, +} from './engine-workspace.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries'); -const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist'); const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8')); -const archMap = { x64: 'x86_64', arm64: 'aarch64' }; -const platformMap = { - darwin: 'apple-darwin', - win32: 'pc-windows-msvc', - linux: 'unknown-linux-gnu', -}; - -function argValue(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`; -const target = argValue('--target') - || process.env.TAURI_ENV_TARGET_TRIPLE - || process.env.FIRELINK_TARGET_TRIPLE - || hostTarget; +const target = resolveTargetTriple(); +const outputRoot = assertSafeOutputRoot(resolveOutputRoot(), [ + repoRoot, + path.join(repoRoot, 'src-tauri'), + path.join(repoRoot, 'src-tauri', 'engine-dist'), +]); const isWindowsTarget = target.includes('windows'); const suffix = isWindowsTarget ? '.exe' : ''; const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']; @@ -107,37 +96,25 @@ if (targetLock) { } } +fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 }); const destination = path.join(outputRoot, target); -const stagingLockPath = `${outputRoot}.lock`; -const inheritedLockPid = process.env.FIRELINK_ENGINE_STAGING_LOCK_PID; -const inheritedLockToken = process.env.FIRELINK_ENGINE_STAGING_LOCK_TOKEN; -const inheritedStagingLock = inheritedLockPid !== undefined || inheritedLockToken !== undefined; -if (inheritedStagingLock) { - assertExclusiveFileLockHeld(stagingLockPath, { - pid: Number(inheritedLockPid), - token: inheritedLockToken, - }); -} -const releaseStageLock = inheritedStagingLock - ? null - : await acquireExclusiveFileLock(stagingLockPath); +const temporaryRoot = fs.mkdtempSync(path.join(outputRoot, `.staging-${target}-${process.pid}-`)); +const temporaryDestination = path.join(temporaryRoot, target); + try { - // Tauri packages the shared engine-dist root. Keep exactly one target in it; - // the lock serializes this replacement when local stage commands overlap. - fs.rmSync(outputRoot, { recursive: true, force: true }); - fs.mkdirSync(destination, { recursive: true }); + fs.mkdirSync(temporaryDestination, { recursive: true, mode: 0o700 }); for (const name of expectedNames) { - fs.copyFileSync(path.join(source, name), path.join(destination, name)); + fs.copyFileSync(path.join(source, name), path.join(temporaryDestination, name)); if (!isWindowsTarget) { - fs.chmodSync(path.join(destination, name), 0o755); + fs.chmodSync(path.join(temporaryDestination, name), 0o755); } } for (const runtimeDir of ['_internal', 'aria2-libs']) { const sourceDir = path.join(source, runtimeDir); if (fs.existsSync(sourceDir)) { - fs.cpSync(sourceDir, path.join(destination, runtimeDir), { + fs.cpSync(sourceDir, path.join(temporaryDestination, runtimeDir), { recursive: true, dereference: false, preserveTimestamps: true, @@ -146,10 +123,11 @@ try { } const payloadManifest = path.join(source, 'payload-manifest.json'); if (fs.existsSync(payloadManifest)) { - fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json')); + fs.copyFileSync(payloadManifest, path.join(temporaryDestination, 'payload-manifest.json')); } + await promoteDirectory(temporaryDestination, destination); } finally { - releaseStageLock?.(); + await removePathWithRetry(temporaryRoot); } -console.log(`Staged Firelink engines for ${target} from ${source}`); +console.log(`Staged Firelink engines for ${target} from ${source} into ${destination}`); diff --git a/scripts/tauri-command.js b/scripts/tauri-command.js index fef2529..b89c590 100644 --- a/scripts/tauri-command.js +++ b/scripts/tauri-command.js @@ -1,22 +1,27 @@ #!/usr/bin/env node import path from 'node:path'; -import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; import { - acquireExclusiveFileLock, - readExclusiveFileLockOwner, -} from './engine-staging-lock.js'; + createEngineWorkspace, + engineResourceConfig, + removeEngineWorkspace, + resolveTargetTriple, +} from './engine-workspace.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js'); -const stagingLockPath = path.join(repoRoot, 'src-tauri', 'engine-dist.lock'); const ENGINE_TREE_COMMANDS = new Set(['dev', 'build', 'bundle']); export function commandUsesEngineTree(args) { return args.some(argument => ENGINE_TREE_COMMANDS.has(argument)); } +export function commandIsStandaloneBundle(args) { + return args.includes('bundle'); +} + function signalExitCode(signal) { return { SIGHUP: 129, @@ -26,31 +31,111 @@ function signalExitCode(signal) { } const args = process.argv.slice(2); -const holdsEngineLock = commandUsesEngineTree(args); -let releaseEngineLock; +const usesEngineWorkspace = commandUsesEngineTree(args); +let engineWorkspace; let child; +let receivedSignal; +let escalationTimer; +let interruptedProcessPid; -function handleSignal(signal) { - if (child && child.exitCode === null && child.signalCode === null) { +function windowsTaskkillPath() { + const systemRoot = process.env.SystemRoot || process.env.WINDIR; + return systemRoot ? path.join(systemRoot, 'System32', 'taskkill.exe') : 'taskkill.exe'; +} + +function forceTerminateProcessTree(pid) { + if (!pid) return Promise.resolve(); + + if (process.platform !== 'win32') { try { - child.kill(signal); + process.kill(-pid, 'SIGKILL'); } catch (error) { if (error?.code !== 'ESRCH') { - console.error(`[WARN] Could not terminate Tauri: ${error.message}`); + console.error(`[WARN] Could not force-terminate the Tauri process group: ${error.message}`); } } + return Promise.resolve(); + } + + return new Promise(resolve => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + let killer; + try { + killer = spawn( + windowsTaskkillPath(), + ['/PID', String(pid), '/T', '/F'], + { stdio: 'ignore', windowsHide: true }, + ); + } catch { + finish(); + return; + } + killer.once('error', finish); + killer.once('close', finish); + }); +} + +function handleSignal(signal) { + receivedSignal ??= signal; + if (child && child.exitCode === null && child.signalCode === null) { + const pid = child.pid; + interruptedProcessPid ??= pid; + if (process.platform === 'win32') { + // Node's Windows child.kill() does not reliably terminate descendants. + // taskkill's process-tree mode is the OS-supported equivalent of the + // POSIX process-group kill used below. + void forceTerminateProcessTree(pid); + return; + } + try { + if (!pid) { + child.kill(signal); + } else { + process.kill(-pid, signal); + } + } catch (error) { + if (error?.code !== 'ESRCH') { + console.error(`[WARN] Could not terminate the Tauri process group: ${error.message}`); + } + } + if (!escalationTimer) { + escalationTimer = setTimeout(() => { + void forceTerminateProcessTree(pid); + }, 2_000); + escalationTimer.unref(); + } return; } - // No child has started yet, so there is no packaging work to preserve. If - // lock acquisition was interrupted after ownership was granted, release it - // before exiting; otherwise exit promptly instead of waiting out the lock. - try { - releaseEngineLock?.(); - } catch (error) { - console.error(`[WARN] Could not release the engine staging lock: ${error.message}`); - } - process.exit(signalExitCode(signal)); + process.exitCode = signalExitCode(signal); +} + +function runChild(command, commandArgs, env) { + const spawned = spawn(command, commandArgs, { + cwd: repoRoot, + env, + stdio: 'inherit', + windowsHide: true, + detached: process.platform !== 'win32', + }); + child = spawned; + + return new Promise((resolve, reject) => { + let settled = false; + const settle = callback => value => { + if (settled) return; + settled = true; + if (child === spawned) child = undefined; + callback(value); + }; + spawned.once('error', settle(reject)); + spawned.once('close', (code, signal) => settle(resolve)({ code, signal })); + }); } async function run() { @@ -62,37 +147,70 @@ async function run() { try { const env = { ...process.env }; - if (holdsEngineLock) { - releaseEngineLock = await acquireExclusiveFileLock(stagingLockPath); - const owner = readExclusiveFileLockOwner(stagingLockPath); - // beforeBuildCommand and beforeDevCommand run as child processes. The - // wrapper keeps the lock until Tauri has finished consuming resources; - // nested staging/verifying must therefore use this held lease instead - // of trying to acquire the same path again. - env.FIRELINK_ENGINE_STAGING_LOCK_PID = String(owner.pid); - env.FIRELINK_ENGINE_STAGING_LOCK_TOKEN = owner.token; + let commandArgs = args; + if (usesEngineWorkspace) { + const target = resolveTargetTriple(args, env); + engineWorkspace = createEngineWorkspace(target); + env.FIRELINK_ENGINE_WORKSPACE = engineWorkspace.workspace; + env.FIRELINK_ENGINE_OUTPUT_ROOT = engineWorkspace.outputRoot; + env.FIRELINK_ENGINE_RUNTIME_ROOT = engineWorkspace.runtimeRoot; + env.FIRELINK_TARGET_TRIPLE = target; + + if ( + (args.includes('build') || args.includes('bundle')) + && env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1' + ) { + commandArgs = [...args, '--config', engineResourceConfig(engineWorkspace.outputRoot)]; + } } - child = spawn(process.execPath, [tauriCli, ...args], { - cwd: repoRoot, - env, - stdio: 'inherit', - windowsHide: true, - }); + if (receivedSignal) return; - const result = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', (code, signal) => resolve({ code, signal })); - }); + if (usesEngineWorkspace && commandIsStandaloneBundle(args) && env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1') { + let preparation; + try { + preparation = await runChild( + process.execPath, + [path.join(repoRoot, 'scripts', 'prepare-tauri-engines.js')], + env, + ); + } catch (error) { + throw new Error(`Engine preparation failed: ${error.message}`, { cause: error }); + } + if (receivedSignal) return; + if (preparation.signal) { + process.exitCode = signalExitCode(preparation.signal); + return; + } + if (preparation.code !== 0) { + process.exitCode = preparation.code ?? 1; + return; + } + env.FIRELINK_ENGINE_BUNDLE_PREPARED = '1'; + } - if (result.signal) { + if (receivedSignal) return; + + const result = await runChild(process.execPath, [tauriCli, ...commandArgs], env); + + if (receivedSignal) { + process.exitCode = signalExitCode(receivedSignal); + } else if (result.signal) { process.exitCode = signalExitCode(result.signal); } else { process.exitCode = result.code ?? 1; } } finally { for (const [signal, handler] of handlers) process.removeListener(signal, handler); - releaseEngineLock?.(); + if (escalationTimer) clearTimeout(escalationTimer); + if (interruptedProcessPid) await forceTerminateProcessTree(interruptedProcessPid); + if (engineWorkspace) { + try { + await removeEngineWorkspace(engineWorkspace.workspace); + } catch (error) { + console.error(`[WARN] Could not remove the temporary engine workspace: ${error.message}`); + } + } } } diff --git a/scripts/tauri-command.node-test.js b/scripts/tauri-command.node-test.js index 8566e4a..7ebd165 100644 --- a/scripts/tauri-command.node-test.js +++ b/scripts/tauri-command.node-test.js @@ -1,11 +1,36 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; import test from 'node:test'; -import { commandUsesEngineTree } from './tauri-command.js'; +import { commandIsStandaloneBundle, commandUsesEngineTree } from './tauri-command.js'; -test('Tauri engine-consuming commands hold the shared staging lease', () => { +test('Tauri engine-consuming commands use an engine workspace', () => { assert.equal(commandUsesEngineTree(['dev']), true); assert.equal(commandUsesEngineTree(['build', '--target', 'x86_64-unknown-linux-gnu']), true); assert.equal(commandUsesEngineTree(['bundle', '--bundles', 'appimage']), true); assert.equal(commandUsesEngineTree(['info']), false); assert.equal(commandUsesEngineTree(['--help']), false); }); + +test('standalone bundle commands prepare engines before Tauri starts', () => { + assert.equal(commandIsStandaloneBundle(['bundle', '--bundles', 'app']), true); + assert.equal(commandIsStandaloneBundle(['build', '--bundles', 'app']), false); +}); + +test('the bundle hook accepts a completed wrapper preflight', () => { + const result = spawnSync( + process.execPath, + [path.join(import.meta.dirname, 'before-tauri-bundle.js')], + { + cwd: path.join(import.meta.dirname, '..'), + env: { + ...process.env, + FIRELINK_ENGINE_BUNDLE_PREPARED: '1', + FIRELINK_SKIP_ENGINE_RESOURCE: '', + FIRELINK_ENGINE_OUTPUT_ROOT: '', + }, + stdio: 'pipe', + }, + ); + assert.equal(result.status, 0, result.stderr.toString()); +}); diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index 0a8fe70..7767a00 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -6,9 +6,9 @@ import net from 'node:net'; import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { - acquireExclusiveFileLock, - assertExclusiveFileLockHeld, -} from './engine-staging-lock.js'; + resolveOutputRoot, + resolveTargetTriple, +} from './engine-workspace.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -33,9 +33,7 @@ if (!currentArch || !currentPlatform) { process.exit(1); } -const targetTriple = argValue('--target') - || process.env.FIRELINK_TARGET_TRIPLE - || `${currentArch}-${currentPlatform}`; +const targetTriple = resolveTargetTriple(); const hostTriple = `${currentArch}-${currentPlatform}`; const canExecuteTarget = targetTriple === hostTriple; const isWindows = targetTriple.includes('windows'); @@ -85,7 +83,7 @@ function findEngineRoot(root) { const configuredRoot = argValue('--root') || (process.argv.includes('--staged') - ? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple) + ? path.join(resolveOutputRoot(), targetTriple) : searchRoot ? findEngineRoot(searchRoot) : null); @@ -94,19 +92,6 @@ const binariesDir = configuredRoot : path.join(scriptsDir, '..', 'src-tauri', 'binaries'); const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']; const stagedVerification = process.argv.includes('--staged'); -const stagingLockPath = path.join(scriptsDir, '..', 'src-tauri', 'engine-dist.lock'); -const inheritedLockPid = process.env.FIRELINK_ENGINE_STAGING_LOCK_PID; -const inheritedLockToken = process.env.FIRELINK_ENGINE_STAGING_LOCK_TOKEN; -const inheritedStagingLock = inheritedLockPid !== undefined || inheritedLockToken !== undefined; -if (stagedVerification && inheritedStagingLock) { - assertExclusiveFileLockHeld(stagingLockPath, { - pid: Number(inheritedLockPid), - token: inheritedLockToken, - }); -} -const releaseStagingLock = stagedVerification && !inheritedStagingLock - ? await acquireExclusiveFileLock(stagingLockPath) - : null; const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar']; const FORBIDDEN_STDERR = [ @@ -189,7 +174,6 @@ for (const eng of requiredEngines) { if (exitCode !== 0) { console.error('\nAborting: missing required sidecars.'); - releaseStagingLock?.(); process.exit(1); } @@ -602,7 +586,6 @@ if (canExecuteTarget) { // ───── Result ───── console.log(''); -releaseStagingLock?.(); if (exitCode !== 0) { console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`); process.exit(1); diff --git a/src-tauri/build.rs b/src-tauri/build.rs index a270b53..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,5 +1,3 @@ fn main() { - std::fs::create_dir_all("engine-dist") - .expect("failed to create generated engine resource directory"); tauri_build::build() } diff --git a/src-tauri/src/engines.rs b/src-tauri/src/engines.rs index 5da82d8..9daebe2 100644 --- a/src-tauri/src/engines.rs +++ b/src-tauri/src/engines.rs @@ -8,6 +8,23 @@ pub fn resolve_bundled_binary_path( let binary_name = crate::platform::engine_binary_name(engine); let target = crate::platform::target_triple(); + #[cfg(debug_assertions)] + if let Some(runtime_root) = std::env::var_os("FIRELINK_ENGINE_RUNTIME_ROOT") { + for candidate in runtime_candidates(Path::new(&runtime_root), &target, &binary_name) { + if candidate.is_file() { + let absolute = candidate.canonicalize().map_err(|error| { + format!("Failed to canonicalize '{}': {error}", candidate.display()) + })?; + log::info!( + "Resolved development engine '{}' for target '{}'", + engine, + target + ); + return Ok(absolute); + } + } + } + if let Ok(resource_dir) = app_handle.path().resource_dir() { for candidate in packaged_candidates(&resource_dir, &target, &binary_name) { if candidate.is_file() { @@ -119,6 +136,11 @@ fn executable_relative_candidates( candidates } +#[cfg(any(debug_assertions, test))] +fn runtime_candidates(root: &Path, target: &str, binary_name: &str) -> Vec { + vec![root.join(target).join(binary_name)] +} + #[cfg(any(debug_assertions, test))] fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec { let roots = [cwd.to_path_buf(), cwd.join("src-tauri")]; @@ -163,7 +185,10 @@ fn aria2_openssl_modules_dir(binary_path: &Path) -> Option { #[cfg(test)] mod tests { - use super::{development_candidates, development_candidates_for_runtime, packaged_candidates}; + use super::{ + development_candidates, development_candidates_for_runtime, packaged_candidates, + runtime_candidates, + }; use std::path::Path; #[test] @@ -194,6 +219,21 @@ mod tests { ); } + #[test] + fn configured_development_layout_is_target_scoped() { + let candidates = runtime_candidates( + Path::new("/tmp/firelink-engine-run/engine-dist"), + "x86_64-unknown-linux-gnu", + "yt-dlp-x86_64-unknown-linux-gnu", + ); + assert_eq!( + candidates[0], + Path::new( + "/tmp/firelink-engine-run/engine-dist/x86_64-unknown-linux-gnu/yt-dlp-x86_64-unknown-linux-gnu" + ) + ); + } + #[test] fn development_resolution_is_disabled_in_release_builds() { let candidates = development_candidates_for_runtime( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4246703..253d6ae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,6 +7,7 @@ "beforeDevCommand": "node scripts/stage-engines.js && npm run dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "node scripts/before-tauri-build.js", + "beforeBundleCommand": "node scripts/before-tauri-bundle.js", "frontendDist": "../dist" }, "app": { @@ -36,7 +37,6 @@ "icons/icon.ico" ], "resources": { - "engine-dist/": "engine-dist/", "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" }, "fileAssociations": [