// 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