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