expo-updates 58.0.7 → 58.0.8
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 +9 -0
- package/android/build.gradle +8 -2
- package/android/src/main/java/expo/modules/updates/IUpdatesController.kt +2 -1
- package/android/src/main/java/expo/modules/updates/UpdatesModule.kt +11 -2
- package/android/src/main/java/expo/modules/updates/loader/FileDownloader.kt +28 -1
- package/android/src/main/java/expo/modules/updates/procedures/FetchUpdateProcedure.kt +2 -1
- package/android/src/main/java/expo/modules/updates/procedures/StartupProcedure.kt +4 -3
- package/android/src/main/java/expo/modules/updates/statemachine/UpdatesStateMachine.kt +31 -2
- package/expo-updates-gradle-plugin/.gradle/9.4.1/executionHistory/executionHistory.lock +0 -0
- package/expo-updates-gradle-plugin/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/expo-updates-gradle-plugin/.gradle/file-system.probe +0 -0
- package/ios/EXUpdates/AppLoader/FileDownloader.swift +15 -1
- package/ios/EXUpdates/Exceptions.swift +58 -0
- package/ios/EXUpdates/Procedures/StartupProcedure.swift +13 -2
- package/ios/EXUpdates/UpdatesModule.swift +4 -4
- package/ios/EXUpdates/UpdatesStateMachine.swift +44 -4
- package/ios/EXUpdates.podspec +11 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
+
## 58.0.8 — 2026-09-23
|
|
14
|
+
|
|
15
|
+
### 🐛 Bug fixes
|
|
16
|
+
|
|
17
|
+
- Log a warning naming the event and its error message when the updates state machine drops an event that is not allowed from the current state. Previously such an event was discarded silently in release builds, so an error could disappear without a trace. ([#50163](https://github.com/expo/expo/pull/50163) by [@douglowder](https://github.com/douglowder))
|
|
18
|
+
- Add an `EX_UPDATES_ASSERT_INVALID_STATE` build flag that makes the updates state machine stop the app when it drops an event, so an invalid transition fails an E2E run instead of passing unnoticed. ([#50163](https://github.com/expo/expo/pull/50163) by [@douglowder](https://github.com/douglowder))
|
|
19
|
+
|
|
13
20
|
## 58.0.7 — 2026-09-22
|
|
14
21
|
|
|
15
22
|
_This version does not introduce any user-facing changes._
|
|
@@ -44,6 +51,8 @@ _This version does not introduce any user-facing changes._
|
|
|
44
51
|
|
|
45
52
|
- [iOS] Apply bundle diffs against the embedded bundle in the app binary when the launched update is the embedded one, instead of failing to resolve a patch base and downloading the full bundle. ([#50018](https://github.com/expo/expo/pull/50018) by [@alanjhughes](https://github.com/alanjhughes))
|
|
46
53
|
- [Android] Apply bundle diffs against the embedded bundle in the app binary when the launched update is the embedded one, instead of failing to resolve a patch base and downloading the full bundle. ([#50019](https://github.com/expo/expo/pull/50019) by [@alanjhughes](https://github.com/alanjhughes))
|
|
54
|
+
- Fix issues with error propagation when `checkForUpdateAsync`, `fetchUpdateAsync`, `readLogEntriesAsync` and `clearLogEntriesAsync` reject with an error, so that error causes/reasons are preserved. ([#50098](https://github.com/expo/expo/pull/50098) by [@douglowder](https://github.com/douglowder))
|
|
55
|
+
- [iOS] Report a startup update failure through `downloadError` when the updates state machine is idle, instead of dropping it, so that `useUpdates()` reflects background update failures as it already did on Android. ([#50098](https://github.com/expo/expo/pull/50098) by [@douglowder](https://github.com/douglowder))
|
|
47
56
|
|
|
48
57
|
## 58.0.0 — 2026-09-10
|
|
49
58
|
|
package/android/build.gradle
CHANGED
|
@@ -42,7 +42,7 @@ expoModule {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
group = 'host.exp.exponent'
|
|
45
|
-
version = '58.0.
|
|
45
|
+
version = '58.0.8'
|
|
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
|
|
@@ -78,6 +78,11 @@ def exUpdatesAndroidDelayLoadApp = getBoolStringFromPropOrEnv("EX_UPDATES_ANDROI
|
|
|
78
78
|
// If true, updates will copy embedded assets to file system when startup. (default false)
|
|
79
79
|
def exUpdatesCopyEmbeddedAssets = getBoolStringFromPropOrEnv("EX_UPDATES_COPY_EMBEDDED_ASSETS", false)
|
|
80
80
|
|
|
81
|
+
// If true, the state machine throws when it drops an event that is not allowed from the current
|
|
82
|
+
// state, instead of only logging a warning. E2E tests turn this on so an invalid transition fails
|
|
83
|
+
// the test run rather than passing unnoticed. (default false)
|
|
84
|
+
def exUpdatesAssertInvalidState = getBoolStringFromPropOrEnv("EX_UPDATES_ASSERT_INVALID_STATE", false)
|
|
85
|
+
|
|
81
86
|
def useDevClient = findProject(":expo-dev-client") != null
|
|
82
87
|
|
|
83
88
|
def reactNativeArchitectures() {
|
|
@@ -98,7 +103,7 @@ android {
|
|
|
98
103
|
namespace "expo.modules.updates"
|
|
99
104
|
defaultConfig {
|
|
100
105
|
versionCode 31
|
|
101
|
-
versionName '58.0.
|
|
106
|
+
versionName '58.0.8'
|
|
102
107
|
consumerProguardFiles("proguard-rules.pro")
|
|
103
108
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
104
109
|
|
|
@@ -106,6 +111,7 @@ android {
|
|
|
106
111
|
buildConfigField("boolean", "EX_UPDATES_CUSTOM_INIT", exUpdatesCustomInit)
|
|
107
112
|
buildConfigField("boolean", "EX_UPDATES_ANDROID_DELAY_LOAD_APP", exUpdatesAndroidDelayLoadApp)
|
|
108
113
|
buildConfigField("boolean", "EX_UPDATES_COPY_EMBEDDED_ASSETS", exUpdatesCopyEmbeddedAssets)
|
|
114
|
+
buildConfigField("boolean", "EX_UPDATES_ASSERT_INVALID_STATE", exUpdatesAssertInvalidState)
|
|
109
115
|
buildConfigField("boolean", "USE_DEV_CLIENT", useDevClient.toString())
|
|
110
116
|
|
|
111
117
|
externalNativeBuild {
|
|
@@ -4,6 +4,7 @@ import android.os.Bundle
|
|
|
4
4
|
import com.facebook.react.ReactHost
|
|
5
5
|
import com.facebook.react.bridge.ReactContext
|
|
6
6
|
import com.facebook.react.devsupport.interfaces.DevSupportManager
|
|
7
|
+
import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
|
|
7
8
|
import expo.modules.updates.db.entity.AssetEntity
|
|
8
9
|
import expo.modules.updates.db.entity.UpdateEntity
|
|
9
10
|
import expo.modules.updates.events.IUpdatesEventManager
|
|
@@ -104,7 +105,7 @@ interface IUpdatesController {
|
|
|
104
105
|
) {
|
|
105
106
|
fun toModuleConstantsMap(): Map<String, Any?> = mutableMapOf<String, Any?>().apply {
|
|
106
107
|
this["isEmergencyLaunch"] = emergencyLaunchException != null
|
|
107
|
-
this["emergencyLaunchReason"] = emergencyLaunchException?.
|
|
108
|
+
this["emergencyLaunchReason"] = emergencyLaunchException?.localizedMessageWithCauseLocalizedMessage()
|
|
108
109
|
this["isEmbeddedLaunch"] = embeddedUpdate !== null && launchedUpdate?.id?.equals(embeddedUpdate.id) ?: false
|
|
109
110
|
this["isEnabled"] = isEnabled
|
|
110
111
|
this["launchDuration"] = launchDuration?.toLong(DurationUnit.MILLISECONDS)
|
|
@@ -3,6 +3,7 @@ package expo.modules.updates
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.net.Uri
|
|
5
5
|
import android.os.Bundle
|
|
6
|
+
import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
|
|
6
7
|
import expo.modules.kotlin.exception.CodedException
|
|
7
8
|
import expo.modules.kotlin.exception.Exceptions
|
|
8
9
|
import expo.modules.kotlin.functions.Coroutine
|
|
@@ -69,7 +70,11 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
|
|
|
69
70
|
AsyncFunction("checkForUpdateAsync") Coroutine { ->
|
|
70
71
|
when (val result = UpdatesController.instance.checkForUpdate()) {
|
|
71
72
|
is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
|
|
72
|
-
throw CodedException(
|
|
73
|
+
throw CodedException(
|
|
74
|
+
"ERR_UPDATES_CHECK",
|
|
75
|
+
"Failed to check for update: ${result.error.localizedMessageWithCauseLocalizedMessage()}",
|
|
76
|
+
result.error
|
|
77
|
+
)
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
|
|
@@ -103,7 +108,11 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
|
|
|
103
108
|
AsyncFunction("fetchUpdateAsync") Coroutine { ->
|
|
104
109
|
when (val result = UpdatesController.instance.fetchUpdate()) {
|
|
105
110
|
is IUpdatesController.FetchUpdateResult.ErrorResult -> {
|
|
106
|
-
throw CodedException(
|
|
111
|
+
throw CodedException(
|
|
112
|
+
"ERR_UPDATES_FETCH",
|
|
113
|
+
"Failed to download new update: ${result.error.localizedMessageWithCauseLocalizedMessage()}",
|
|
114
|
+
result.error
|
|
115
|
+
)
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
is IUpdatesController.FetchUpdateResult.Failure -> {
|
|
@@ -2,6 +2,7 @@ package expo.modules.updates.loader
|
|
|
2
2
|
|
|
3
3
|
import androidx.annotation.VisibleForTesting
|
|
4
4
|
import com.facebook.react.modules.network.OkHttpClientProvider
|
|
5
|
+
import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
|
|
5
6
|
import expo.modules.jsonutils.require
|
|
6
7
|
import expo.modules.structuredheaders.Dictionary
|
|
7
8
|
import expo.modules.structuredheaders.OuterList
|
|
@@ -699,6 +700,30 @@ class FileDownloader(
|
|
|
699
700
|
)
|
|
700
701
|
}
|
|
701
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Summarizes an unsuccessful response for use as an error message. The status code is included
|
|
705
|
+
* because the body alone is often an opaque HTML page, and the body is truncated because it ends
|
|
706
|
+
* up in the error that `checkForUpdateAsync` and `fetchUpdateAsync` reject with.
|
|
707
|
+
*/
|
|
708
|
+
private fun describeUnsuccessfulResponse(response: Response): String {
|
|
709
|
+
val body = try {
|
|
710
|
+
response.body?.string()
|
|
711
|
+
} catch (e: IOException) {
|
|
712
|
+
logger.warn("Could not read body of unsuccessful remote update response: ${e.localizedMessageWithCauseLocalizedMessage()}", UpdatesErrorCode.UpdateFailedToLoad)
|
|
713
|
+
null
|
|
714
|
+
}
|
|
715
|
+
val summary = body?.takeIf { it.isNotBlank() }?.let { truncateForErrorMessage(it) } ?: "Unknown error"
|
|
716
|
+
return "HTTP ${response.code}: $summary"
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private fun truncateForErrorMessage(body: String): String {
|
|
720
|
+
return if (body.length <= MAX_ERROR_BODY_LENGTH) {
|
|
721
|
+
body
|
|
722
|
+
} else {
|
|
723
|
+
"${body.take(MAX_ERROR_BODY_LENGTH)}… (truncated, ${body.length} characters total)"
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
702
727
|
suspend fun downloadRemoteUpdate(
|
|
703
728
|
extraHeaders: JSONObject?
|
|
704
729
|
): UpdateResponse {
|
|
@@ -709,7 +734,7 @@ class FileDownloader(
|
|
|
709
734
|
|
|
710
735
|
if (!response.isSuccessful) {
|
|
711
736
|
val message = "Remote update request not successful"
|
|
712
|
-
val underlyingError = IOException(response
|
|
737
|
+
val underlyingError = IOException(describeUnsuccessfulResponse(response))
|
|
713
738
|
logger.error(message, underlyingError, UpdatesErrorCode.UpdateFailedToLoad)
|
|
714
739
|
throw IOException(message, underlyingError)
|
|
715
740
|
}
|
|
@@ -983,6 +1008,8 @@ class FileDownloader(
|
|
|
983
1008
|
}
|
|
984
1009
|
|
|
985
1010
|
companion object {
|
|
1011
|
+
private const val MAX_ERROR_BODY_LENGTH = 512
|
|
1012
|
+
|
|
986
1013
|
fun getExtraHeadersForRemoteUpdateRequest(
|
|
987
1014
|
database: UpdatesDatabase,
|
|
988
1015
|
configuration: UpdatesConfiguration,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package expo.modules.updates.procedures
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
|
|
4
5
|
import expo.modules.updates.IUpdatesController
|
|
5
6
|
import expo.modules.updates.UpdatesConfiguration
|
|
6
7
|
import expo.modules.updates.db.UpdatesDatabase
|
|
@@ -41,7 +42,7 @@ class FetchUpdateProcedure(
|
|
|
41
42
|
} catch (e: Exception) {
|
|
42
43
|
logger.error("Failed to download new update", e)
|
|
43
44
|
procedureContext.processStateEvent(
|
|
44
|
-
UpdatesStateEvent.DownloadError("Failed to download new update: ${e.
|
|
45
|
+
UpdatesStateEvent.DownloadError("Failed to download new update: ${e.localizedMessageWithCauseLocalizedMessage()}")
|
|
45
46
|
)
|
|
46
47
|
callback(IUpdatesController.FetchUpdateResult.ErrorResult(e))
|
|
47
48
|
} finally {
|
|
@@ -2,6 +2,7 @@ package expo.modules.updates.procedures
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import com.facebook.react.devsupport.interfaces.DevSupportManager
|
|
5
|
+
import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
|
|
5
6
|
import expo.modules.updates.UpdatesConfiguration
|
|
6
7
|
import expo.modules.updates.db.BuildData
|
|
7
8
|
import expo.modules.updates.db.UpdatesDatabase
|
|
@@ -164,18 +165,18 @@ class StartupProcedure(
|
|
|
164
165
|
UpdatesStateValue.Idle -> {
|
|
165
166
|
procedureContext.processStateEvent(UpdatesStateEvent.Download())
|
|
166
167
|
procedureContext.processStateEvent(
|
|
167
|
-
UpdatesStateEvent.DownloadError(exception.
|
|
168
|
+
UpdatesStateEvent.DownloadError(exception.localizedMessageWithCauseLocalizedMessage())
|
|
168
169
|
)
|
|
169
170
|
}
|
|
170
171
|
UpdatesStateValue.Checking -> {
|
|
171
172
|
procedureContext.processStateEvent(
|
|
172
|
-
UpdatesStateEvent.CheckError(exception.
|
|
173
|
+
UpdatesStateEvent.CheckError(exception.localizedMessageWithCauseLocalizedMessage())
|
|
173
174
|
)
|
|
174
175
|
}
|
|
175
176
|
else -> {
|
|
176
177
|
// .downloading
|
|
177
178
|
procedureContext.processStateEvent(
|
|
178
|
-
UpdatesStateEvent.DownloadError(exception.
|
|
179
|
+
UpdatesStateEvent.DownloadError(exception.localizedMessageWithCauseLocalizedMessage())
|
|
179
180
|
)
|
|
180
181
|
}
|
|
181
182
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package expo.modules.updates.statemachine
|
|
2
2
|
|
|
3
3
|
import expo.modules.manifests.core.toMap
|
|
4
|
+
import expo.modules.updates.BuildConfig
|
|
4
5
|
import expo.modules.updates.EnabledUpdatesController
|
|
5
6
|
import expo.modules.updates.events.IUpdatesEventManager
|
|
6
7
|
import expo.modules.updates.logging.UpdatesLogger
|
|
@@ -114,18 +115,46 @@ class UpdatesStateMachine(
|
|
|
114
115
|
private fun transition(event: UpdatesStateEvent): Boolean {
|
|
115
116
|
val allowedEvents: Set<UpdatesStateEventType> = updatesStateAllowedEvents[state] ?: setOf()
|
|
116
117
|
if (!allowedEvents.contains(event.type)) {
|
|
117
|
-
|
|
118
|
+
reportDroppedEvent(event)
|
|
118
119
|
return false
|
|
119
120
|
}
|
|
120
121
|
val newStateValue = updatesStateTransitions[event.type] ?: UpdatesStateValue.Idle
|
|
121
122
|
if (!validUpdatesStateValues.contains(newStateValue)) {
|
|
122
|
-
|
|
123
|
+
reportDroppedEvent(event)
|
|
123
124
|
return false
|
|
124
125
|
}
|
|
125
126
|
state = newStateValue
|
|
126
127
|
return true
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
/**
|
|
131
|
+
Records an event the machine cannot process. The drop is always logged, so it is visible through
|
|
132
|
+
`readLogEntriesAsync` in a shipping app. With EX_UPDATES_ASSERT_INVALID_STATE the drop also
|
|
133
|
+
throws, so an invalid transition fails an E2E run instead of passing unnoticed.
|
|
134
|
+
*/
|
|
135
|
+
private fun reportDroppedEvent(event: UpdatesStateEvent) {
|
|
136
|
+
val message = droppedEventWarning(event)
|
|
137
|
+
logger.warn(message)
|
|
138
|
+
if (BuildConfig.EX_UPDATES_ASSERT_INVALID_STATE) {
|
|
139
|
+
throw AssertionError(message)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
The warning written when an event is dropped. It carries the error text of an error event,
|
|
145
|
+
because some callers do not log the failure themselves before sending the event, which would
|
|
146
|
+
leave the updates log with no record of what was lost.
|
|
147
|
+
*/
|
|
148
|
+
private fun droppedEventWarning(event: UpdatesStateEvent): String {
|
|
149
|
+
val errorMessage = when (event) {
|
|
150
|
+
is UpdatesStateEvent.CheckError -> event.error.message
|
|
151
|
+
is UpdatesStateEvent.DownloadError -> event.error.message
|
|
152
|
+
else -> null
|
|
153
|
+
}
|
|
154
|
+
val error = errorMessage?.let { ", error = $it" } ?: ""
|
|
155
|
+
return "UpdatesState: invalid transition requested, event dropped: state = $state, event = ${event.type}$error"
|
|
156
|
+
}
|
|
157
|
+
|
|
129
158
|
fun sendContextToJS() {
|
|
130
159
|
eventManager.sendStateMachineContextEvent(context)
|
|
131
160
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -40,8 +40,22 @@ private extension String {
|
|
|
40
40
|
|
|
41
41
|
return self
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
Truncates a response body for inclusion in an error message. The message reaches JavaScript and
|
|
46
|
+
crash reporters, so an unbounded body (typically an HTML error page) must not be embedded whole.
|
|
47
|
+
Mirrors `MAX_ERROR_BODY_LENGTH` in the Android `FileDownloader`.
|
|
48
|
+
*/
|
|
49
|
+
func truncatedForErrorMessage() -> String {
|
|
50
|
+
if count <= maxErrorBodyLength {
|
|
51
|
+
return self
|
|
52
|
+
}
|
|
53
|
+
return "\(prefix(maxErrorBodyLength))… (truncated, \(count) characters total)"
|
|
54
|
+
}
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
private let maxErrorBodyLength = 512
|
|
58
|
+
|
|
45
59
|
private extension Dictionary where Iterator.Element == (key: String, value: Any) {
|
|
46
60
|
func stringValueForCaseInsensitiveKey(_ searchKey: Key) -> String? {
|
|
47
61
|
let valueRaw = self.first { (key: Key, _: Value) in
|
|
@@ -1219,7 +1233,7 @@ public final class FileDownloader {
|
|
|
1219
1233
|
httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
|
|
1220
1234
|
let encoding = FileDownloader.encoding(fromResponse: httpResponse)
|
|
1221
1235
|
let body = data.let { it in
|
|
1222
|
-
String(data: it, encoding: encoding)
|
|
1236
|
+
String(data: it, encoding: encoding)?.truncatedForErrorMessage()
|
|
1223
1237
|
} ?? "Unknown body response"
|
|
1224
1238
|
let cause = UpdatesError.fileDownloaderHTTPResponseError(statusCode: httpResponse.statusCode, body: body)
|
|
1225
1239
|
self.logger.error(cause: cause, code: .unknown)
|
|
@@ -99,4 +99,62 @@ internal final class InvalidRequestHeadersOverrideException: Exception {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Base class for exceptions that wrap an underlying error and need its description to reach
|
|
104
|
+
* JavaScript.
|
|
105
|
+
*
|
|
106
|
+
* `Promise.reject(code:description:)` and `Exception(name:description:code:)` cannot be used for
|
|
107
|
+
* this: they set `description` but leave `reason` at its default, and the JS-facing message is
|
|
108
|
+
* derived from `reason`, so the description is dropped before it reaches the caller. Overriding
|
|
109
|
+
* `reason` is the supported way to control that message.
|
|
110
|
+
*/
|
|
111
|
+
internal class UpdatesUnderlyingErrorException: Exception, @unchecked Sendable {
|
|
112
|
+
internal let underlyingError: Error
|
|
113
|
+
|
|
114
|
+
internal init(_ underlyingError: Error, file: String = #fileID, line: UInt = #line, function: String = #function) {
|
|
115
|
+
self.underlyingError = underlyingError
|
|
116
|
+
super.init(file: file, line: line, function: function)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
internal final class CheckForUpdateException: UpdatesUnderlyingErrorException, @unchecked Sendable {
|
|
121
|
+
override var code: String {
|
|
122
|
+
"ERR_UPDATES_CHECK"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
override var reason: String {
|
|
126
|
+
"Failed to check for update: \(underlyingError.localizedDescription)"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
internal final class FetchUpdateException: UpdatesUnderlyingErrorException, @unchecked Sendable {
|
|
131
|
+
override var code: String {
|
|
132
|
+
"ERR_UPDATES_FETCH"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override var reason: String {
|
|
136
|
+
"Failed to download new update: \(underlyingError.localizedDescription)"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
internal final class ReadLogEntriesException: UpdatesUnderlyingErrorException, @unchecked Sendable {
|
|
141
|
+
override var code: String {
|
|
142
|
+
"ERR_UPDATES_READ_LOGS"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
override var reason: String {
|
|
146
|
+
"Failed to read log entries: \(underlyingError.localizedDescription)"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
internal final class ClearLogEntriesException: UpdatesUnderlyingErrorException, @unchecked Sendable {
|
|
151
|
+
override var code: String {
|
|
152
|
+
"ERR_UPDATES_READ_LOGS"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
override var reason: String {
|
|
156
|
+
"Failed to clear log entries: \(underlyingError.localizedDescription)"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
// swiftlint:enable line_length
|
|
@@ -211,10 +211,21 @@ final class StartupProcedure: StateMachineProcedure, AppLoaderTaskDelegate, AppL
|
|
|
211
211
|
)
|
|
212
212
|
// Since errors can happen through a number of paths, we do these checks
|
|
213
213
|
// to make sure the state machine is valid
|
|
214
|
-
|
|
214
|
+
switch self.procedureContext.getCurrentState() {
|
|
215
|
+
case .checking:
|
|
215
216
|
self.procedureContext.processStateEvent(.checkError(errorMessage: error.localizedDescription))
|
|
216
|
-
|
|
217
|
+
case .downloading:
|
|
217
218
|
self.procedureContext.processStateEvent(.downloadError(errorMessage: error.localizedDescription))
|
|
219
|
+
case .idle:
|
|
220
|
+
// `downloadError` on its own is an illegal transition out of idle and would be dropped,
|
|
221
|
+
// leaving `useUpdates()` with no record of the failure. Move into the downloading state
|
|
222
|
+
// first, as the Android implementation does.
|
|
223
|
+
self.procedureContext.processStateEvent(.download)
|
|
224
|
+
self.procedureContext.processStateEvent(.downloadError(errorMessage: error.localizedDescription))
|
|
225
|
+
case .restarting:
|
|
226
|
+
// The machine accepts no events while restarting, so there is nowhere to report this. The
|
|
227
|
+
// error is already recorded in the log above.
|
|
228
|
+
logger.warn(message: "Background update failed while restarting: \(error.localizedDescription)")
|
|
218
229
|
}
|
|
219
230
|
case .updateAvailable:
|
|
220
231
|
remoteLoadStatus = .NewUpdateLoaded
|
|
@@ -68,7 +68,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
|
|
|
68
68
|
])
|
|
69
69
|
return
|
|
70
70
|
case .error(let error):
|
|
71
|
-
promise.reject(
|
|
71
|
+
promise.reject(CheckForUpdateException(error))
|
|
72
72
|
return
|
|
73
73
|
}
|
|
74
74
|
} error: { error in
|
|
@@ -97,7 +97,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
|
|
|
97
97
|
do {
|
|
98
98
|
return try UpdatesLogReader().getLogEntries(newerThan: Date(timeIntervalSinceNow: TimeInterval(-1 * (maxAge / 1000))))
|
|
99
99
|
} catch {
|
|
100
|
-
throw
|
|
100
|
+
throw ReadLogEntriesException(error)
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -107,7 +107,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
|
|
|
107
107
|
promise.resolve(nil)
|
|
108
108
|
return
|
|
109
109
|
}
|
|
110
|
-
promise.reject(
|
|
110
|
+
promise.reject(ClearLogEntriesException(error))
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -134,7 +134,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
|
|
|
134
134
|
])
|
|
135
135
|
return
|
|
136
136
|
case .error(let error):
|
|
137
|
-
promise.reject(
|
|
137
|
+
promise.reject(FetchUpdateException(error))
|
|
138
138
|
return
|
|
139
139
|
}
|
|
140
140
|
} error: { error in
|
|
@@ -89,6 +89,23 @@ internal enum UpdatesStateEvent {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
The failure text carried by an error event. The state machine logs it when it drops an event,
|
|
94
|
+
so the message does not disappear with the event.
|
|
95
|
+
*/
|
|
96
|
+
var errorMessage: String? {
|
|
97
|
+
switch self {
|
|
98
|
+
case let .checkError(errorMessage):
|
|
99
|
+
return errorMessage
|
|
100
|
+
case let .downloadError(errorMessage):
|
|
101
|
+
return errorMessage
|
|
102
|
+
case .startStartup, .endStartup, .check, .checkCompleteUnavailable, .checkCompleteWithUpdate,
|
|
103
|
+
.checkCompleteWithRollback, .download, .downloadCompleteUnavailable, .downloadCompleteWithUpdate,
|
|
104
|
+
.downloadCompleteWithRollback, .downloadProgress, .restart:
|
|
105
|
+
return nil
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
92
109
|
var toMap: [String: Any] {
|
|
93
110
|
switch self {
|
|
94
111
|
case .checkCompleteWithUpdate(manifest: let manifest):
|
|
@@ -418,12 +435,12 @@ internal class UpdatesStateMachine {
|
|
|
418
435
|
private func transition(_ event: UpdatesStateEvent) -> Bool {
|
|
419
436
|
let allowedEvents: Set<UpdatesStateEvent.InternalType> = UpdatesStateMachine.updatesStateAllowedEvents[state] ?? []
|
|
420
437
|
if !allowedEvents.contains(event.type) {
|
|
421
|
-
|
|
438
|
+
reportDroppedEvent(event)
|
|
422
439
|
return false
|
|
423
440
|
}
|
|
424
441
|
let newStateValue = UpdatesStateMachine.updatesStateTransitions[event.type] ?? .idle
|
|
425
442
|
if !validUpdatesStateValues.contains(newStateValue) {
|
|
426
|
-
|
|
443
|
+
reportDroppedEvent(event)
|
|
427
444
|
return false
|
|
428
445
|
}
|
|
429
446
|
// Successful transition
|
|
@@ -431,6 +448,29 @@ internal class UpdatesStateMachine {
|
|
|
431
448
|
return true
|
|
432
449
|
}
|
|
433
450
|
|
|
451
|
+
/**
|
|
452
|
+
Records an event the machine cannot process. The drop is always logged, so it is visible through
|
|
453
|
+
`readLogEntriesAsync` in a shipping app. With EX_UPDATES_ASSERT_INVALID_STATE the drop also
|
|
454
|
+
traps, so an invalid transition fails an E2E run instead of passing unnoticed.
|
|
455
|
+
*/
|
|
456
|
+
private func reportDroppedEvent(_ event: UpdatesStateEvent) {
|
|
457
|
+
let message = droppedEventWarning(event)
|
|
458
|
+
logger.warn(message: message)
|
|
459
|
+
#if EX_UPDATES_ASSERT_INVALID_STATE
|
|
460
|
+
preconditionFailure(message)
|
|
461
|
+
#endif
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
The warning written when an event is dropped. It carries the error text of an error event,
|
|
466
|
+
because some callers do not log the failure themselves before sending the event, which would
|
|
467
|
+
leave the updates log with no record of what was lost.
|
|
468
|
+
*/
|
|
469
|
+
private func droppedEventWarning(_ event: UpdatesStateEvent) -> String {
|
|
470
|
+
let error = event.errorMessage.map { ", error = \($0)" } ?? ""
|
|
471
|
+
return "UpdatesState: invalid transition requested, event dropped: state = \(state), event = \(event.type)\(error)"
|
|
472
|
+
}
|
|
473
|
+
|
|
434
474
|
/**
|
|
435
475
|
Given an allowed event and a context, return a new context with the changes
|
|
436
476
|
made by processing the event.
|
|
@@ -547,8 +587,8 @@ internal class UpdatesStateMachine {
|
|
|
547
587
|
|
|
548
588
|
/**
|
|
549
589
|
For a particular machine state, only certain events may be processed.
|
|
550
|
-
If the machine receives an unexpected event,
|
|
551
|
-
|
|
590
|
+
If the machine receives an unexpected event, the event is dropped and a warning
|
|
591
|
+
is written to the updates log.
|
|
552
592
|
*/
|
|
553
593
|
private static let updatesStateAllowedEvents: [UpdatesStateValue: Set<UpdatesStateEvent.InternalType>] = [
|
|
554
594
|
.idle: [.startStartup, .endStartup, .check, .download, .restart],
|
package/ios/EXUpdates.podspec
CHANGED
|
@@ -78,6 +78,11 @@ Pod::Spec.new do |s|
|
|
|
78
78
|
ex_updates_native_debug = ENV['EX_UPDATES_NATIVE_DEBUG'] == '1'
|
|
79
79
|
ex_updates_custom_init = ENV['EX_UPDATES_CUSTOM_INIT'] == '1'
|
|
80
80
|
ex_updates_copy_embedded_assets = ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] == '1'
|
|
81
|
+
# If set, the state machine traps when it drops an event that is not allowed from the current
|
|
82
|
+
# state, instead of only logging a warning. E2E tests turn this on so an invalid transition fails
|
|
83
|
+
# the test run rather than passing unnoticed. Both '1' and 'true' are accepted, so the same value
|
|
84
|
+
# works here and in the Android Gradle build.
|
|
85
|
+
ex_updates_assert_invalid_state = ['1', 'true'].include?(ENV['EX_UPDATES_ASSERT_INVALID_STATE'].to_s.downcase)
|
|
81
86
|
if ex_updates_native_debug
|
|
82
87
|
other_debug_c_flags << ' -DEX_UPDATES_NATIVE_DEBUG=1'
|
|
83
88
|
other_debug_swift_flags << ' -DEX_UPDATES_NATIVE_DEBUG'
|
|
@@ -94,6 +99,12 @@ Pod::Spec.new do |s|
|
|
|
94
99
|
other_release_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
|
|
95
100
|
other_release_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
|
|
96
101
|
end
|
|
102
|
+
if ex_updates_assert_invalid_state
|
|
103
|
+
other_debug_c_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE=1'
|
|
104
|
+
other_debug_swift_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE'
|
|
105
|
+
other_release_c_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE=1'
|
|
106
|
+
other_release_swift_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE'
|
|
107
|
+
end
|
|
97
108
|
if use_dev_client
|
|
98
109
|
other_debug_c_flags << ' -DUSE_DEV_CLIENT=1'
|
|
99
110
|
other_debug_swift_flags << ' -DUSE_DEV_CLIENT'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-updates",
|
|
3
|
-
"version": "58.0.
|
|
3
|
+
"version": "58.0.8",
|
|
4
4
|
"description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"expo",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"@types/picomatch": "^4.0.0",
|
|
72
72
|
"@types/react": "~19.3.0",
|
|
73
73
|
"@vercel/ncc": "^0.38.3",
|
|
74
|
-
"expo-dev-client": "58.0.
|
|
74
|
+
"expo-dev-client": "58.0.6",
|
|
75
75
|
"expo-module-scripts": "56.0.3",
|
|
76
76
|
"express": "^5.1.0",
|
|
77
77
|
"form-data": "^4.0.4",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"optional": true
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
|
-
"gitHead": "
|
|
94
|
+
"gitHead": "8aeb4368d396d2d71a2fc8c652c4d1d4d53ed858",
|
|
95
95
|
"scripts": {
|
|
96
96
|
"build": "pnpm run build:lib && pnpm run build:plugin && pnpm run build:utils && pnpm run build:cli",
|
|
97
97
|
"build:lib": "expo-build src",
|