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