expo-updates 29.0.18 → 29.0.20

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,23 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 29.0.20 — 2026-08-17
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [iOS] Set `always_out_of_date` on the `Generate updates resources for expo-updates` script_phase to silence the Xcode "run script phase will run on every build" dependency-analysis warning. ([#47622](https://github.com/expo/expo/pull/47622) by [@ramonclaudio](https://github.com/ramonclaudio))
18
+ - Reject updates whose asset key or file extension contains a path separator, which previously let a manifest write and delete files outside the updates directory. ([#48762](https://github.com/expo/expo/pull/48762), [#48763](https://github.com/expo/expo/pull/48763) by [@alanjhughes](https://github.com/alanjhughes))
19
+
20
+ ### 💡 Others
21
+
22
+ - [iOS] Link `libc++` in the test spec so the unit test bundle resolves the C++ symbols it pulls from `ExpoModulesCore`. ([#48762](https://github.com/expo/expo/pull/48762) by [@alanjhughes](https://github.com/alanjhughes))
23
+
24
+ ## 29.0.19 — 2026-07-15
25
+
26
+ ### 💡 Others
27
+
28
+ - [Internal] Align find-up `package.json` search utilities ([#47127](https://github.com/expo/expo/pull/47127) by [@kitten](https://github.com/kitten))
29
+
13
30
  ## 29.0.18 — 2026-05-28
14
31
 
15
32
  ### 🐛 Bug fixes
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '29.0.18'
45
+ version = '29.0.20'
46
46
 
47
47
  // Utility method to derive boolean values from the environment or from Java properties,
48
48
  // and return them as strings to be used in BuildConfig fields
@@ -88,7 +88,7 @@ android {
88
88
  namespace "expo.modules.updates"
89
89
  defaultConfig {
90
90
  versionCode 31
91
- versionName '29.0.18'
91
+ versionName '29.0.20'
92
92
  consumerProguardFiles("proguard-rules.pro")
93
93
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
94
94
 
@@ -157,6 +157,17 @@ object UpdatesUtils {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Asset filenames are built from manifest-controlled values, so a filename holding a path
162
+ * separator or a `..` component would resolve outside the updates directory.
163
+ */
164
+ fun isSafeFilename(filename: String): Boolean {
165
+ return filename.isNotEmpty() &&
166
+ filename != "." &&
167
+ filename != ".." &&
168
+ filename.none { it == '/' || it == '\\' || it == '\u0000' }
169
+ }
170
+
160
171
  fun shouldCheckForUpdateOnLaunch(
161
172
  updatesConfiguration: UpdatesConfiguration,
162
173
  logger: UpdatesLogger,
@@ -2,10 +2,12 @@ package expo.modules.updates.db
2
2
 
3
3
  import android.util.Log
4
4
  import expo.modules.updates.UpdatesConfiguration
5
+ import expo.modules.updates.UpdatesUtils
5
6
  import expo.modules.updates.db.entity.AssetEntity
6
7
  import expo.modules.updates.db.entity.UpdateEntity
7
8
  import expo.modules.updates.manifest.ManifestMetadata
8
9
  import expo.modules.updates.selectionpolicy.SelectionPolicy
10
+ import expo.modules.updates.utils.AndroidResourceAssetUtils
9
11
  import java.io.File
10
12
 
11
13
  /**
@@ -47,7 +49,20 @@ object Reaper {
47
49
  )
48
50
  continue
49
51
  }
50
- val path = File(updatesDirectory, asset.relativePath)
52
+ val relativePath = asset.relativePath
53
+ // Embedded assets are served from the APK, so there is no file here to delete.
54
+ if (relativePath == null || AndroidResourceAssetUtils.isAndroidResourceAsset(relativePath)) {
55
+ continue
56
+ }
57
+ // A row written before asset filenames were validated may point outside the updates directory.
58
+ if (!UpdatesUtils.isSafeFilename(relativePath)) {
59
+ Log.e(
60
+ TAG,
61
+ "Refusing to delete asset with URL " + asset.url + " at unsafe path " + relativePath
62
+ )
63
+ continue
64
+ }
65
+ val path = File(updatesDirectory, relativePath)
51
66
  try {
52
67
  if (path.exists() && !path.delete()) {
53
68
  Log.e(TAG, "Failed to delete asset with URL " + asset.url + " at path " + path.toString())
@@ -353,6 +353,12 @@ class FileDownloader(
353
353
  }
354
354
 
355
355
  val filename = UpdatesUtils.createFilenameForAsset(asset)
356
+ if (!UpdatesUtils.isSafeFilename(filename)) {
357
+ val message = "Failed to download asset ${asset.key}"
358
+ val error = IOException("Asset filename \"$filename\" would resolve outside the updates directory")
359
+ logger.error(message, error, UpdatesErrorCode.AssetsFailedToLoad)
360
+ throw IOException(message, error)
361
+ }
356
362
  val path = File(destinationDirectory, filename)
357
363
 
358
364
  if (path.exists()) {
@@ -14,6 +14,7 @@ import expo.modules.updates.db.enums.UpdateStatus
14
14
  import org.json.JSONArray
15
15
  import org.json.JSONException
16
16
  import org.json.JSONObject
17
+ import java.io.IOException
17
18
  import java.text.ParseException
18
19
  import java.util.*
19
20
 
@@ -95,27 +96,43 @@ class ExpoUpdatesUpdate private constructor(
95
96
  companion object {
96
97
  private val TAG = Update::class.java.simpleName
97
98
 
98
- @Throws(JSONException::class)
99
+ @Throws(Exception::class)
99
100
  fun fromExpoUpdatesManifest(
100
101
  manifest: ExpoUpdatesManifest,
101
102
  extensions: JSONObject?,
102
103
  configuration: UpdatesConfiguration
103
- ): ExpoUpdatesUpdate = ExpoUpdatesUpdate(
104
- manifest,
105
- id = UUID.fromString(manifest.getID()),
106
- configuration.scopeKey,
107
- commitTime = try {
108
- UpdatesUtils.parseDateString(manifest.getCreatedAt())
109
- } catch (e: ParseException) {
110
- Log.e(TAG, "Could not parse manifest createdAt string; falling back to current time", e)
111
- Date()
112
- },
113
- runtimeVersion = manifest.getRuntimeVersion(),
114
- launchAsset = manifest.getLaunchAsset(),
115
- assets = manifest.getAssets(),
116
- extensions = extensions,
117
- url = configuration.updateUrl,
118
- requestHeaders = configuration.requestHeaders
119
- )
104
+ ): ExpoUpdatesUpdate {
105
+ val update = ExpoUpdatesUpdate(
106
+ manifest,
107
+ id = UUID.fromString(manifest.getID()),
108
+ configuration.scopeKey,
109
+ commitTime = try {
110
+ UpdatesUtils.parseDateString(manifest.getCreatedAt())
111
+ } catch (e: ParseException) {
112
+ Log.e(TAG, "Could not parse manifest createdAt string; falling back to current time", e)
113
+ Date()
114
+ },
115
+ runtimeVersion = manifest.getRuntimeVersion(),
116
+ launchAsset = manifest.getLaunchAsset(),
117
+ assets = manifest.getAssets(),
118
+ extensions = extensions,
119
+ url = configuration.updateUrl,
120
+ requestHeaders = configuration.requestHeaders
121
+ )
122
+
123
+ update.assetEntityList.forEach { asset ->
124
+ val filename = UpdatesUtils.createFilenameForAsset(asset)
125
+ if (!UpdatesUtils.isSafeFilename(filename)) {
126
+ throw IOException(
127
+ "Update ${manifest.getID()} could not be loaded because the asset filename " +
128
+ "\"$filename\" is not a valid filename. Assets are stored under their key and file " +
129
+ "extension, so neither may contain a path separator. Check the server that produced " +
130
+ "this manifest."
131
+ )
132
+ }
133
+ }
134
+
135
+ return update
136
+ }
120
137
  }
121
138
  }
@@ -32,6 +32,11 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
32
32
  public var launchAssetUrl: URL?
33
33
  public var assetFilesMap: [String: String]?
34
34
 
35
+ /// Set when the launched update is served directly from the embedded app binary. Tracked
36
+ /// explicitly because `assetFilesMap` is now populated with the bundle-resolved embedded assets,
37
+ /// so it can no longer double as the "using embedded assets" signal.
38
+ private var launchedFromEmbeddedBundle = false
39
+
35
40
  private let launcherQueue: DispatchQueue
36
41
  private var completedAssets: Int
37
42
  private let config: UpdatesConfig
@@ -54,7 +59,7 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
54
59
  }
55
60
 
56
61
  public func isUsingEmbeddedAssets() -> Bool {
57
- return assetFilesMap == nil
62
+ return launchedFromEmbeddedBundle
58
63
  }
59
64
 
60
65
  public static func launchableUpdate(
@@ -178,7 +183,10 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
178
183
  }
179
184
 
180
185
  if launchedUpdate.status == UpdateStatus.StatusEmbedded {
181
- precondition(assetFilesMap == nil, "assetFilesMap should be null for embedded updates")
186
+ launchedFromEmbeddedBundle = true
187
+ // The embedded update's assets aren't copied into the cache, so resolve them from the app
188
+ // binary. Populating the map here keeps `Updates.localAssets` available instead of empty.
189
+ assetFilesMap = UpdatesUtils.embeddedAssetsMap(withConfig: config, database: database, logger: logger)
182
190
  launchAssetUrl = Bundle.main.url(
183
191
  forResource: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFilename,
184
192
  withExtension: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFileType
@@ -8,14 +8,12 @@
8
8
  import Foundation
9
9
 
10
10
  /**
11
- * Subclass of AppLoader which handles copying the embedded update's assets into the
12
- * expo-updates cache location.
11
+ * Subclass of AppLoader which loads the embedded update.
13
12
  *
14
- * Rather than launching the embedded update directly from its location in the app bundle/apk, we
15
- * first try to read it into the expo-updates cache and database and launch it like any other
16
- * update. The benefits of this include (a) a single code path for launching most updates and (b)
17
- * assets included in embedded updates and copied into the cache in this way do not need to be
18
- * redownloaded if included in future updates.
13
+ * By default (`EX_UPDATES_COPY_EMBEDDED_ASSETS` off) the update is registered but its assets aren't
14
+ * read into the database; it keeps StatusEmbedded and launches from the app binary, so first launch
15
+ * stays fast. When the flag is on, assets are read into the database and it launches like any other
16
+ * update, so a later update reusing them avoids a redownload.
19
17
  */
20
18
  @objc(EXUpdatesEmbeddedAppLoader)
21
19
  @objcMembers
@@ -29,6 +27,24 @@ public final class EmbeddedAppLoader: AppLoader {
29
27
 
30
28
  private static var embeddedManifestInternal: EmbeddedUpdate?
31
29
 
30
+ /// Whether to read and hash embedded assets into the database on first launch. Defaults to the
31
+ /// build-time `EX_UPDATES_COPY_EMBEDDED_ASSETS` flag (off). Overridable for testing.
32
+ internal var shouldCopyEmbeddedAssets: Bool = UpdatesUtils.shouldCopyEmbeddedAssets()
33
+
34
+ internal let completionQueue: DispatchQueue
35
+
36
+ public required override init(
37
+ config: UpdatesConfig,
38
+ logger: UpdatesLogger,
39
+ database: UpdatesDatabase,
40
+ directory: URL,
41
+ launchedUpdate: Update?,
42
+ completionQueue: DispatchQueue
43
+ ) {
44
+ self.completionQueue = completionQueue
45
+ super.init(config: config, logger: logger, database: database, directory: directory, launchedUpdate: launchedUpdate, completionQueue: completionQueue)
46
+ }
47
+
32
48
  /**
33
49
  Gets the embedded update.
34
50
  If the `UpdatesConfig.hasEmbeddedUpdate` is false, it returns nil
@@ -136,6 +152,18 @@ public final class EmbeddedAppLoader: AppLoader {
136
152
  self.assetBlock = assetBlock
137
153
  self.successBlock = successBlock
138
154
  self.errorBlock = errorBlock
155
+ startEmbeddedLoad(fromEmbeddedManifest: embeddedManifest)
156
+ }
157
+
158
+ /// Loads the embedded update: with copying off (default) registers it without ingesting assets,
159
+ /// otherwise loads them like any other update. Separate from `loadUpdateResponseFromEmbeddedManifest`
160
+ /// so tests can pass a manifest without reading the bundle.
161
+ internal func startEmbeddedLoad(fromEmbeddedManifest embeddedManifest: Update) {
162
+ if !shouldCopyEmbeddedAssets {
163
+ registerEmbeddedUpdateWithoutCopying(embeddedManifest)
164
+ return
165
+ }
166
+
139
167
  startLoading(fromUpdateResponse: UpdateResponse(
140
168
  responseHeaderData: nil,
141
169
  manifestUpdateResponsePart: ManifestUpdateResponsePart(updateManifest: embeddedManifest),
@@ -143,6 +171,40 @@ public final class EmbeddedAppLoader: AppLoader {
143
171
  ))
144
172
  }
145
173
 
174
+ /// Registers the embedded update without reading, hashing, or copying its assets. A StatusEmbedded
175
+ /// update resolves its bundle and assets from the app binary at launch. Tradeoff: a future update
176
+ /// reusing an embedded asset re-downloads it.
177
+ private func registerEmbeddedUpdateWithoutCopying(_ embeddedManifest: Update) {
178
+ database.databaseQueue.async {
179
+ do {
180
+ let existingUpdate = try? self.database.update(withId: embeddedManifest.updateId, config: self.config)
181
+ if existingUpdate == nil {
182
+ try self.database.addUpdate(embeddedManifest, config: self.config)
183
+ }
184
+ // Mark finished (keep = 1) so the reaper retains it; it stays StatusEmbedded since copying is off.
185
+ try self.database.markUpdateFinished(embeddedManifest)
186
+ } catch {
187
+ let errorBlock = self.errorBlock
188
+ self.completionQueue.async {
189
+ errorBlock?(UpdatesError.appLoaderUnknownError(cause: error))
190
+ self.reset()
191
+ }
192
+ return
193
+ }
194
+
195
+ let successBlock = self.successBlock
196
+ let updateResponse = UpdateResponse(
197
+ responseHeaderData: nil,
198
+ manifestUpdateResponsePart: ManifestUpdateResponsePart(updateManifest: embeddedManifest),
199
+ directiveUpdateResponsePart: nil
200
+ )
201
+ self.completionQueue.async {
202
+ successBlock?(updateResponse)
203
+ self.reset()
204
+ }
205
+ }
206
+ }
207
+
146
208
  override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
147
209
  FileDownloader.assetFilesQueue.async {
148
210
  self.handleAssetDownloadAlreadyExists(asset)
@@ -99,6 +99,13 @@ public final class RemoteAppLoader: AppLoader {
99
99
  }
100
100
 
101
101
  override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
102
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
103
+ self.handleAssetDownload(
104
+ withError: UpdatesError.remoteAppLoaderUnsafeAssetFilename(filename: asset.filename),
105
+ asset: asset
106
+ )
107
+ return
108
+ }
102
109
  let urlOnDisk = self.directory.appendingPathComponent(asset.filename)
103
110
 
104
111
  let progressBlock = { [weak self] fractionCompleted in
@@ -280,7 +280,11 @@ public final class UpdatesDatabase: NSObject {
280
280
  }
281
281
 
282
282
  public func markUpdateFinished(_ update: Update) throws {
283
- if update.status != UpdateStatus.StatusDevelopment {
283
+ // With copying off, embedded updates have no asset rows, so they must stay StatusEmbedded to keep
284
+ // launching from the bundle. Promoting to StatusReady would route launch through the database
285
+ // asset path, which has no rows for them.
286
+ let keepEmbedded = update.status == UpdateStatus.StatusEmbedded && !UpdatesUtils.shouldCopyEmbeddedAssets()
287
+ if update.status != UpdateStatus.StatusDevelopment && !keepEmbedded {
284
288
  update.status = UpdateStatus.StatusReady
285
289
  }
286
290
 
@@ -71,6 +71,10 @@ public final class UpdatesReaper: NSObject {
71
71
 
72
72
  let beginDeleteAssets = Date()
73
73
  for asset in assetsForDeletion {
74
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
75
+ logger.warn(message: "Refusing to delete asset at unsafe path \(asset.filename)")
76
+ continue
77
+ }
74
78
  let localUrl = directory.appendingPathComponent(asset.filename)
75
79
  if FileManager.default.fileExists(atPath: localUrl.path) {
76
80
  do {
@@ -77,6 +77,7 @@ public enum UpdateStatus: Int {
77
77
  public enum UpdateError: Error, Sendable, LocalizedError {
78
78
  case invalidExpoProtocolVersion(protocolVersion: Int)
79
79
  case legacyManifestInstantiationInvalid
80
+ case unsafeAssetFilename(updateId: String, filename: String)
80
81
 
81
82
  public var errorDescription: String? {
82
83
  switch self {
@@ -84,6 +85,10 @@ public enum UpdateError: Error, Sendable, LocalizedError {
84
85
  return "Invalid Expo Updates protocol version: \(protocolVersion)"
85
86
  case .legacyManifestInstantiationInvalid:
86
87
  return "This version of expo-updates can no longer load legacy manifests"
88
+ case let .unsafeAssetFilename(updateId, filename):
89
+ return "Update \(updateId) could not be loaded because the asset filename \"\(filename)\" " +
90
+ "is not a valid filename. Assets are stored under their key and file extension, so neither " +
91
+ "may contain a path separator. Check the server that produced this manifest."
87
92
  }
88
93
  }
89
94
  }
@@ -157,12 +162,21 @@ public class Update: NSObject {
157
162
  }
158
163
  switch protocolVersion {
159
164
  case 0, 1:
160
- return ExpoUpdatesUpdate.update(
165
+ let update = ExpoUpdatesUpdate.update(
161
166
  withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: withManifest),
162
167
  extensions: extensions,
163
168
  config: config,
164
169
  database: database
165
170
  )
171
+ try update.assets()?.forEach { asset in
172
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
173
+ throw UpdateError.unsafeAssetFilename(
174
+ updateId: update.updateId.uuidString,
175
+ filename: asset.filename
176
+ )
177
+ }
178
+ }
179
+ return update
166
180
  default:
167
181
  throw UpdateError.invalidExpoProtocolVersion(protocolVersion: protocolVersion)
168
182
  }
@@ -26,6 +26,7 @@ public enum UpdatesError: Error, Sendable, LocalizedError {
26
26
  case fileDownloaderFailedUpdateIDsFailure(cause: Error)
27
27
  case fileDownloaderUnknownError(cause: Error)
28
28
  case remoteAppLoaderAssetMissingUrl
29
+ case remoteAppLoaderUnsafeAssetFilename(filename: String)
29
30
  case remoteAppLoaderHeaderDataError(cause: Error)
30
31
  case remoteAppLoaderUnknownError(cause: Error)
31
32
  case appLoaderFailedToLoadAllAssets
@@ -92,6 +93,8 @@ public enum UpdatesError: Error, Sendable, LocalizedError {
92
93
  return "Unknown error: \(cause.localizedDescription)"
93
94
  case .remoteAppLoaderAssetMissingUrl:
94
95
  return "Failed to download asset with no URL provided"
96
+ case let .remoteAppLoaderUnsafeAssetFilename(filename):
97
+ return "Failed to download asset: filename \"\(filename)\" would resolve outside the updates directory"
95
98
  case let .remoteAppLoaderHeaderDataError(cause):
96
99
  return "Error persisting header data to disk: \(cause.localizedDescription)"
97
100
  case .appLoaderFailedToLoadAllAssets:
@@ -59,6 +59,23 @@ public final class UpdatesUtils: NSObject {
59
59
  return updatesDirectory
60
60
  }
61
61
 
62
+ /**
63
+ * Asset filenames are built from manifest-controlled values, so a filename holding a path
64
+ * separator or a `..` component would resolve outside the updates directory.
65
+ *
66
+ * Matching is done on unicode scalars because `String.contains` compares grapheme clusters, and
67
+ * a separator followed by a combining mark forms one cluster that does not equal the separator.
68
+ * The filesystem still sees the separator byte.
69
+ */
70
+ public static func isSafeFilename(_ filename: String) -> Bool {
71
+ return !filename.isEmpty &&
72
+ filename != "." &&
73
+ filename != ".." &&
74
+ !filename.unicodeScalars.contains("/") &&
75
+ !filename.unicodeScalars.contains("\\") &&
76
+ !filename.unicodeScalars.contains("\0")
77
+ }
78
+
62
79
  // MARK: - Internal methods
63
80
 
64
81
  public static func defaultNativeStateMachineContextJson() -> [String: Any?] {
@@ -149,6 +166,14 @@ public final class UpdatesUtils: NSObject {
149
166
  #endif
150
167
  }
151
168
 
169
+ internal static func shouldCopyEmbeddedAssets() -> Bool {
170
+ #if EX_UPDATES_COPY_EMBEDDED_ASSETS
171
+ return true
172
+ #else
173
+ return false
174
+ #endif
175
+ }
176
+
152
177
  internal static func runBlockOnMainThread(_ block: @escaping () -> Void) {
153
178
  if Thread.isMainThread {
154
179
  block()
@@ -9,6 +9,9 @@ end
9
9
  if ENV['EX_UPDATES_CUSTOM_INIT'] != '1'
10
10
  ENV['EX_UPDATES_CUSTOM_INIT'] = podfile_properties['updatesCustomInit'] == 'true' ? '1' : '0'
11
11
  end
12
+ if ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] != '1'
13
+ ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] = podfile_properties['updatesCopyEmbeddedAssets'] == 'true' ? '1' : '0'
14
+ end
12
15
 
13
16
  use_dev_client = false
14
17
  begin
@@ -61,6 +64,7 @@ Pod::Spec.new do |s|
61
64
 
62
65
  ex_updates_native_debug = ENV['EX_UPDATES_NATIVE_DEBUG'] == '1'
63
66
  ex_updates_custom_init = ENV['EX_UPDATES_CUSTOM_INIT'] == '1'
67
+ ex_updates_copy_embedded_assets = ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] == '1'
64
68
  if ex_updates_native_debug
65
69
  other_debug_c_flags << ' -DEX_UPDATES_NATIVE_DEBUG=1'
66
70
  other_debug_swift_flags << ' -DEX_UPDATES_NATIVE_DEBUG'
@@ -71,6 +75,12 @@ Pod::Spec.new do |s|
71
75
  other_release_c_flags << ' -DEX_UPDATES_CUSTOM_INIT=1'
72
76
  other_release_swift_flags << ' -DEX_UPDATES_CUSTOM_INIT'
73
77
  end
78
+ if ex_updates_copy_embedded_assets
79
+ other_debug_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
80
+ other_debug_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
81
+ other_release_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
82
+ other_release_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
83
+ end
74
84
  if use_dev_client
75
85
  other_debug_c_flags << ' -DUSE_DEV_CLIENT=1'
76
86
  other_debug_swift_flags << ' -DUSE_DEV_CLIENT'
@@ -99,11 +109,17 @@ Pod::Spec.new do |s|
99
109
 
100
110
  if $expo_updates_create_updates_resources != false
101
111
  force_bundling_flag = ex_updates_native_debug ? "export FORCE_BUNDLING=1\n" : ""
102
- s.script_phase = {
112
+ script_phase = {
103
113
  :name => 'Generate updates resources for expo-updates',
104
114
  :script => force_bundling_flag + 'bash -l -c "$PODS_TARGET_SRCROOT/../scripts/create-updates-resources-ios.sh"',
105
115
  :execution_position => :before_compile
106
116
  }
117
+ # :always_out_of_date is only available in CocoaPods 1.13.0 and later
118
+ if Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.13.0')
119
+ # always run the script without warning
120
+ script_phase[:always_out_of_date] = "1"
121
+ end
122
+ s.script_phase = script_phase
107
123
 
108
124
  # Generate EXUpdates.bundle without existing resources
109
125
  # `create-updates-resources-ios.sh` will generate updates resources in EXUpdates.bundle
@@ -121,6 +137,7 @@ Pod::Spec.new do |s|
121
137
  test_spec.dependency 'ExpoModulesTestCore'
122
138
 
123
139
  test_spec.pod_target_xcconfig = {
140
+ 'OTHER_LDFLAGS' => '$(inherited) -lc++',
124
141
  'USER_HEADER_SEARCH_PATHS' => '"${CONFIGURATION_TEMP_DIR}/EXUpdates.build/DerivedSources"',
125
142
  'GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS' => 'YES',
126
143
  'GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS' => 'YES',
@@ -0,0 +1,94 @@
1
+ // Copyright (c) 2024 650 Industries, Inc. All rights reserved.
2
+
3
+ import Testing
4
+
5
+ @testable import EXUpdates
6
+
7
+ import EXManifests
8
+
9
+ @Suite("EmbeddedAppLoader", .serialized)
10
+ @MainActor
11
+ class EmbeddedAppLoaderTests {
12
+ var testDatabaseDir: URL
13
+ var db: UpdatesDatabase
14
+
15
+ init() throws {
16
+ let applicationSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).last
17
+ testDatabaseDir = applicationSupportDir!.appendingPathComponent("EmbeddedAppLoaderTests")
18
+
19
+ try? FileManager.default.removeItem(atPath: testDatabaseDir.path)
20
+
21
+ if !FileManager.default.fileExists(atPath: testDatabaseDir.path) {
22
+ try FileManager.default.createDirectory(atPath: testDatabaseDir.path, withIntermediateDirectories: true)
23
+ }
24
+
25
+ db = UpdatesDatabase()
26
+ db.databaseQueue.sync {
27
+ try! db.openDatabase(inDirectory: testDatabaseDir, logger: UpdatesLogger())
28
+ }
29
+ }
30
+
31
+ deinit {
32
+ db.databaseQueue.sync {
33
+ db.closeDatabase()
34
+ }
35
+ try? FileManager.default.removeItem(atPath: testDatabaseDir.path)
36
+ }
37
+
38
+ @Test
39
+ func `no-copy registers the embedded update without ingesting non-launch assets`() async throws {
40
+ let config = try UpdatesConfig.config(fromDictionary: [
41
+ UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com",
42
+ UpdatesConfig.EXUpdatesConfigScopeKeyKey: "dummyScope",
43
+ UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1",
44
+ ])
45
+
46
+ // Build an embedded update with a couple of non-launch assets without touching the app bundle,
47
+ // mirroring UpdateTests' `works for embedded bare manifest`.
48
+ let embeddedManifestJSON: [String: Any] = [
49
+ "id": "0eef8214-4833-4089-9dff-b4138a14f196",
50
+ "commitTime": 1609975977832,
51
+ "assets": [
52
+ ["packagerHash": "embedded-asset-1", "type": "png", "nsBundleFilename": "image1"],
53
+ ["packagerHash": "embedded-asset-2", "type": "png", "nsBundleFilename": "image2"],
54
+ ],
55
+ ]
56
+ let embeddedUpdate = Update.update(
57
+ withRawEmbeddedManifest: embeddedManifestJSON,
58
+ config: config,
59
+ database: db
60
+ )
61
+
62
+ let loader = EmbeddedAppLoader(
63
+ config: config,
64
+ logger: UpdatesLogger(),
65
+ database: db,
66
+ directory: testDatabaseDir,
67
+ launchedUpdate: nil,
68
+ completionQueue: DispatchQueue.global(qos: .default)
69
+ )
70
+ loader.shouldCopyEmbeddedAssets = false
71
+
72
+ let success: Bool = await withCheckedContinuation { continuation in
73
+ loader.updateResponseBlock = { _ in true }
74
+ loader.assetBlock = { _, _, _, _ in }
75
+ loader.successBlock = { _ in continuation.resume(returning: true) }
76
+ loader.errorBlock = { _ in continuation.resume(returning: false) }
77
+ loader.startEmbeddedLoad(fromEmbeddedManifest: embeddedUpdate)
78
+ }
79
+
80
+ #expect(success == true)
81
+
82
+ db.databaseQueue.sync {
83
+ // The update row is registered and stays StatusEmbedded (not promoted to StatusReady), so later
84
+ // launches keep resolving it from the app bundle rather than the empty database asset path.
85
+ let storedUpdate = try! db.update(withId: embeddedUpdate.updateId, config: config)
86
+ #expect(storedUpdate != nil)
87
+ #expect(storedUpdate?.status == .StatusEmbedded)
88
+
89
+ // Its non-launch assets were not read, hashed, or inserted.
90
+ let asset = try? db.asset(withKey: "embedded-asset-1")
91
+ #expect(asset == nil)
92
+ }
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "29.0.18",
3
+ "version": "29.0.20",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -73,5 +73,5 @@
73
73
  "react": "*",
74
74
  "react-native": "*"
75
75
  },
76
- "gitHead": "45c60e10956764bbac6c62454890eeb25c74bbd6"
76
+ "gitHead": "5b42e3d21e0ac5e086752361ca8a5cb4de53bec1"
77
77
  }
@@ -7,13 +7,14 @@ exports.findUpProjectRoot = findUpProjectRoot;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  function findUpProjectRoot(cwd) {
10
- if (['.', path_1.default.sep].includes(cwd)) {
10
+ if (cwd === path_1.default.sep || cwd === '.') {
11
11
  return null;
12
12
  }
13
- if (fs_1.default.existsSync(path_1.default.join(cwd, 'package.json'))) {
14
- return cwd;
15
- }
16
- else {
17
- return findUpProjectRoot(path_1.default.dirname(cwd));
13
+ for (let dir = cwd; path_1.default.dirname(dir) !== dir; dir = path_1.default.dirname(dir)) {
14
+ const file = path_1.default.resolve(dir, 'package.json');
15
+ if (fs_1.default.existsSync(file)) {
16
+ return dir;
17
+ }
18
18
  }
19
+ return null;
19
20
  }
@@ -2,13 +2,15 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
4
  export function findUpProjectRoot(cwd: string): string | null {
5
- if (['.', path.sep].includes(cwd)) {
5
+ if (cwd === path.sep || cwd === '.') {
6
6
  return null;
7
7
  }
8
8
 
9
- if (fs.existsSync(path.join(cwd, 'package.json'))) {
10
- return cwd;
11
- } else {
12
- return findUpProjectRoot(path.dirname(cwd));
9
+ for (let dir = cwd; path.dirname(dir) !== dir; dir = path.dirname(dir)) {
10
+ const file = path.resolve(dir, 'package.json');
11
+ if (fs.existsSync(file)) {
12
+ return dir;
13
+ }
13
14
  }
15
+ return null;
14
16
  }