1 //  Copyright © 2019 650 Industries. All rights reserved.
2 
3 // Member variable names here are kept to ease transition to swift. Can rename at end.
4 // swiftlint:disable identifier_name
5 
6 import Foundation
7 
8 @objc(EXUpdatesCheckAutomaticallyConfig)
9 public enum CheckAutomaticallyConfig: Int {
10   case Always = 0
11   case WifiOnly = 1
12   case Never = 2
13   case ErrorRecoveryOnly = 3
14   var asString: String {
15     switch self {
16     case .Always:
17       return "ALWAYS"
18     case .WifiOnly:
19       return "WIFI_ONLY"
20     case .Never:
21       return "NEVER"
22     case .ErrorRecoveryOnly:
23       return "ERROR_RECOVERY_ONLY"
24     }
25   }
26 }
27 
28 @objc(EXUpdatesConfigError)
29 public enum UpdatesConfigError: Int, Error {
30   case ExpoUpdatesConfigPlistError
31 }
32 
33 /**
34  * Holds global, immutable configuration values for updates, as well as doing some rudimentary
35  * validation.
36  *
37  * In most apps, these configuration values are baked into the build, and this class functions as a
38  * utility for reading and memoizing the values.
39  *
40  * In development clients (including Expo Go) where this configuration is intended to be dynamic at
41  * runtime and updates from multiple scopes can potentially be opened, multiple instances of this
42  * class may be created over the lifetime of the app, but only one should be active at a time.
43  */
44 @objc(EXUpdatesConfig)
45 @objcMembers
46 public final class UpdatesConfig: NSObject {
47   public static let PlistName = "Expo"
48 
49   public static let EXUpdatesConfigEnableAutoSetupKey = "EXUpdatesAutoSetup"
50   public static let EXUpdatesConfigEnabledKey = "EXUpdatesEnabled"
51   public static let EXUpdatesConfigScopeKeyKey = "EXUpdatesScopeKey"
52   public static let EXUpdatesConfigUpdateUrlKey = "EXUpdatesURL"
53   public static let EXUpdatesConfigRequestHeadersKey = "EXUpdatesRequestHeaders"
54   public static let EXUpdatesConfigReleaseChannelKey = "EXUpdatesReleaseChannel"
55   public static let EXUpdatesConfigLaunchWaitMsKey = "EXUpdatesLaunchWaitMs"
56   public static let EXUpdatesConfigCheckOnLaunchKey = "EXUpdatesCheckOnLaunch"
57   public static let EXUpdatesConfigSDKVersionKey = "EXUpdatesSDKVersion"
58   public static let EXUpdatesConfigRuntimeVersionKey = "EXUpdatesRuntimeVersion"
59   public static let EXUpdatesConfigHasEmbeddedUpdateKey = "EXUpdatesHasEmbeddedUpdate"
60   public static let EXUpdatesConfigExpectsSignedManifestKey = "EXUpdatesExpectsSignedManifest"
61   public static let EXUpdatesConfigCodeSigningCertificateKey = "EXUpdatesCodeSigningCertificate"
62   public static let EXUpdatesConfigCodeSigningMetadataKey = "EXUpdatesCodeSigningMetadata"
63   public static let EXUpdatesConfigCodeSigningIncludeManifestResponseCertificateChainKey = "EXUpdatesCodeSigningIncludeManifestResponseCertificateChain"
64   public static let EXUpdatesConfigCodeSigningAllowUnsignedManifestsKey = "EXUpdatesConfigCodeSigningAllowUnsignedManifests"
65   public static let EXUpdatesConfigEnableExpoUpdatesProtocolV0CompatibilityModeKey = "EXUpdatesConfigEnableExpoUpdatesProtocolV0CompatibilityMode"
66 
67   public static let EXUpdatesConfigCheckOnLaunchValueAlways = "ALWAYS"
68   public static let EXUpdatesConfigCheckOnLaunchValueWifiOnly = "WIFI_ONLY"
69   public static let EXUpdatesConfigCheckOnLaunchValueErrorRecoveryOnly = "ERROR_RECOVERY_ONLY"
70   public static let EXUpdatesConfigCheckOnLaunchValueNever = "NEVER"
71 
72   private static let ReleaseChannelDefaultValue = "default"
73 
74   public let isEnabled: Bool
75   public let expectsSignedManifest: Bool
76   public let scopeKey: String?
77   public let updateUrl: URL?
78   public let requestHeaders: [String: String]
79   public let releaseChannel: String
80   public let launchWaitMs: Int
81   public let checkOnLaunch: CheckAutomaticallyConfig
82   public let codeSigningConfiguration: CodeSigningConfiguration?
83 
84   // used only in Expo Go to prevent loading rollbacks and other directives, which don't make much sense in the context of Expo Go
85   public let enableExpoUpdatesProtocolV0CompatibilityMode: Bool
86 
87   public let sdkVersion: String?
88   public let runtimeVersion: String?
89 
90   public let hasEmbeddedUpdate: Bool
91 
92   internal required init(
93     isEnabled: Bool,
94     expectsSignedManifest: Bool,
95     scopeKey: String?,
96     updateUrl: URL?,
97     requestHeaders: [String: String],
98     releaseChannel: String,
99     launchWaitMs: Int,
100     checkOnLaunch: CheckAutomaticallyConfig,
101     codeSigningConfiguration: CodeSigningConfiguration?,
102     sdkVersion: String?,
103     runtimeVersion: String?,
104     hasEmbeddedUpdate: Bool,
105     enableExpoUpdatesProtocolV0CompatibilityMode: Bool
106   ) {
107     self.isEnabled = isEnabled
108     self.expectsSignedManifest = expectsSignedManifest
109     self.scopeKey = scopeKey
110     self.updateUrl = updateUrl
111     self.requestHeaders = requestHeaders
112     self.releaseChannel = releaseChannel
113     self.launchWaitMs = launchWaitMs
114     self.checkOnLaunch = checkOnLaunch
115     self.codeSigningConfiguration = codeSigningConfiguration
116     self.sdkVersion = sdkVersion
117     self.runtimeVersion = runtimeVersion
118     self.hasEmbeddedUpdate = hasEmbeddedUpdate
119     self.enableExpoUpdatesProtocolV0CompatibilityMode = enableExpoUpdatesProtocolV0CompatibilityMode
120   }
121 
isMissingRuntimeVersionnull122   public func isMissingRuntimeVersion() -> Bool {
123     return (runtimeVersion?.isEmpty ?? true) && (sdkVersion?.isEmpty ?? true)
124   }
125 
configWithExpoPlistnull126   public static func configWithExpoPlist(mergingOtherDictionary: [String: Any]?) throws -> UpdatesConfig {
127     guard let configPath = Bundle.main.path(forResource: PlistName, ofType: "plist") else {
128       throw UpdatesConfigError.ExpoUpdatesConfigPlistError
129     }
130     return try configWithExpoPlist(configPlistPath: configPath, mergingOtherDictionary: mergingOtherDictionary)
131   }
132 
configWithExpoPlistnull133   public static func configWithExpoPlist(configPlistPath: String, mergingOtherDictionary: [String: Any]?) throws -> UpdatesConfig {
134     // swiftlint:disable:next legacy_objc_type
135     guard let configNSDictionary = NSDictionary(contentsOfFile: configPlistPath) as? [String: Any] else {
136       throw UpdatesConfigError.ExpoUpdatesConfigPlistError
137     }
138 
139     var dictionary: [String: Any] = configNSDictionary
140     if let mergingOtherDictionary = mergingOtherDictionary {
141       dictionary = dictionary.merging(mergingOtherDictionary, uniquingKeysWith: { _, new in new })
142     }
143 
144     return UpdatesConfig.config(fromDictionary: dictionary)
145   }
146 
confignull147   public static func config(fromDictionary config: [String: Any]) -> UpdatesConfig {
148     let isEnabled = config.optionalValue(forKey: EXUpdatesConfigEnabledKey) ?? true
149     let expectsSignedManifest = config.optionalValue(forKey: EXUpdatesConfigExpectsSignedManifestKey) ?? false
150     let updateUrl: URL? = config.optionalValue(forKey: EXUpdatesConfigUpdateUrlKey).let { it in
151       URL(string: it)
152     }
153 
154     var scopeKey: String? = config.optionalValue(forKey: EXUpdatesConfigScopeKeyKey)
155     if scopeKey == nil,
156       let updateUrl = updateUrl {
157       scopeKey = UpdatesConfig.normalizedURLOrigin(url: updateUrl)
158     }
159 
160     let requestHeaders: [String: String] = config.optionalValue(forKey: EXUpdatesConfigRequestHeadersKey) ?? [:]
161     let releaseChannel = config.optionalValue(forKey: EXUpdatesConfigReleaseChannelKey) ?? ReleaseChannelDefaultValue
162     let launchWaitMs = config.optionalValue(forKey: EXUpdatesConfigLaunchWaitMsKey).let { (it: Any) in
163       // The only way I can figure out how to detect numbers is to do a is NSNumber (is any Numeric didn't work).
164       // This might be able to change when we switch out the plist decoder above
165       // swiftlint:disable:next legacy_objc_type
166       if let it = it as? NSNumber {
167         return it.intValue
168       } else if let it = it as? String {
169         let formatter = NumberFormatter()
170         formatter.numberStyle = .none
171         return formatter.number(from: it)?.intValue
172       }
173       return nil
174     } ?? 0
175 
176     let checkOnLaunch = config.optionalValue(forKey: EXUpdatesConfigCheckOnLaunchKey).let { (it: String) in
177       switch it {
178       case EXUpdatesConfigCheckOnLaunchValueNever:
179         return CheckAutomaticallyConfig.Never
180       case EXUpdatesConfigCheckOnLaunchValueErrorRecoveryOnly:
181         return CheckAutomaticallyConfig.ErrorRecoveryOnly
182       case EXUpdatesConfigCheckOnLaunchValueWifiOnly:
183         return CheckAutomaticallyConfig.WifiOnly
184       case EXUpdatesConfigCheckOnLaunchValueAlways:
185         return CheckAutomaticallyConfig.Always
186       default:
187         return CheckAutomaticallyConfig.Always
188       }
189     } ?? CheckAutomaticallyConfig.Always
190 
191     let sdkVersion: String? = config.optionalValue(forKey: EXUpdatesConfigSDKVersionKey)
192     let runtimeVersion: String? = config.optionalValue(forKey: EXUpdatesConfigRuntimeVersionKey)
193     let hasEmbeddedUpdate = config.optionalValue(forKey: EXUpdatesConfigHasEmbeddedUpdateKey) ?? true
194 
195     let codeSigningConfiguration = config.optionalValue(forKey: EXUpdatesConfigCodeSigningCertificateKey).let { (certificateString: String) in
196       let codeSigningMetadata: [String: String] = config.requiredValue(forKey: EXUpdatesConfigCodeSigningMetadataKey)
197       let codeSigningIncludeManifestResponseCertificateChain: Bool = config.optionalValue(
198         forKey: EXUpdatesConfigCodeSigningIncludeManifestResponseCertificateChainKey
199       ) ?? false
200       let codeSigningAllowUnsignedManifests: Bool = config.optionalValue(forKey: EXUpdatesConfigCodeSigningAllowUnsignedManifestsKey) ?? false
201 
202       return (try? UpdatesConfig.codeSigningConfigurationForCodeSigningCertificate(
203         certificateString,
204         codeSigningMetadata: codeSigningMetadata,
205         codeSigningIncludeManifestResponseCertificateChain: codeSigningIncludeManifestResponseCertificateChain,
206         codeSigningAllowUnsignedManifests: codeSigningAllowUnsignedManifests
207       )).require("Invalid code signing configuration")
208     }
209 
210     let enableExpoUpdatesProtocolV0CompatibilityMode = config.optionalValue(forKey: EXUpdatesConfigEnableExpoUpdatesProtocolV0CompatibilityModeKey) ?? false
211 
212     return UpdatesConfig(
213       isEnabled: isEnabled,
214       expectsSignedManifest: expectsSignedManifest,
215       scopeKey: scopeKey,
216       updateUrl: updateUrl,
217       requestHeaders: requestHeaders,
218       releaseChannel: releaseChannel,
219       launchWaitMs: launchWaitMs,
220       checkOnLaunch: checkOnLaunch,
221       codeSigningConfiguration: codeSigningConfiguration,
222       sdkVersion: sdkVersion,
223       runtimeVersion: runtimeVersion,
224       hasEmbeddedUpdate: hasEmbeddedUpdate,
225       enableExpoUpdatesProtocolV0CompatibilityMode: enableExpoUpdatesProtocolV0CompatibilityMode
226     )
227   }
228 
229   private static func codeSigningConfigurationForCodeSigningCertificate(
230     _ codeSigningCertificate: String,
231     codeSigningMetadata: [String: String],
232     codeSigningIncludeManifestResponseCertificateChain: Bool,
233     codeSigningAllowUnsignedManifests: Bool
234   ) throws -> CodeSigningConfiguration? {
235     return try CodeSigningConfiguration(
236       embeddedCertificateString: codeSigningCertificate,
237       metadata: codeSigningMetadata,
238       includeManifestResponseCertificateChain: codeSigningIncludeManifestResponseCertificateChain,
239       allowUnsignedManifests: codeSigningAllowUnsignedManifests
240     )
241   }
242 
normalizedURLOriginnull243   public static func normalizedURLOrigin(url: URL) -> String {
244     let scheme = url.scheme.require("updateUrl must have a valid scheme")
245     let host = url.host.require("updateUrl must have a valid host")
246     var portOuter: Int? = url.port
247     if let port = portOuter,
248       Int(port) > -1,
249       port == UpdatesConfig.defaultPortForScheme(scheme: scheme) {
250       portOuter = nil
251     }
252 
253     guard let port = portOuter,
254       Int(port) > -1 else {
255       return "\(scheme)://\(host)"
256     }
257 
258     return "\(scheme)://\(host):\(Int(port))"
259   }
260 
defaultPortForSchemenull261   private static func defaultPortForScheme(scheme: String?) -> Int? {
262     switch scheme {
263     case "http":
264       return 80
265     case "ws":
266       return 80
267     case "https":
268       return 443
269     case "wss":
270       return 443
271     case "ftp":
272       return 21
273     default:
274       return nil
275     }
276   }
277 }
278