expo-dev-launcher 57.0.0 → 57.0.2

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,26 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.2 — 2026-06-27
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
17
+ ## 57.0.1 — 2026-06-25
18
+
19
+ ### 🎉 New features
20
+
21
+ - Add a Settings toggle to switch between auto-launching the most recent app and showing the launcher. ([#47131](https://github.com/expo/expo/pull/47131) by [@alanjhughes](https://github.com/alanjhughes))
22
+
23
+ ### 🐛 Bug fixes
24
+
25
+ - [iOS] Present Local Network permission as a status row instead of a toggle, and make the Settings debug actions native, full-row-tappable list rows. ([#46758](https://github.com/expo/expo/pull/46758) by [@alanjhughes](https://github.com/alanjhughes))
26
+ - [iOS] Improve the Updates tab empty state with clearer guidance on how to publish an update. ([#46759](https://github.com/expo/expo/pull/46759) by [@alanjhughes](https://github.com/alanjhughes))
27
+ - [iOS] Present the development server info dialog as a native sheet. ([#46760](https://github.com/expo/expo/pull/46760) by [@alanjhughes](https://github.com/alanjhughes))
28
+ - [iOS] Reduce accidental logout in the account selector with a confirmation step and a less prominent button, and add more spacing around the header title. ([#46761](https://github.com/expo/expo/pull/46761) by [@alanjhughes](https://github.com/alanjhughes))
29
+ - [iOS] Make the development server list reliable, keep discovery running across tab switches, periodically re-verify discovered servers, add pull-to-refresh on the Home tab, and show a searching state instead of "No development servers found". ([#46811](https://github.com/expo/expo/pull/46811) by [@alanjhughes](https://github.com/alanjhughes))
30
+ - [iOS] Explain why a project failed to load instead of showing a generic "Failed to connect" message. ([#46866](https://github.com/expo/expo/pull/46866) by [@alanjhughes](https://github.com/alanjhughes))
31
+ - [Android] Fix auto-launching into the most recently opened project on startup. ([#47131](https://github.com/expo/expo/pull/47131) by [@alanjhughes](https://github.com/alanjhughes))
32
+
13
33
  ## 57.0.0 — 2026-06-25
14
34
 
15
35
  _This version does not introduce any user-facing changes._
@@ -26,13 +26,13 @@ expoModule {
26
26
  }
27
27
 
28
28
  group = "host.exp.exponent"
29
- version = "57.0.0"
29
+ version = "57.0.2"
30
30
 
31
31
  android {
32
32
  namespace "expo.modules.devlauncher"
33
33
  defaultConfig {
34
34
  versionCode 9
35
- versionName "57.0.0"
35
+ versionName "57.0.2"
36
36
  }
37
37
 
38
38
  buildTypes {
@@ -337,10 +337,21 @@ class DevLauncherController private constructor(
337
337
  return@let
338
338
  }
339
339
 
340
- val shouldTryToLaunchLastOpenedBundle = getMetadataValue(context, "DEV_CLIENT_TRY_TO_LAUNCH_LAST_BUNDLE", "true").toBoolean()
340
+ val shouldTryToLaunchLastOpenedBundle =
341
+ DependencyInjection.devMenuPreferences?.tryToLaunchLastBundle ?: true
341
342
  val lastOpenedApp = recentlyOpedAppsRegistry.getMostRecentApp()
342
343
  if (shouldTryToLaunchLastOpenedBundle && lastOpenedApp != null) {
343
- launchDefaultUrlOrNavigateToLauncher(coroutineScope, defaultLaunchUrl, activityToBeInvalidated)
344
+ coroutineScope.launch {
345
+ try {
346
+ loadApp(lastOpenedApp.url.toUri(), activityToBeInvalidated)
347
+ } catch (_: Throwable) {
348
+ if (useDefaultLaunchUrlFallback) {
349
+ launchDefaultUrlOrNavigateToLauncher(coroutineScope, defaultLaunchUrl, activityToBeInvalidated)
350
+ } else {
351
+ navigateToLauncher()
352
+ }
353
+ }
354
+ }
344
355
  return true
345
356
  }
346
357
 
@@ -10,6 +10,7 @@ import expo.modules.devmenu.DevMenuPreferences
10
10
 
11
11
  data class SettingsState(
12
12
  val showMenuAtLaunch: Boolean = false,
13
+ val autoLaunchMostRecent: Boolean = true,
13
14
  val isShakeEnable: Boolean = true,
14
15
  val isThreeFingerLongPressEnable: Boolean = true,
15
16
  val isKeyCommandEnabled: Boolean = true,
@@ -23,6 +24,7 @@ data class SettingsState(
23
24
 
24
25
  sealed interface SettingsAction {
25
26
  data class ToggleShowMenuAtLaunch(val newValue: Boolean) : SettingsAction
27
+ data class ToggleAutoLaunchMostRecent(val newValue: Boolean) : SettingsAction
26
28
  data class ToggleShakeEnable(val newValue: Boolean) : SettingsAction
27
29
  data class ToggleThreeFingerLongPressEnable(val newValue: Boolean) : SettingsAction
28
30
  data class ToggleKeyCommandEnable(val newValue: Boolean) : SettingsAction
@@ -40,6 +42,7 @@ class SettingsViewModel : ViewModel() {
40
42
  private val _state = mutableStateOf(
41
43
  SettingsState(
42
44
  showMenuAtLaunch = menuPreferences.showsAtLaunch,
45
+ autoLaunchMostRecent = menuPreferences.tryToLaunchLastBundle,
43
46
  isShakeEnable = menuPreferences.motionGestureEnabled,
44
47
  isThreeFingerLongPressEnable = menuPreferences.touchGestureEnabled,
45
48
  isKeyCommandEnabled = menuPreferences.keyCommandsEnabled,
@@ -57,6 +60,7 @@ class SettingsViewModel : ViewModel() {
57
60
  private val menuListener = {
58
61
  _state.value = _state.value.copy(
59
62
  showMenuAtLaunch = menuPreferences.showsAtLaunch,
63
+ autoLaunchMostRecent = menuPreferences.tryToLaunchLastBundle,
60
64
  isShakeEnable = menuPreferences.motionGestureEnabled,
61
65
  isThreeFingerLongPressEnable = menuPreferences.touchGestureEnabled,
62
66
  isKeyCommandEnabled = menuPreferences.keyCommandsEnabled,
@@ -86,6 +90,7 @@ class SettingsViewModel : ViewModel() {
86
90
  fun onAction(action: SettingsAction) {
87
91
  when (action) {
88
92
  is SettingsAction.ToggleShowMenuAtLaunch -> menuPreferences.showsAtLaunch = action.newValue
93
+ is SettingsAction.ToggleAutoLaunchMostRecent -> menuPreferences.tryToLaunchLastBundle = action.newValue
89
94
  is SettingsAction.ToggleShakeEnable -> menuPreferences.motionGestureEnabled = action.newValue
90
95
  is SettingsAction.ToggleThreeFingerLongPressEnable -> menuPreferences.touchGestureEnabled = action.newValue
91
96
  is SettingsAction.ToggleKeyCommandEnable -> menuPreferences.keyCommandsEnabled = action.newValue
@@ -72,25 +72,7 @@ fun SettingsScreen(
72
72
  )
73
73
  }
74
74
 
75
- NewMenuButton(
76
- icon = {
77
- LauncherIcons.ShowAtLaunch(
78
- size = 20.dp,
79
- tint = NewAppTheme.colors.icon.tertiary
80
- )
81
- },
82
- content = {
83
- NewText(
84
- text = "Show menu at launch"
85
- )
86
- },
87
- rightComponent = {
88
- ToggleSwitch(
89
- isToggled = state.showMenuAtLaunch
90
- )
91
- },
92
- onClick = { onAction(SettingsAction.ToggleShowMenuAtLaunch(!state.showMenuAtLaunch)) }
93
- )
75
+ LaunchBehaviourSection(state, onAction)
94
76
 
95
77
  Spacer(NewAppTheme.spacing.`6`)
96
78
 
@@ -131,6 +113,66 @@ fun SettingsScreen(
131
113
  }
132
114
  }
133
115
 
116
+ @Composable
117
+ private fun LaunchBehaviourSection(state: SettingsState, onAction: (SettingsAction) -> Unit) {
118
+ Column(
119
+ verticalArrangement = Arrangement.spacedBy(NewAppTheme.spacing.`3`)
120
+ ) {
121
+ Section.Header("LAUNCH BEHAVIOUR")
122
+
123
+ RoundedSurface {
124
+ Column {
125
+ NewMenuButton(
126
+ withSurface = false,
127
+ icon = {
128
+ LauncherIcons.ShowAtLaunch(
129
+ size = 20.dp,
130
+ tint = NewAppTheme.colors.icon.tertiary
131
+ )
132
+ },
133
+ content = {
134
+ NewText(
135
+ text = "Show menu at launch"
136
+ )
137
+ },
138
+ rightComponent = {
139
+ ToggleSwitch(
140
+ isToggled = state.showMenuAtLaunch
141
+ )
142
+ },
143
+ onClick = { onAction(SettingsAction.ToggleShowMenuAtLaunch(!state.showMenuAtLaunch)) }
144
+ )
145
+
146
+ Divider(
147
+ thickness = 0.5.dp,
148
+ color = NewAppTheme.colors.border.default
149
+ )
150
+
151
+ NewMenuButton(
152
+ withSurface = false,
153
+ icon = {
154
+ LauncherIcons.ShowAtLaunch(
155
+ size = 20.dp,
156
+ tint = NewAppTheme.colors.icon.tertiary
157
+ )
158
+ },
159
+ content = {
160
+ NewText(
161
+ text = "Auto-launch most recent app"
162
+ )
163
+ },
164
+ rightComponent = {
165
+ ToggleSwitch(
166
+ isToggled = state.autoLaunchMostRecent
167
+ )
168
+ },
169
+ onClick = { onAction(SettingsAction.ToggleAutoLaunchMostRecent(!state.autoLaunchMostRecent)) }
170
+ )
171
+ }
172
+ }
173
+ }
174
+ }
175
+
134
176
  @Composable
135
177
  private fun MenuGesturesSection(state: SettingsState, onAction: (SettingsAction) -> Unit) {
136
178
  Column(
@@ -98,7 +98,7 @@ fun ServerUrlInput(
98
98
  TextInput(
99
99
  placeholder = {
100
100
  NewText(
101
- text = "exp://",
101
+ text = "http://",
102
102
  style = NewAppTheme.font.md,
103
103
  color = NewAppTheme.colors.text.secondary
104
104
  )
@@ -136,6 +136,11 @@ Pod::Spec.new do |s|
136
136
  test_spec.dependency "OHHTTPStubs"
137
137
  # ExpoModulesCore requires React-hermes or React-jsc in tests, add ExpoModulesTestCore for the underlying dependencies
138
138
  test_spec.dependency 'ExpoModulesTestCore'
139
+ # The linked static libs contain C++ but the test bundle has no C++ sources,
140
+ # so the C++ runtime must be linked explicitly
141
+ test_spec.pod_target_xcconfig = {
142
+ 'OTHER_LDFLAGS' => '$(inherited) -lc++'
143
+ }
139
144
  end
140
145
 
141
146
  s.default_subspec = 'Main'
@@ -91,6 +91,11 @@ static const NSTimeInterval EXDevLauncherDefaultRequestTimeout = 10.0;
91
91
  self.defaultLaunchURLString = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"DEV_CLIENT_DEFAULT_LAUNCHER_URL"];
92
92
  self.useDefaultLaunchUrlFallback = self.defaultLaunchURLString.length != 0;
93
93
  self.defaultLaunchURL = [NSURL URLWithString:self.defaultLaunchURLString];
94
+
95
+ NSNumber *tryToLaunchLastBundle = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"DEV_CLIENT_TRY_TO_LAUNCH_LAST_BUNDLE"];
96
+ [[NSUserDefaults standardUserDefaults] registerDefaults:@{
97
+ @"EXDevLauncherTryToLaunchLastBundle": tryToLaunchLastBundle ?: @YES
98
+ }];
94
99
  }
95
100
  return self;
96
101
  }
@@ -236,8 +241,8 @@ static const NSTimeInterval EXDevLauncherDefaultRequestTimeout = 10.0;
236
241
  return;
237
242
  }
238
243
 
239
- NSNumber *devClientTryToLaunchLastBundleValue = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"DEV_CLIENT_TRY_TO_LAUNCH_LAST_BUNDLE"];
240
- BOOL shouldTryToLaunchLastOpenedBundle = (devClientTryToLaunchLastBundleValue != nil) ? [devClientTryToLaunchLastBundleValue boolValue] : YES;
244
+ // Default registered from the config plugin in init; the Settings toggle overrides it.
245
+ BOOL shouldTryToLaunchLastOpenedBundle = [[NSUserDefaults standardUserDefaults] boolForKey:@"EXDevLauncherTryToLaunchLastBundle"];
241
246
  BOOL useDefaultLaunchUrlFallback = self.useDefaultLaunchUrlFallback;
242
247
 
243
248
  if (!hasGrantedNetworkPermission) {
@@ -5,6 +5,7 @@ import SwiftUI
5
5
  struct AccountSheet: View {
6
6
  @Environment(\.dismiss) private var dismiss
7
7
  @EnvironmentObject var viewModel: DevLauncherViewModel
8
+ @State private var showingLogoutConfirmation = false
8
9
 
9
10
  var body: some View {
10
11
  VStack(spacing: 0) {
@@ -26,7 +27,7 @@ struct AccountSheet: View {
26
27
  loginSignupCard
27
28
  }
28
29
  }
29
- .padding(.horizontal, 16)
30
+ .padding(.horizontal, 20)
30
31
  }
31
32
  .frame(maxWidth: .infinity, maxHeight: .infinity)
32
33
  #if !os(tvOS) && !os(macOS)
@@ -53,7 +54,7 @@ struct AccountSheet: View {
53
54
  .frame(width: 44, height: 44)
54
55
  }
55
56
  }
56
- .padding(.horizontal, 16)
57
+ .padding(.horizontal, 20)
57
58
  .padding(.top, 8)
58
59
  }
59
60
  }
@@ -76,19 +77,32 @@ struct AccountSheet: View {
76
77
  }
77
78
  }
78
79
 
79
- Button {
80
+ logOutButton
81
+ }
82
+ }
83
+
84
+ private var logOutButton: some View {
85
+ Button(role: .destructive) {
86
+ showingLogoutConfirmation = true
87
+ }
88
+ label: {
89
+ Text("Log out")
90
+ .font(.headline)
91
+ .foregroundColor(.red)
92
+ .frame(maxWidth: .infinity)
93
+ .padding(.vertical, 12)
94
+ }
95
+ .background(Color.expoSecondarySystemBackground)
96
+ .cornerRadius(12)
97
+ .confirmationDialog(
98
+ "Are you sure you want to log out?",
99
+ isPresented: $showingLogoutConfirmation,
100
+ titleVisibility: .visible
101
+ ) {
102
+ Button("Log out", role: .destructive) {
80
103
  viewModel.signOut()
81
104
  }
82
- label: {
83
- Text("Log out")
84
- .font(.headline)
85
- .fontWeight(.bold)
86
- .foregroundColor(.white)
87
- .frame(maxWidth: .infinity)
88
- .padding(.vertical, 12)
89
- }
90
- .background(Color.black)
91
- .cornerRadius(12)
105
+ Button("Cancel", role: .cancel) {}
92
106
  }
93
107
  }
94
108
 
@@ -0,0 +1,65 @@
1
+ // Copyright 2015-present 650 Industries. All rights reserved.
2
+
3
+ import Foundation
4
+
5
+ enum DevLauncherLoadErrorMessage {
6
+ static func message(for error: NSError, url: String) -> String {
7
+ if error.domain == "DevelopmentClient" {
8
+ return error.localizedDescription
9
+ }
10
+
11
+ guard error.domain == NSURLErrorDomain else {
12
+ return "Could not load the app from \(url). \(error.localizedDescription)"
13
+ }
14
+
15
+ let host = URL(string: url)?.host ?? ""
16
+
17
+ switch error.code {
18
+ case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
19
+ return "Could not find the server at \(url). The hostname could not be resolved. Check that the URL is correct and that this device is on the same network as the dev server."
20
+ case NSURLErrorCannotConnectToHost:
21
+ if isLocalhost(host) {
22
+ return localhostMessage(url)
23
+ }
24
+ return "Could not connect to \(url). The server refused the connection. Make sure the dev server is running (npx expo start) and the port is correct."
25
+ case NSURLErrorTimedOut:
26
+ if isLocalhost(host) {
27
+ return localhostMessage(url)
28
+ }
29
+ if isPrivateNetworkHost(host) {
30
+ return "Could not reach \(url). This is a local network address. Make sure this device is on the same Wi-Fi network as the dev server."
31
+ }
32
+ return "The request to \(url) timed out. Check that the server is running and reachable from this device."
33
+ case NSURLErrorNotConnectedToInternet:
34
+ return "Could not load the app because this device is offline. Connect to a network and try again."
35
+ case NSURLErrorNetworkConnectionLost:
36
+ return "The connection to \(url) was interrupted. Try again."
37
+ default:
38
+ return "Could not load the app from \(url). \(error.localizedDescription)"
39
+ }
40
+ }
41
+
42
+ private static func localhostMessage(_ url: String) -> String {
43
+ return "Could not reach \(url). On a physical device, localhost points at the device itself, use your computer's LAN IP instead, or start the server with npx expo start and select it from the list."
44
+ }
45
+
46
+ private static func isLocalhost(_ host: String) -> Bool {
47
+ return host == "localhost" || host.hasPrefix("127.")
48
+ }
49
+
50
+ private static func isPrivateNetworkHost(_ host: String) -> Bool {
51
+ if host.hasSuffix(".local") {
52
+ return true
53
+ }
54
+ if host.hasPrefix("10.") || host.hasPrefix("192.168.") || host.hasPrefix("169.254.") {
55
+ return true
56
+ }
57
+ if host.hasPrefix("172.") {
58
+ let octets = host.split(separator: ".")
59
+ if octets.count == 4, let second = Int(octets[1]) {
60
+ return (16...31).contains(second)
61
+ }
62
+ }
63
+ return false
64
+ }
65
+ }
@@ -69,7 +69,7 @@ import ExpoModulesCore
69
69
  hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
70
70
  hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
71
71
  hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
72
- hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
72
+ hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
73
73
  ])
74
74
 
75
75
  #if !os(macOS)
@@ -50,6 +50,11 @@ class DevLauncherViewModel: ObservableObject {
50
50
  DevMenuManager.shared.setShowsAtLaunch(showOnLaunch)
51
51
  }
52
52
  }
53
+ @Published var autoLaunchMostRecent = true {
54
+ didSet {
55
+ UserDefaults.standard.set(autoLaunchMostRecent, forKey: "EXDevLauncherTryToLaunchLastBundle")
56
+ }
57
+ }
53
58
  @Published var isAuthenticated = false
54
59
  @Published var isAuthenticating = false
55
60
  @Published var user: User?
@@ -62,6 +67,9 @@ class DevLauncherViewModel: ObservableObject {
62
67
 
63
68
  private var browser: NWBrowser?
64
69
  private var pingTask: Task<Void, Never>?
70
+ private var periodicRefreshTask: Task<Void, Never>?
71
+ private var pendingEmptyVerification = false
72
+ private static let refreshInterval: UInt64 = 10_000_000_000
65
73
 
66
74
  #if !os(tvOS)
67
75
  private let presentationContext = DevLauncherAuthPresentationContext()
@@ -97,6 +105,16 @@ class DevLauncherViewModel: ObservableObject {
97
105
  }
98
106
 
99
107
  private func updateDevServers(_ servers: [DevServer]) {
108
+ if servers.isEmpty && !devServers.isEmpty && !pendingEmptyVerification {
109
+ pendingEmptyVerification = true
110
+ stopServerDiscovery()
111
+ startServerDiscovery()
112
+ return
113
+ }
114
+ pendingEmptyVerification = false
115
+ if !servers.isEmpty {
116
+ markNetworkPermissionGranted()
117
+ }
100
118
  devServers = servers.sorted(by: <)
101
119
  }
102
120
 
@@ -163,8 +181,8 @@ class DevLauncherViewModel: ObservableObject {
163
181
  self?.isLoadingServer = false
164
182
  }
165
183
  },
166
- onError: { [weak self] _ in
167
- let message = "Failed to connect to \(url)"
184
+ onError: { [weak self] error in
185
+ let message = DevLauncherLoadErrorMessage.message(for: error as NSError, url: url)
168
186
  DispatchQueue.main.async {
169
187
  self?.isLoadingServer = false
170
188
  self?.showErrorAlert(message)
@@ -204,9 +222,65 @@ class DevLauncherViewModel: ObservableObject {
204
222
 
205
223
  stopServerDiscovery()
206
224
  startDevServerBrowser()
225
+ startPeriodicRefresh()
226
+ }
227
+
228
+ func refreshDevServers() async {
229
+ await restartBrowser()
230
+ }
231
+
232
+ private func restartBrowser() async {
233
+ pingTask?.cancel()
234
+ browser?.cancel()
235
+ pingTask = nil
236
+ browser = nil
237
+ startDevServerBrowser()
238
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
239
+ await pingCurrentBrowseResults()
240
+ }
241
+
242
+ private func pingCurrentBrowseResults() async {
243
+ guard let browser, !browser.browseResults.isEmpty else {
244
+ return
245
+ }
246
+ await pingDiscoveryResults(browser.browseResults.map { result in
247
+ DiscoveryResult(
248
+ name: NetworkUtilities.getNWBrowserResultName(result),
249
+ endpoint: result.endpoint
250
+ )
251
+ })
252
+ }
253
+
254
+ private func startPeriodicRefresh() {
255
+ periodicRefreshTask?.cancel()
256
+ periodicRefreshTask = Task { [weak self] in
257
+ while !Task.isCancelled {
258
+ try? await Task.sleep(nanoseconds: Self.refreshInterval)
259
+ guard !Task.isCancelled else {
260
+ return
261
+ }
262
+ await self?.refreshIfNeeded()
263
+ }
264
+ }
265
+ }
266
+
267
+ private func refreshIfNeeded() async {
268
+ guard let browser else {
269
+ return
270
+ }
271
+ if devServers.isEmpty {
272
+ await restartBrowser()
273
+ } else if browser.browseResults.isEmpty {
274
+ updateDevServers([])
275
+ } else {
276
+ await pingCurrentBrowseResults()
277
+ }
207
278
  }
208
279
 
209
280
  func markNetworkPermissionGranted() {
281
+ guard permissionStatus != .granted else {
282
+ return
283
+ }
210
284
  UserDefaults.standard.set(true, forKey: networkPermissionGrantedKey)
211
285
  permissionStatus = .granted
212
286
  }
@@ -272,8 +346,10 @@ class DevLauncherViewModel: ObservableObject {
272
346
  }
273
347
 
274
348
  func stopServerDiscovery() {
349
+ periodicRefreshTask?.cancel()
275
350
  pingTask?.cancel()
276
351
  browser?.cancel()
352
+ periodicRefreshTask = nil
277
353
  pingTask = nil
278
354
  browser = nil
279
355
  }
@@ -408,9 +484,10 @@ class DevLauncherViewModel: ObservableObject {
408
484
  private func loadMenuPreferences() {
409
485
  let defaults = UserDefaults.standard
410
486
 
411
- shakeDevice = defaults.object(forKey: "EXDevMenuMotionGestureEnabled") as? Bool ?? true
412
- threeFingerLongPress = defaults.object(forKey: "EXDevMenuTouchGestureEnabled") as? Bool ?? true
413
- showOnLaunch = defaults.object(forKey: "EXDevMenuShowsAtLaunch") as? Bool ?? false
487
+ shakeDevice = defaults.bool(forKey: "EXDevMenuMotionGestureEnabled")
488
+ threeFingerLongPress = defaults.bool(forKey: "EXDevMenuTouchGestureEnabled")
489
+ showOnLaunch = defaults.bool(forKey: "EXDevMenuShowsAtLaunch")
490
+ autoLaunchMostRecent = defaults.bool(forKey: "EXDevLauncherTryToLaunchLastBundle")
414
491
  }
415
492
 
416
493
  private func checkAuthenticationStatus() {
@@ -13,7 +13,7 @@ public struct DevLauncherRootView: View {
13
13
  || UserDefaults.standard.bool(forKey: "expo.devlauncher.hasGrantedNetworkPermission")
14
14
  _hasCompletedPermissionFlow = State(initialValue: shouldSkipPermissionFlow)
15
15
  }
16
-
16
+
17
17
  private static var isSimulator: Bool {
18
18
  #if targetEnvironment(simulator)
19
19
  return true
@@ -31,7 +31,7 @@ public struct DevLauncherRootView: View {
31
31
  mainContent
32
32
  }
33
33
  }
34
-
34
+
35
35
  @ViewBuilder
36
36
  private var mainContent: some View {
37
37
  let tabView = TabView {
@@ -76,6 +76,12 @@ public struct DevLauncherRootView: View {
76
76
  #endif
77
77
 
78
78
  navigationStack
79
+ .onAppear {
80
+ viewModel.startServerDiscovery()
81
+ }
82
+ .onDisappear {
83
+ viewModel.stopServerDiscovery()
84
+ }
79
85
  .sheet(isPresented: $showingUserProfile) {
80
86
  AccountSheet()
81
87
  .environmentObject(viewModel)
@@ -3,50 +3,35 @@
3
3
  import SwiftUI
4
4
 
5
5
  struct DevServerInfoModal: View {
6
- @Binding var showingInfoDialog: Bool
6
+ @Environment(\.dismiss) private var dismiss
7
7
 
8
8
  var body: some View {
9
- Group {
10
- if #available(iOS 15.0, tvOS 16.0, *) {
11
- if showingInfoDialog {
12
- Color.black.opacity(0.4)
13
- .ignoresSafeArea(.all)
14
- .onTapGesture {
15
- showingInfoDialog = false
16
- }
17
- content
18
- }
19
- }
20
- }
21
- .animation(.easeInOut(duration: 0.3), value: showingInfoDialog)
22
- }
23
-
24
- private var content: some View {
25
- VStack(spacing: 16) {
9
+ VStack(alignment: .leading, spacing: 16) {
26
10
  header
27
- Divider()
28
11
  info
12
+ Spacer()
29
13
  }
30
14
  .padding(20)
31
- .background(Color.expoSystemBackground)
32
- .clipShape(RoundedRectangle(cornerRadius: 16))
33
- .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5)
34
- .padding(.horizontal, 40)
15
+ .frame(maxWidth: .infinity, alignment: .leading)
16
+ #if os(iOS)
17
+ .presentationDetents([.medium])
18
+ #endif
35
19
  }
36
20
 
37
21
  private var header: some View {
38
22
  HStack {
39
23
  Text("Development Servers")
40
- .font(.headline)
41
- .fontWeight(.semibold)
24
+ .font(.title2)
25
+ .fontWeight(.bold)
42
26
  Spacer()
43
27
  Button {
44
- showingInfoDialog = false
28
+ dismiss()
45
29
  } label: {
46
- Image(systemName: "xmark")
47
- .font(.title3)
30
+ Image(systemName: "xmark.circle.fill")
31
+ .font(.title2)
48
32
  .foregroundColor(.secondary)
49
33
  }
34
+ .buttonStyle(.plain)
50
35
  }
51
36
  }
52
37
 
@@ -23,6 +23,7 @@ private func sanitizeUrlString(_ urlString: String) -> String? {
23
23
  }
24
24
 
25
25
  private let urlInputAnimation = Animation.easeInOut(duration: 0.3)
26
+ private let keyboardShortcuts = ["http://", "https://", "127.0.0.1", ":", "/"]
26
27
 
27
28
  struct DevServersView: View {
28
29
  @EnvironmentObject var viewModel: DevLauncherViewModel
@@ -49,11 +50,17 @@ struct DevServersView: View {
49
50
 
50
51
  LazyVStack(alignment: .leading, spacing: 6) {
51
52
  if viewModel.devServers.isEmpty {
52
- Text("No development servers found")
53
- .foregroundColor(.primary)
54
- .multilineTextAlignment(.leading)
53
+ if viewModel.permissionStatus != .denied {
54
+ HStack {
55
+ Text("Searching for development servers...")
56
+ .foregroundColor(.secondary)
57
+ Spacer()
58
+ ProgressView()
59
+ .controlSize(.small)
60
+ }
55
61
  .padding()
56
- Divider()
62
+ Divider()
63
+ }
57
64
  } else {
58
65
  ForEach(viewModel.devServers, id: \.self) { server in
59
66
  DevServerRow(server: server) {
@@ -67,12 +74,6 @@ struct DevServersView: View {
67
74
  enterUrl
68
75
  }
69
76
  }
70
- .onAppear {
71
- viewModel.startServerDiscovery()
72
- }
73
- .onDisappear {
74
- viewModel.stopServerDiscovery()
75
- }
76
77
  }
77
78
 
78
79
  private var enterUrl: some View {
@@ -100,15 +101,27 @@ struct DevServersView: View {
100
101
  }
101
102
 
102
103
  if showingURLInput {
103
- TextField("exp://", text: $urlText)
104
+ TextField("http://", text: $urlText)
104
105
  .onSubmit {
105
106
  connectToURL()
106
107
  }
107
108
  .submitLabel(.go)
108
109
  #if !os(macOS)
110
+ .keyboardType(.URL)
109
111
  .autocapitalization(.none)
110
112
  #endif
111
113
  .disableAutocorrection(true)
114
+ .toolbar {
115
+ ToolbarItemGroup(placement: .keyboard) {
116
+ ForEach(keyboardShortcuts, id: \.self) { shortcut in
117
+ Button {
118
+ urlText += shortcut
119
+ } label: {
120
+ Text(shortcut)
121
+ }
122
+ }
123
+ }
124
+ }
112
125
  .padding(.horizontal, 16)
113
126
  .padding(.vertical, 12)
114
127
  .foregroundColor(.primary)
@@ -142,13 +155,17 @@ struct DevServersView: View {
142
155
  Text("Load embedded bundle")
143
156
  .foregroundColor(.primary)
144
157
  Spacer()
145
- if viewModel.isLoadingLocalBundle {
146
- ProgressView()
147
- } else {
148
- Image(systemName: "chevron.right")
149
- .font(.caption)
150
- .foregroundColor(.secondary)
158
+ Group {
159
+ if viewModel.isLoadingLocalBundle {
160
+ ProgressView()
161
+ .controlSize(.small)
162
+ } else {
163
+ Image(systemName: "chevron.right")
164
+ .font(.caption)
165
+ .foregroundColor(.secondary)
166
+ }
151
167
  }
168
+ .frame(width: 20, height: 20)
152
169
  }
153
170
  .padding()
154
171
  .background(Color.expoSecondarySystemBackground)
@@ -231,13 +248,17 @@ struct DevServerRow: View {
231
248
 
232
249
  Spacer()
233
250
 
234
- if viewModel.isLoadingServer {
235
- ProgressView()
236
- } else {
237
- Image(systemName: "chevron.right")
238
- .font(.caption)
239
- .foregroundColor(.secondary)
251
+ Group {
252
+ if viewModel.isLoadingServer {
253
+ ProgressView()
254
+ .controlSize(.small)
255
+ } else {
256
+ Image(systemName: "chevron.right")
257
+ .font(.caption)
258
+ .foregroundColor(.secondary)
259
+ }
240
260
  }
261
+ .frame(width: 20, height: 20)
241
262
  }
242
263
  .padding()
243
264
  .background(Color.expoSecondarySystemBackground)
@@ -55,13 +55,18 @@ struct HomeTabView: View {
55
55
  }
56
56
  .padding()
57
57
  }
58
+ #if !os(tvOS)
59
+ .refreshable {
60
+ await viewModel.refreshDevServers()
61
+ }
62
+ #endif
58
63
  }
59
64
  #if os(tvOS)
60
65
  .background()
61
66
  #endif
62
- .overlay(
63
- DevServerInfoModal(showingInfoDialog: $showingInfoDialog)
64
- )
67
+ .sheet(isPresented: $showingInfoDialog) {
68
+ DevServerInfoModal()
69
+ }
65
70
  }
66
71
 
67
72
  private var crashReportBanner: some View {
@@ -33,7 +33,7 @@ func buildHttpHost(endpoint: NWEndpoint) -> String? {
33
33
  private final class AtomicFlag: @unchecked Sendable {
34
34
  private var _value = false
35
35
  private let lock = NSLock()
36
-
36
+
37
37
  func testAndSet() -> Bool {
38
38
  lock.lock()
39
39
  defer { lock.unlock() }
@@ -51,7 +51,7 @@ func connectionStart(
51
51
  try await withTaskCancellationHandler {
52
52
  try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
53
53
  let didResume = AtomicFlag()
54
-
54
+
55
55
  let timeoutWork = DispatchWorkItem { [weak connection] in
56
56
  guard didResume.testAndSet() else { return }
57
57
  connection?.stateUpdateHandler = nil
@@ -59,7 +59,7 @@ func connectionStart(
59
59
  cont.resume(throwing: ConnectionError.failedConnection)
60
60
  }
61
61
  queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
62
-
62
+
63
63
  connection.stateUpdateHandler = { [weak connection] state in
64
64
  switch state {
65
65
  case .ready:
@@ -33,7 +33,7 @@ struct SettingsTabView: View {
33
33
  VStack(alignment: .leading, spacing: 24) {
34
34
  SafeAreaTopPadding(manualInset: viewModel.topSafeAreaInset)
35
35
  titleSection
36
- showMenuAtLaunch
36
+ launchBehaviour
37
37
  gestures
38
38
 
39
39
  Text(selectedGesturesInfoMessage)
@@ -48,10 +48,14 @@ struct SettingsTabView: View {
48
48
  Text("system".uppercased())
49
49
  .font(.caption)
50
50
  .foregroundColor(.primary.opacity(0.6))
51
- Divider()
52
- version
53
- Divider()
54
- copyToClipboardButton
51
+
52
+ VStack(spacing: 0) {
53
+ version
54
+ Divider()
55
+ copyToClipboardButton
56
+ }
57
+ .background(Color.expoSecondarySystemBackground)
58
+ .cornerRadius(12)
55
59
  }
56
60
 
57
61
  if isAdminUser {
@@ -78,34 +82,47 @@ struct SettingsTabView: View {
78
82
  #endif
79
83
  }
80
84
 
81
- private var showMenuAtLaunch: some View {
82
- HStack {
83
- Image("show-menu-at-launch", bundle: getDevLauncherBundle())
84
- .resizable()
85
- .frame(width: 24, height: 24)
86
- .opacity(0.6)
87
- Toggle("Show menu at launch", isOn: $viewModel.showOnLaunch)
85
+ private var launchBehaviour: some View {
86
+ VStack(alignment: .leading) {
87
+ Text("Launch behaviour".uppercased())
88
+ .font(.caption)
89
+ .foregroundColor(.primary.opacity(0.6))
90
+
91
+ VStack(spacing: 0) {
92
+ HStack {
93
+ Image("show-menu-at-launch", bundle: getDevLauncherBundle())
94
+ .resizable()
95
+ .aspectRatio(contentMode: .fit)
96
+ .frame(width: 24, height: 24)
97
+ .opacity(0.6)
98
+ Toggle("Show menu at launch", isOn: $viewModel.showOnLaunch)
99
+ }
100
+ .padding(.horizontal)
101
+ .padding(.vertical, 12)
102
+
103
+ Divider()
104
+
105
+ HStack {
106
+ Image(systemName: "arrow.trianglehead.clockwise.rotate.90")
107
+ .resizable()
108
+ .aspectRatio(contentMode: .fit)
109
+ .frame(width: 24, height: 24)
110
+ .opacity(0.6)
111
+ Toggle("Auto-launch most recent app", isOn: $viewModel.autoLaunchMostRecent)
112
+ }
113
+ .padding(.horizontal)
114
+ .padding(.vertical, 12)
115
+ }
116
+ .background(Color.expoSecondarySystemBackground)
117
+ .cornerRadius(12)
88
118
  }
89
- .padding()
90
- .background(Color.expoSecondarySystemBackground)
91
- .cornerRadius(12)
92
119
  }
93
120
 
94
121
  private var titleSection: some View {
95
- HStack {
96
- Spacer()
97
- VStack {
98
- Image(systemName: "gearshape")
99
- .resizable()
100
- .frame(width: 56, height: 56)
101
- .opacity(0.3)
102
-
103
- Text("Settings")
104
- .font(.title2)
105
- .padding(.horizontal, 20)
106
- }
107
- Spacer()
108
- }
122
+ Text("Settings")
123
+ .font(.largeTitle)
124
+ .fontWeight(.bold)
125
+ .frame(maxWidth: .infinity, alignment: .leading)
109
126
  }
110
127
 
111
128
  private var gestures: some View {
@@ -114,7 +131,7 @@ struct SettingsTabView: View {
114
131
  .font(.caption)
115
132
  .foregroundColor(.primary.opacity(0.6))
116
133
 
117
- VStack {
134
+ VStack(spacing: 0) {
118
135
  HStack {
119
136
  Image("shake-device", bundle: getDevLauncherBundle())
120
137
  .resizable()
@@ -122,7 +139,8 @@ struct SettingsTabView: View {
122
139
  .opacity(0.6)
123
140
  Toggle("Shake device", isOn: $viewModel.shakeDevice)
124
141
  }
125
- .padding()
142
+ .padding(.horizontal)
143
+ .padding(.vertical, 12)
126
144
 
127
145
  Divider()
128
146
 
@@ -133,7 +151,8 @@ struct SettingsTabView: View {
133
151
  .opacity(0.6)
134
152
  Toggle("Three-finger long-press", isOn: $viewModel.threeFingerLongPress)
135
153
  }
136
- .padding()
154
+ .padding(.horizontal)
155
+ .padding(.vertical, 12)
137
156
  }
138
157
  .background(Color.expoSecondarySystemBackground)
139
158
  .cornerRadius(12)
@@ -144,34 +163,40 @@ struct SettingsTabView: View {
144
163
  HStack {
145
164
  Text("Version")
146
165
  Spacer()
147
- Text(viewModel.buildInfo["runtimeVersion"] as? String ?? "")
166
+ Text(viewModel.buildInfo["appVersion"] as? String ?? "")
148
167
  .foregroundColor(.secondary)
149
168
  }
169
+ .padding(.horizontal)
170
+ .padding(.vertical, 12)
150
171
  }
151
172
 
152
173
  private var copyToClipboardButton: some View {
153
- HStack {
154
174
  #if os(tvOS)
155
- Button("Clipboard not available on tvOS") {}
175
+ Text("Clipboard not available on tvOS")
176
+ .padding(.horizontal)
177
+ .padding(.vertical, 12)
156
178
  #else
157
- Button(showCopiedMessage ? "Copied to clipboard!" : "Copy system info") {
158
- let buildInfoJSON = createBuildInfoJSON()
159
- let clipboard = UIPasteboard.general
160
- clipboard.string = buildInfoJSON
161
- showCopiedMessage = true
162
-
163
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
164
- showCopiedMessage = false
165
- }
179
+ Button {
180
+ UIPasteboard.general.string = createBuildInfoJSON()
181
+ showCopiedMessage = true
182
+
183
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
184
+ showCopiedMessage = false
166
185
  }
167
- Spacer()
168
- Image(systemName: "clipboard")
169
- .resizable()
170
- .scaledToFit()
171
- .frame(width: 16, height: 16)
172
- .foregroundColor(.blue)
173
- #endif
186
+ } label: {
187
+ HStack {
188
+ Text(showCopiedMessage ? "Copied to clipboard!" : "Copy system info")
189
+ Spacer()
190
+ Image(systemName: showCopiedMessage ? "checkmark" : "clipboard")
191
+ .foregroundColor(.secondary)
192
+ }
193
+ .contentShape(Rectangle())
174
194
  }
195
+ .buttonStyle(.plain)
196
+ .foregroundColor(showCopiedMessage ? .green : .primary)
197
+ .padding(.horizontal)
198
+ .padding(.vertical, 12)
199
+ #endif
175
200
  }
176
201
 
177
202
  private var isAdminUser: Bool {
@@ -179,37 +204,74 @@ struct SettingsTabView: View {
179
204
  }
180
205
 
181
206
  private var debugSettings: some View {
182
- Section("Debug Settings") {
183
- Button(showCacheClearedMessage ? "Network cache cleared!" : "Clear network cache") {
184
- clearNetworkCache()
207
+ VStack(alignment: .leading, spacing: 8) {
208
+ Text("Debug Settings".uppercased())
209
+ .font(.caption)
210
+ .foregroundColor(.primary.opacity(0.6))
211
+
212
+ VStack(spacing: 0) {
213
+ clearNetworkCacheRow
214
+ Divider()
215
+ defaultPageSizeRow
216
+ }
217
+ .background(Color.expoSecondarySystemBackground)
218
+ .cornerRadius(12)
219
+ }
220
+ }
221
+
222
+ private var clearNetworkCacheRow: some View {
223
+ Button {
224
+ clearNetworkCache()
225
+ } label: {
226
+ HStack {
227
+ Text(showCacheClearedMessage ? "Network cache cleared!" : "Clear network cache")
228
+ Spacer()
229
+ Image(systemName: showCacheClearedMessage ? "checkmark" : "trash")
230
+ .foregroundColor(showCacheClearedMessage ? .green : .secondary)
185
231
  }
186
- .foregroundColor(showCacheClearedMessage ? .green : nil)
232
+ .contentShape(Rectangle())
233
+ }
234
+ .buttonStyle(.plain)
235
+ .foregroundColor(showCacheClearedMessage ? .green : .primary)
236
+ .padding(.horizontal)
237
+ .padding(.vertical, 12)
238
+ }
187
239
 
188
- VStack(alignment: .leading) {
189
- Text("Default Page Size")
190
- Text("Sets the number of items fetched for branches and updates")
191
- .foregroundStyle(.secondary)
192
- .font(.footnote)
193
- Picker("", selection: $defaultPageSize) {
194
- Text("1").tag(1)
195
- Text("5").tag(5)
196
- Text("10").tag(10)
197
- }
198
- .pickerStyle(.segmented)
240
+ private var defaultPageSizeRow: some View {
241
+ VStack(alignment: .leading, spacing: 4) {
242
+ Text("Default Page Size")
243
+ Text("Sets the number of items fetched for branches and updates")
244
+ .foregroundStyle(.secondary)
245
+ .font(.footnote)
246
+ Picker("", selection: $defaultPageSize) {
247
+ Text("1").tag(1)
248
+ Text("5").tag(5)
249
+ Text("10").tag(10)
199
250
  }
251
+ .pickerStyle(.segmented)
200
252
  }
253
+ .padding(.horizontal)
254
+ .padding(.vertical, 12)
201
255
  }
202
256
 
203
257
  private var easUpdateConfig: some View {
204
- Section("EAS Update Configuration") {
258
+ VStack(alignment: .leading, spacing: 8) {
259
+ Text("EAS Update Configuration".uppercased())
260
+ .font(.caption)
261
+ .foregroundColor(.primary.opacity(0.6))
262
+
205
263
  VStack(alignment: .leading, spacing: 8) {
206
264
  Text(createEASConfigJSON())
207
265
  .font(.system(.caption, design: .monospaced))
208
266
  #if !os(tvOS)
209
267
  .textSelection(.enabled)
210
268
  #endif
211
- .padding(.vertical, 4)
212
269
  }
270
+ .frame(maxWidth: .infinity, alignment: .leading)
271
+ .padding(.horizontal)
272
+ .padding(.vertical, 12)
273
+ .background(Color.expoSecondarySystemBackground)
274
+ .cornerRadius(12)
213
275
  }
214
276
  }
215
277
 
@@ -239,36 +301,70 @@ struct SettingsTabView: View {
239
301
  }
240
302
 
241
303
  #if !targetEnvironment(simulator)
304
+ private var localNetworkStatus: (text: String, icon: String, color: Color)? {
305
+ switch viewModel.permissionStatus {
306
+ case .granted:
307
+ return ("Allowed", "checkmark.circle.fill", .green)
308
+ case .denied:
309
+ return ("Not allowed", "xmark.circle.fill", .red)
310
+ case .checking, .unknown:
311
+ return nil
312
+ }
313
+ }
314
+
242
315
  private var localNetworkDebugSettings: some View {
243
316
  VStack(alignment: .leading, spacing: 8) {
244
317
  VStack(spacing: 0) {
245
- Toggle("Local Network", isOn: .constant(viewModel.permissionStatus == .granted))
246
- .disabled(true)
247
- .padding()
318
+ localNetworkStatusRow
248
319
 
249
320
  if viewModel.permissionStatus == .denied {
250
321
  Divider()
251
-
252
- #if os(iOS)
253
- Button {
254
- if let url = URL(string: UIApplication.openSettingsURLString) {
255
- UIApplication.shared.open(url)
256
- }
257
- } label: {
258
- HStack {
259
- Text("Open App Settings")
260
- Spacer()
261
- Image(systemName: "gear")
262
- .foregroundColor(.blue)
263
- }
264
- }
265
- .padding()
266
- #endif
322
+ openAppSettingsRow
267
323
  }
268
324
  }
269
325
  .background(Color.expoSecondarySystemBackground)
270
326
  .cornerRadius(12)
271
327
  }
272
328
  }
329
+
330
+ private var localNetworkStatusRow: some View {
331
+ HStack {
332
+ Text("Local Network")
333
+ Spacer()
334
+ if let status = localNetworkStatus {
335
+ Text(status.text)
336
+ .foregroundColor(.secondary)
337
+ Image(systemName: status.icon)
338
+ .foregroundColor(status.color)
339
+ } else {
340
+ ProgressView()
341
+ }
342
+ }
343
+ .padding(.horizontal)
344
+ .padding(.vertical, 12)
345
+ }
346
+
347
+ @ViewBuilder
348
+ private var openAppSettingsRow: some View {
349
+ #if os(iOS)
350
+ Button {
351
+ if let url = URL(string: UIApplication.openSettingsURLString) {
352
+ UIApplication.shared.open(url)
353
+ }
354
+ } label: {
355
+ HStack {
356
+ Text("Open App Settings")
357
+ Spacer()
358
+ Image(systemName: "gear")
359
+ .foregroundColor(.secondary)
360
+ }
361
+ .contentShape(Rectangle())
362
+ }
363
+ .buttonStyle(.plain)
364
+ .foregroundColor(.primary)
365
+ .padding(.horizontal)
366
+ .padding(.vertical, 12)
367
+ #endif
368
+ }
273
369
  #endif
274
370
  }
@@ -172,22 +172,44 @@ struct UpdatesListView: View {
172
172
  }
173
173
 
174
174
  private var emptyUpdates: some View {
175
- Section {
176
- VStack(spacing: 16) {
177
- Image(systemName: "tray")
178
- .font(.largeTitle)
179
- .foregroundColor(.gray)
180
-
181
- Text("No updates available")
182
- .font(.headline)
183
-
184
- Text(filterByCompatibility ? "No compatible updates found for this runtime version." : "No updates found.")
185
- .font(.caption)
186
- .foregroundStyle(.secondary)
187
- .multilineTextAlignment(.center)
175
+ VStack(spacing: 16) {
176
+ Image(systemName: "tray")
177
+ .resizable()
178
+ .scaledToFit()
179
+ .frame(width: 44, height: 44)
180
+ .foregroundColor(.gray)
181
+
182
+ if filterByCompatibility {
183
+ VStack(spacing: 8) {
184
+ Text("No compatible updates")
185
+ .font(.headline)
186
+ .multilineTextAlignment(.center)
187
+
188
+ Text("No updates match this runtime version. Turn off \"Compatible only\" to see all updates.")
189
+ .font(.system(size: 14))
190
+ .multilineTextAlignment(.center)
191
+ .foregroundStyle(.secondary)
192
+ }
193
+ } else {
194
+ VStack(spacing: 8) {
195
+ Text("No updates yet")
196
+ .font(.headline)
197
+ .multilineTextAlignment(.center)
198
+
199
+ Text("Publish an update with EAS Update and it will appear here.")
200
+ .font(.system(size: 14))
201
+ .multilineTextAlignment(.center)
202
+ .foregroundStyle(.secondary)
203
+
204
+ if let destination = URL(string: "https://docs.expo.dev/eas-update/getting-started/") {
205
+ Link("Learn how to publish", destination: destination)
206
+ .font(.system(size: 14))
207
+ .foregroundColor(.blue)
208
+ }
209
+ }
188
210
  }
189
- .padding()
190
211
  }
212
+ .padding()
191
213
  }
192
214
  }
193
215
  // swiftlint:enable closure_body_length
@@ -0,0 +1,71 @@
1
+ // Copyright 2015-present 650 Industries. All rights reserved.
2
+
3
+ import XCTest
4
+
5
+ @testable import EXDevLauncher
6
+
7
+ class DevLauncherLoadErrorMessageTests: XCTestCase {
8
+ private func urlError(_ code: Int) -> NSError {
9
+ return NSError(domain: NSURLErrorDomain, code: code)
10
+ }
11
+
12
+ func testCannotFindHost() {
13
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorCannotFindHost), url: "http://myserver.example:8081")
14
+ XCTAssertTrue(message.contains("http://myserver.example:8081"))
15
+ XCTAssertTrue(message.lowercased().contains("could not be resolved"))
16
+ }
17
+
18
+ func testDNSLookupFailed() {
19
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorDNSLookupFailed), url: "http://myserver.example:8081")
20
+ XCTAssertTrue(message.lowercased().contains("could not be resolved"))
21
+ }
22
+
23
+ func testConnectionRefused() {
24
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorCannotConnectToHost), url: "http://192.168.1.5:8081")
25
+ XCTAssertTrue(message.contains("npx expo start"))
26
+ }
27
+
28
+ func testTimeoutOnPrivateAddressMentionsWifi() {
29
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorTimedOut), url: "http://192.168.1.5:8081")
30
+ XCTAssertTrue(message.lowercased().contains("same wi-fi network"))
31
+ }
32
+
33
+ func testTimeoutOnTenDotAddressMentionsWifi() {
34
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorTimedOut), url: "http://10.0.1.2:8081")
35
+ XCTAssertTrue(message.lowercased().contains("same wi-fi network"))
36
+ }
37
+
38
+ func testTimeoutOnPublicAddressDoesNotMentionWifi() {
39
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorTimedOut), url: "https://u.expo.dev/abc")
40
+ XCTAssertFalse(message.lowercased().contains("same wi-fi network"))
41
+ XCTAssertTrue(message.lowercased().contains("timed out"))
42
+ }
43
+
44
+ func testLocalhostOnTimeoutSuggestsLanIP() {
45
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorTimedOut), url: "http://localhost:8081")
46
+ XCTAssertTrue(message.lowercased().contains("lan ip"))
47
+ }
48
+
49
+ func testLocalhostOnConnectionRefusedSuggestsLanIP() {
50
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorCannotConnectToHost), url: "http://127.0.0.1:8081")
51
+ XCTAssertTrue(message.lowercased().contains("lan ip"))
52
+ }
53
+
54
+ func testOffline() {
55
+ let message = DevLauncherLoadErrorMessage.message(for: urlError(NSURLErrorNotConnectedToInternet), url: "http://192.168.1.5:8081")
56
+ XCTAssertTrue(message.lowercased().contains("offline"))
57
+ }
58
+
59
+ func testDevelopmentClientErrorsPassThrough() {
60
+ let original = "Couldn't parse the manifest. The data isn't in the correct format."
61
+ let error = NSError(domain: "DevelopmentClient", code: 1, userInfo: [NSLocalizedDescriptionKey: original])
62
+ XCTAssertEqual(DevLauncherLoadErrorMessage.message(for: error, url: "http://192.168.1.5:8081"), original)
63
+ }
64
+
65
+ func testUnknownErrorFallsBackToDescription() {
66
+ let error = NSError(domain: "SomeDomain", code: 42, userInfo: [NSLocalizedDescriptionKey: "Something odd happened"])
67
+ let message = DevLauncherLoadErrorMessage.message(for: error, url: "http://192.168.1.5:8081")
68
+ XCTAssertTrue(message.contains("http://192.168.1.5:8081"))
69
+ XCTAssertTrue(message.contains("Something odd happened"))
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expo-dev-launcher",
3
3
  "title": "Expo Development Launcher",
4
- "version": "57.0.0",
4
+ "version": "57.0.2",
5
5
  "description": "Pre-release version of the Expo development launcher package for testing.",
6
6
  "repository": {
7
7
  "type": "git",
@@ -16,7 +16,7 @@
16
16
  "homepage": "https://docs.expo.dev",
17
17
  "dependencies": {
18
18
  "@expo/schema-utils": "^57.0.0",
19
- "expo-dev-menu": "~57.0.0",
19
+ "expo-dev-menu": "~57.0.2",
20
20
  "expo-manifests": "~57.0.0"
21
21
  },
22
22
  "peerDependencies": {
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.14.0",
28
- "expo": "57.0.0-preview.0"
28
+ "expo": "57.0.0-preview.1"
29
29
  },
30
- "gitHead": "e3eb896c5fdcd89e0cded98ff4e35c9db12cc9c0",
30
+ "gitHead": "b134c8d1eb906156ef7d5f6fee1a70ff0d9a8f22",
31
31
  "scripts": {
32
32
  "build:plugin": "expo-module build plugin",
33
33
  "expo-module": "expo-module"
@@ -1 +0,0 @@
1
- {"root":["./src/index.ts","./src/pluginconfig.ts","./src/withdevlauncher.ts"],"version":"6.0.2"}