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("reloadAsync") { (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.extraHeaders(
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("readLogEntriesAsync") { (maxAge: Int) -> [[String: Any]] in
134       // maxAge is in milliseconds, convert to seconds
135       do {
136         return try UpdatesLogReader().getLogEntries(newerThan: Date(timeIntervalSinceNow: TimeInterval(-1 * (maxAge / 1000))))
137       } catch {
138         throw Exception(name: "ERR_UPDATES_READ_LOGS", description: error.localizedDescription)
139       }
140     }
141 
142     AsyncFunction("clearLogEntriesAsync") { (promise: Promise) in
143       UpdatesLogReader().purgeLogEntries(olderThan: Date()) { error in
144         guard let error = error else {
145           promise.resolve(nil)
146           return
147         }
148         promise.reject("ERR_UPDATES_READ_LOGS", error.localizedDescription)
149       }
150     }
151 
152     AsyncFunction("fetchUpdateAsync") { (promise: Promise) in
153       guard let updatesService = updatesService,
154         let config = updatesService.config,
155         let selectionPolicy = updatesService.selectionPolicy,
156         config.isEnabled else {
157         throw UpdatesDisabledException()
158       }
159       guard updatesService.isStarted else {
160         throw UpdatesNotInitializedException()
161       }
162 
163       let remoteAppLoader = RemoteAppLoader(
164         config: config,
165         database: updatesService.database,
166         directory: updatesService.directory,
167         launchedUpdate: updatesService.launchedUpdate,
168         completionQueue: methodQueue
169       )
170       remoteAppLoader.loadUpdate(
171         // swiftlint:disable:next force_unwrapping
172         fromURL: config.updateUrl!
173       ) { updateResponse in
174         if let updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective {
175           switch updateDirective {
176           case is NoUpdateAvailableUpdateDirective:
177             return false
178           case is RollBackToEmbeddedUpdateDirective:
179             return true
180           default:
181             NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise()
182             return false
183           }
184         }
185 
186         guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else {
187           return false
188         }
189 
190         return selectionPolicy.shouldLoadNewUpdate(
191           update,
192           withLaunchedUpdate: updatesService.launchedUpdate,
193           filters: updateResponse.responseHeaderData?.manifestFilters
194         )
195       } asset: { _, _, _, _ in
196         // do nothing for now
197       } success: { updateResponse in
198         if updateResponse?.directiveUpdateResponsePart?.updateDirective is RollBackToEmbeddedUpdateDirective {
199           promise.resolve([
200             "isRollBackToEmbedded": true
201           ])
202         } else {
203           if let update = updateResponse?.manifestUpdateResponsePart?.updateManifest {
204             updatesService.resetSelectionPolicy()
205             promise.resolve([
206               "isNew": true,
207               "manifest": update.manifest.rawManifestJSON()
208             ])
209           } else {
210             promise.resolve([
211               "isNew": false
212             ])
213           }
214         }
215       } error: { error in
216         promise.reject("ERR_UPDATES_FETCH", "Failed to download new update: \(error.localizedDescription)")
217       }
218     }
219   }
220 }
221