1 //  Copyright © 2019 650 Industries. All rights reserved.
2 
3 // swiftlint:disable type_body_length
4 // swiftlint:disable closure_body_length
5 // swiftlint:disable force_unwrapping
6 // swiftlint:disable type_name
7 
8 import Foundation
9 
10 public typealias AppLauncherUpdateCompletionBlock = (_ error: Error?, _ update: Update?) -> Void
11 public typealias AppLauncherQueryCompletionBlock = (_ error: Error?, _ updateIds: [UUID]?) -> Void
12 
13 /**
14  * Implementation of AppLauncher that uses the SQLite database and expo-updates file store
15  * as the source of updates.
16  *
17  * Uses the SelectionPolicy to choose an update from SQLite to launch, then ensures that
18  * the update is safe and ready to launch (i.e. all the assets that SQLite expects to be stored on
19  * disk are actually there).
20  *
21  * This class also includes failsafe code to attempt to re-download any assets unexpectedly missing
22  * from disk (since it isn't necessarily safe to just revert to an older update in this case).
23  * Distinct from the AppLoader classes, though, this class does *not* make any major
24  * modifications to the database; its role is mostly to read the database and ensure integrity with
25  * the file system.
26  *
27  * It's important that the update to launch is selected *before* any other checks, e.g. the above
28  * check for assets on disk. This is to preserve the invariant that no older update should ever be
29  * launched after a newer one has been launched.
30  */
31 @objc(EXUpdatesAppLauncherWithDatabase)
32 @objcMembers
33 public class AppLauncherWithDatabase: NSObject, AppLauncher {
34   private static let ErrorDomain = "AppLauncher"
35 
36   public var launchedUpdate: Update?
37   public var launchAssetUrl: URL?
38   public var assetFilesMap: [String: Any]?
39 
40   private let launcherQueue: DispatchQueue
41   private var completedAssets: Int
42   private let config: UpdatesConfig
43   private let database: UpdatesDatabase
44   private let directory: URL
45   public private(set) var completionQueue: DispatchQueue
46   public private(set) var completion: AppLauncherCompletionBlock?
47 
48   private var launchAssetError: Error?
49 
50   public required init(config: UpdatesConfig, database: UpdatesDatabase, directory: URL, completionQueue: DispatchQueue) {
51     self.launcherQueue = DispatchQueue(label: "expo.launcher.LauncherQueue")
52     self.completedAssets = 0
53     self.config = config
54     self.database = database
55     self.directory = directory
56     self.completionQueue = completionQueue
57   }
58 
59   public func isUsingEmbeddedAssets() -> Bool {
60     return assetFilesMap == nil
61   }
62 
63   public static func launchableUpdate(
64     withConfig config: UpdatesConfig,
65     database: UpdatesDatabase,
66     selectionPolicy: SelectionPolicy,
67     completionQueue: DispatchQueue,
68     completion: @escaping AppLauncherUpdateCompletionBlock
69   ) {
70     database.databaseQueue.async {
71       var launchableUpdates: [Update]?
72       var launchableUpdatesError: Error?
73       do {
74         launchableUpdates = try database.launchableUpdates(withConfig: config)
75       } catch {
76         launchableUpdatesError = error
77       }
78 
79       var manifestFilters: [String: Any]?
80       var manifestFiltersError: Error?
81       do {
82         manifestFilters = try database.manifestFilters(withScopeKey: config.scopeKey!)
83       } catch {
84         manifestFiltersError = error
85       }
86 
87       completionQueue.async {
88         guard let launchableUpdates = launchableUpdates else {
89           completion(launchableUpdatesError!, nil)
90           return
91         }
92 
93         if let manifestFiltersError = manifestFiltersError {
94           completion(manifestFiltersError, nil)
95           return
96         }
97 
98         // We can only run an update marked as embedded if it's actually the update embedded in the
99         // current binary. We might have an older update from a previous binary still listed in the
100         // database with Embedded status so we need to filter that out here.
101         let embeddedManifest = EmbeddedAppLoader.embeddedManifest(withConfig: config, database: database)
102         var filteredLaunchableUpdates: [Update] = []
103 
104         for update in launchableUpdates {
105           if update.status == UpdateStatus.StatusEmbedded {
106             if embeddedManifest != nil && update.updateId != embeddedManifest!.updateId {
107               continue
108             }
109           }
110           filteredLaunchableUpdates.append(update)
111         }
112 
113         completion(nil, selectionPolicy.launchableUpdate(fromUpdates: filteredLaunchableUpdates, filters: manifestFilters))
114       }
115     }
116   }
117 
118   public func launchableUpdate(
119     selectionPolicy: SelectionPolicy,
120     completion: @escaping AppLauncherUpdateCompletionBlock
121   ) {
122     AppLauncherWithDatabase.launchableUpdate(
123       withConfig: config,
124       database: database,
125       selectionPolicy: selectionPolicy,
126       completionQueue: completionQueue,
127       completion: completion
128     )
129   }
130 
131   public func launchUpdate(
132     withSelectionPolicy selectionPolicy: SelectionPolicy,
133     completion: @escaping AppLauncherCompletionBlock
134   ) {
135     precondition(self.completion == nil, "AppLauncher:launchUpdateWithSelectionPolicy:completion should not be called twice on the same instance")
136     self.completion = completion
137 
138     if launchedUpdate == nil {
139       launchableUpdate(selectionPolicy: selectionPolicy) { error, launchableUpdate in
140         if error != nil || launchableUpdate == nil {
141           if let completionInner = self.completion {
142             self.completionQueue.async {
143               var userInfo: [String: Any] = [
144                 NSLocalizedDescriptionKey: "No launchable updates found in database"
145               ]
146               if let error = error {
147                 userInfo[NSUnderlyingErrorKey] = error
148               }
149               completionInner(NSError(domain: AppLauncherWithDatabase.ErrorDomain, code: 1011, userInfo: userInfo), false)
150             }
151           }
152         } else {
153           self.launchedUpdate = launchableUpdate
154           self.finishLaunch()
155         }
156       }
157     } else {
158       finishLaunch()
159     }
160   }
161 
162   public static func storedUpdateIds(
163     inDatabase database: UpdatesDatabase,
164     completion: @escaping AppLauncherQueryCompletionBlock
165   ) {
166     database.databaseQueue.async {
167       do {
168         let readyUpdateIds = try database.allUpdateIds(withStatus: .StatusReady)
169         completion(nil, readyUpdateIds)
170       } catch {
171         completion(error, nil)
172       }
173     }
174   }
175 
176   private func finishLaunch() {
177     markUpdateAccessed()
178     ensureAllAssetsExist()
179   }
180 
181   private func markUpdateAccessed() {
182     guard launchedUpdate != nil else {
183       preconditionFailure("launchedUpdate should be nonnull before calling markUpdateAccessed")
184     }
185     database.databaseQueue.async {
186       do {
187         try self.database.markUpdateAccessed(self.launchedUpdate!)
188       } catch {
189         NSLog("Failed to mark update as recently accessed: %@", error.localizedDescription)
190       }
191     }
192   }
193 
194   public func ensureAllAssetsExist() {
195     guard let launchedUpdate = launchedUpdate else {
196       preconditionFailure("launchedUpdate should be nonnull before calling markUpdateAccessed")
197     }
198 
199     if launchedUpdate.status == UpdateStatus.StatusEmbedded {
200       precondition(assetFilesMap == nil, "assetFilesMap should be null for embedded updates")
201       launchAssetUrl = Bundle.main.url(
202         forResource: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFilename,
203         withExtension: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFileType
204       )
205 
206       completionQueue.async {
207         self.completion!(self.launchAssetError, self.launchAssetUrl != nil)
208         self.completion = nil
209       }
210       return
211     } else if launchedUpdate.status == UpdateStatus.StatusDevelopment {
212       completionQueue.async {
213         self.completion!(nil, true)
214         self.completion = nil
215       }
216       return
217     }
218 
219     assetFilesMap = [:]
220 
221     let assets = launchedUpdate.assets()!
222     let totalAssetCount = assets.count
223     for asset in assets {
224       let assetLocalUrl = directory.appendingPathComponent(asset.filename)
225       ensureAssetExists(asset: asset, withLocalUrl: assetLocalUrl) { exists in
226         dispatchPrecondition(condition: .onQueue(self.launcherQueue))
227         self.completedAssets += 1
228 
229         if exists {
230           if asset.isLaunchAsset {
231             self.launchAssetUrl = assetLocalUrl
232           } else {
233             if let assetKey = asset.key {
234               self.assetFilesMap![assetKey] = assetLocalUrl.absoluteString
235             }
236           }
237         }
238 
239         if self.completedAssets == totalAssetCount {
240           self.completionQueue.async {
241             self.completion!(self.launchAssetError, self.launchAssetUrl != nil)
242             self.completion = nil
243           }
244         }
245       }
246     }
247   }
248 
249   private func ensureAssetExists(asset: UpdateAsset, withLocalUrl assetLocalUrl: URL, completion: @escaping (Bool) -> Void) {
250     checkExistence(ofAsset: asset, withLocalUrl: assetLocalUrl) { exists in
251       if exists {
252         completion(true)
253         return
254       }
255 
256       self.maybeCopyAssetFromMainBundle(asset, withLocalUrl: assetLocalUrl) { success, error in
257         if success {
258           completion(true)
259           return
260         }
261 
262         if let error = error {
263           NSLog("Error copying embedded asset %@: %@", [asset.key, error.localizedDescription])
264         }
265 
266         self.downloadAsset(asset, withLocalUrl: assetLocalUrl) { downloadAssetError, downloadAssetAsset, _ in
267           if let downloadAssetError = downloadAssetError {
268             if downloadAssetAsset.isLaunchAsset {
269               // save the error -- since this is the launch asset, the launcher will fail
270               // so we want to propagate this error
271               self.launchAssetError = downloadAssetError
272             }
273             NSLog("Failed to load missing asset %@: %@", [downloadAssetAsset.key, downloadAssetError.localizedDescription])
274             completion(false)
275           } else {
276             // attempt to update the database record to match the newly downloaded asset
277             // but don't block launching on this
278             self.database.databaseQueue.async {
279               do {
280                 try self.database.updateAsset(downloadAssetAsset)
281               } catch {
282                 NSLog("Could not write data for downloaded asset to database: %@", [error.localizedDescription])
283               }
284             }
285             completion(true)
286           }
287         }
288       }
289     }
290   }
291 
292   private func checkExistence(ofAsset asset: UpdateAsset, withLocalUrl assetLocalUrl: URL, completion: @escaping (Bool) -> Void) {
293     FileDownloader.assetFilesQueue.async {
294       let exists = FileManager.default.fileExists(atPath: assetLocalUrl.path)
295       self.launcherQueue.async {
296         completion(exists)
297       }
298     }
299   }
300 
301   private func maybeCopyAssetFromMainBundle(
302     _ asset: UpdateAsset,
303     withLocalUrl assetLocalUrl: URL,
304     completion: @escaping (Bool, Error?) -> Void
305   ) {
306     guard let embeddedManifest = EmbeddedAppLoader.embeddedManifest(withConfig: config, database: database) else {
307       completion(false, nil)
308       return
309     }
310 
311     let matchingAsset = embeddedManifest.assets()!.first { embeddedAsset in
312       return embeddedAsset.key != nil && embeddedAsset.key == asset.key
313     }
314 
315     if let matchingAsset = matchingAsset, matchingAsset.mainBundleFilename != nil {
316       FileDownloader.assetFilesQueue.async {
317         guard let bundlePath = UpdatesUtils.path(forBundledAsset: matchingAsset) else {
318           self.launcherQueue.async {
319             completion(
320               false,
321               NSError(
322                 domain: AppLauncherWithDatabase.ErrorDomain,
323                 code: 1013,
324                 userInfo: [NSLocalizedDescriptionKey: "Asset bundlePath was unexpectedly nil"]
325               )
326             )
327           }
328           return
329         }
330 
331         do {
332           try FileManager.default.copyItem(atPath: bundlePath, toPath: assetLocalUrl.path)
333           self.launcherQueue.async {
334             completion(true, nil)
335           }
336         } catch {
337           self.launcherQueue.async {
338             completion(
339               false,
340               NSError(
341                 domain: AppLauncherWithDatabase.ErrorDomain,
342                 code: 1013,
343                 userInfo: [NSLocalizedDescriptionKey: "Asset copy failed"]
344               )
345             )
346           }
347         }
348       }
349       return
350     } else {
351       self.launcherQueue.async {
352         completion(false, nil)
353       }
354     }
355   }
356 
357   private func downloadAsset(_ asset: UpdateAsset, withLocalUrl assetLocalUrl: URL, completion: @escaping (Error?, UpdateAsset, URL) -> Void) {
358     guard let assetUrl = asset.url else {
359       completion(
360         NSError(
361           domain: AppLauncherWithDatabase.ErrorDomain,
362           code: 1007,
363           userInfo: [NSLocalizedDescriptionKey: "Failed to download asset with no URL provided"]
364         ),
365         asset,
366         assetLocalUrl
367       )
368       return
369     }
370 
371     FileDownloader.assetFilesQueue.async {
372       self.downloader.downloadFile(
373         fromURL: assetUrl,
374         verifyingHash: asset.expectedHash,
375         toPath: assetLocalUrl.path,
376         extraHeaders: asset.extraRequestHeaders ?? [:]
377       ) { _, response, base64URLEncodedSHA256Hash in
378         self.launcherQueue.async {
379           if let response = response as? HTTPURLResponse {
380             asset.headers = response.allHeaderFields as? [String: Any]
381           }
382           asset.contentHash = base64URLEncodedSHA256Hash
383           asset.downloadTime = Date()
384           completion(nil, asset, assetLocalUrl)
385         }
386       } errorBlock: { error in
387         self.launcherQueue.async {
388           completion(error, asset, assetLocalUrl)
389         }
390       }
391     }
392   }
393 
394   private lazy var downloader: FileDownloader = {
395     FileDownloader(config: config)
396   }()
397 }
398