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("EXUpdates")
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, updatesService.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, updatesService.config.isEnabled else {
84         throw UpdatesDisabledException()
85       }
86       guard updatesService.isStarted else {
87         throw UpdatesNotInitializedException()
88       }
89 
90       var extraHeaders: [String: Any] = [:]
91       updatesService.database.databaseQueue.sync {
92         extraHeaders = FileDownloader.extraHeaders(
93           withDatabase: updatesService.database,
94           config: updatesService.config,
95           launchedUpdate: updatesService.launchedUpdate,
96           embeddedUpdate: updatesService.embeddedUpdate
97         )
98       }
99 
100       let fileDownloader = FileDownloader(config: updatesService.config)
101       fileDownloader.downloadManifest(
102         // swiftlint:disable:next force_unwrapping
103         fromURL: updatesService.config.updateUrl!,
104         withDatabase: updatesService.database,
105         extraHeaders: extraHeaders
106       ) { update in
107         let launchedUpdate = updatesService.launchedUpdate
108         let selectionPolicy = updatesService.selectionPolicy
109         if selectionPolicy.shouldLoadNewUpdate(update, withLaunchedUpdate: launchedUpdate, filters: update.manifestFilters) {
110           promise.resolve([
111             "isAvailable": true,
112             "manifest": update.manifest.rawManifestJSON()
113           ])
114         }
115       } errorBlock: { error in
116         promise.reject("ERR_UPDATES_CHECK", error.localizedDescription)
117       }
118     }
119 
120     AsyncFunction("readLogEntriesAsync") { (maxAge: Int) -> [[String: Any]] in
121       // maxAge is in milliseconds, convert to seconds
122       do {
123         return try UpdatesLogReader().getLogEntries(newerThan: Date(timeIntervalSinceNow: TimeInterval(-1 * (maxAge / 1000))))
124       } catch {
125         throw Exception(name: "ERR_UPDATES_READ_LOGS", description: error.localizedDescription)
126       }
127     }
128 
129     AsyncFunction("clearLogEntriesAsync") { (promise: Promise) in
130       UpdatesLogReader().purgeLogEntries(olderThan: Date()) { error in
131         guard let error = error else {
132           promise.resolve(nil)
133           return
134         }
135         promise.reject("ERR_UPDATES_READ_LOGS", error.localizedDescription)
136       }
137     }
138 
139     AsyncFunction("fetchUpdateAsync") { (promise: Promise) in
140       guard let updatesService = updatesService, updatesService.config.isEnabled else {
141         throw UpdatesDisabledException()
142       }
143       guard updatesService.isStarted else {
144         throw UpdatesNotInitializedException()
145       }
146 
147       let remoteAppLoader = RemoteAppLoader(
148         config: updatesService.config,
149         database: updatesService.database,
150         directory: updatesService.directory,
151         launchedUpdate: updatesService.launchedUpdate,
152         completionQueue: methodQueue
153       )
154       remoteAppLoader.loadUpdate(
155         // swiftlint:disable:next force_unwrapping
156         fromURL: updatesService.config.updateUrl!
157       ) { update in
158         return updatesService.selectionPolicy.shouldLoadNewUpdate(
159           update,
160           withLaunchedUpdate: updatesService.launchedUpdate,
161           filters: update.manifestFilters
162         )
163       } asset: { _, _, _, _ in
164         // do nothing for now
165       } success: { update in
166         if let update = update {
167           updatesService.resetSelectionPolicy()
168           promise.resolve([
169             "isNew": true,
170             "manifest": update.manifest.rawManifestJSON()
171           ])
172         } else {
173           promise.resolve([
174             "isNew": false
175           ])
176         }
177       } error: { error in
178         promise.reject("ERR_UPDATES_FETCH", "Failed to download new update: \(error.localizedDescription)")
179       }
180     }
181   }
182 }
183