1 // Copyright © 2019 650 Industries. All rights reserved. 2 3 // swiftlint:disable force_unwrapping 4 // swiftlint:disable closure_body_length 5 // swiftlint:disable superfluous_else 6 7 import Foundation 8 import SystemConfiguration 9 import CommonCrypto 10 import Reachability 11 import ExpoModulesCore 12 13 internal extension Array where Element: Equatable { removenull14 mutating func remove(_ element: Element) { 15 if let index = firstIndex(of: element) { 16 remove(at: index) 17 } 18 } 19 } 20 21 @objc(EXUpdatesUtils) 22 @objcMembers 23 public final class UpdatesUtils: NSObject { 24 private static let EXUpdatesEventName = "Expo.nativeUpdatesEvent" 25 private static let EXUpdatesUtilsErrorDomain = "EXUpdatesUtils" 26 public static let methodQueue = DispatchQueue(label: "expo.modules.EXUpdatesQueue") 27 28 // MARK: - Public methods 29 30 // Refactored to a common method used by both UpdatesUtils and ErrorRecovery updatesApplicationDocumentsDirectorynull31 public static func updatesApplicationDocumentsDirectory() -> URL { 32 let fileManager = FileManager.default 33 #if os(tvOS) 34 let applicationDocumentsDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).last! 35 #else 36 let applicationDocumentsDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).last! 37 #endif 38 return applicationDocumentsDirectory 39 } 40 initializeUpdatesDirectorynull41 public static func initializeUpdatesDirectory() throws -> URL { 42 let fileManager = FileManager.default 43 let applicationDocumentsDirectory = UpdatesUtils.updatesApplicationDocumentsDirectory() 44 let updatesDirectory = applicationDocumentsDirectory.appendingPathComponent(".expo-internal") 45 let updatesDirectoryPath = updatesDirectory.path 46 47 var isDir = ObjCBool(false) 48 let exists = fileManager.fileExists(atPath: updatesDirectoryPath, isDirectory: &isDir) 49 50 if exists { 51 if !isDir.boolValue { 52 throw NSError( 53 domain: EXUpdatesUtilsErrorDomain, 54 code: 1005, 55 userInfo: [ 56 NSLocalizedDescriptionKey: "Failed to create the Updates Directory; a file already exists with the required directory name" 57 ] 58 ) 59 } 60 } else { 61 try fileManager.createDirectory(atPath: updatesDirectoryPath, withIntermediateDirectories: true) 62 } 63 return updatesDirectory 64 } 65 66 /** 67 The implementation of checkForUpdateAsync(). 68 The UpdatesService is passed in when this is called from JS through UpdatesModule 69 */ 70 public static func checkForUpdate(_ block: @escaping ([String: Any]) -> Void) { 71 sendStateEvent(UpdatesStateEventCheck()) 72 do { 73 let constants = try startJSAPICall() 74 75 var extraHeaders: [String: Any] = [:] 76 constants.database.databaseQueue.sync { 77 extraHeaders = FileDownloader.extraHeadersForRemoteUpdateRequest( 78 withDatabase: constants.database, 79 config: constants.config, 80 launchedUpdate: constants.launchedUpdate, 81 embeddedUpdate: constants.embeddedUpdate 82 ) 83 } 84 85 let fileDownloader = FileDownloader(config: constants.config) 86 fileDownloader.downloadRemoteUpdate( 87 fromURL: constants.config.updateUrl!, 88 withDatabase: constants.database, 89 extraHeaders: extraHeaders 90 ) { updateResponse in 91 let launchedUpdate = constants.launchedUpdate 92 let manifestFilters = updateResponse.responseHeaderData?.manifestFilters 93 94 if let updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective { 95 switch updateDirective { 96 case is NoUpdateAvailableUpdateDirective: 97 block([:]) 98 sendStateEvent(UpdatesStateEventCheckComplete()) 99 return 100 case let rollBackUpdateDirective as RollBackToEmbeddedUpdateDirective: 101 if !constants.config.hasEmbeddedUpdate { 102 let reason = RemoteCheckResultNotAvailableReason.rollbackNoEmbedded 103 block([ 104 "reason": "\(reason)" 105 ]) 106 sendStateEvent(UpdatesStateEventCheckComplete()) 107 return 108 } 109 110 guard let embeddedManifest = EmbeddedAppLoader.embeddedManifest(withConfig: constants.config, database: constants.database) else { 111 let reason = RemoteCheckResultNotAvailableReason.rollbackNoEmbedded 112 block([ 113 "reason": "\(reason)" 114 ]) 115 sendStateEvent(UpdatesStateEventCheckComplete()) 116 return 117 } 118 119 if !constants.selectionPolicy.shouldLoadRollBackToEmbeddedDirective( 120 rollBackUpdateDirective, 121 withEmbeddedUpdate: embeddedManifest, 122 launchedUpdate: launchedUpdate, 123 filters: manifestFilters 124 ) { 125 let reason = RemoteCheckResultNotAvailableReason.rollbackRejectedBySelectionPolicy 126 block([ 127 "reason": "\(reason)" 128 ]) 129 sendStateEvent(UpdatesStateEventCheckComplete()) 130 return 131 } 132 133 block([ 134 "isRollBackToEmbedded": true 135 ]) 136 sendStateEvent( 137 UpdatesStateEventCheckCompleteWithRollback( 138 rollbackCommitTime: RollBackToEmbeddedUpdateDirective.rollbackCommitTime(rollBackUpdateDirective) 139 ) 140 ) 141 return 142 default: 143 return handleCheckError(UpdatesUnsupportedDirectiveException(), block: block) 144 } 145 } 146 147 guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else { 148 let reason = RemoteCheckResultNotAvailableReason.noUpdateAvailableOnServer 149 block([ 150 "reason": "\(reason)" 151 ]) 152 sendStateEvent(UpdatesStateEventCheckComplete()) 153 return 154 } 155 156 var shouldLaunch = false 157 var failedPreviously = false 158 if constants.selectionPolicy.shouldLoadNewUpdate( 159 update, 160 withLaunchedUpdate: launchedUpdate, 161 filters: manifestFilters 162 ) { 163 // If "update" has failed to launch previously, then 164 // "launchedUpdate" will be an earlier update, and the test above 165 // will return true (incorrectly). 166 // We check to see if the new update is already in the DB, and if so, 167 // only allow the update if it has had no launch failures. 168 shouldLaunch = true 169 constants.database.databaseQueue.sync { 170 do { 171 let storedUpdate = try constants.database.update(withId: update.updateId, config: constants.config) 172 if let storedUpdate = storedUpdate { 173 shouldLaunch = storedUpdate.failedLaunchCount == 0 || storedUpdate.successfulLaunchCount > 0 174 failedPreviously = !shouldLaunch 175 AppController.sharedInstance.logger.info(message: "Stored update found: ID = \(update.updateId), failureCount = \(storedUpdate.failedLaunchCount)") 176 } 177 } catch {} 178 } 179 } 180 if shouldLaunch { 181 block([ 182 "manifest": update.manifest.rawManifestJSON() 183 ]) 184 sendStateEvent(UpdatesStateEventCheckCompleteWithUpdate(manifest: update.manifest.rawManifestJSON())) 185 } else { 186 let reason = failedPreviously ? 187 RemoteCheckResultNotAvailableReason.updatePreviouslyFailed : 188 RemoteCheckResultNotAvailableReason.updateRejectedBySelectionPolicy 189 block([ 190 "reason": "\(reason)" 191 ]) 192 sendStateEvent(UpdatesStateEventCheckComplete()) 193 } 194 } errorBlock: { error in 195 return handleCheckError(error, block: block) 196 } 197 } catch { 198 return handleCheckError(error, block: block) 199 } 200 } 201 202 /** 203 The implementation of fetchUpdateAsync(). 204 The UpdatesService is passed in when this is called from JS through UpdatesModule 205 */ 206 public static func fetchUpdate(_ block: @escaping ([String: Any]) -> Void) { 207 sendStateEvent(UpdatesStateEventDownload()) 208 do { 209 let constants = try startJSAPICall() 210 let remoteAppLoader = RemoteAppLoader( 211 config: constants.config, 212 database: constants.database, 213 directory: constants.directory, 214 launchedUpdate: constants.launchedUpdate, 215 completionQueue: methodQueue 216 ) 217 remoteAppLoader.loadUpdate( 218 fromURL: constants.config.updateUrl! 219 ) { updateResponse in 220 if let updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective { 221 switch updateDirective { 222 case is NoUpdateAvailableUpdateDirective: 223 return false 224 case is RollBackToEmbeddedUpdateDirective: 225 return true 226 default: 227 NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise() 228 return false 229 } 230 } 231 232 guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else { 233 return false 234 } 235 236 return constants.selectionPolicy.shouldLoadNewUpdate( 237 update, 238 withLaunchedUpdate: constants.launchedUpdate, 239 filters: updateResponse.responseHeaderData?.manifestFilters 240 ) 241 } asset: { asset, successfulAssetCount, failedAssetCount, totalAssetCount in 242 let body = [ 243 "assetInfo": [ 244 "assetName": asset.filename, 245 "successfulAssetCount": successfulAssetCount, 246 "failedAssetCount": failedAssetCount, 247 "totalAssetCount": totalAssetCount 248 ] as [String: Any] 249 ] as [String: Any] 250 AppController.sharedInstance.logger.info( 251 message: "fetchUpdateAsync didLoadAsset: \(body)", 252 code: .none, 253 updateId: nil, 254 assetId: asset.contentHash 255 ) 256 } success: { updateResponse in 257 RemoteAppLoader.processSuccessLoaderResult( 258 config: constants.config, 259 database: constants.database, 260 selectionPolicy: constants.selectionPolicy, 261 launchedUpdate: constants.launchedUpdate, 262 directory: constants.directory, 263 loaderTaskQueue: DispatchQueue(label: "expo.loader.LoaderTaskQueue"), 264 updateResponse: updateResponse, 265 priorError: nil 266 ) { updateToLaunch, error, didRollBackToEmbedded in 267 if let error = error { 268 return handleFetchError(error, block: block) 269 } 270 271 if didRollBackToEmbedded { 272 block([ 273 "isNew": false, 274 "isRollBackToEmbedded": true 275 ]) 276 sendStateEvent(UpdatesStateEventDownloadCompleteWithRollback()) 277 return 278 } else { 279 if let update = updateToLaunch { 280 AppController.sharedInstance.resetSelectionPolicyToDefault() 281 block([ 282 "isNew": true, 283 "isRollBackToEmbedded": false, 284 "manifest": update.manifest.rawManifestJSON() 285 ] as [String: Any]) 286 sendStateEvent(UpdatesStateEventDownloadCompleteWithUpdate(manifest: update.manifest.rawManifestJSON())) 287 return 288 } else { 289 block([ 290 "isNew": false, 291 "isRollBackToEmbedded": false 292 ]) 293 sendStateEvent(UpdatesStateEventDownloadComplete()) 294 return 295 } 296 } 297 } 298 } error: { error in 299 return handleFetchError(error, block: block) 300 } 301 } catch { 302 handleFetchError(error, block: block) 303 } 304 } 305 306 // MARK: - Internal methods 307 defaultNativeStateMachineContextJsonnull308 internal static func defaultNativeStateMachineContextJson() -> [String: Any?] { 309 return UpdatesStateContext().json 310 } 311 312 internal static func getNativeStateMachineContextJson(_ block: @escaping ([String: Any?]) -> Void) { 313 do { 314 let constants = try startJSAPICall() 315 let result = constants.context?.json ?? defaultNativeStateMachineContextJson() 316 block(result) 317 } catch { 318 handleCheckError(error, block: block) 319 } 320 } 321 shouldCheckForUpdatenull322 internal static func shouldCheckForUpdate(withConfig config: UpdatesConfig) -> Bool { 323 func isConnectedToWifi() -> Bool { 324 do { 325 return try Reachability().connection == .wifi 326 } catch { 327 return false 328 } 329 } 330 331 switch config.checkOnLaunch { 332 case .Always: 333 return true 334 case .WifiOnly: 335 return isConnectedToWifi() 336 case .Never: 337 return false 338 case .ErrorRecoveryOnly: 339 // check will happen later on if there's an error 340 return false 341 } 342 } 343 sendStateEventnull344 internal static func sendStateEvent(_ event: UpdatesStateEvent) { 345 AppController.sharedInstance.stateMachine?.processEvent(event) 346 } 347 getRuntimeVersionnull348 internal static func getRuntimeVersion(withConfig config: UpdatesConfig) -> String { 349 // various places in the code assume that we have a nonnull runtimeVersion, so if the developer 350 // hasn't configured either runtimeVersion or sdkVersion, we'll use a dummy value of "1" but warn 351 // the developer in JS that they need to configure one of these values 352 return config.runtimeVersion ?? config.sdkVersion ?? "1" 353 } 354 urlnull355 internal static func url(forBundledAsset asset: UpdateAsset) -> URL? { 356 guard let mainBundleDir = asset.mainBundleDir else { 357 return Bundle.main.url(forResource: asset.mainBundleFilename, withExtension: asset.type) 358 } 359 return Bundle.main.url(forResource: asset.mainBundleFilename, withExtension: asset.type, subdirectory: mainBundleDir) 360 } 361 pathnull362 internal static func path(forBundledAsset asset: UpdateAsset) -> String? { 363 guard let mainBundleDir = asset.mainBundleDir else { 364 return Bundle.main.path(forResource: asset.mainBundleFilename, ofType: asset.type) 365 } 366 return Bundle.main.path(forResource: asset.mainBundleFilename, ofType: asset.type, inDirectory: mainBundleDir) 367 } 368 369 /** 370 Purges entries in the expo-updates log file that are older than 1 day 371 */ purgeUpdatesLogsOlderThanOneDaynull372 internal static func purgeUpdatesLogsOlderThanOneDay() { 373 UpdatesLogReader().purgeLogEntries { error in 374 if let error = error { 375 NSLog("UpdatesUtils: error in purgeOldUpdatesLogs: %@", error.localizedDescription) 376 } 377 } 378 } 379 isNativeDebuggingEnablednull380 internal static func isNativeDebuggingEnabled() -> Bool { 381 #if EX_UPDATES_NATIVE_DEBUG 382 return true 383 #else 384 return false 385 #endif 386 } 387 388 internal static func runBlockOnMainThread(_ block: @escaping () -> Void) { 389 if Thread.isMainThread { 390 block() 391 } else { 392 DispatchQueue.main.async { 393 block() 394 } 395 } 396 } 397 hexEncodedSHA256WithDatanull398 internal static func hexEncodedSHA256WithData(_ data: Data) -> String { 399 var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 400 data.withUnsafeBytes { bytes in 401 _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) 402 } 403 return digest.reduce("") { $0 + String(format: "%02x", $1) } 404 } 405 base64UrlEncodedSHA256WithDatanull406 internal static func base64UrlEncodedSHA256WithData(_ data: Data) -> String { 407 var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 408 data.withUnsafeBytes { bytes in 409 _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) 410 } 411 let base64EncodedDigest = Data(digest).base64EncodedString() 412 413 // ref. https://datatracker.ietf.org/doc/html/rfc4648#section-5 414 return base64EncodedDigest 415 .trimmingCharacters(in: CharacterSet(charactersIn: "=")) // remove extra padding 416 .replacingOccurrences(of: "+", with: "-") // replace "+" character w/ "-" 417 .replacingOccurrences(of: "/", with: "_") // replace "/" character w/ "_" 418 } 419 420 // MARK: - Private methods used by API calls 421 422 /** 423 If any error occurs in checkForUpdate(), this will call the 424 completion block and fire the error notification 425 */ 426 private static func handleCheckError(_ error: Error, block: @escaping ([String: Any]) -> Void) { 427 let body = ["message": error.localizedDescription] 428 sendStateEvent(UpdatesStateEventCheckError(message: error.localizedDescription)) 429 block(body) 430 } 431 432 /** 433 If any error occurs in fetchUpdate(), this will call the 434 completion block and fire the error notification 435 */ 436 private static func handleFetchError(_ error: Error, block: @escaping ([String: Any]) -> Void) { 437 let body = ["message": error.localizedDescription] 438 sendStateEvent(UpdatesStateEventDownloadError(message: error.localizedDescription)) 439 block(body) 440 } 441 442 /** 443 Code that runs at the start of both checkForUpdate and fetchUpdate, to do sanity 444 checks and return the config, selection policy, database, etc. 445 When called from JS, the UpdatesService object will be passed in. 446 When called from elsewhere (e.g. in response to a notification), 447 a nil object is passed in, in which case we return the results directly 448 from the AppController. 449 Throws if expo-updates is not enabled or not started. 450 */ startJSAPICallnull451 private static func startJSAPICall() throws -> ( 452 config: UpdatesConfig, 453 selectionPolicy: SelectionPolicy, 454 database: UpdatesDatabase, 455 directory: URL, 456 launchedUpdate: Update?, 457 embeddedUpdate: Update?, 458 context: UpdatesStateContext? 459 ) { 460 let maybeConfig: UpdatesConfig? = AppController.sharedInstance.config 461 let maybeSelectionPolicy: SelectionPolicy? = AppController.sharedInstance.selectionPolicy() 462 let maybeIsStarted: Bool? = AppController.sharedInstance.isStarted 463 464 guard let config = maybeConfig, 465 let selectionPolicy = maybeSelectionPolicy, 466 config.isEnabled 467 else { 468 throw UpdatesDisabledException() 469 } 470 guard maybeIsStarted ?? false else { 471 throw UpdatesNotInitializedException() 472 } 473 474 let database = AppController.sharedInstance.database 475 let launchedUpdate = AppController.sharedInstance.launchedUpdate() 476 let embeddedUpdate = EmbeddedAppLoader.embeddedManifest(withConfig: config, database: database) 477 guard let directory = AppController.sharedInstance.updatesDirectory else { 478 throw UpdatesNotInitializedException() 479 } 480 let context = AppController.sharedInstance.stateMachine?.context 481 return (config, selectionPolicy, database, directory, launchedUpdate, embeddedUpdate, context) 482 } 483 } 484 485 // swiftlint:enable force_unwrapping 486 // swiftlint:enable closure_body_length 487 // swiftlint:enable superfluous_else 488