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