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