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