Skip to content

Commit b66b16e

Browse files
committed
feat: strip framework binaries to avoid duplicate symbol errors
1 parent ae21134 commit b66b16e

3 files changed

Lines changed: 156 additions & 1 deletion

File tree

packages/cli/src/brownfield/commands/packageIos.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Command } from 'commander';
1717

1818
import { isBrownieInstalled } from '../../brownie/config.js';
1919
import { runCodegen } from '../../brownie/commands/codegen.js';
20-
import { getProjectInfo } from '../utils/index.js';
20+
import { getProjectInfo, stripFrameworkBinary } from '../utils/index.js';
2121
import {
2222
actionRunner,
2323
curryOptions,
@@ -101,6 +101,11 @@ export const packageIosCommand = curryOptions(
101101
outputPath: brownieOutputPath,
102102
});
103103

104+
// Strip the binary from Brownie.xcframework to make it interface-only.
105+
// This avoids duplicate symbols when consumer apps embed both BrownfieldLib
106+
// (which contains Brownie symbols) and Brownie.xcframework.
107+
await stripFrameworkBinary(brownieOutputPath);
108+
104109
logger.success(
105110
`Brownie.xcframework created at ${colorLink(relativeToCwd(brownieOutputPath))}`
106111
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './paths.js';
22
export * from './rn-cli.js';
3+
export * from './stripFrameworkBinary.js';
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { execSync } from 'node:child_process';
5+
import { logger } from '@rock-js/tools';
6+
7+
interface SliceConfig {
8+
target: string;
9+
/** Additional targets for fat binaries */
10+
additionalTargets?: string[];
11+
}
12+
13+
const SLICE_CONFIGS: Record<string, SliceConfig> = {
14+
'ios-arm64': {
15+
target: 'arm64-apple-ios15.0',
16+
},
17+
'ios-arm64_x86_64-simulator': {
18+
target: 'arm64-apple-ios15.0-simulator',
19+
additionalTargets: ['x86_64-apple-ios15.0-simulator'],
20+
},
21+
};
22+
23+
/**
24+
* Creates an empty static library for the given target.
25+
*/
26+
function createEmptyStaticLib(target: string): string {
27+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'framework-strip-'));
28+
const tempObj = path.join(tempDir, 'empty.o');
29+
const tempLib = path.join(tempDir, 'empty.a');
30+
31+
try {
32+
execSync(
33+
`echo "" | xcrun clang -x c -c - -o "${tempObj}" -target ${target}`,
34+
{
35+
stdio: 'pipe',
36+
}
37+
);
38+
39+
execSync(`xcrun ar rcs "${tempLib}" "${tempObj}"`, {
40+
stdio: 'pipe',
41+
});
42+
} catch (error) {
43+
fs.rmSync(tempDir, { recursive: true });
44+
throw new Error(
45+
`Failed to create empty static library for target ${target}: ${error instanceof Error ? error.message : error}`
46+
);
47+
}
48+
49+
fs.unlinkSync(tempObj);
50+
51+
return tempLib;
52+
}
53+
54+
/**
55+
* Creates a fat static library combining multiple architectures.
56+
*/
57+
function createFatStaticLib(targets: string[]): string {
58+
const libs = targets.map((target) => createEmptyStaticLib(target));
59+
60+
const outputDir = fs.mkdtempSync(
61+
path.join(os.tmpdir(), 'framework-strip-fat-')
62+
);
63+
const outputLib = path.join(outputDir, 'fat.a');
64+
65+
try {
66+
execSync(
67+
`xcrun lipo -create ${libs.map((l) => `"${l}"`).join(' ')} -output "${outputLib}"`,
68+
{
69+
stdio: 'pipe',
70+
}
71+
);
72+
} catch (error) {
73+
libs.forEach((lib) => fs.rmSync(path.dirname(lib), { recursive: true }));
74+
fs.rmSync(outputDir, { recursive: true });
75+
throw new Error(
76+
`Failed to create fat static library: ${error instanceof Error ? error.message : error}`
77+
);
78+
}
79+
80+
libs.forEach((lib) => {
81+
fs.unlinkSync(lib);
82+
fs.rmSync(path.dirname(lib), { recursive: true });
83+
});
84+
85+
return outputLib;
86+
}
87+
88+
/**
89+
* Strips the binary from an xcframework, keeping only Swift module interfaces.
90+
* This creates an "interface-only" framework where consumers can import the module
91+
* but the actual symbols must come from another framework (e.g., BrownfieldLib).
92+
*
93+
* @param xcframeworkPath - Path to the .xcframework directory
94+
*/
95+
export function stripFrameworkBinary(xcframeworkPath: string): void {
96+
if (!fs.existsSync(xcframeworkPath)) {
97+
throw new Error(`XCFramework not found at: ${xcframeworkPath}`);
98+
}
99+
100+
const frameworkName = path.basename(xcframeworkPath, '.xcframework');
101+
102+
logger.info(
103+
`Stripping binary from ${frameworkName}.xcframework (interface-only)...`
104+
);
105+
106+
const slices = fs.readdirSync(xcframeworkPath).filter((entry) => {
107+
const fullPath = path.join(xcframeworkPath, entry);
108+
return fs.statSync(fullPath).isDirectory() && entry.startsWith('ios-');
109+
});
110+
111+
for (const sliceName of slices) {
112+
const frameworkDir = path.join(
113+
xcframeworkPath,
114+
sliceName,
115+
`${frameworkName}.framework`
116+
);
117+
const binaryPath = path.join(frameworkDir, frameworkName);
118+
119+
if (!fs.existsSync(binaryPath)) {
120+
logger.warn(`No binary found at ${binaryPath}, skipping`);
121+
continue;
122+
}
123+
124+
const config = SLICE_CONFIGS[sliceName];
125+
if (!config) {
126+
logger.warn(`Unknown slice type: ${sliceName}, skipping`);
127+
continue;
128+
}
129+
130+
let emptyLib: string;
131+
if (config.additionalTargets) {
132+
// Create fat library for multiple architectures
133+
emptyLib = createFatStaticLib([
134+
config.target,
135+
...config.additionalTargets,
136+
]);
137+
} else {
138+
// Create single-arch library
139+
emptyLib = createEmptyStaticLib(config.target);
140+
}
141+
142+
// Replace original binary with empty stub
143+
fs.copyFileSync(emptyLib, binaryPath);
144+
fs.unlinkSync(emptyLib);
145+
fs.rmSync(path.dirname(emptyLib), { recursive: true });
146+
}
147+
148+
logger.success(`${frameworkName}.xcframework is now interface-only`);
149+
}

0 commit comments

Comments
 (0)