expo-modules-autolinking 55.0.23 → 55.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 55.0.24 — 2026-05-21
14
+
15
+ ### 🎉 New features
16
+
17
+ - [iOS] Include and consume shared SPM dependencies in the precompiled pod / npm publish pipeline. ([#46069](https://github.com/expo/expo/pull/46069) by [@chrfalch](https://github.com/chrfalch))
18
+
13
19
  ## 55.0.23 — 2026-05-19
14
20
 
15
21
  ### 🐛 Bug fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-autolinking",
3
- "version": "55.0.23",
3
+ "version": "55.0.24",
4
4
  "description": "Scripts that autolink Expo modules.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -43,5 +43,5 @@
43
43
  "chalk": "^4.1.0",
44
44
  "commander": "^7.2.0"
45
45
  },
46
- "gitHead": "fcb091766242d53248cd3c5949965961dbc5ec1d"
46
+ "gitHead": "bb1d4bd298e5bcaff86b04aabca7c56659e57138"
47
47
  }
@@ -54,6 +54,11 @@ module Expo
54
54
  # Centralized build output directory under packages/precompile/
55
55
  PRECOMPILE_BUILD_DIR = '.build'.freeze
56
56
 
57
+ # Subdir under packages/precompile/.build/ holding <Name>/<flavor>/<Name>.xcframework (monorepo source).
58
+ SHARED_SPM_DEPS_SOURCE_DIR = '.spm-deps'.freeze
59
+ # Subdir inside an npm package that bundles shared SPM xcframeworks for standalone consumers.
60
+ BUNDLED_SHARED_SPM_DEPS_SUBPATH = File.join('prebuilds', 'spm-deps').freeze
61
+
57
62
  # Apple platforms supported by CocoaPods podspecs
58
63
  APPLE_PLATFORMS = %w[ios osx tvos watchos visionos].freeze
59
64
 
@@ -185,10 +190,14 @@ module Expo
185
190
  # - Pods that vendor xcframeworks (already precompiled)
186
191
  # - Source-built pods that depend on React-Core (non-modular includes)
187
192
  #
193
+ # Also stages shared SPM dep xcframework symlinks inside their owner pod's
194
+ # directory — must run before `generate_pods_project` reads each xcframework's
195
+ # Info.plist to slice it.
196
+ #
188
197
  # @param installer [Pod::Installer] The CocoaPods installer instance
189
198
  def perform_pre_install(installer)
190
199
  return unless enabled?
191
- return unless prebuilt_react_active?
200
+ ensure_shared_spm_deps(installer)
192
201
  return if linkage(installer).nil?
193
202
 
194
203
  pods_to_downgrade = Set.new(installer.podfile.framework_modules_to_patch)
@@ -212,6 +221,63 @@ module Expo
212
221
  end
213
222
  end
214
223
 
224
+ # Symlinks each shared SPM dependency xcframework (e.g. SDWebImage) into the
225
+ # pod directory of its owner. Ownership is whatever `build_vendored_paths` set
226
+ # in `@framework_owner_map` during `store_podspec` (resolution-first);
227
+ # falls back to alphabetical-first only if the map has no entry. Must run
228
+ # before `generate_pods_project` so CocoaPods sees the symlinks when reading
229
+ # each xcframework's Info.plist.
230
+ def ensure_shared_spm_deps(installer)
231
+ return unless enabled?
232
+
233
+ consumers_by_dep = collect_shared_spm_deps(installer)
234
+ return if consumers_by_dep.empty?
235
+
236
+ @framework_owner_map ||= {}
237
+ @claimed_vendored_frameworks ||= Set.new
238
+
239
+ unresolved = []
240
+ staged = 0
241
+
242
+ consumers_by_dep.each do |dep_name, consumers|
243
+ existing = @framework_owner_map[dep_name]
244
+ owner_name = (existing && consumers.key?(existing)) ? existing : consumers.keys.sort.first
245
+ owner_info = consumers[owner_name]
246
+ @framework_owner_map[dep_name] ||= owner_name
247
+ @claimed_vendored_frameworks.add(dep_name)
248
+
249
+ source_path = shared_spm_dep_xcframework_path(dep_name, owner_info, build_flavor)
250
+ owner_pod_dir = File.join(installer.sandbox.root, owner_name)
251
+ unless source_path && File.directory?(owner_pod_dir)
252
+ unresolved << dep_name
253
+ next
254
+ end
255
+
256
+ FileUtils.rm_rf(File.join(owner_pod_dir, "#{dep_name}.xcframework"))
257
+ File.symlink(source_path, File.join(owner_pod_dir, "#{dep_name}.xcframework"))
258
+ staged += 1
259
+ end
260
+
261
+ if unresolved.any?
262
+ Pod::UI.warn "[Expo-precompiled] Shared SPM xcframeworks not found for: #{unresolved.join(', ')} (flavor: #{build_flavor}). The Expo modules that depend on them will fail at runtime with dyld 'Library not loaded: @rpath/<Name>.framework/<Name>'. Run the precompile prebuild pipeline, or ensure each consuming npm package ships prebuilds/spm-deps/<Name>/<flavor>/<Name>.xcframework."
263
+ end
264
+
265
+ Pod::UI.puts "[Expo] ".blue + "Staged #{staged}/#{consumers_by_dep.size} shared SPM xcframework(s) (#{build_flavor})" if staged > 0
266
+ end
267
+
268
+ # dep_name => { pod_name => pod_info } for shared SPM deps consumed by enabled prebuilt pods in this install.
269
+ def collect_shared_spm_deps(installer)
270
+ by_dep = {}
271
+ installer.pod_targets.each do |pod_target|
272
+ info = pod_lookup_map[pod_target.name]
273
+ next unless info && has_prebuilt_xcframework?(pod_target.name)
274
+ (info[:spm_dependency_frameworks] || []).each do |dep_name|
275
+ (by_dep[dep_name] ||= {})[pod_target.name] = info
276
+ end
277
+ end
278
+ by_dep
279
+ end
280
+
215
281
  # ──────────────────────────────────────────────────────────────────────
216
282
  # Cache management
217
283
  # ──────────────────────────────────────────────────────────────────────
@@ -1074,15 +1140,10 @@ module Expo
1074
1140
  end
1075
1141
  end
1076
1142
 
1077
- # Builds the vendored_frameworks paths array for a prebuilt pod.
1078
- # Deduplicates shared SPM dependency frameworks across multiple prebuilt pods:
1079
- # the first pod to claim a framework "owns" it; subsequent pods skip it and
1080
- # instead get FRAMEWORK_SEARCH_PATHS pointing at the owning pod's directory.
1081
- #
1082
- # @param product_name [String] The product/module name
1083
- # @param pod_info [Hash] Package info from spm.config.json lookup
1084
- # @param pod_name [String] The pod name (for summary tracking)
1085
- # @return [Array<String>] vendored framework paths
1143
+ # Returns vendored_frameworks paths for a prebuilt pod: the product's own
1144
+ # xcframework plus any shared SPM deps this pod owns (first to claim wins;
1145
+ # non-owners get FRAMEWORK_SEARCH_PATHS instead). Shared dep entries are
1146
+ # symlinks staged by `ensure_shared_spm_deps` inside the owner's pod dir.
1086
1147
  def build_vendored_paths(product_name, pod_info, pod_name)
1087
1148
  @claimed_vendored_frameworks ||= Set.new
1088
1149
  @framework_owner_map ||= {}
@@ -1092,38 +1153,27 @@ module Expo
1092
1153
  @framework_owner_map[product_name] = pod_name
1093
1154
 
1094
1155
  (pod_info[:spm_dependency_frameworks] || []).each do |dep_name|
1095
- if @claimed_vendored_frameworks.include?(dep_name)
1096
- owner = @framework_owner_map[dep_name]
1097
- Pod::UI.puts "#{'[Expo-precompiled] '.blue}Skipping #{dep_name}.xcframework from #{pod_name} — already vendored by #{owner}"
1098
- else
1156
+ owner = (@framework_owner_map[dep_name] ||= pod_name)
1157
+ if owner == pod_name
1099
1158
  paths << "#{dep_name}.xcframework"
1100
- @claimed_vendored_frameworks.add(dep_name)
1101
- @framework_owner_map[dep_name] = pod_name
1159
+ else
1160
+ Pod::UI.puts "#{'[Expo-precompiled] '.blue}Skipping #{dep_name}.xcframework from #{pod_name} — already vendored by #{owner}"
1102
1161
  end
1103
1162
  log_spm_dependency(pod_name, dep_name)
1104
1163
  end
1105
1164
  paths
1106
1165
  end
1107
1166
 
1108
- # Returns FRAMEWORK_SEARCH_PATHS entries for shared SPM dependency frameworks
1109
- # that were claimed by another prebuilt pod. The non-owning pod needs these
1110
- # paths so the linker can find the xcframeworks at build time.
1111
- #
1112
- # @param pod_name [String] The pod name
1113
- # @param pod_info [Hash] Package info from spm.config.json lookup
1114
- # @return [Array<String>] framework search path entries
1167
+ # FRAMEWORK_SEARCH_PATHS entries for shared SPM deps claimed by another pod.
1168
+ # CocoaPods slices each xcframework into `${PODS_XCFRAMEWORKS_BUILD_DIR}/<owner>/`,
1169
+ # which is where the linker resolves the framework.
1115
1170
  def framework_search_paths_for_skipped_deps(pod_name, pod_info)
1116
- @claimed_vendored_frameworks ||= Set.new
1117
1171
  @framework_owner_map ||= {}
1118
-
1119
- paths = []
1120
- (pod_info[:spm_dependency_frameworks] || []).each do |dep_name|
1172
+ owners = (pod_info[:spm_dependency_frameworks] || []).filter_map do |dep_name|
1121
1173
  owner = @framework_owner_map[dep_name]
1122
- if owner && owner != pod_name
1123
- paths << "\"${PODS_ROOT}/#{owner}\""
1124
- end
1125
- end
1126
- paths.uniq
1174
+ owner if owner && owner != pod_name
1175
+ end.uniq
1176
+ owners.flat_map { |owner| [%("${PODS_XCFRAMEWORKS_BUILD_DIR}/#{owner}"), %("${PODS_ROOT}/#{owner}")] }
1127
1177
  end
1128
1178
 
1129
1179
  # ──────────────────────────────────────────────────────────────────────
@@ -1150,11 +1200,13 @@ module Expo
1150
1200
  package_root_var = "#{pods_parent}/#{package_root_rel}"
1151
1201
  dsym_stamp = "$(DERIVED_FILE_DIR)/expo-dsym-resolve-#{product_name}-$(CONFIGURATION).stamp"
1152
1202
 
1203
+ shared_deps = shared_dep_switch_args(spec_name, pod_info)
1204
+
1153
1205
  switch_phase = {
1154
1206
  'name' => "[Expo] Switch #{spec_name} XCFramework for build configuration",
1155
1207
  'execution_position' => 'before_compile',
1156
1208
  'input_files' => ["#{pods_parent}/#{switch_script_rel}"],
1157
- 'script' => xcframework_switch_script(product_name, xcframeworks_dir_var, switch_script_path),
1209
+ 'script' => xcframework_switch_script(product_name, xcframeworks_dir_var, switch_script_path, shared_deps),
1158
1210
  }
1159
1211
 
1160
1212
  if Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.13.0')
@@ -1197,32 +1249,55 @@ module Expo
1197
1249
  SH
1198
1250
  end
1199
1251
 
1200
- # Returns the shell script for the xcframework switch phase.
1201
- def xcframework_switch_script(product_name, xcframeworks_dir, script_path)
1202
- <<~SH
1203
- # Switch between debug/release XCFramework based on build configuration
1204
- # This script is auto-generated by expo-modules-autolinking
1205
-
1252
+ # Shell script for the xcframework switch phase. With no shared deps the
1253
+ # script short-circuits in shell when the per-pod state file matches; with
1254
+ # shared deps Node is always invoked so each dep's symlink can be repointed
1255
+ # (it has its own per-dep state file inside replace-xcframework.js).
1256
+ def xcframework_switch_script(product_name, xcframeworks_dir, script_path, shared_deps = [])
1257
+ config_detect = <<~SH.chomp
1206
1258
  CONFIG="release"
1207
1259
  if echo "$GCC_PREPROCESSOR_DEFINITIONS" | grep -q "DEBUG=1"; then
1208
1260
  CONFIG="debug"
1209
1261
  fi
1210
-
1211
- # Early exit: Skip Node.js invocation if configuration hasn't changed
1212
- # This optimization avoids ~100-200ms overhead per module on incremental builds
1213
- LAST_CONFIG_FILE="#{xcframeworks_dir}/artifacts/.last_build_configuration"
1214
- if [ -f "$LAST_CONFIG_FILE" ] && [ "$(cat "$LAST_CONFIG_FILE")" = "$CONFIG" ]; then
1215
- exit 0
1216
- fi
1217
-
1218
- # Configuration changed or first build - invoke Node.js to extract tarball
1219
- . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh"
1220
-
1221
- "$NODE_BINARY" "#{script_path}" \\
1222
- -c "$CONFIG" \\
1223
- -m "#{product_name}" \\
1224
- -x "#{xcframeworks_dir}"
1225
1262
  SH
1263
+
1264
+ if shared_deps.empty?
1265
+ <<~SH
1266
+ # Auto-generated by expo-modules-autolinking
1267
+ #{config_detect}
1268
+ LAST_CONFIG_FILE="#{xcframeworks_dir}/artifacts/.last_build_configuration"
1269
+ if [ -f "$LAST_CONFIG_FILE" ] && [ "$(cat "$LAST_CONFIG_FILE")" = "$CONFIG" ]; then
1270
+ exit 0
1271
+ fi
1272
+ . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh"
1273
+ "$NODE_BINARY" "#{script_path}" -c "$CONFIG" -m "#{product_name}" -x "#{xcframeworks_dir}"
1274
+ SH
1275
+ else
1276
+ shared_args = shared_deps.map { |arg| " #{arg}" }.join(" \\\n")
1277
+ <<~SH
1278
+ # Auto-generated by expo-modules-autolinking
1279
+ #{config_detect}
1280
+ . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh"
1281
+ "$NODE_BINARY" "#{script_path}" \\
1282
+ -c "$CONFIG" \\
1283
+ -m "#{product_name}" \\
1284
+ -x "#{xcframeworks_dir}" \\
1285
+ #{shared_args}
1286
+ SH
1287
+ end
1288
+ end
1289
+
1290
+ # '--shared "<Name>:<source_base>"' tokens for shared SPM deps this pod owns.
1291
+ # Non-owners reach the framework via FRAMEWORK_SEARCH_PATHS at link time.
1292
+ def shared_dep_switch_args(pod_name, pod_info)
1293
+ return [] unless pod_info && pod_info[:spm_dependency_frameworks]
1294
+ @framework_owner_map ||= {}
1295
+ pod_info[:spm_dependency_frameworks].filter_map do |dep_name|
1296
+ next nil unless @framework_owner_map[dep_name] == pod_name
1297
+ source_base = shared_spm_dep_source_base(dep_name, pod_info)
1298
+ next nil unless source_base
1299
+ %(--shared "#{dep_name}:#{source_base.gsub(/[\\"$`]/) { |c| "\\#{c}" }}")
1300
+ end
1226
1301
  end
1227
1302
 
1228
1303
  # Returns the shell script for the dSYM source map resolution phase.
@@ -1811,6 +1886,43 @@ module Expo
1811
1886
  own_resolution
1812
1887
  end
1813
1888
 
1889
+ # Candidate parent dirs (each holds <flavor>/<Name>.xcframework subtrees) for
1890
+ # a shared SPM dep. Ordered: EXPO_PRECOMPILED_MODULES_PATH override, monorepo
1891
+ # .spm-deps, then the consumer-side npm-bundled location.
1892
+ def shared_spm_dep_source_base_candidates(dep_name, pod_info)
1893
+ candidates = []
1894
+ candidates << File.join(custom_modules_path, SHARED_SPM_DEPS_SOURCE_DIR, dep_name) if custom_modules_path
1895
+ candidates << File.join(memoized_repo_root, 'packages', 'precompile', PRECOMPILE_BUILD_DIR, SHARED_SPM_DEPS_SOURCE_DIR, dep_name) if memoized_repo_root
1896
+ candidates << File.join(pod_info[:package_root], BUNDLED_SHARED_SPM_DEPS_SUBPATH, dep_name) if pod_info && pod_info[:package_root]
1897
+ candidates
1898
+ end
1899
+
1900
+ # First candidate base that has at least one flavor on disk (used to build switch-script source_base args).
1901
+ def shared_spm_dep_source_base(dep_name, pod_info)
1902
+ shared_spm_dep_source_base_candidates(dep_name, pod_info).find do |base|
1903
+ %w[debug release].any? { |f| File.directory?(File.join(base, f, "#{dep_name}.xcframework")) }
1904
+ end
1905
+ end
1906
+
1907
+ # First candidate that has the requested flavor on disk (walks all candidates so a partial monorepo doesn't shadow a complete npm bundle).
1908
+ def shared_spm_dep_xcframework_path(dep_name, pod_info, flavor)
1909
+ shared_spm_dep_source_base_candidates(dep_name, pod_info).each do |base|
1910
+ path = File.join(base, flavor, "#{dep_name}.xcframework")
1911
+ return path if File.directory?(path)
1912
+ end
1913
+ nil
1914
+ end
1915
+
1916
+ def memoized_repo_root
1917
+ return @repo_root if @memoized_repo_root_set
1918
+ begin
1919
+ @repo_root = find_repo_root
1920
+ ensure
1921
+ @memoized_repo_root_set = true
1922
+ end
1923
+ @repo_root
1924
+ end
1925
+
1814
1926
  def resolve_prebuilt_tarball(pod_info, product_name, flavor, pod_name = nil)
1815
1927
  tarball = File.join(pod_info[:build_output_dir], flavor, 'xcframeworks', "#{product_name}.tar.gz")
1816
1928
  return tarball if File.exist?(tarball)
@@ -2,52 +2,32 @@
2
2
  /**
3
3
  * Replace XCFramework for Debug/Release Configuration
4
4
  *
5
- * This script extracts the correct flavor tarball to switch between debug and release
6
- * xcframeworks. It's invoked from a CocoaPods script_phase before each compile
7
- * to ensure the correct XCFramework variant is linked.
5
+ * Per-pod product swap: extracts <xcframeworksDir>/artifacts/<module>-<config>.tar.gz
6
+ * over <Product>.xcframework, gated on <xcframeworksDir>/artifacts/.last_build_configuration.
7
+ * Sibling shared-dep symlinks under <xcframeworksDir>/ are preserved (only the product
8
+ * xcframework is wiped before re-extracting).
8
9
  *
9
- * Directory structure:
10
- * <xcframeworks_dir>/
11
- * artifacts/
12
- * <Product>-debug.tar.gz (tarball, source of truth)
13
- * <Product>-release.tar.gz (tarball, source of truth)
14
- * .last_build_configuration
15
- * <Product>.xcframework/ (real dir, extracted from tarball)
16
- * <Dependency>.xcframework/ (real dir, if any, extracted from same tarball)
10
+ * Shared-dep repoint (each --shared entry): atomically replaces
11
+ * <xcframeworksDir>/<Name>.xcframework with a symlink to <source_base>/<config>/<Name>.xcframework
12
+ * and writes <xcframeworksDir>/artifacts/<Name>.last_config. The owner pod (decided at pod
13
+ * install time by ensure_shared_spm_deps) receives --shared args for each dep it owns.
17
14
  *
18
15
  * Usage:
19
- * node replace-xcframework.js -c <CONFIG> -m <MODULE_NAME> -x <XCFRAMEWORKS_DIR>
20
- *
21
- * Arguments:
22
- * -c, --config Build configuration: "debug" or "release"
23
- * -m, --module Module/product name (used for tarball lookup and logging)
24
- * -x, --xcframeworks Path to the pod directory (Pods/<PodName>/)
25
- *
26
- * The script:
27
- * 1. Finds the tarball: <xcframeworksDir>/artifacts/<module>-<config>.tar.gz
28
- * 2. Checks artifacts/.last_build_configuration — skips if unchanged
29
- * 3. Removes all *.xcframework directories in xcframeworksDir
30
- * 4. Extracts the tarball: tar -xzf ... -C <xcframeworksDir>
31
- * 5. Writes the new config to artifacts/.last_build_configuration
16
+ * node replace-xcframework.js -c <CONFIG> -m <MODULE> -x <XCFRAMEWORKS_DIR>
17
+ * [--shared <Name>:<source_base>]...
32
18
  *
33
19
  * Based on React Native's replace-rncore-version.js pattern.
34
20
  */
35
21
 
22
+ const { spawnSync } = require('child_process');
36
23
  const fs = require('fs');
37
24
  const path = require('path');
38
- const { spawnSync } = require('child_process');
39
25
 
40
26
  const LOG_PREFIX = '[Expo XCFramework]';
41
27
 
42
- // Parse command line arguments
43
28
  function parseArgs() {
44
29
  const args = process.argv.slice(2);
45
- const result = {
46
- config: null,
47
- module: null,
48
- xcframeworksDir: null,
49
- };
50
-
30
+ const result = { config: null, module: null, xcframeworksDir: null, sharedDeps: [] };
51
31
  for (let i = 0; i < args.length; i++) {
52
32
  switch (args[i]) {
53
33
  case '-c':
@@ -62,116 +42,120 @@ function parseArgs() {
62
42
  case '--xcframeworks':
63
43
  result.xcframeworksDir = args[++i];
64
44
  break;
45
+ case '-s':
46
+ case '--shared': {
47
+ const spec = args[++i] || '';
48
+ const colon = spec.indexOf(':');
49
+ if (colon === -1) {
50
+ console.error(`${LOG_PREFIX} Invalid --shared (expected "<Name>:<source_base>"): ${spec}`);
51
+ process.exit(1);
52
+ }
53
+ result.sharedDeps.push({ name: spec.slice(0, colon), sourceBase: spec.slice(colon + 1) });
54
+ break;
55
+ }
65
56
  }
66
57
  }
67
-
68
58
  return result;
69
59
  }
70
60
 
71
- function main() {
72
- const args = parseArgs();
73
-
74
- // Validate arguments
75
- if (!args.config || !args.module || !args.xcframeworksDir) {
76
- console.error(
77
- 'Usage: replace-xcframework.js -c <CONFIG> -m <MODULE_NAME> -x <XCFRAMEWORKS_DIR>'
78
- );
79
- console.error(' -c, --config Build configuration: "debug" or "release"');
80
- console.error(' -m, --module Module/product name');
81
- console.error(' -x, --xcframeworks Path to the xcframeworks directory');
82
- process.exit(1);
83
- }
84
-
85
- // Normalize config to lowercase
86
- const configLower = args.config.toLowerCase();
87
- if (configLower !== 'debug' && configLower !== 'release') {
88
- console.error(
89
- `${LOG_PREFIX} Invalid configuration: ${args.config}. Must be "debug" or "release".`
90
- );
91
- process.exit(1);
61
+ function readState(file) {
62
+ try {
63
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim() : null;
64
+ } catch {
65
+ return null;
92
66
  }
67
+ }
93
68
 
94
- const xcframeworksDir = args.xcframeworksDir;
95
- const moduleName = args.module;
96
-
97
- // Validate xcframeworksDir exists
69
+ function processPerPodSwap(args, configLower) {
70
+ const { xcframeworksDir, module: moduleName } = args;
98
71
  if (!fs.existsSync(xcframeworksDir) || !fs.statSync(xcframeworksDir).isDirectory()) {
99
72
  console.error(`${LOG_PREFIX} ${moduleName}: Directory not found: ${xcframeworksDir}`);
100
73
  process.exit(1);
101
74
  }
102
75
 
103
- // Ensure artifacts directory exists
104
76
  const artifactsDir = path.join(xcframeworksDir, 'artifacts');
105
77
  fs.mkdirSync(artifactsDir, { recursive: true });
106
-
107
- // Find the tarball for the requested configuration (stored in artifacts/)
108
78
  const tarballPath = path.join(artifactsDir, `${moduleName}-${configLower}.tar.gz`);
109
79
  const lastConfigFile = path.join(artifactsDir, '.last_build_configuration');
110
80
 
111
- // Check if tarball exists
112
81
  if (!fs.existsSync(tarballPath)) {
113
- console.error(
114
- `${LOG_PREFIX} ${moduleName}: Tarball not found at ${tarballPath}, skipping.`
115
- );
82
+ console.error(`${LOG_PREFIX} ${moduleName}: Tarball not found at ${tarballPath}, skipping.`);
116
83
  return;
117
84
  }
118
85
 
119
- // Read last build configuration
120
- let lastConfig = null;
121
- if (fs.existsSync(lastConfigFile)) {
122
- try {
123
- lastConfig = fs.readFileSync(lastConfigFile, 'utf8').trim();
124
- } catch (e) {
125
- // Ignore read errors — will proceed with extraction
126
- }
127
- }
128
-
129
- // Check if configuration has changed
86
+ const lastConfig = readState(lastConfigFile);
130
87
  if (lastConfig === configLower) {
131
88
  console.log(`${LOG_PREFIX} ${moduleName}: Already extracted ${configLower}, skipping.`);
132
89
  return;
133
90
  }
134
91
 
135
- // Remove all existing *.xcframework directories
136
- const entries = fs.readdirSync(xcframeworksDir);
137
- for (const entry of entries) {
138
- if (!entry.endsWith('.xcframework')) continue;
139
- const entryPath = path.join(xcframeworksDir, entry);
140
-
141
- try {
142
- const stat = fs.lstatSync(entryPath);
143
- if (stat.isDirectory() || stat.isSymbolicLink()) {
144
- fs.rmSync(entryPath, { recursive: true, force: true });
145
- }
146
- } catch (e) {
147
- console.error(`${LOG_PREFIX} ${moduleName}: Warning: failed to remove ${entry}: ${e.message}`);
92
+ // Only remove the product xcframework — shared-dep symlinks staged by
93
+ // ensure_shared_spm_deps are repointed separately below via --shared.
94
+ const productXcfw = path.join(xcframeworksDir, `${moduleName}.xcframework`);
95
+ try {
96
+ fs.rmSync(productXcfw, { recursive: true, force: true });
97
+ } catch (e) {
98
+ if (e.code !== 'ENOENT') {
99
+ console.error(`${LOG_PREFIX} ${moduleName}: failed to remove product xcframework: ${e.message}`);
148
100
  }
149
101
  }
150
102
 
151
- // Extract the tarball using spawnSync to avoid shell interpretation of paths
152
- const result = spawnSync('tar', ['-xzf', tarballPath, '-C', xcframeworksDir], {
153
- stdio: 'pipe',
154
- });
155
-
103
+ const result = spawnSync('tar', ['-xzf', tarballPath, '-C', xcframeworksDir], { stdio: 'pipe' });
156
104
  if (result.status !== 0) {
157
- const stderr = result.stderr ? result.stderr.toString().trim() : 'unknown error';
158
- console.error(`${LOG_PREFIX} ${moduleName}: Failed to extract tarball: ${stderr}`);
105
+ console.error(`${LOG_PREFIX} ${moduleName}: tar failed: ${result.stderr?.toString().trim()}`);
159
106
  process.exit(1);
160
107
  }
161
108
 
162
- // Write last build configuration
163
- try {
164
- fs.writeFileSync(lastConfigFile, configLower);
165
- } catch (e) {
166
- console.error(`${LOG_PREFIX} ${moduleName}: Warning: failed to write config file: ${e.message}`);
109
+ fs.writeFileSync(lastConfigFile, configLower);
110
+ console.log(
111
+ lastConfig
112
+ ? `${LOG_PREFIX} ${moduleName}: Switched from ${lastConfig} to ${configLower}.`
113
+ : `${LOG_PREFIX} ${moduleName}: Extracted ${configLower} tarball.`
114
+ );
115
+ }
116
+
117
+ function repointSharedDep(xcframeworksDir, name, sourceBase, configLower) {
118
+ const artifactsDir = path.join(xcframeworksDir, 'artifacts');
119
+ fs.mkdirSync(artifactsDir, { recursive: true });
120
+ const stateFile = path.join(artifactsDir, `${name}.last_config`);
121
+ const linkPath = path.join(xcframeworksDir, `${name}.xcframework`);
122
+
123
+ // Trust the state file only when the symlink is still present — an externally
124
+ // deleted symlink (e.g. clear_cocoapods_cache wiping the pod dir) must trigger
125
+ // a re-link even if the state file claims the right config.
126
+ if (readState(stateFile) === configLower && fs.existsSync(linkPath)) return;
127
+
128
+ const target = path.join(sourceBase, configLower, `${name}.xcframework`);
129
+ if (!fs.existsSync(target)) {
130
+ console.error(
131
+ `${LOG_PREFIX} Shared dep ${name}: target not found at ${target}. Run the precompile prebuild pipeline for the ${configLower} flavor, or ensure prebuilds/spm-deps/${name}/${configLower}/${name}.xcframework ships with the consuming package.`
132
+ );
133
+ process.exit(1);
167
134
  }
168
135
 
169
- if (lastConfig && lastConfig !== configLower) {
170
- console.log(
171
- `${LOG_PREFIX} ${moduleName}: Switched from ${lastConfig} to ${configLower} (extracted tarball).`
136
+ fs.rmSync(linkPath, { recursive: true, force: true });
137
+ fs.symlinkSync(target, linkPath);
138
+ fs.writeFileSync(stateFile, configLower);
139
+ console.log(`${LOG_PREFIX} Shared dep ${name}: repointed to ${configLower} (${target}).`);
140
+ }
141
+
142
+ function main() {
143
+ const args = parseArgs();
144
+ if (!args.config || !args.module || !args.xcframeworksDir) {
145
+ console.error(
146
+ 'Usage: replace-xcframework.js -c <CONFIG> -m <MODULE> -x <XCFRAMEWORKS_DIR> [--shared <Name>:<source_base>]...'
172
147
  );
173
- } else {
174
- console.log(`${LOG_PREFIX} ${moduleName}: Extracted ${configLower} tarball.`);
148
+ process.exit(1);
149
+ }
150
+ const configLower = args.config.toLowerCase();
151
+ if (configLower !== 'debug' && configLower !== 'release') {
152
+ console.error(`${LOG_PREFIX} Invalid configuration: ${args.config}. Must be "debug" or "release".`);
153
+ process.exit(1);
154
+ }
155
+
156
+ processPerPodSwap(args, configLower);
157
+ for (const dep of args.sharedDeps) {
158
+ repointSharedDep(args.xcframeworksDir, dep.name, dep.sourceBase, configLower);
175
159
  }
176
160
  }
177
161