Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,4 @@ libtokenizers
pretokenizer
repoint
repoints
basenames
2 changes: 1 addition & 1 deletion packages/react-native-executorch/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ because a factory that throws part-way now releases what it had allocated
| `tasks/` | One suite per task pipeline, plus the shared construction-failure behavior. `remainingTasks.ts` holds the pipelines that only get schema acceptance and disposal |
| `hooks/` | `useModel`, `useResourceDownload`, and the task hooks end to end |
| `extensions/` | The pure-TypeScript helpers: box/point scaling, seeded generators |
| `api/` | Export snapshot, model registry rules, label constants, source-level conventions |
| `api/` | Export snapshot, model registry rules, label constants, source-level conventions, the podspec's public headers |
| `support/` | The fake runtime, the mocks, and the fixtures |

## What is deliberately not covered
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* The public headers the iOS podspec exposes.
*
* An app that sets `use_frameworks!` - directly, as Firebase requires, or
* through expo-build-properties' `useFrameworks: "static"` - makes CocoaPods
* build this pod as a framework instead of a static library. Xcode's Headers
* build phase then copies every *public* header into one flat
* `react_native_executorch.framework/Headers/`, so two public headers sharing
* a basename become two build commands writing the same file and the build
* dies while it is still being planned:
*
* error: Multiple commands produce '.../Headers/Types.h'
*
* The source tree has 21 such basenames - `Types.h` sits in eleven task
* directories, `constants.h` in thirteen phonemis ones - so leaving every
* header public is not survivable. The podspec narrows the public set to the
* Objective-C entry points and reaches the C++ headers through
* HEADER_SEARCH_PATHS instead. See discussion #203.
*
* Nothing else notices when that narrowing is dropped or widened: the default
* setup builds the pod as a static library, where public headers are not
* copied into a framework at all, so every example app stays green while the
* release is broken for `use_frameworks!` users. The invariant is checked here
* instead, by reading the podspec.
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';

const PACKAGE_ROOT = join(__dirname, '..', '..');
const PODSPEC = join(PACKAGE_ROOT, 'react-native-executorch.podspec');

/**
* The globs assigned to `public_header_files`, in declaration order.
*
* The podspec builds the list over several statements (a base list plus the
* legacy entry point), so every `public_header_files = [...]` and
* `public_header_files += [...]` contributes. The trailing
* `s.public_header_files = public_header_files` carries no array literal and
* is skipped by the same pattern.
*/
function declaredPublicHeaderGlobs(podspec: string): string[] {
const assignments = podspec.matchAll(/public_header_files\s*\+?=\s*\[([^\]]*)\]/g);
return [...assignments].flatMap((assignment) =>
[...assignment[1]!.matchAll(/"([^"]+)"/g)].map((quoted) => quoted[1]!)
);
}

/**
* A CocoaPods file glob as a `RegExp` over paths relative to the package root.
*
* Covers the constructs the podspec uses - `**` for any depth, `*` within one
* segment, `{h,mm}` alternatives - and throws on anything else rather than
* quietly matching nothing, which would turn a real collision into a passing
* test.
*/
function globToRegExp(glob: string): RegExp {
if (/[?[\]!]/.test(glob)) {
throw new Error(`unsupported glob syntax in podspec pattern: ${glob}`);
}
const segments = glob.split('/');
const pattern = segments.flatMap((segment, index) => {
// `**` stands for any number of directories, none included, so it brings
// its own trailing separator: `a/**/b.h` has to match `a/b.h` as well.
if (segment === '**') return ['(?:[^/]+/)*'];
const escaped = segment
.replace(/[.+^$()|\\]/g, '\\$&')
.replace(
/\{([^{}]*)\}/g,
(_, alternatives: string) => `(?:${alternatives.split(',').join('|')})`
)
.replace(/\*/g, '[^/]*');
return index < segments.length - 1 ? [escaped, '/'] : [escaped];
});
return new RegExp(`^${pattern.join('')}$`);
}

/** Every file under `directory`, as a path relative to the package root. */
function filesUnder(directory: string, prefix: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const full = join(directory, entry);
const relative = `${prefix}/${entry}`;
return statSync(full).isDirectory() ? filesUnder(full, relative) : [relative];
});
}

/**
* The files a glob matches. Only the top-level directory the glob names is
* walked, so the phonemis submodule is never read: it holds no public header,
* and CI installs without checking it out.
*/
function matches(glob: string): string[] {
const root = glob.split('/')[0]!;
const rootPath = join(PACKAGE_ROOT, root);
if (!existsSync(rootPath)) return [];
const regexp = globToRegExp(glob);
const candidates = statSync(rootPath).isDirectory() ? filesUnder(rootPath, root) : [root];
return candidates.filter((file) => regexp.test(file));
}

const globs = declaredPublicHeaderGlobs(readFileSync(PODSPEC, 'utf8'));
const publicHeaders = new Map(globs.map((glob) => [glob, matches(glob)]));

describe('podspec public headers', () => {
it('narrows the public set with public_header_files', () => {
// Without the attribute CocoaPods promotes every header in `source_files`,
// which is the state that broke `use_frameworks!` builds in 0.10.0.
expect(globs).not.toHaveLength(0);
});

it('declares no glob that matches nothing', () => {
// A moved or renamed entry point would otherwise leave the framework with
// no importable header, and no failing build until an app tried to use it.
const empty = [...publicHeaders]
.filter(([, files]) => files.length === 0)
.map(([glob]) => glob);
expect(empty).toEqual([]);
});

it('gives every public header a unique basename', () => {
const byBasename = new Map<string, string[]>();
for (const file of [...publicHeaders.values()].flat()) {
const basename = file.split('/').pop()!;
byBasename.set(basename, [...(byBasename.get(basename) ?? []), file]);
}

// Each entry here is one `Multiple commands produce` error in an app built
// with `use_frameworks!`. Rename the header, or drop it from
// `public_header_files` when nothing outside the pod imports it.
const collisions = Object.fromEntries([...byBasename].filter(([, files]) => files.length > 1));
expect(collisions).toEqual({});
});
});
41 changes: 41 additions & 0 deletions packages/react-native-executorch/react-native-executorch.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,34 @@ Pod::Spec.new do |s|
exclude_files += phonemis_source_files unless enable_phonemis
s.exclude_files = exclude_files

# --- Public headers ---
# `use_frameworks!` - set directly for Firebase, or through
# expo-build-properties' `useFrameworks: "static"` - makes CocoaPods build
# this pod as a framework, and Xcode's Headers build phase copies every
# *public* header into one flat `Headers/` directory. With no
# `public_header_files` every header in `source_files` is public, and the
# tree has 21 basenames that occur more than once (`Types.h` in eleven task
# directories, `constants.h` in thirteen phonemis ones), so the build fails
# at planning with `Multiple commands produce .../Headers/Types.h` before a
# single file compiles. See discussion #203.
#
# Only the Objective-C entry points have to be visible to the app. The C++
# headers are reached through the HEADER_SEARCH_PATHS below, so narrowing the
# public set costs nothing, and `__tests__/api/podspecPublicHeaders.test.ts`
# keeps it collision-free.
public_header_files = [
"ios/**/*.h",
]
# ==============================================================================
# LEGACY SUPPORT: include the legacy entry point
# (Remove when react-native-executorch/legacy is dropped)
# ==============================================================================
public_header_files += [
"legacy/ios/**/*.h",
]
# ==============================================================================
s.public_header_files = public_header_files

# --- Preprocessor flags ---
extra_compiler_flags = []
extra_compiler_flags << "-DRNE_ENABLE_OPENCV" if enable_opencv
Expand Down Expand Up @@ -189,6 +217,19 @@ Pod::Spec.new do |s|
# resource path. `s.ios.resource` achieves that via CocoaPods' resource copy.
s.ios.resource = "third-party/ios/libs/executorch/mlx.metallib" if enable_mlx

# An app that turns on `use_frameworks!` without naming a linkage gets the
# default, dynamic - which is what Firebase's own setup instructions show.
# CocoaPods then refuses to install at all, because a dynamic framework may
# not carry statically linked binaries and opencv-rne vendors one:
#
# [!] The 'Pods-YourApp' target has transitive dependencies that include
# statically linked binaries: (.../opencv-rne/opencv2.xcframework)
#
# Declaring the pod a static framework resolves that without the app having
# to spell out `:linkage => :static`, and is inert when the pod is built as a
# static library, which is what happens with no `use_frameworks!` at all.
s.static_framework = true

# Backend xcframeworks are linked via force_load in OTHER_LDFLAGS (needed to
# preserve __attribute__((constructor)) backend registrations). Only
# ExecutorchLib goes in vendored_frameworks to avoid duplicate symbol errors.
Expand Down