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