diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8135f7e259d..67640a7e3d7 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -9,7 +9,9 @@ import { createStageWorkspaceConfig, createStagePnpmConfig, DESKTOP_ASAR_UNPACK, + parseFfiRsLockfileVersion, resolveDesktopRuntimeDependencies, + resolveFfiRsNativeDependencies, resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, @@ -171,8 +173,55 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); - it("unpacks the fff shared library for filesystem and FFI access", () => { - assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); + it("unpacks the fff shared library and ffi-rs native bindings for FFI access", () => { + assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, [ + "node_modules/@ff-labs/fff-bin-*/**/*", + "node_modules/@yuuang/ffi-rs-*/**/*", + ]); + }); + + it("promotes the target ffi-rs native binding to a direct staged dependency", () => { + assert.deepStrictEqual(resolveFfiRsNativeDependencies("mac", "arm64", "1.3.2"), { + "@yuuang/ffi-rs-darwin-arm64": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("mac", "universal", "1.3.2"), { + "@yuuang/ffi-rs-darwin-arm64": "1.3.2", + "@yuuang/ffi-rs-darwin-x64": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("win", "x64", "1.3.2"), { + "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("win", "arm64", "1.3.2"), { + "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("linux", "arm64", "1.3.2"), { + "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", + "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", + }); + }); + + it("reads the single resolved ffi-rs version from the lockfile", () => { + const lockfile = [ + "packages:", + " fetch-nodeshim@0.4.10: {}", + " ffi-rs@1.3.2:", + " resolution: {integrity: sha512-deadbeef==}", + " fill-range@7.1.1:", + "snapshots:", + " ffi-rs@1.3.2:", + " optionalDependencies:", + " '@yuuang/ffi-rs-win32-x64-msvc': 1.3.2", + "", + ].join("\n"); + assert.equal(parseFfiRsLockfileVersion(lockfile), "1.3.2"); + }); + + it("fails when ffi-rs is absent or resolves to multiple versions", () => { + assert.throws(() => parseFfiRsLockfileVersion("packages:\n effect@4.0.0: {}\n"), /Could not find ffi-rs/); + assert.throws( + () => parseFfiRsLockfileVersion("packages:\n ffi-rs@1.3.2:\n ffi-rs@1.4.0:\n"), + /single ffi-rs version/, + ); }); it("promotes target fff binaries to direct staged dependencies", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6b519b1d4e3..44e126c152a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -291,7 +291,13 @@ interface StagePackageJson { } export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; -export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; +export const DESKTOP_ASAR_UNPACK = [ + "node_modules/@ff-labs/fff-bin-*/**/*", + // ffi-rs (used by @ff-labs/fff-node) loads its prebuilt native addon from a + // platform-specific @yuuang/ffi-rs-* package. The `.node` must live outside + // the asar archive so Electron can dlopen it at runtime. + "node_modules/@yuuang/ffi-rs-*/**/*", +] as const; export function resolveFffNativeDependencies( platform: typeof BuildPlatform.Type, @@ -319,6 +325,88 @@ export function resolveFffNativeDependencies( ); } +export function resolveFfiRsNativeDependencies( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, + version: string, +): Record { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return Object.fromEntries( + architectures.map((architecture) => [`@yuuang/ffi-rs-darwin-${architecture}`, version]), + ); + } + + if (platform === "win") { + return Object.fromEntries( + architectures.map((architecture) => [`@yuuang/ffi-rs-win32-${architecture}-msvc`, version]), + ); + } + + return Object.fromEntries( + architectures.flatMap((architecture) => + ["gnu", "musl"].map((libc) => [`@yuuang/ffi-rs-linux-${architecture}-${libc}`, version]), + ), + ); +} + +// ffi-rs is a transitive dependency (via @ff-labs/fff-node), so its version is +// not declared in any package.json. Its prebuilt @yuuang/ffi-rs-* bindings are +// published in lockstep with ffi-rs, so we read the resolved version from the +// lockfile to pin the binding we promote into the staged install. +export function parseFfiRsLockfileVersion(lockfileContents: string): string { + const versions = new Set(); + const pattern = /(?:^|\n) {2}ffi-rs@([^:\s(]+):/g; + for (const match of lockfileContents.matchAll(pattern)) { + const version = match[1]; + if (version !== undefined) { + versions.add(version); + } + } + + const uniqueVersions = [...versions]; + if (uniqueVersions.length > 1) { + throw new Error( + `Expected a single ffi-rs version in pnpm-lock.yaml but found: ${uniqueVersions.sort().join(", ")}.`, + ); + } + + const [version] = uniqueVersions; + if (version === undefined) { + throw new Error("Could not find ffi-rs in pnpm-lock.yaml."); + } + + return version; +} + +const resolveFfiRsBindingVersion = Effect.fn("resolveFfiRsBindingVersion")(function* ( + repoRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lockfileContents = yield* fs + .readFileString(path.join(repoRoot, "pnpm-lock.yaml")) + .pipe( + Effect.mapError( + (cause) => + new BuildScriptError({ + message: "Could not read pnpm-lock.yaml to resolve ffi-rs native bindings.", + cause, + }), + ), + ); + + return yield* Effect.try({ + try: () => parseFfiRsLockfileVersion(lockfileContents), + catch: (cause) => + new BuildScriptError({ + message: "Could not resolve the ffi-rs native binding version from pnpm-lock.yaml.", + cause, + }), + }); +}); + export function createStageWorkspaceConfig( platform: typeof BuildPlatform.Type, arch: typeof BuildArch.Type, @@ -956,14 +1044,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const fffNodeVersion = serverPackageJson.dependencies["@ff-labs/fff-node"]; + const ffiRsNativeDependencies = fffNodeVersion + ? resolveFfiRsNativeDependencies( + options.platform, + options.arch, + yield* resolveFfiRsBindingVersion(repoRoot), + ) + : {}; const stageDependencies = { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), + ...resolveFffNativeDependencies(options.platform, options.arch, fffNodeVersion), + ...ffiRsNativeDependencies, }; const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); const stagePackageJson: StagePackageJson = {