expo-dev-menu 58.0.5 → 58.0.7

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,18 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 58.0.7 — 2026-09-25
14
+
15
+ ### 🎉 New features
16
+
17
+ - Add the `ExpoLauncherURL` parser for the reserved `__expo_*` deep linking URL params. ([#50287](https://github.com/expo/expo/pull/50287) by [@gabrieldonadel](https://github.com/gabrieldonadel))
18
+
19
+ ## 58.0.6 — 2026-09-23
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - [iOS] Fix a data race on `DevMenuManager`'s registered callbacks and app context that could crash the app when one app context registered its dev menu callbacks while another tore down, such as on a dev-client reload. ([#49559](https://github.com/expo/expo/pull/49559) by [@vonovak](https://github.com/vonovak))
24
+
13
25
  ## 58.0.5 — 2026-09-22
14
26
 
15
27
  ### 🐛 Bug fixes
@@ -12,7 +12,7 @@ apply plugin: 'expo-module-gradle-plugin'
12
12
  apply plugin: 'org.jetbrains.kotlin.plugin.compose'
13
13
 
14
14
  group = 'host.exp.exponent'
15
- version = '58.0.5'
15
+ version = '58.0.7'
16
16
 
17
17
  def hasDevLauncher = findProject(":expo-dev-launcher") != null
18
18
  def configureInRelease = findProperty("expo.devmenu.configureInRelease") == "true"
@@ -29,7 +29,7 @@ android {
29
29
 
30
30
  defaultConfig {
31
31
  versionCode 10
32
- versionName '58.0.5'
32
+ versionName '58.0.7'
33
33
  }
34
34
 
35
35
  buildTypes {
@@ -140,4 +140,9 @@ dependencies {
140
140
  debugOnlyApi 'io.github.lukmccall:radix-ui-colors:1.0.1'
141
141
 
142
142
  debugOnlyApi "androidx.browser:browser:1.9.0"
143
+
144
+ testImplementation 'androidx.test:core:1.7.0'
145
+ testImplementation "com.google.truth:truth:1.4.5"
146
+ testImplementation 'io.mockk:mockk:1.12.3'
147
+ testImplementation "org.robolectric:robolectric:4.16"
143
148
  }
@@ -34,6 +34,7 @@ import expo.modules.devmenu.detectors.ThreeFingerLongPressDetector
34
34
  import expo.modules.devmenu.devtools.DevMenuDevToolsDelegate
35
35
  import expo.modules.devmenu.fab.MovableFloatingActionButton
36
36
  import expo.modules.devmenu.helpers.isAcceptingText
37
+ import expo.modules.devmenu.launch.DevMenuLaunchOverrides
37
38
  import expo.modules.kotlin.weak
38
39
  import java.lang.ref.WeakReference
39
40
 
@@ -68,7 +69,8 @@ class DevMenuFragment(
68
69
  override fun onCreate(savedInstanceState: Bundle?) {
69
70
  super.onCreate(savedInstanceState)
70
71
 
71
- val shouldShowAtLaunch = preferences.showsAtLaunch || !preferences.isOnboardingFinished
72
+ val shouldShowAtLaunch = DevMenuLaunchOverrides.canLaunchDevMenuOnStart &&
73
+ (preferences.showsAtLaunch || !preferences.isOnboardingFinished)
72
74
  if (shouldShowAtLaunch) {
73
75
  showMenuAtLaunch()
74
76
  }
@@ -32,6 +32,7 @@ import androidx.compose.ui.unit.Dp
32
32
  import androidx.compose.ui.unit.DpSize
33
33
  import androidx.compose.ui.unit.dp
34
34
  import expo.modules.devmenu.compose.DevMenuState
35
+ import expo.modules.devmenu.launch.DevMenuLaunchOverrides
35
36
  import kotlinx.coroutines.CoroutineScope
36
37
  import kotlinx.coroutines.coroutineScope
37
38
  import kotlinx.coroutines.delay
@@ -96,6 +97,7 @@ fun MovableFloatingActionButton(
96
97
  val fab = rememberFabState(bounds)
97
98
 
98
99
  val isFabDisplayable = state.showFab &&
100
+ DevMenuLaunchOverrides.canShowFab &&
99
101
  !state.isInPictureInPictureMode &&
100
102
  bounds.x >= 0f &&
101
103
  bounds.y >= 0f
@@ -0,0 +1,22 @@
1
+ package expo.modules.devmenu.launch
2
+
3
+ import androidx.compose.runtime.getValue
4
+ import androidx.compose.runtime.mutableStateOf
5
+ import androidx.compose.runtime.setValue
6
+
7
+ /**
8
+ * One-time dev menu switches from the launch URL. They live for the current process only and are never
9
+ * written to the preferences, so the next launch without them shows the menu and the tools button again.
10
+ */
11
+ object DevMenuLaunchOverrides {
12
+ @Volatile
13
+ var canLaunchDevMenuOnStart = true
14
+
15
+ /** Compose state, so an open app hides the tools button as soon as a launch URL asks for it. */
16
+ var canShowFab by mutableStateOf(true)
17
+
18
+ internal fun reset() {
19
+ canLaunchDevMenuOnStart = true
20
+ canShowFab = true
21
+ }
22
+ }
@@ -0,0 +1,59 @@
1
+ package expo.modules.devmenu.launch
2
+
3
+ import android.net.Uri
4
+
5
+ /**
6
+ * Parses the reserved `__expo_*` query params of a launch URL. A URL is a launcher command when it
7
+ * carries any `__expo_*` param or its host is the legacy `expo-development-client`.
8
+ * Keep in sync with `packages/expo-dev-menu/ios/Launch/ExpoLauncherURL.swift`.
9
+ */
10
+ class ExpoLauncherUrl(val url: Uri) {
11
+ private val paramNames: Set<String> = if (url.isHierarchical) url.queryParameterNames else emptySet()
12
+ private val hasReservedParams = paramNames.any { it.startsWith(RESERVED_PREFIX) }
13
+
14
+ val isLegacyHost = url.host == LEGACY_HOST
15
+ val isLauncherCommand = isLegacyHost || hasReservedParams
16
+
17
+ /** From `__expo_url`, or the legacy `url` next to the legacy host. */
18
+ val targetUrl: Uri? = param("url", legacy = "url")?.takeIf { it.isNotEmpty() }?.let(Uri::parse)
19
+
20
+ /** `__expo_disable_onboarding=1`, or the legacy `disableOnboarding=1` next to the legacy host. */
21
+ val disablesOnboarding = param("disable_onboarding", legacy = "disableOnboarding") == "1"
22
+
23
+ /** `__expo_disable_fab=1`: hide the floating tools button for this process. */
24
+ val disablesFab = param("disable_fab") == "1"
25
+
26
+ /** `__expo_disable_auto_launch=1`: do not open the dev menu at launch in this process. */
27
+ val disablesAutoLaunch = param("disable_auto_launch") == "1"
28
+
29
+ /** The URL without its `__expo_*` params. */
30
+ val strippedUrl: Uri = if (!hasReservedParams) {
31
+ url
32
+ } else {
33
+ val remaining = url.encodedQuery.orEmpty().split('&')
34
+ .filter { it.isNotEmpty() && !Uri.decode(it.substringBefore('=')).startsWith(RESERVED_PREFIX) }
35
+ url.buildUpon().encodedQuery(remaining.joinToString("&").ifEmpty { null }).build()
36
+ }
37
+
38
+ /** The other query params, decoded. */
39
+ val passthroughParams: Map<String, String> = paramNames
40
+ .filterNot { it.startsWith(RESERVED_PREFIX) }
41
+ .associateWith { url.getQueryParameter(it).orEmpty() }
42
+
43
+ /** Whether [strippedUrl] still points somewhere an app can route: a host or a path. */
44
+ val remainderHasDestination: Boolean =
45
+ strippedUrl.host.let { !it.isNullOrEmpty() && it != LEGACY_HOST } ||
46
+ strippedUrl.path.let { !it.isNullOrEmpty() && it != "/" }
47
+
48
+ /** `__expo_<name>`, or the [legacy] param next to the legacy host. */
49
+ private fun param(name: String, legacy: String? = null): String? {
50
+ if (!url.isHierarchical) return null
51
+ return url.getQueryParameter(RESERVED_PREFIX + name)
52
+ ?: legacy?.takeIf { isLegacyHost }?.let(url::getQueryParameter)
53
+ }
54
+
55
+ companion object {
56
+ const val RESERVED_PREFIX = "__expo_"
57
+ const val LEGACY_HOST = "expo-development-client"
58
+ }
59
+ }
@@ -0,0 +1,19 @@
1
+ package expo.modules.devmenu.launch
2
+
3
+ import expo.modules.devmenu.DevMenuPreferences
4
+
5
+ /**
6
+ * Applies the dev menu params of a launch URL. `__expo_disable_onboarding` finishes onboarding in the
7
+ * saved preferences; the other two are one-time switches for this process, see [DevMenuLaunchOverrides].
8
+ */
9
+ fun ExpoLauncherUrl.applyDevMenuLaunchParams(preferences: DevMenuPreferences) {
10
+ if (disablesOnboarding) {
11
+ preferences.isOnboardingFinished = true
12
+ }
13
+ if (disablesFab) {
14
+ DevMenuLaunchOverrides.canShowFab = false
15
+ }
16
+ if (disablesAutoLaunch) {
17
+ DevMenuLaunchOverrides.canLaunchDevMenuOnStart = false
18
+ }
19
+ }
@@ -0,0 +1,71 @@
1
+ package expo.modules.devmenu.launch
2
+
3
+ import android.net.Uri
4
+ import com.google.common.truth.Truth.assertThat
5
+ import expo.modules.devmenu.DevMenuPreferences
6
+ import io.mockk.mockk
7
+ import io.mockk.verify
8
+ import org.junit.Before
9
+ import org.junit.Test
10
+ import org.junit.runner.RunWith
11
+ import org.robolectric.RobolectricTestRunner
12
+
13
+ @RunWith(RobolectricTestRunner::class)
14
+ internal class ExpoLauncherUrlLaunchParamsTest {
15
+ private val preferences = mockk<DevMenuPreferences>(relaxed = true)
16
+
17
+ @Before
18
+ fun reset() {
19
+ DevMenuLaunchOverrides.reset()
20
+ }
21
+
22
+ @Test
23
+ fun `disable fab hides the fab for this process only`() {
24
+ ExpoLauncherUrl(Uri.parse("exp://h:8081?__expo_disable_fab=1")).applyDevMenuLaunchParams(preferences)
25
+
26
+ assertThat(DevMenuLaunchOverrides.canShowFab).isFalse()
27
+ assertThat(DevMenuLaunchOverrides.canLaunchDevMenuOnStart).isTrue()
28
+ verify(exactly = 0) { preferences.showFab = any() }
29
+ verify(exactly = 0) { preferences.showsAtLaunch = any() }
30
+ verify(exactly = 0) { preferences.isOnboardingFinished = any() }
31
+ }
32
+
33
+ @Test
34
+ fun `disable auto launch keeps the menu closed for this process only`() {
35
+ ExpoLauncherUrl(Uri.parse("exp://h:8081?__expo_disable_auto_launch=1")).applyDevMenuLaunchParams(preferences)
36
+
37
+ assertThat(DevMenuLaunchOverrides.canLaunchDevMenuOnStart).isFalse()
38
+ assertThat(DevMenuLaunchOverrides.canShowFab).isTrue()
39
+ verify(exactly = 0) { preferences.showsAtLaunch = any() }
40
+ verify(exactly = 0) { preferences.isOnboardingFinished = any() }
41
+ }
42
+
43
+ @Test
44
+ fun `disable onboarding finishes onboarding in the preferences`() {
45
+ ExpoLauncherUrl(Uri.parse("exp://h:8081?__expo_disable_onboarding=1")).applyDevMenuLaunchParams(preferences)
46
+
47
+ verify(exactly = 1) { preferences.isOnboardingFinished = true }
48
+ assertThat(DevMenuLaunchOverrides.canShowFab).isTrue()
49
+ assertThat(DevMenuLaunchOverrides.canLaunchDevMenuOnStart).isTrue()
50
+ }
51
+
52
+ @Test
53
+ fun `legacy disableFab and disableAutoLaunch are not applied here`() {
54
+ ExpoLauncherUrl(Uri.parse("scheme://expo-development-client/?url=http%3A%2F%2Fh%3A8081&disableFab=1&disableAutoLaunch=1"))
55
+ .applyDevMenuLaunchParams(preferences)
56
+
57
+ assertThat(DevMenuLaunchOverrides.canShowFab).isTrue()
58
+ assertThat(DevMenuLaunchOverrides.canLaunchDevMenuOnStart).isTrue()
59
+ verify(exactly = 0) { preferences.showFab = any() }
60
+ verify(exactly = 0) { preferences.showsAtLaunch = any() }
61
+ }
62
+
63
+ @Test
64
+ fun `a plain url changes nothing`() {
65
+ ExpoLauncherUrl(Uri.parse("exp://h:8081?x=1")).applyDevMenuLaunchParams(preferences)
66
+
67
+ assertThat(DevMenuLaunchOverrides.canShowFab).isTrue()
68
+ assertThat(DevMenuLaunchOverrides.canLaunchDevMenuOnStart).isTrue()
69
+ verify(exactly = 0) { preferences.isOnboardingFinished = any() }
70
+ }
71
+ }
@@ -0,0 +1,173 @@
1
+ package expo.modules.devmenu.launch
2
+
3
+ import android.net.Uri
4
+ import com.google.common.truth.Truth.assertThat
5
+ import org.junit.Test
6
+ import org.junit.runner.RunWith
7
+ import org.robolectric.RobolectricTestRunner
8
+
9
+ @RunWith(RobolectricTestRunner::class)
10
+ internal class ExpoLauncherUrlTest {
11
+ @Test
12
+ fun `legacy host with url and disableOnboarding`() {
13
+ val raw = "exp+slug://expo-development-client/?url=http%3A%2F%2F10.0.0.5%3A8081&disableOnboarding=1"
14
+ val launch = ExpoLauncherUrl(Uri.parse(raw))
15
+
16
+ assertThat(launch.isLauncherCommand).isTrue()
17
+ assertThat(launch.isLegacyHost).isTrue()
18
+ assertThat(launch.targetUrl).isEqualTo(Uri.parse("http://10.0.0.5:8081"))
19
+ assertThat(launch.disablesOnboarding).isTrue()
20
+ assertThat(launch.disablesFab).isFalse()
21
+ assertThat(launch.disablesAutoLaunch).isFalse()
22
+ // The legacy `url=` form is kept intact so apps and expo-router keep working.
23
+ assertThat(launch.strippedUrl.toString()).isEqualTo(raw)
24
+ }
25
+
26
+ @Test
27
+ fun `new shape with every reserved param`() {
28
+ val launch = ExpoLauncherUrl(
29
+ Uri.parse(
30
+ "exp+slug://?__expo_url=http%3A%2F%2F10.0.0.5%3A8081" +
31
+ "&__expo_disable_fab=1&__expo_disable_auto_launch=1&__expo_disable_onboarding=1"
32
+ )
33
+ )
34
+
35
+ assertThat(launch.isLauncherCommand).isTrue()
36
+ assertThat(launch.isLegacyHost).isFalse()
37
+ assertThat(launch.targetUrl).isEqualTo(Uri.parse("http://10.0.0.5:8081"))
38
+ assertThat(launch.disablesFab).isTrue()
39
+ assertThat(launch.disablesAutoLaunch).isTrue()
40
+ assertThat(launch.disablesOnboarding).isTrue()
41
+ assertThat(launch.remainderHasDestination).isFalse()
42
+ assertThat(launch.strippedUrl.query).isNull()
43
+ assertThat(launch.passthroughParams).isEmpty()
44
+ }
45
+
46
+ @Test
47
+ fun `app deep link carrying a reserved param`() {
48
+ val launch = ExpoLauncherUrl(Uri.parse("myapp://login?__expo_disable_fab=1&x=1"))
49
+
50
+ assertThat(launch.isLauncherCommand).isTrue()
51
+ assertThat(launch.targetUrl).isNull()
52
+ assertThat(launch.disablesFab).isTrue()
53
+ assertThat(launch.remainderHasDestination).isTrue()
54
+ assertThat(launch.strippedUrl.toString()).isEqualTo("myapp://login?x=1")
55
+ assertThat(launch.passthroughParams).containsExactly("x", "1")
56
+ }
57
+
58
+ @Test
59
+ fun `new shape without a destination`() {
60
+ val launch = ExpoLauncherUrl(Uri.parse("myapp://?__expo_url=http%3A%2F%2Flocalhost%3A8081"))
61
+
62
+ assertThat(launch.targetUrl).isEqualTo(Uri.parse("http://localhost:8081"))
63
+ assertThat(launch.remainderHasDestination).isFalse()
64
+ }
65
+
66
+ @Test
67
+ fun `plain app deep link is not a launcher command`() {
68
+ val raw = "myapp://login?x=1"
69
+ val launch = ExpoLauncherUrl(Uri.parse(raw))
70
+
71
+ assertThat(launch.isLauncherCommand).isFalse()
72
+ assertThat(launch.targetUrl).isNull()
73
+ assertThat(launch.strippedUrl.toString()).isEqualTo(raw)
74
+ assertThat(launch.passthroughParams).containsExactly("x", "1")
75
+ }
76
+
77
+ @Test
78
+ fun `expo go url keeps the other params`() {
79
+ val launch = ExpoLauncherUrl(Uri.parse("exp://h:8081/--/p?__expo_disable_fab=1&x=1"))
80
+
81
+ assertThat(launch.isLauncherCommand).isTrue()
82
+ assertThat(launch.disablesFab).isTrue()
83
+ assertThat(launch.targetUrl).isNull()
84
+ assertThat(launch.remainderHasDestination).isTrue()
85
+ assertThat(launch.strippedUrl.toString()).isEqualTo("exp://h:8081/--/p?x=1")
86
+ }
87
+
88
+ @Test
89
+ fun `params inside the target url are ignored`() {
90
+ val launch = ExpoLauncherUrl(
91
+ Uri.parse("exp+slug://?__expo_url=http%3A%2F%2Flocalhost%3A8081%2F%3F__expo_disable_fab%3D1")
92
+ )
93
+
94
+ assertThat(launch.disablesFab).isFalse()
95
+ assertThat(launch.targetUrl).isEqualTo(Uri.parse("http://localhost:8081/?__expo_disable_fab=1"))
96
+ }
97
+
98
+ @Test
99
+ fun `only exact values act`() {
100
+ val launch = ExpoLauncherUrl(
101
+ Uri.parse("exp://h:8081?__expo_disable_fab=0&__expo_disable_auto_launch=true&__expo_disable_onboarding=yes")
102
+ )
103
+
104
+ assertThat(launch.isLauncherCommand).isTrue()
105
+ assertThat(launch.disablesFab).isFalse()
106
+ assertThat(launch.disablesAutoLaunch).isFalse()
107
+ assertThat(launch.disablesOnboarding).isFalse()
108
+ assertThat(launch.strippedUrl.query).isNull()
109
+ }
110
+
111
+ @Test
112
+ fun `unknown reserved params are stripped`() {
113
+ val launch = ExpoLauncherUrl(Uri.parse("exp://h:8081?__expo_foo=1&x=1"))
114
+
115
+ assertThat(launch.isLauncherCommand).isTrue()
116
+ assertThat(launch.strippedUrl.toString()).isEqualTo("exp://h:8081?x=1")
117
+ }
118
+
119
+ @Test
120
+ fun `legacy aliases only apply on the legacy host`() {
121
+ val raw = "exp://h:8081?disableOnboarding=1&disableFab=1&disableAutoLaunch=1&url=http%3A%2F%2Fother"
122
+ val launch = ExpoLauncherUrl(Uri.parse(raw))
123
+
124
+ assertThat(launch.isLauncherCommand).isFalse()
125
+ assertThat(launch.disablesOnboarding).isFalse()
126
+ assertThat(launch.disablesFab).isFalse()
127
+ assertThat(launch.disablesAutoLaunch).isFalse()
128
+ assertThat(launch.targetUrl).isNull()
129
+ assertThat(launch.strippedUrl.toString()).isEqualTo(raw)
130
+ }
131
+
132
+ @Test
133
+ fun `legacy host with url only`() {
134
+ val raw = "exp+slug://expo-development-client/?url=http%3A%2F%2F10.0.0.5%3A8081"
135
+ val launch = ExpoLauncherUrl(Uri.parse(raw))
136
+
137
+ assertThat(launch.isLauncherCommand).isTrue()
138
+ assertThat(launch.isLegacyHost).isTrue()
139
+ assertThat(launch.targetUrl).isEqualTo(Uri.parse("http://10.0.0.5:8081"))
140
+ assertThat(launch.disablesOnboarding).isFalse()
141
+ assertThat(launch.disablesFab).isFalse()
142
+ assertThat(launch.disablesAutoLaunch).isFalse()
143
+ assertThat(launch.strippedUrl.toString()).isEqualTo(raw)
144
+ assertThat(launch.passthroughParams).containsExactly("url", "http://10.0.0.5:8081")
145
+ }
146
+
147
+ @Test
148
+ fun `legacy disableFab and disableAutoLaunch are left to the launcher`() {
149
+ val launch = ExpoLauncherUrl(
150
+ Uri.parse("exp+slug://expo-development-client/?url=http%3A%2F%2F10.0.0.5%3A8081&disableFab=1&disableAutoLaunch=1")
151
+ )
152
+
153
+ assertThat(launch.disablesFab).isFalse()
154
+ assertThat(launch.disablesAutoLaunch).isFalse()
155
+ assertThat(launch.passthroughParams).containsEntry("disableFab", "1")
156
+ }
157
+
158
+ @Test
159
+ fun `opaque uri does not throw`() {
160
+ val launch = ExpoLauncherUrl(Uri.parse("mailto:a@b.c"))
161
+
162
+ assertThat(launch.isLauncherCommand).isFalse()
163
+ assertThat(launch.targetUrl).isNull()
164
+ assertThat(launch.strippedUrl.toString()).isEqualTo("mailto:a@b.c")
165
+ }
166
+
167
+ @Test
168
+ fun `preserves percent encoding of the other params`() {
169
+ val launch = ExpoLauncherUrl(Uri.parse("exp://h:8081/?snack-channel=a%2Bb&__expo_disable_fab=1"))
170
+
171
+ assertThat(launch.strippedUrl.toString()).isEqualTo("exp://h:8081/?snack-channel=a%2Bb")
172
+ }
173
+ }
@@ -112,6 +112,9 @@ Pod::Spec.new do |s|
112
112
  test_spec.dependency 'React-CoreModules'
113
113
  # ExpoModulesCore requires React-hermes or React-jsc in tests, add ExpoModulesTestCore for the underlying dependencies
114
114
  test_spec.dependency 'ExpoModulesTestCore'
115
+ test_spec.pod_target_xcconfig = {
116
+ 'OTHER_LDFLAGS' => '$(inherited) -lc++'
117
+ }
115
118
  test_spec.platforms = {
116
119
  :ios => '16.4'
117
120
  }
@@ -128,6 +131,9 @@ Pod::Spec.new do |s|
128
131
  test_spec.dependency 'React'
129
132
  # ExpoModulesCore requires React-hermes or React-jsc in tests, add ExpoModulesTestCore for the underlying dependencies
130
133
  test_spec.dependency 'ExpoModulesTestCore'
134
+ test_spec.pod_target_xcconfig = {
135
+ 'OTHER_LDFLAGS' => '$(inherited) -lc++'
136
+ }
131
137
  test_spec.platforms = {
132
138
  :ios => '16.4'
133
139
  }
@@ -55,6 +55,18 @@ class DevMenuCacheContainer<T> {
55
55
  }
56
56
  }
57
57
 
58
+ final class DevMenuCallbacksRegistry {
59
+ private let storage = Mutex<[DevMenuManager.Callback]>([])
60
+
61
+ var callbacks: [DevMenuManager.Callback] {
62
+ storage.withLock { $0 }
63
+ }
64
+
65
+ func replace(with callbacks: [DevMenuManager.Callback]) {
66
+ storage.withLock { $0 = callbacks }
67
+ }
68
+ }
69
+
58
70
  /**
59
71
  Manages the dev menu and provides most of the public API.
60
72
  */
@@ -72,12 +84,20 @@ open class DevMenuManager: NSObject {
72
84
 
73
85
  var packagerConnectionHandler: DevMenuPackagerConnectionHandler?
74
86
  var canLaunchDevMenuOnStart = true
87
+ var canShowFloatingActionButton = true
75
88
  @objc public var isReactAppRunning = false
76
89
 
90
+ private let appContextLock = NSLock()
91
+ private weak var _currentAppContext: AppContext?
92
+
77
93
  /**
78
94
  The AppContext for the currently running React app. Set by DevMenuModule.OnCreate.
79
95
  */
80
- public private(set) weak var currentAppContext: AppContext?
96
+ public var currentAppContext: AppContext? {
97
+ appContextLock.lock()
98
+ defer { appContextLock.unlock() }
99
+ return _currentAppContext
100
+ }
81
101
 
82
102
  @objc public var configuration = DevMenuConfiguration()
83
103
 
@@ -179,7 +199,9 @@ open class DevMenuManager: NSObject {
179
199
 
180
200
  @objc
181
201
  public func setAppContext(_ appContext: AppContext?) {
182
- currentAppContext = appContext
202
+ appContextLock.lock()
203
+ _currentAppContext = appContext
204
+ appContextLock.unlock()
183
205
  if appContext != nil {
184
206
  isReloading = false
185
207
  lastReloadEventAt = Date()
@@ -202,10 +224,17 @@ open class DevMenuManager: NSObject {
202
224
  */
203
225
  @objc
204
226
  public func clearAppContext(current context: AppContext?) {
205
- if currentAppContext != nil && currentAppContext !== context {
227
+ appContextLock.lock()
228
+ let shouldClear = _currentAppContext == nil || _currentAppContext === context
229
+ if shouldClear {
230
+ _currentAppContext = nil
231
+ }
232
+ appContextLock.unlock()
233
+ guard shouldClear else {
206
234
  return
207
235
  }
208
- setAppContext(nil)
236
+ isReactAppRunning = false
237
+ updateAutoLaunchObserver()
209
238
  }
210
239
 
211
240
  @objc
@@ -470,9 +499,18 @@ open class DevMenuManager: NSObject {
470
499
  callbacksSubject.eraseToAnyPublisher()
471
500
  }
472
501
 
473
- public var registeredCallbacks: [Callback] = [] {
474
- didSet {
475
- callbacksSubject.send(registeredCallbacks)
502
+ private let callbacksRegistry = DevMenuCallbacksRegistry()
503
+
504
+ public var registeredCallbacks: [Callback] {
505
+ get {
506
+ callbacksRegistry.callbacks
507
+ }
508
+ set {
509
+ callbacksRegistry.replace(with: newValue)
510
+ // PassthroughSubject doesn't support concurrent sends.
511
+ DispatchQueue.main.async { [weak self] in
512
+ self?.callbacksSubject.send(newValue)
513
+ }
476
514
  }
477
515
  }
478
516
 
@@ -580,6 +618,7 @@ open class DevMenuManager: NSObject {
580
618
  }
581
619
 
582
620
  let shouldShow = DevMenuPreferences.showFloatingActionButton
621
+ && self.canShowFloatingActionButton
583
622
  && !self.isVisible
584
623
  && self.isReactAppRunning
585
624
  && !self.isNavigatingHome
@@ -0,0 +1,26 @@
1
+ // Copyright 2015-present 650 Industries. All rights reserved.
2
+
3
+ import Foundation
4
+
5
+ extension DevMenuManager {
6
+ /**
7
+ Applies the dev menu params of a launch URL. `__expo_disable_onboarding=1` finishes onboarding in the
8
+ saved preferences. `__expo_disable_fab=1` and `__expo_disable_auto_launch=1` are one-time switches for
9
+ this process and never touch the preferences.
10
+ */
11
+ @objc(applyLaunchParamsFromURL:)
12
+ public func applyLaunchParams(from url: URL) {
13
+ let launch = ExpoLauncherURL(url)
14
+ if launch.disablesOnboarding {
15
+ DevMenuPreferences.isOnboardingFinished = true
16
+ }
17
+ if launch.disablesFab {
18
+ canShowFloatingActionButton = false
19
+ updateFABVisibility()
20
+ }
21
+ if launch.disablesAutoLaunch {
22
+ canLaunchDevMenuOnStart = false
23
+ updateAutoLaunchObserver()
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,71 @@
1
+ // Copyright 2015-present 650 Industries. All rights reserved.
2
+
3
+ import Foundation
4
+
5
+ /**
6
+ Parses the reserved `__expo_*` query params of a launch URL. A URL is a launcher command when it
7
+ carries any `__expo_*` param or its host is the legacy `expo-development-client`.
8
+ Keep in sync with `packages/expo-dev-menu/android/src/debug/java/expo/modules/devmenu/launch/ExpoLauncherUrl.kt`.
9
+ */
10
+ public struct ExpoLauncherURL {
11
+ public static let reservedPrefix = "__expo_"
12
+ public static let legacyHost = "expo-development-client"
13
+
14
+ public let url: URL
15
+ public let isLegacyHost: Bool
16
+ public let isLauncherCommand: Bool
17
+ /// From `__expo_url`, or the legacy `url` next to the legacy host.
18
+ public let targetURL: URL?
19
+ /// `__expo_disable_onboarding=1`, or the legacy `disableOnboarding=1` next to the legacy host.
20
+ public let disablesOnboarding: Bool
21
+ /// `__expo_disable_fab=1`: hide the floating tools button for this process.
22
+ public let disablesFab: Bool
23
+ /// `__expo_disable_auto_launch=1`: do not open the dev menu at launch in this process.
24
+ public let disablesAutoLaunch: Bool
25
+ /// The URL without its `__expo_*` params.
26
+ public let strippedURL: URL
27
+ /// The other query params, percent-decoded.
28
+ public let passthroughParams: [String: String]
29
+ /// Whether `strippedURL` still points somewhere an app can route: a host or a path.
30
+ public let remainderHasDestination: Bool
31
+
32
+ public init(_ url: URL) {
33
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
34
+ let isLegacyHost = url.host == Self.legacyHost
35
+ let isReserved = { (item: URLQueryItem) in item.name.hasPrefix(Self.reservedPrefix) }
36
+ // `__expo_<name>`, or the legacy param next to the legacy host.
37
+ func param(_ name: String, legacy: String? = nil) -> String? {
38
+ if let value = items.first(where: { $0.name == Self.reservedPrefix + name })?.value {
39
+ return value
40
+ }
41
+ guard isLegacyHost, let legacy else {
42
+ return nil
43
+ }
44
+ return items.first(where: { $0.name == legacy })?.value
45
+ }
46
+ let strippedURL = items.contains(where: isReserved) ? Self.strippingReservedParams(from: url) : url
47
+ let host = strippedURL.host ?? ""
48
+
49
+ self.url = url
50
+ self.isLegacyHost = isLegacyHost
51
+ self.isLauncherCommand = isLegacyHost || items.contains(where: isReserved)
52
+ self.targetURL = param("url", legacy: "url").flatMap { $0.isEmpty ? nil : URL(string: $0) }
53
+ self.disablesOnboarding = param("disable_onboarding", legacy: "disableOnboarding") == "1"
54
+ self.disablesFab = param("disable_fab") == "1"
55
+ self.disablesAutoLaunch = param("disable_auto_launch") == "1"
56
+ self.strippedURL = strippedURL
57
+ self.passthroughParams = items.filter { !isReserved($0) }.reduce(into: [:]) { $0[$1.name] = $1.value ?? "" }
58
+ self.remainderHasDestination = (!host.isEmpty && host != Self.legacyHost)
59
+ || (!strippedURL.path.isEmpty && strippedURL.path != "/")
60
+ }
61
+
62
+ private static func strippingReservedParams(from url: URL) -> URL {
63
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
64
+ let items = components.percentEncodedQueryItems else {
65
+ return url
66
+ }
67
+ let remaining = items.filter { !($0.name.removingPercentEncoding ?? $0.name).hasPrefix(reservedPrefix) }
68
+ components.percentEncodedQueryItems = remaining.isEmpty ? nil : remaining
69
+ return components.url ?? url
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-dev-menu",
3
- "version": "58.0.5",
3
+ "version": "58.0.7",
4
4
  "description": "Expo/React Native module with the developer menu.",
5
5
  "keywords": [
6
6
  "dev",
@@ -40,7 +40,7 @@
40
40
  "expo": "*",
41
41
  "react-native": "*"
42
42
  },
43
- "gitHead": "add8cbf49f477cfd2283ae05af35b4713a773c55",
43
+ "gitHead": "ad41d067bd3df6dd12f1788acc6b54a68e7a650d",
44
44
  "scripts": {
45
45
  "build": "pnpm run build:lib && pnpm run build:plugin",
46
46
  "build:lib": "expo-build src",