ssap_app/node_modules/expo-dev-launcher/ios/SwiftUI/DevLauncherViewModel.swift

404 lines
12 KiB
Swift

// Copyright 2015-present 650 Industries. All rights reserved.
import ExpoModulesCore
import Foundation
import AuthenticationServices
private let selectedAccountKey = "expo-selected-account-id"
private let sessionKey = "expo-session-secret"
private let DEV_LAUNCHER_DEFAULT_SCHEME = "expo-dev-launcher"
@MainActor
class DevLauncherViewModel: ObservableObject {
@Published var recentlyOpenedApps: [RecentlyOpenedApp] = []
@Published var buildInfo: [AnyHashable: Any] = [:]
@Published var updatesConfig: [AnyHashable: Any] = [:]
@Published var devServers: [DevServer] = []
@Published var currentError: EXDevLauncherAppError?
@Published var showingCrashReport = false
@Published var showingErrorAlert = false
@Published var errorAlertMessage = ""
@Published var storedCrashInstance: EXDevLauncherErrorInstance?
@Published var hasStoredCrash = false
@Published var shakeDevice = true {
didSet {
saveMenuPreference(key: "EXDevMenuMotionGestureEnabled", value: shakeDevice)
}
}
@Published var threeFingerLongPress = false {
didSet {
saveMenuPreference(key: "EXDevMenuTouchGestureEnabled", value: threeFingerLongPress)
}
}
@Published var showOnLaunch = false {
didSet {
saveMenuPreference(key: "EXDevMenuShowsAtLaunch", value: showOnLaunch)
}
}
@Published var isAuthenticated = false
@Published var isAuthenticating = false
@Published var user: User?
@Published var selectedAccountId: String?
#if !os(tvOS)
private let presentationContext = DevLauncherAuthPresentationContext()
#endif
var selectedAccount: UserAccount? {
guard let userData = user,
let selectedAccountId = selectedAccountId else {
return nil
}
return userData.accounts.first { $0.id == selectedAccountId }
}
var structuredBuildInfo: BuildInfo {
return BuildInfo(buildInfo: buildInfo, updatesConfig: updatesConfig)
}
var isLoggedIn: Bool {
return isAuthenticated && user != nil
}
init() {
loadData()
discoverDevServers()
checkAuthenticationStatus()
checkForStoredCrashes()
}
private func loadData() {
let controller = EXDevLauncherController.sharedInstance()
self.buildInfo = controller.getBuildInfo()
self.updatesConfig = controller.getUpdatesConfig(nil)
loadRecentlyOpenedApps()
loadMenuPreferences()
}
private func loadRecentlyOpenedApps() {
let apps = EXDevLauncherController.sharedInstance().recentlyOpenedAppsRegistry.recentlyOpenedApps()
self.recentlyOpenedApps = apps.compactMap { app in
guard let name = app["name"] as? String,
let url = app["url"] as? String,
let timestampInt64 = app["timestamp"] as? Int64,
let isEasUpdate = app["isEasUpdate"] as? Bool? else {
return nil
}
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampInt64))
return RecentlyOpenedApp(name: name, url: url, timestamp: timestamp, isEasUpdate: isEasUpdate)
}
}
func openApp(url: String) {
guard let bundleUrl = URL(string: url) else {
return
}
EXDevLauncherController.sharedInstance().loadApp(
bundleUrl,
onSuccess: nil,
onError: { [weak self] _ in
let message = "Failed to connect to \(url)"
DispatchQueue.main.async {
self?.showErrorAlert(message)
}
})
}
func clearRecentlyOpenedApps() {
EXDevLauncherController.sharedInstance().clearRecentlyOpenedApps()
self.recentlyOpenedApps = []
}
func isCompatibleRuntime(_ runtimeVersion: String) -> Bool {
return runtimeVersion == structuredBuildInfo.runtimeVersion
}
func discoverDevServers() {
Task {
var discoveredServers: [DevServer] = []
// swiftlint:disable number_separator
let portsToCheck = [8081, 8082, 8_083, 8084, 8085, 19000, 19001, 19002]
// swiftlint:enable number_separator
let ipsToScan = NetworkUtilities.getIPAddressesToScan()
await withTaskGroup(of: DevServer?.self) { group in
for ip in ipsToScan {
for port in portsToCheck {
group.addTask {
let baseAddress = ip == "localhost" ? "http://localhost" : "http://\(ip)"
return await self.checkDevServer(url: "\(baseAddress):\(port)")
}
}
}
for await server in group {
if let server = server {
discoveredServers.append(server)
}
}
}
await MainActor.run {
self.devServers = discoveredServers.sorted { $0.url < $1.url }
}
}
}
private func checkDevServer(url: String) async -> DevServer? {
guard let statusURL = URL(string: "\(url)/status") else {
return nil
}
do {
let (data, response) = try await URLSession.shared.data(from: statusURL)
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
if let statusString = String(data: data, encoding: .utf8),
statusString.contains("packager-status:running") {
return DevServer(
url: url,
description: url,
source: "local"
)
}
}
} catch {
// Server not running or not reachable
}
return nil
}
func showError(_ error: EXDevLauncherAppError) {
currentError = error
}
func showErrorAlert(_ message: String) {
errorAlertMessage = message
showingErrorAlert = true
}
func dismissErrorAlert() {
errorAlertMessage = ""
showingErrorAlert = false
}
func dismissCrashReport() {
currentError = nil
showingCrashReport = false
storedCrashInstance = nil
hasStoredCrash = false
}
func showCrashReport() {
if let storedCrashInstance {
let error = EXDevLauncherAppError(message: storedCrashInstance.message, stack: nil)
currentError = error
showingCrashReport = true
}
}
private func loadMenuPreferences() {
let defaults = UserDefaults.standard
shakeDevice = defaults.object(forKey: "EXDevMenuMotionGestureEnabled") as? Bool ?? true
threeFingerLongPress = defaults.object(forKey: "EXDevMenuTouchGestureEnabled") as? Bool ?? true
showOnLaunch = defaults.object(forKey: "EXDevMenuShowsAtLaunch") as? Bool ?? false
}
private func saveMenuPreference(key: String, value: Bool) {
UserDefaults.standard.set(value, forKey: key)
UserDefaults.standard.synchronize()
}
private func checkAuthenticationStatus() {
let sessionSecret = UserDefaults.standard.string(forKey: sessionKey)
if let sessionSecret {
APIClient.shared.setSession(sessionSecret)
isAuthenticated = true
loadUserInfo()
} else {
isAuthenticated = false
user = nil
}
}
private func validateSession() {
Task {
do {
let user = try await Queries.getUserProfile()
await MainActor.run {
self.isAuthenticated = true
self.user = user
}
} catch {
await MainActor.run {
self.clearInvalidSession()
}
}
}
}
private func clearInvalidSession() {
UserDefaults.standard.removeObject(forKey: sessionKey)
APIClient.shared.setSession(nil)
isAuthenticated = false
user = nil
}
private func loadUserInfo() {
if isAuthenticated {
Task {
do {
let user = try await Queries.getUserProfile()
await MainActor.run {
self.user = user
let savedAccountId = UserDefaults.standard.string(forKey: selectedAccountKey)
if let savedAccountId,
user.accounts.contains(where: { $0.id == savedAccountId }) {
self.selectedAccountId = savedAccountId
} else if let firstAccount = user.accounts.first {
self.selectedAccountId = firstAccount.id
UserDefaults.standard.set(firstAccount.id, forKey: selectedAccountKey)
}
}
} catch {
await MainActor.run {
self.clearInvalidSession()
}
}
}
} else {
}
}
func signIn() async {
isAuthenticating = true
do {
let success = try await performAuthentication(isSignUp: false)
await MainActor.run {
if success {
self.isAuthenticated = true
self.loadUserInfo()
}
isAuthenticating = false
}
} catch {
await MainActor.run {
isAuthenticating = false
}
}
}
func signUp() async {
isAuthenticating = true
do {
let success = try await performAuthentication(isSignUp: true)
await MainActor.run {
if success {
self.isAuthenticated = true
self.loadUserInfo()
}
isAuthenticating = false
}
} catch {
await MainActor.run {
isAuthenticating = false
}
}
}
func signOut() {
UserDefaults.standard.removeObject(forKey: sessionKey)
UserDefaults.standard.removeObject(forKey: selectedAccountKey)
APIClient.shared.setSession(nil)
isAuthenticated = false
user = nil
selectedAccountId = nil
}
func selectAccount(accountId: String) {
selectedAccountId = accountId
UserDefaults.standard.set(accountId, forKey: selectedAccountKey)
}
private func performAuthentication(isSignUp: Bool) async throws -> Bool {
#if os(tvOS)
throw Exception(name: "NotImplementedError", description: "Not implemented on tvOS")
#else
return try await withCheckedThrowingContinuation { continuation in
let websiteOrigin = APIClient.shared.websiteOrigin
let authType = isSignUp ? "signup" : "login"
let scheme = getURLScheme()
let redirectBase = "\(scheme)://auth"
guard let encodedRedirectURI = redirectBase.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(websiteOrigin)/\(authType)?confirm_account=1&app_redirect_uri=\(encodedRedirectURI)") else {
continuation.resume(throwing: AuthError.invalidURL)
return
}
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: scheme
) { callbackURL, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let callbackURL,
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let sessionSecret = components.queryItems?.first(where: { $0.name == "session_secret" })?.value else {
continuation.resume(throwing: AuthError.noSessionSecret)
return
}
UserDefaults.standard.set(sessionSecret, forKey: sessionKey)
APIClient.shared.setSession(sessionSecret)
continuation.resume(returning: true)
}
session.presentationContextProvider = presentationContext
session.prefersEphemeralWebBrowserSession = true
session.start()
}
#endif
}
private func getURLScheme() -> String {
guard let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] else {
return DEV_LAUNCHER_DEFAULT_SCHEME
}
return urlTypes.compactMap { urlType in
(urlType["CFBundleURLSchemes"] as? [String])?.first
}.first ?? DEV_LAUNCHER_DEFAULT_SCHEME
}
func checkForStoredCrashes() {
let registry = EXDevLauncherErrorRegistry()
storedCrashInstance = registry.consumeException()
hasStoredCrash = storedCrashInstance != nil
}
}
#if !os(tvOS)
private class DevLauncherAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
let window = UIApplication.shared.windows.first { $0.isKeyWindow }
return window ?? ASPresentationAnchor()
}
}
#endif