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