From 7d0e0a7ccbd42a8892e833a2ca2c13569f81cd45 Mon Sep 17 00:00:00 2001 From: Vladislav Cusnir Date: Tue, 20 Oct 2026 11:40:11 +0300 Subject: [PATCH] fix(plugin-brownfield-ios): keep relative symlinks when copying XCFrameworks fs.cpSync rewrites relative symlinks into absolute paths unless verbatimSymlinks is set. Versioned framework slices, such as the Mac Catalyst slice of hermesvm.xcframework, then point into the packaging machine's Pods folder, and SwiftPM refuses to extract a binary target whose symlinks resolve outside the archive. Fixes #740 Co-Authored-By: Claude Opus 5.5 --- .changeset/quiet-links-stay.md | 6 ++ .../src/lib/utils/mergeFrameworks.ts | 6 +- .../__tests__/copyHermesXcframework.test.ts | 71 +++++++++++++++++++ .../src/lib/copyHermesXcframework.ts | 2 +- .../src/lib/copyReactXcframeworks.ts | 2 + 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-links-stay.md create mode 100644 packages/plugin-brownfield-ios/src/lib/__tests__/copyHermesXcframework.test.ts diff --git a/.changeset/quiet-links-stay.md b/.changeset/quiet-links-stay.md new file mode 100644 index 000000000..e70d36d81 --- /dev/null +++ b/.changeset/quiet-links-stay.md @@ -0,0 +1,6 @@ +--- +'@rock-js/plugin-brownfield-ios': patch +'@rock-js/platform-apple-helpers': patch +--- + +Preserve relative symlinks when copying XCFrameworks during `package:ios`. `fs.cpSync` rewrote them into absolute paths on the packaging machine, so versioned framework slices (such as the Mac Catalyst slice of `hermesvm.xcframework`) shipped symlinks pointing outside the bundle, which SwiftPM refuses to extract from a binary target archive. diff --git a/packages/platform-apple-helpers/src/lib/utils/mergeFrameworks.ts b/packages/platform-apple-helpers/src/lib/utils/mergeFrameworks.ts index b7555e7c1..964b436e9 100644 --- a/packages/platform-apple-helpers/src/lib/utils/mergeFrameworks.ts +++ b/packages/platform-apple-helpers/src/lib/utils/mergeFrameworks.ts @@ -24,7 +24,11 @@ function copyFile(sourcePath: string, destinationPath: string) { // Copy an entire directory tree, overwriting existing content. function copyDirectory(sourcePath: string, destinationPath: string) { ensureDirectory(path.dirname(destinationPath)); - fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true }); + fs.cpSync(sourcePath, destinationPath, { + recursive: true, + force: true, + verbatimSymlinks: true, + }); } // Generate a minimal Info.plist for a synthesized framework wrapper. diff --git a/packages/plugin-brownfield-ios/src/lib/__tests__/copyHermesXcframework.test.ts b/packages/plugin-brownfield-ios/src/lib/__tests__/copyHermesXcframework.test.ts new file mode 100644 index 000000000..d68a3e645 --- /dev/null +++ b/packages/plugin-brownfield-ios/src/lib/__tests__/copyHermesXcframework.test.ts @@ -0,0 +1,71 @@ +import type * as Fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type * as Tools from '@rock-js/tools'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('node:fs'); + +const fs = await vi.importActual('node:fs'); + +vi.mock('@rock-js/tools', async (importOriginal) => ({ + ...(await importOriginal()), + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), +})); + +const { copyHermesXcframework } = await import('../copyHermesXcframework.js'); + +const CATALYST_FRAMEWORK = + 'hermesvm.xcframework/ios-arm64_x86_64-maccatalyst/hermesvm.framework'; + +describe('copyHermesXcframework', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copy-hermes-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('keeps relative symlinks of versioned framework slices relative', () => { + const sourceDir = path.join(tempDir, 'ios'); + const destinationDir = path.join(tempDir, 'package'); + const sourceFramework = path.join( + sourceDir, + 'Pods/hermes-engine/destroot/Library/Frameworks/universal', + CATALYST_FRAMEWORK, + ); + fs.mkdirSync(path.join(sourceFramework, 'Versions/1/Resources'), { + recursive: true, + }); + fs.writeFileSync(path.join(sourceFramework, 'Versions/1/hermesvm'), ''); + fs.symlinkSync('1', path.join(sourceFramework, 'Versions/Current')); + fs.symlinkSync( + 'Versions/Current/hermesvm', + path.join(sourceFramework, 'hermesvm'), + ); + fs.symlinkSync( + 'Versions/Current/Resources', + path.join(sourceFramework, 'Resources'), + ); + + copyHermesXcframework({ + sourceDir, + destinationDir, + reactNativeVersion: '0.82.0', + }); + + const copiedFramework = path.join(destinationDir, CATALYST_FRAMEWORK); + expect( + fs.readlinkSync(path.join(copiedFramework, 'Versions/Current')), + ).toBe('1'); + expect(fs.readlinkSync(path.join(copiedFramework, 'hermesvm'))).toBe( + 'Versions/Current/hermesvm', + ); + expect(fs.readlinkSync(path.join(copiedFramework, 'Resources'))).toBe( + 'Versions/Current/Resources', + ); + }); +}); diff --git a/packages/plugin-brownfield-ios/src/lib/copyHermesXcframework.ts b/packages/plugin-brownfield-ios/src/lib/copyHermesXcframework.ts index f15993466..dfa90cd95 100644 --- a/packages/plugin-brownfield-ios/src/lib/copyHermesXcframework.ts +++ b/packages/plugin-brownfield-ios/src/lib/copyHermesXcframework.ts @@ -31,7 +31,7 @@ export function copyHermesXcframework({ `Pods/hermes-engine/destroot/Library/Frameworks/universal/${hermesFrameworkName}`, ), hermesDestination, - { recursive: true, force: true }, + { recursive: true, force: true, verbatimSymlinks: true }, ); loader.stop(`Copied ${color.bold(hermesFrameworkName)}`); diff --git a/packages/plugin-brownfield-ios/src/lib/copyReactXcframeworks.ts b/packages/plugin-brownfield-ios/src/lib/copyReactXcframeworks.ts index 181b98e02..46be52143 100644 --- a/packages/plugin-brownfield-ios/src/lib/copyReactXcframeworks.ts +++ b/packages/plugin-brownfield-ios/src/lib/copyReactXcframeworks.ts @@ -35,6 +35,7 @@ export function copyReactXcframeworks({ fs.cpSync(reactFrameworkSource, reactDestination, { recursive: true, force: true, + verbatimSymlinks: true, }); loader.stop(`Copied ${color.bold(react)}`); @@ -56,6 +57,7 @@ export function copyReactXcframeworks({ fs.cpSync(reactNativeDepsSource, reactNativeDepsDestination, { recursive: true, force: true, + verbatimSymlinks: true, }); loader.stop(`Copied ${color.bold(reactNativeDependencies)}`);