Skip to content

Commit bb8d401

Browse files
authored
feat: strip framework binaries to avoid duplicate symbol errors (#205)
1 parent 36d58bb commit bb8d401

File tree

4 files changed

+336
-1
lines changed

4 files changed

+336
-1
lines changed

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: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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+
6+
import * as rockTools from '@rock-js/tools';
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8+
9+
import { stripFrameworkBinary } from '../stripFrameworkBinary.js';
10+
11+
vi.mock('@rock-js/tools', async (importOriginal) => {
12+
const actual = await importOriginal<typeof rockTools>();
13+
return {
14+
...actual,
15+
logger: {
16+
...actual.logger,
17+
error: vi.fn(),
18+
warn: vi.fn(),
19+
info: vi.fn(),
20+
success: vi.fn(),
21+
debug: vi.fn(),
22+
},
23+
};
24+
});
25+
26+
const mockLoggerWarn = rockTools.logger.warn as ReturnType<typeof vi.fn>;
27+
const mockLoggerSuccess = rockTools.logger.success as ReturnType<typeof vi.fn>;
28+
29+
function createMockXcframework(
30+
baseDir: string,
31+
name: string,
32+
slices: string[]
33+
): string {
34+
const xcframeworkPath = path.join(baseDir, `${name}.xcframework`);
35+
fs.mkdirSync(xcframeworkPath, { recursive: true });
36+
37+
for (const slice of slices) {
38+
const frameworkDir = path.join(xcframeworkPath, slice, `${name}.framework`);
39+
fs.mkdirSync(frameworkDir, { recursive: true });
40+
41+
const binaryPath = path.join(frameworkDir, name);
42+
fs.writeFileSync(binaryPath, 'fake binary content for testing');
43+
}
44+
45+
return xcframeworkPath;
46+
}
47+
48+
describe('stripFrameworkBinary', () => {
49+
let tempDir: string;
50+
51+
beforeEach(() => {
52+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'strip-framework-test-'));
53+
vi.clearAllMocks();
54+
});
55+
56+
afterEach(() => {
57+
fs.rmSync(tempDir, { recursive: true, force: true });
58+
});
59+
60+
it('throws when xcframework does not exist', () => {
61+
const nonExistentPath = path.join(tempDir, 'NonExistent.xcframework');
62+
63+
expect(() => stripFrameworkBinary(nonExistentPath)).toThrow(
64+
'XCFramework not found at:'
65+
);
66+
});
67+
68+
it('strips binary from ios-arm64 slice', () => {
69+
const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [
70+
'ios-arm64',
71+
]);
72+
const binaryPath = path.join(
73+
xcframeworkPath,
74+
'ios-arm64',
75+
'TestFramework.framework',
76+
'TestFramework'
77+
);
78+
const originalContent = fs.readFileSync(binaryPath, 'utf-8');
79+
80+
stripFrameworkBinary(xcframeworkPath);
81+
82+
const newContent = fs.readFileSync(binaryPath);
83+
expect(newContent.toString()).not.toBe(originalContent);
84+
expect(fs.existsSync(binaryPath)).toBe(true);
85+
expect(mockLoggerSuccess).toHaveBeenCalledWith(
86+
'TestFramework.xcframework is now interface-only'
87+
);
88+
});
89+
90+
it('strips binary from simulator slice with fat binary', () => {
91+
const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [
92+
'ios-arm64_x86_64-simulator',
93+
]);
94+
const binaryPath = path.join(
95+
xcframeworkPath,
96+
'ios-arm64_x86_64-simulator',
97+
'TestFramework.framework',
98+
'TestFramework'
99+
);
100+
const originalContent = fs.readFileSync(binaryPath, 'utf-8');
101+
102+
stripFrameworkBinary(xcframeworkPath);
103+
104+
const newContent = fs.readFileSync(binaryPath);
105+
expect(newContent.toString()).not.toBe(originalContent);
106+
107+
const archInfo = execSync(`xcrun lipo -info "${binaryPath}"`, {
108+
encoding: 'utf-8',
109+
});
110+
expect(archInfo).toContain('arm64');
111+
expect(archInfo).toContain('x86_64');
112+
});
113+
114+
it('handles multiple slices', () => {
115+
const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [
116+
'ios-arm64',
117+
'ios-arm64_x86_64-simulator',
118+
]);
119+
120+
stripFrameworkBinary(xcframeworkPath);
121+
122+
const deviceBinary = path.join(
123+
xcframeworkPath,
124+
'ios-arm64',
125+
'TestFramework.framework',
126+
'TestFramework'
127+
);
128+
const simBinary = path.join(
129+
xcframeworkPath,
130+
'ios-arm64_x86_64-simulator',
131+
'TestFramework.framework',
132+
'TestFramework'
133+
);
134+
135+
expect(fs.existsSync(deviceBinary)).toBe(true);
136+
expect(fs.existsSync(simBinary)).toBe(true);
137+
expect(mockLoggerSuccess).toHaveBeenCalledOnce();
138+
});
139+
140+
it('warns and skips unknown slice types', () => {
141+
const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [
142+
'ios-unknown-slice',
143+
]);
144+
145+
stripFrameworkBinary(xcframeworkPath);
146+
147+
expect(mockLoggerWarn).toHaveBeenCalledWith(
148+
'Unknown slice type: ios-unknown-slice, skipping'
149+
);
150+
});
151+
152+
it('warns and skips slices without binary', () => {
153+
const xcframeworkPath = path.join(tempDir, 'TestFramework.xcframework');
154+
const frameworkDir = path.join(
155+
xcframeworkPath,
156+
'ios-arm64',
157+
'TestFramework.framework'
158+
);
159+
fs.mkdirSync(frameworkDir, { recursive: true });
160+
161+
stripFrameworkBinary(xcframeworkPath);
162+
163+
expect(mockLoggerWarn).toHaveBeenCalledWith(
164+
expect.stringContaining('No binary found at')
165+
);
166+
});
167+
168+
it('ignores non-ios directories', () => {
169+
const xcframeworkPath = createMockXcframework(tempDir, 'TestFramework', [
170+
'ios-arm64',
171+
]);
172+
fs.mkdirSync(path.join(xcframeworkPath, 'macos-arm64'), {
173+
recursive: true,
174+
});
175+
176+
stripFrameworkBinary(xcframeworkPath);
177+
178+
expect(mockLoggerSuccess).toHaveBeenCalledOnce();
179+
});
180+
});
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)