1 // Copyright 2019 650 Industries. All rights reserved.
2 
3 // swiftlint:disable closure_body_length
4 // swiftlint:disable function_body_length
5 // swiftlint:disable type_body_length
6 
7 import ExpoModulesCore
8 
9 /**
10  * Exported module which provides to the JS runtime information about the currently running update
11  * and updates state, along with methods to check for and download new updates, reload with the
12  * newest downloaded update applied, and read/clear native log entries.
13  *
14  * Communicates with the updates hub (AppController in most apps, EXAppLoaderExpoUpdates in
15  * Expo Go and legacy standalone apps) via EXUpdatesService, an internal module which is overridden
16  * by EXUpdatesBinding, a scoped module, in Expo Go.
17  */
18 public final class UpdatesModule: Module {
19   private let updatesService: EXUpdatesModuleInterface?
20   private let methodQueue = DispatchQueue(label: "expo.modules.EXUpdatesQueue")
21 
22   public required init(appContext: AppContext) {
23     updatesService = appContext.legacyModule(implementing: EXUpdatesModuleInterface.self)
24     super.init(appContext: appContext)
25   }
26 
27   // swiftlint:disable cyclomatic_complexity
28   public func definition() -> ModuleDefinition {
29     Name("ExpoUpdates")
30 
31     Constants {
32       let releaseChannel = updatesService?.config?.releaseChannel
33       let channel = updatesService?.config?.requestHeaders["expo-channel-name"] ?? ""
34       let runtimeVersion = updatesService?.config?.runtimeVersion ?? ""
35       let checkAutomatically = updatesService?.config?.checkOnLaunch.asString ?? CheckAutomaticallyConfig.Always.asString
36       let isMissingRuntimeVersion = updatesService?.config?.isMissingRuntimeVersion()
37 
38       guard let updatesService = updatesService,
39         updatesService.isStarted,
40         let launchedUpdate = updatesService.launchedUpdate else {
41         return [
42           "isEnabled": false,
43           "isEmbeddedLaunch": false,
44           "isMissingRuntimeVersion": isMissingRuntimeVersion,
45           "releaseChannel": releaseChannel,
46           "runtimeVersion": runtimeVersion,
47           "checkAutomatically": checkAutomatically,
48           "channel": channel
49         ]
50       }
51 
52       let commitTime = UInt64(floor(launchedUpdate.commitTime.timeIntervalSince1970 * 1000))
53       return [
54         "isEnabled": true,
55         "isEmbeddedLaunch": updatesService.isEmbeddedLaunch,
56         "isUsingEmbeddedAssets": updatesService.isUsingEmbeddedAssets,
57         "updateId": launchedUpdate.updateId.uuidString,
58         "manifest": launchedUpdate.manifest.rawManifestJSON(),
59         "localAssets": updatesService.assetFilesMap ?? [:],
60         "isEmergencyLaunch": updatesService.isEmergencyLaunch,
61         "isMissingRuntimeVersion": isMissingRuntimeVersion,
62         "releaseChannel": releaseChannel,
63         "runtimeVersion": runtimeVersion,
64         "checkAutomatically": checkAutomatically,
65         "channel": channel,
66         "commitTime": commitTime,
67         "nativeDebug": UpdatesUtils.isNativeDebuggingEnabled()
68       ]
69     }
70 
71     AsyncFunction("reload") { (promise: Promise) in
72       guard let updatesService = updatesService, let config = updatesService.config, config.isEnabled else {
73         throw UpdatesDisabledException()
74       }
75       guard updatesService.canRelaunch else {
76         throw UpdatesNotInitializedException()
77       }
78       updatesService.requestRelaunch { success in
79         if success {
80           promise.resolve(nil)
81         } else {
82           promise.reject(UpdatesReloadException())
83         }
84       }
85     }
86 
87     AsyncFunction("checkForUpdateAsync") { (promise: Promise) in
88       guard let updatesService = updatesService,
89         let config = updatesService.config,
90         let selectionPolicy = updatesService.selectionPolicy,
91         config.isEnabled else {
92         throw UpdatesDisabledException()
93       }
94       guard updatesService.isStarted else {
95         throw UpdatesNotInitializedException()
96       }
97 
98       var extraHeaders: [String: Any] = [:]
99       updatesService.database.databaseQueue.sync {
100         extraHeaders = FileDownloader.extraHeadersForRemoteUpdateRequest(
101           withDatabase: updatesService.database,
102           config: config,
103           launchedUpdate: updatesService.launchedUpdate,
104           embeddedUpdate: updatesService.embeddedUpdate
105         )
106       }
107 
108       let fileDownloader = FileDownloader(config: config)
109       fileDownloader.downloadRemoteUpdate(
110         // swiftlint:disable:next force_unwrapping
111         fromURL: config.updateUrl!,
112         withDatabase: updatesService.database,
113         extraHeaders: extraHeaders
114       ) { updateResponse in
115         guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else {
116           promise.resolve([
117             "isAvailable": false
118           ])
119           return
120         }
121 
122         let launchedUpdate = updatesService.launchedUpdate
123         if selectionPolicy.shouldLoadNewUpdate(update, withLaunchedUpdate: launchedUpdate, filters: updateResponse.responseHeaderData?.manifestFilters) {
124           promise.resolve([
125             "isAvailable": true,
126             "manifest": update.manifest.rawManifestJSON()
127           ])
128         } else {
129           promise.resolve([
130             "isAvailable": false
131           ])
132         }
133       } errorBlock: { error in
134         promise.reject("ERR_UPDATES_CHECK", error.localizedDescription)
135       }
136     }
137 
138     AsyncFunction("getExtraClientParamsAsync") { (promise: Promise) in
139       guard let updatesService = updatesService,
140         let config = updatesService.config,
141         config.isEnabled else {
142         throw UpdatesDisabledException()
143       }
144 
145       guard let scopeKey = config.scopeKey else {
146         throw Exception(name: "ERR_UPDATES_SCOPE_KEY", description: "Muse have scopeKey in config")
147       }
148 
149       updatesService.database.databaseQueue.async {
150         do {
151           promise.resolve(try updatesService.database.extraParams(withScopeKey: scopeKey))
152         } catch {
153           promise.reject(error)
154         }
155       }
156     }
157 
158     AsyncFunction("setExtraParamAsync") { (key: String, value: String?, promise: Promise) in
159       guard let updatesService = updatesService,
160         let config = updatesService.config,
161         config.isEnabled else {
162         throw UpdatesDisabledException()
163       }
164 
165       guard let scopeKey = config.scopeKey else {
166         throw Exception(name: "ERR_UPDATES_SCOPE_KEY", description: "Muse have scopeKey in config")
167       }
168 
169       updatesService.database.databaseQueue.async {
170         do {
171           try updatesService.database.setExtraParam(key: key, value: value, withScopeKey: scopeKey)
172           promise.resolve(nil)
173         } catch {
174           promise.reject(error)
175         }
176       }
177     }
178 
179     AsyncFunction("readLogEntriesAsync") { (maxAge: Int) -> [[String: Any]] in
180       // maxAge is in milliseconds, convert to seconds
181       do {
182         return try UpdatesLogReader().getLogEntries(newerThan: Date(timeIntervalSinceNow: TimeInterval(-1 * (maxAge / 1000))))
183       } catch {
184         throw Exception(name: "ERR_UPDATES_READ_LOGS", description: error.localizedDescription)
185       }
186     }
187 
188     AsyncFunction("clearLogEntriesAsync") { (promise: Promise) in
189       UpdatesLogReader().purgeLogEntries(olderThan: Date()) { error in
190         guard let error = error else {
191           promise.resolve(nil)
192           return
193         }
194         promise.reject("ERR_UPDATES_READ_LOGS", error.localizedDescription)
195       }
196     }
197 
198     AsyncFunction("fetchUpdateAsync") { (promise: Promise) in
199       guard let updatesService = updatesService,
200         let config = updatesService.config,
201         let selectionPolicy = updatesService.selectionPolicy,
202         config.isEnabled else {
203         throw UpdatesDisabledException()
204       }
205       guard updatesService.isStarted else {
206         throw UpdatesNotInitializedException()
207       }
208 
209       let remoteAppLoader = RemoteAppLoader(
210         config: config,
211         database: updatesService.database,
212         directory: updatesService.directory,
213         launchedUpdate: updatesService.launchedUpdate,
214         completionQueue: methodQueue
215       )
216       remoteAppLoader.loadUpdate(
217         // swiftlint:disable:next force_unwrapping
218         fromURL: 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 selectionPolicy.shouldLoadNewUpdate(
237           update,
238           withLaunchedUpdate: updatesService.launchedUpdate,
239           filters: updateResponse.responseHeaderData?.manifestFilters
240         )
241       } asset: { _, _, _, _ in
242         // do nothing for now
243       } success: { updateResponse in
244         if updateResponse?.directiveUpdateResponsePart?.updateDirective is RollBackToEmbeddedUpdateDirective {
245           promise.resolve([
246             "isRollBackToEmbedded": true
247           ])
248         } else {
249           if let update = updateResponse?.manifestUpdateResponsePart?.updateManifest {
250             updatesService.resetSelectionPolicy()
251             promise.resolve([
252               "isNew": true,
253               "manifest": update.manifest.rawManifestJSON()
254             ])
255           } else {
256             promise.resolve([
257               "isNew": false
258             ])
259           }
260         }
261       } error: { error in
262         promise.reject("ERR_UPDATES_FETCH", "Failed to download new update: \(error.localizedDescription)")
263       }
264     }
265   }
266 }
267