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 +20 -0
- package/android/build.gradle +2 -2
- package/android/src/debug/java/expo/modules/devlauncher/DevLauncherController.kt +13 -2
- package/android/src/debug/java/expo/modules/devlauncher/compose/models/SettingsViewModel.kt +5 -0
- package/android/src/debug/java/expo/modules/devlauncher/compose/screens/SettingsScreen.kt +61 -19
- package/android/src/debug/java/expo/modules/devlauncher/compose/ui/ServerUrlInput.kt +1 -1
- package/expo-dev-launcher.podspec +5 -0
- package/ios/EXDevLauncherController.m +7 -2
- package/ios/SwiftUI/AccountSheet.swift +27 -13
- package/ios/SwiftUI/DevLauncherLoadErrorMessage.swift +65 -0
- package/ios/SwiftUI/DevLauncherViewController.swift +1 -1
- package/ios/SwiftUI/DevLauncherViewModel.swift +82 -5
- package/ios/SwiftUI/DevLauncherViews.swift +8 -2
- package/ios/SwiftUI/DevServerInfoModal.swift +13 -28
- package/ios/SwiftUI/DevServersView.swift +44 -23
- package/ios/SwiftUI/HomeTabView.swift +8 -3
- package/ios/SwiftUI/NetworkUtilities.swift +3 -3
- package/ios/SwiftUI/SettingsTabView.swift +183 -87
- package/ios/SwiftUI/UpdatesTab/UpdatesListView.swift +36 -14
- package/ios/Tests/DevLauncherLoadErrorMessageTests.swift +71 -0
- package/package.json +4 -4
- package/plugin/tsconfig.tsbuildinfo +0 -1
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._
|
package/android/build.gradle
CHANGED
|
@@ -26,13 +26,13 @@ expoModule {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
group = "host.exp.exponent"
|
|
29
|
-
version = "57.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.
|
|
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 =
|
|
340
|
+
val shouldTryToLaunchLastOpenedBundle =
|
|
341
|
+
DependencyInjection.devMenuPreferences?.tryToLaunchLastBundle ?: true
|
|
341
342
|
val lastOpenedApp = recentlyOpedAppsRegistry.getMostRecentApp()
|
|
342
343
|
if (shouldTryToLaunchLastOpenedBundle && lastOpenedApp != null) {
|
|
343
|
-
|
|
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
|
-
|
|
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(
|
|
@@ -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
|
-
|
|
240
|
-
BOOL shouldTryToLaunchLastOpenedBundle =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
167
|
-
let message =
|
|
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.
|
|
412
|
-
threeFingerLongPress = defaults.
|
|
413
|
-
showOnLaunch = defaults.
|
|
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
|
-
@
|
|
6
|
+
@Environment(\.dismiss) private var dismiss
|
|
7
7
|
|
|
8
8
|
var body: some View {
|
|
9
|
-
|
|
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
|
-
.
|
|
32
|
-
|
|
33
|
-
.
|
|
34
|
-
|
|
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(.
|
|
41
|
-
.fontWeight(.
|
|
24
|
+
.font(.title2)
|
|
25
|
+
.fontWeight(.bold)
|
|
42
26
|
Spacer()
|
|
43
27
|
Button {
|
|
44
|
-
|
|
28
|
+
dismiss()
|
|
45
29
|
} label: {
|
|
46
|
-
Image(systemName: "xmark")
|
|
47
|
-
.font(.
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
.
|
|
63
|
-
DevServerInfoModal(
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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["
|
|
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
|
-
|
|
175
|
+
Text("Clipboard not available on tvOS")
|
|
176
|
+
.padding(.horizontal)
|
|
177
|
+
.padding(.vertical, 12)
|
|
156
178
|
#else
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
.
|
|
232
|
+
.contentShape(Rectangle())
|
|
233
|
+
}
|
|
234
|
+
.buttonStyle(.plain)
|
|
235
|
+
.foregroundColor(showCacheClearedMessage ? .green : .primary)
|
|
236
|
+
.padding(.horizontal)
|
|
237
|
+
.padding(.vertical, 12)
|
|
238
|
+
}
|
|
187
239
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
28
|
+
"expo": "57.0.0-preview.1"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
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"}
|