1 // Copyright 2015-present 650 Industries. All rights reserved.
2 
3 import EXDevMenuInterface
4 import EXManifests
5 import CoreGraphics
6 import CoreMedia
7 
8 class Dispatch {
9   static func mainSync<T>(_ closure: () -> T) -> T {
10     if Thread.isMainThread {
11       return closure()
12     } else {
13       var result: T?
14       DispatchQueue.main.sync {
15         result = closure()
16       }
17       return result!
18     }
19   }
20 }
21 
22 /**
23  A container for array.
24  NSMapTable requires the second generic type to be a class, so `[DevMenuScreen]` is not allowed.
25  */
26 class DevMenuCacheContainer<T> {
27   fileprivate let items: [T]
28 
29   fileprivate init(items: [T]) {
30     self.items = items
31   }
32 }
33 
34 /**
35  A hash map storing an array of dev menu items for specific extension.
36  */
37 private let extensionToDevMenuItemsMap = NSMapTable<DevMenuExtensionProtocol, DevMenuItemsContainerProtocol>.weakToStrongObjects()
38 
39 /**
40  A hash map storing an array of dev menu screens for specific extension.
41  */
42 private let extensionToDevMenuScreensMap = NSMapTable<DevMenuExtensionProtocol, DevMenuCacheContainer<DevMenuScreen>>.weakToStrongObjects()
43 
44 /**
45  A hash map storing an array of dev menu screens for specific extension.
46  */
47 private let extensionToDevMenuDataSourcesMap = NSMapTable<DevMenuExtensionProtocol, DevMenuCacheContainer<DevMenuDataSourceProtocol>>.weakToStrongObjects()
48 
49 /**
50  Manages the dev menu and provides most of the public API.
51  */
52 @objc
53 open class DevMenuManager: NSObject {
54   var packagerConnectionHandler: DevMenuPackagerConnectionHandler?
55   lazy var extensionSettings: DevMenuExtensionSettingsProtocol = DevMenuExtensionDefaultSettings(manager: self)
56   var canLaunchDevMenuOnStart = true
57 
58   public var expoApiClient: DevMenuExpoApiClientProtocol = DevMenuExpoApiClient()
59 
60   /**
61    Shared singleton instance.
62    */
63   @objc
64   static public let shared = DevMenuManager()
65   /**
66    The window that controls and displays the dev menu view.
67    */
68   var window: DevMenuWindow?
69 
70   /**
71    `DevMenuAppInstance` instance that is responsible for initializing and managing React Native context for the dev menu.
72    */
73   lazy var appInstance: DevMenuAppInstance = DevMenuAppInstance(manager: self)
74 
75   var currentScreen: String?
76 
77   /**
78    For backwards compatibility in projects that call this method from AppDelegate
79    */
80   @available(*, deprecated, message: "Manual setup of DevMenuManager in AppDelegate is deprecated in favor of automatic setup with Expo Modules")
81   @objc
82   public static func configure(withBridge bridge: AnyObject) { }
83 
84   @objc
85   public var currentBridge: RCTBridge? {
86     didSet {
87       guard self.canLaunchDevMenuOnStart && (DevMenuPreferences.showsAtLaunch || self.shouldShowOnboarding()), let bridge = currentBridge else {
88         return
89       }
90 
91       if bridge.isLoading {
92         NotificationCenter.default.addObserver(self, selector: #selector(DevMenuManager.autoLaunch), name: DevMenuViewController.ContentDidAppearNotification, object: nil)
93       } else {
94         autoLaunch()
95       }
96     }
97   }
98   @objc
99   public var currentManifest: EXManifestsManifestBehavior?
100 
101   @objc
102   public var currentManifestURL: URL?
103 
104 
105   @objc
106   public func setSession(_ session: String) {
107     self.expoApiClient.setSessionSecret(session)
108   }
109 
110   @objc
111   public func autoLaunch(_ shouldRemoveObserver: Bool = true) {
112     NotificationCenter.default.removeObserver(self)
113 
114     DispatchQueue.main.async {
115       self.openMenu()
116     }
117   }
118 
119   override init() {
120     super.init()
121     self.window = DevMenuWindow(manager: self)
122     self.packagerConnectionHandler = DevMenuPackagerConnectionHandler(manager: self)
123     self.packagerConnectionHandler?.setup()
124     DevMenuPreferences.setup()
125     self.readAutoLaunchDisabledState()
126   }
127 
128   /**
129    Whether the dev menu window is visible on the device screen.
130    */
131   @objc
132   public var isVisible: Bool {
133     return Dispatch.mainSync { !(window?.isHidden ?? true) }
134   }
135 
136   /**
137    Opens up the dev menu.
138    */
139   @objc
140   @discardableResult
141   public func openMenu(_ screen: String? = nil) -> Bool {
142     return setVisibility(true, screen: screen)
143   }
144 
145   @objc
146   @discardableResult
147   public func openMenu() -> Bool {
148     appInstance.sendOpenEvent()
149     return openMenu(nil)
150   }
151 
152   /**
153    Sends an event to JS to start collapsing the dev menu bottom sheet.
154    */
155   @objc
156   @discardableResult
157   public func closeMenu() -> Bool {
158     if isVisible {
159       appInstance.sendCloseEvent()
160       return true
161     }
162 
163     return false
164   }
165 
166   /**
167    Forces the dev menu to hide. Called by JS once collapsing the bottom sheet finishes.
168    */
169   @objc
170   @discardableResult
171   public func hideMenu() -> Bool {
172     return setVisibility(false)
173   }
174 
175   /**
176    Toggles the visibility of the dev menu.
177    */
178   @objc
179   @discardableResult
180   public func toggleMenu() -> Bool {
181     return isVisible ? closeMenu() : openMenu()
182   }
183 
184   @objc
185   public func setCurrentScreen(_ screenName: String?) {
186     currentScreen = screenName
187   }
188 
189   @objc
190   public func sendEventToDelegateBridge(_ eventName: String, data: Any?) {
191     guard let bridge = currentBridge else {
192       return
193     }
194 
195     let args = data == nil ? [eventName] : [eventName, data!]
196     bridge.enqueueJSCall("RCTDeviceEventEmitter.emit", args: args)
197   }
198 
199   // MARK: internals
200 
201   func dispatchCallable(withId id: String, args: [String: Any]?) {
202     for callable in devMenuCallable {
203       if callable.id == id {
204         switch callable {
205           case let action as DevMenuExportedAction:
206             if args != nil {
207               NSLog("[DevMenu] Action $@ was called with arguments.", id)
208             }
209             action.call()
210           case let function as DevMenuExportedFunction:
211             function.call(args: args)
212           default:
213             NSLog("[DevMenu] Callable $@ has unknown type.", id)
214         }
215       }
216     }
217   }
218 
219   /**
220    Returns an array of modules conforming to `DevMenuExtensionProtocol`.
221    Bridge may register multiple modules with the same name – in this case it returns only the one that overrides the others.
222    */
223   var extensions: [DevMenuExtensionProtocol]? {
224     guard let bridge = currentBridge else {
225       return nil
226     }
227     let allExtensions = bridge.modulesConforming(to: DevMenuExtensionProtocol.self) as! [DevMenuExtensionProtocol]
228 
229     let uniqueExtensionNames = Set(
230       allExtensions
231         .map { type(of: $0).moduleName!() }
232         .compactMap { $0 } // removes nils
233     ).sorted()
234 
235     return uniqueExtensionNames
236       .map({ bridge.module(forName: DevMenuUtils.stripRCT($0)) })
237       .filter({ $0 is DevMenuExtensionProtocol }) as? [DevMenuExtensionProtocol]
238   }
239 
240   /**
241    Gathers `DevMenuItem`s from all dev menu extensions and returns them as an array.
242    */
243   var devMenuItems: [DevMenuScreenItem] {
244     return extensions?.map { loadDevMenuItems(forExtension: $0)?.getAllItems() ?? [] }.flatMap { $0 } ?? []
245   }
246 
247   /**
248    Gathers root `DevMenuItem`s (elements on the main screen) from all dev menu extensions and returns them as an array.
249    */
250   var devMenuRootItems: [DevMenuScreenItem] {
251     return extensions?.map { loadDevMenuItems(forExtension: $0)?.getRootItems() ?? [] }.flatMap { $0 } ?? []
252   }
253 
254   /**
255    Gathers `DevMenuScreen`s from all dev menu extensions and returns them as an array.
256    */
257   var devMenuScreens: [DevMenuScreen] {
258     return extensions?.map { loadDevMenuScreens(forExtension: $0) ?? [] }.flatMap { $0 } ?? []
259   }
260 
261   /**
262    Gathers `DevMenuDataSourceProtocol`s from all dev menu extensions and returns them as an array.
263    */
264   var devMenuDataSources: [DevMenuDataSourceProtocol] {
265     return extensions?.map { loadDevMenuDataSources(forExtension: $0) ?? [] }.flatMap { $0 } ?? []
266   }
267 
268   /**
269    Returns an array of `DevMenuExportedCallable`s returned by the dev menu extensions.
270    */
271   var devMenuCallable: [DevMenuExportedCallable] {
272     let providers = currentScreen == nil ?
273       devMenuItems.filter { $0 is DevMenuCallableProvider } :
274       (devMenuScreens.first { $0.screenName == currentScreen }?.getAllItems() ?? []).filter { $0 is DevMenuCallableProvider }
275 
276     // We use compactMap here to remove nils
277     return (providers as! [DevMenuCallableProvider]).compactMap { $0.registerCallable?() }
278   }
279 
280   /**
281    Returns an array of dev menu items serialized to the dictionary.
282    */
283   func serializedDevMenuItems() -> [[String: Any]] {
284     return devMenuRootItems
285       .sorted(by: { $0.importance > $1.importance })
286       .map({ $0.serialize() })
287   }
288 
289   /**
290    Returns an array of dev menu screens serialized to the dictionary.
291    */
292   func serializedDevMenuScreens() -> [[String: Any]] {
293     return devMenuScreens
294       .map({ $0.serialize() })
295   }
296 
297   // MARK: delegate stubs
298 
299   /**
300    Returns a bool value whether the dev menu can change its visibility.
301    Returning `false` entirely disables the dev menu.
302    */
303   func canChangeVisibility(to visible: Bool) -> Bool {
304     if isVisible == visible {
305       return false
306     }
307 
308     return true
309   }
310 
311   /**
312    Returns bool value whether the onboarding view should be displayed by the dev menu view.
313    */
314   func shouldShowOnboarding() -> Bool {
315     return !DevMenuPreferences.isOnboardingFinished
316   }
317 
318   func readAutoLaunchDisabledState() {
319     let userDefaultsValue = UserDefaults.standard.bool(forKey: "EXDevMenuDisableAutoLaunch")
320     if userDefaultsValue {
321       self.canLaunchDevMenuOnStart = false
322       UserDefaults.standard.removeObject(forKey: "EXDevMenuDisableAutoLaunch")
323     } else {
324       self.canLaunchDevMenuOnStart = true
325     }
326   }
327 
328   @available(iOS 12.0, *)
329   var userInterfaceStyle: UIUserInterfaceStyle {
330     return UIUserInterfaceStyle.unspecified
331   }
332 
333 
334   // MARK: private
335 
336   private func loadDevMenuItems(forExtension ext: DevMenuExtensionProtocol) -> DevMenuItemsContainerProtocol? {
337     if let itemsContainer = extensionToDevMenuItemsMap.object(forKey: ext) {
338       return itemsContainer
339     }
340 
341     if let itemsContainer = ext.devMenuItems?(extensionSettings) {
342       extensionToDevMenuItemsMap.setObject(itemsContainer, forKey: ext)
343       return itemsContainer
344     }
345 
346     return nil
347   }
348 
349   private func loadDevMenuScreens(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuScreen]? {
350     if let screenContainer = extensionToDevMenuScreensMap.object(forKey: ext) {
351       return screenContainer.items
352     }
353 
354     if let screens = ext.devMenuScreens?(extensionSettings) {
355       let container = DevMenuCacheContainer<DevMenuScreen>(items: screens)
356       extensionToDevMenuScreensMap.setObject(container, forKey: ext)
357       return screens
358     }
359 
360     return nil
361   }
362 
363   private func loadDevMenuDataSources(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuDataSourceProtocol]? {
364     if let dataSourcesContainer = extensionToDevMenuDataSourcesMap.object(forKey: ext) {
365       return dataSourcesContainer.items
366     }
367 
368     if let dataSources = ext.devMenuDataSources?(extensionSettings) {
369       let container = DevMenuCacheContainer<DevMenuDataSourceProtocol>(items: dataSources)
370       extensionToDevMenuDataSourcesMap.setObject(container, forKey: ext)
371       return dataSources
372     }
373 
374     return nil
375   }
376 
377   private func setVisibility(_ visible: Bool, screen: String? = nil) -> Bool {
378     if !canChangeVisibility(to: visible) {
379       return false
380     }
381     if visible {
382       guard currentBridge != nil else {
383         debugPrint("DevMenuManager: There is no bridge to render DevMenu.")
384         return false
385       }
386       setCurrentScreen(screen)
387       DispatchQueue.main.async { self.window?.makeKeyAndVisible() }
388     } else {
389       DispatchQueue.main.async { self.window?.isHidden = true }
390     }
391     return true
392   }
393 
394   @objc
395   public func getAppInfo() -> [AnyHashable: Any] {
396     return EXDevMenuAppInfo.getAppInfo()
397   }
398 
399   @objc
400   public func getDevSettings() -> [AnyHashable: Any] {
401     return EXDevMenuDevSettings.getDevSettings()
402   }
403 
404   private static var fontsWereLoaded = false
405 
406   @objc
407   public func loadFonts() {
408     if DevMenuManager.fontsWereLoaded {
409        return
410     }
411 
412     let fonts = [
413       "Inter-Black",
414       "Inter-ExtraBold",
415       "Inter-Bold",
416       "Inter-SemiBold",
417       "Inter-Medium",
418       "Inter-Regular",
419       "Inter-Light",
420       "Inter-ExtraLight",
421       "Inter-Thin"
422     ]
423 
424     for font in fonts {
425       let path = DevMenuUtils.resourcesBundle()?.path(forResource: font, ofType: "otf")
426       let data = FileManager.default.contents(atPath: path!)
427       let provider = CGDataProvider(data: data! as CFData)
428       let font = CGFont(provider!)
429       var error: Unmanaged<CFError>?
430       CTFontManagerRegisterGraphicsFont(font!, &error)
431     }
432 
433     DevMenuManager.fontsWereLoaded = true
434     return
435   }
436 }
437