1 // Copyright © 2021 650 Industries. All rights reserved. 2 3 // this class used a bunch of implicit non-null patterns for member variables. not worth refactoring to appease lint. 4 // swiftlint:disable force_unwrapping 5 6 import Foundation 7 import ABI49_0_0EXUpdatesInterface 8 9 /** 10 * Main entry point to expo-updates in development builds with expo-dev-client. Singleton that still 11 * makes use of AppController for keeping track of updates state, but provides capabilities 12 * that are not usually exposed but that expo-dev-client needs (launching and downloading a specific 13 * update by URL, allowing dynamic configuration, introspecting the database). 14 * 15 * Implements the ABI49_0_0EXUpdatesExternalInterface from the expo-updates-interface package. This allows 16 * expo-dev-client to compile without needing expo-updates to be installed. 17 */ 18 @objc(ABI49_0_0EXUpdatesDevLauncherController) 19 @objcMembers 20 public final class DevLauncherController: NSObject, UpdatesExternalInterface { 21 private static let ErrorDomain = "ABI49_0_0EXUpdatesDevLauncherController" 22 23 enum ErrorCode: Int { 24 case invalidUpdateURL = 1 25 case updateLaunchFailed = 4 26 case configFailed = 5 27 } 28 29 private var tempConfig: UpdatesConfig? 30 31 private weak var _bridge: AnyObject? 32 public weak var bridge: AnyObject? { 33 get { 34 return _bridge 35 } 36 set(value) { 37 _bridge = value 38 if let value = value as? ABI49_0_0RCTBridge { 39 AppController.sharedInstance.bridge = value 40 } 41 } 42 } 43 44 public static let sharedInstance = DevLauncherController() 45 46 override init() {} 47 48 public var launchAssetURL: URL? { 49 return AppController.sharedInstance.launchAssetUrl() 50 } 51 resetnull52 public func reset() { 53 let controller = AppController.sharedInstance 54 controller.launcher = nil 55 controller.isStarted = true 56 } 57 58 public func fetchUpdate( 59 withConfiguration configuration: [String: Any], 60 onManifest manifestBlock: @escaping UpdatesManifestBlock, 61 progress progressBlock: @escaping UpdatesProgressBlock, 62 success successBlock: @escaping UpdatesUpdateSuccessBlock, 63 error errorBlock: @escaping UpdatesErrorBlock 64 ) { 65 guard let updatesConfiguration = setup(configuration: configuration, error: errorBlock) else { 66 return 67 } 68 69 let controller = AppController.sharedInstance 70 71 // since controller is a singleton, save its config so we can reset to it if our request fails 72 tempConfig = controller.config 73 74 setDevelopmentSelectionPolicy() 75 controller.setConfigurationInternal(config: updatesConfiguration) 76 77 let loader = RemoteAppLoader( 78 config: updatesConfiguration, 79 database: controller.database, 80 directory: controller.updatesDirectory!, 81 launchedUpdate: nil, 82 completionQueue: controller.controllerQueue 83 ) 84 loader.loadUpdate( 85 fromURL: updatesConfiguration.updateUrl! 86 ) { updateResponse in 87 if let updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective { 88 switch updateDirective { 89 case is NoUpdateAvailableUpdateDirective: 90 return false 91 case is RollBackToEmbeddedUpdateDirective: 92 return false 93 default: 94 NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise() 95 return false 96 } 97 } 98 99 guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else { 100 return false 101 } 102 103 return manifestBlock(update.manifest.rawManifestJSON()) 104 } asset: { _, successfulAssetCount, failedAssetCount, totalAssetCount in 105 progressBlock(UInt(successfulAssetCount), UInt(failedAssetCount), UInt(totalAssetCount)) 106 } success: { updateResponse in 107 guard let updateResponse = updateResponse, 108 let update = updateResponse.manifestUpdateResponsePart?.updateManifest else { 109 successBlock(nil) 110 return 111 } 112 self.launch(update: update, withConfiguration: updatesConfiguration, success: successBlock, error: errorBlock) 113 } error: { error in 114 // reset controller's configuration to what it was before this request 115 controller.setConfigurationInternal(config: self.tempConfig!) 116 errorBlock(error) 117 } 118 } 119 120 public func storedUpdateIds( 121 withConfiguration configuration: [String: Any], 122 success successBlock: @escaping UpdatesQuerySuccessBlock, 123 error errorBlock: @escaping UpdatesErrorBlock 124 ) { 125 guard setup(configuration: configuration, error: errorBlock) != nil else { 126 successBlock([]) 127 return 128 } 129 130 AppLauncherWithDatabase.storedUpdateIds( 131 inDatabase: AppController.sharedInstance.database 132 ) { error, storedUpdateIds in 133 if let error = error { 134 errorBlock(error) 135 } else { 136 successBlock(storedUpdateIds!) 137 } 138 } 139 } 140 141 /** 142 Common initialization for both fetchUpdateWithConfiguration: and storedUpdateIdsWithConfiguration: 143 Sets up ABI49_0_0EXUpdatesAppController shared instance 144 Returns the updatesConfiguration 145 */ setupnull146 private func setup(configuration: [AnyHashable: Any], error errorBlock: UpdatesErrorBlock) -> UpdatesConfig? { 147 let controller = AppController.sharedInstance 148 var updatesConfiguration: UpdatesConfig 149 do { 150 updatesConfiguration = try UpdatesConfig.configWithExpoPlist(mergingOtherDictionary: configuration as? [String: Any] ?? [:]) 151 } catch { 152 errorBlock(NSError( 153 domain: DevLauncherController.ErrorDomain, 154 code: ErrorCode.configFailed.rawValue, 155 userInfo: [ 156 // swiftlint:disable:next line_length 157 NSLocalizedDescriptionKey: "Cannot load configuration from Expo.plist. Please ensure you've followed the setup and installation instructions for expo-updates to create Expo.plist and add it to your Xcode project." 158 ] 159 )) 160 return nil 161 } 162 163 guard updatesConfiguration.updateUrl != nil && updatesConfiguration.scopeKey != nil else { 164 errorBlock(NSError( 165 domain: DevLauncherController.ErrorDomain, 166 code: ErrorCode.invalidUpdateURL.rawValue, 167 userInfo: [ 168 NSLocalizedDescriptionKey: "Failed to read stored updates: configuration object must include a valid update URL" 169 ] 170 )) 171 return nil 172 } 173 174 do { 175 try controller.initializeUpdatesDirectory() 176 try controller.initializeUpdatesDatabase() 177 } catch { 178 errorBlock(error) 179 return nil 180 } 181 182 return updatesConfiguration 183 } 184 setDevelopmentSelectionPolicynull185 private func setDevelopmentSelectionPolicy() { 186 let controller = AppController.sharedInstance 187 controller.resetSelectionPolicyToDefault() 188 let currentSelectionPolicy = controller.selectionPolicy() 189 controller.defaultSelectionPolicy = SelectionPolicy( 190 launcherSelectionPolicy: currentSelectionPolicy.launcherSelectionPolicy, 191 loaderSelectionPolicy: currentSelectionPolicy.loaderSelectionPolicy, 192 reaperSelectionPolicy: ReaperSelectionPolicyDevelopmentClient() 193 ) 194 controller.resetSelectionPolicyToDefault() 195 } 196 197 private func launch( 198 update: Update, 199 withConfiguration configuration: UpdatesConfig, 200 success successBlock: @escaping UpdatesUpdateSuccessBlock, 201 error errorBlock: @escaping UpdatesErrorBlock 202 ) { 203 let controller = AppController.sharedInstance 204 // ensure that we launch the update we want, even if it isn't the latest one 205 let currentSelectionPolicy = controller.selectionPolicy() 206 207 // Calling `setNextSelectionPolicy` allows the Updates module's `reloadAsync` method to reload 208 // with a different (newer) update if one is downloaded, e.g. using `fetchUpdateAsync`. If we set 209 // the default selection policy here instead, the update we are launching here would keep being 210 // launched by `reloadAsync` even if a newer one is downloaded. 211 controller.setNextSelectionPolicy(SelectionPolicy( 212 launcherSelectionPolicy: LauncherSelectionPolicySingleUpdate(updateId: update.updateId), 213 loaderSelectionPolicy: currentSelectionPolicy.loaderSelectionPolicy, 214 reaperSelectionPolicy: currentSelectionPolicy.reaperSelectionPolicy 215 )) 216 217 let launcher = AppLauncherWithDatabase( 218 config: configuration, 219 database: controller.database, 220 directory: controller.updatesDirectory!, 221 completionQueue: controller.controllerQueue 222 ) 223 launcher.launchUpdate(withSelectionPolicy: controller.selectionPolicy()) { error, success in 224 if !success { 225 // reset controller's configuration to what it was before this request 226 controller.setConfigurationInternal(config: self.tempConfig!) 227 errorBlock(error ?? NSError( 228 domain: DevLauncherController.ErrorDomain, 229 code: ErrorCode.updateLaunchFailed.rawValue, 230 userInfo: [NSLocalizedDescriptionKey: "Failed to launch update with an unknown error"] 231 )) 232 return 233 } 234 235 controller.isStarted = true 236 controller.launcher = launcher 237 successBlock(launcher.launchedUpdate?.manifest.rawManifestJSON()) 238 controller.runReaper() 239 } 240 } 241 } 242 243 // swiftlint:enable force_unwrapping 244