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