@onekeyfe/react-native-device-utils 6.7.0-19 → 6.7.0-20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt +29 -0
  2. package/ios/DualScreenDetector.swift +170 -0
  3. package/ios/MainMenuController.swift +170 -0
  4. package/ios/ReactNativeDeviceUtils.swift +29 -4
  5. package/ios/WindowControlsInsetsObserver.swift +151 -0
  6. package/lib/typescript/src/ReactNativeDeviceUtils.nitro.d.ts +46 -0
  7. package/nitrogen/generated/android/c++/JFunc_void_MainMenuCommand.hpp +77 -0
  8. package/nitrogen/generated/android/c++/JFunc_void_WindowControlsInsets.hpp +77 -0
  9. package/nitrogen/generated/android/c++/JHybridReactNativeDeviceUtilsSpec.cpp +45 -0
  10. package/nitrogen/generated/android/c++/JHybridReactNativeDeviceUtilsSpec.hpp +6 -0
  11. package/nitrogen/generated/android/c++/JMainMenuCommand.hpp +61 -0
  12. package/nitrogen/generated/android/c++/JMainMenuConfig.hpp +90 -0
  13. package/nitrogen/generated/android/c++/JMainMenuLink.hpp +61 -0
  14. package/nitrogen/generated/android/c++/JWindowControlsInsets.hpp +69 -0
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/Func_void_MainMenuCommand.kt +78 -0
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/Func_void_WindowControlsInsets.kt +78 -0
  17. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/HybridReactNativeDeviceUtilsSpec.kt +34 -0
  18. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuCommand.kt +24 -0
  19. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuConfig.kt +66 -0
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuLink.kt +56 -0
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/WindowControlsInsets.kt +66 -0
  22. package/nitrogen/generated/android/reactnativedeviceutilsOnLoad.cpp +4 -0
  23. package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Bridge.cpp +16 -0
  24. package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Bridge.hpp +73 -0
  25. package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Umbrella.hpp +12 -0
  26. package/nitrogen/generated/ios/c++/HybridReactNativeDeviceUtilsSpecSwift.hpp +55 -1
  27. package/nitrogen/generated/ios/swift/Func_void_MainMenuCommand.swift +46 -0
  28. package/nitrogen/generated/ios/swift/Func_void_WindowControlsInsets.swift +46 -0
  29. package/nitrogen/generated/ios/swift/HybridReactNativeDeviceUtilsSpec.swift +6 -0
  30. package/nitrogen/generated/ios/swift/HybridReactNativeDeviceUtilsSpec_cxx.swift +79 -0
  31. package/nitrogen/generated/ios/swift/MainMenuCommand.swift +44 -0
  32. package/nitrogen/generated/ios/swift/MainMenuConfig.swift +50 -0
  33. package/nitrogen/generated/ios/swift/MainMenuLink.swift +34 -0
  34. package/nitrogen/generated/ios/swift/WindowControlsInsets.swift +44 -0
  35. package/nitrogen/generated/shared/c++/HybridReactNativeDeviceUtilsSpec.cpp +6 -0
  36. package/nitrogen/generated/shared/c++/HybridReactNativeDeviceUtilsSpec.hpp +15 -0
  37. package/nitrogen/generated/shared/c++/MainMenuCommand.hpp +80 -0
  38. package/nitrogen/generated/shared/c++/MainMenuConfig.hpp +98 -0
  39. package/nitrogen/generated/shared/c++/MainMenuLink.hpp +87 -0
  40. package/nitrogen/generated/shared/c++/WindowControlsInsets.hpp +95 -0
  41. package/package.json +1 -1
  42. package/src/ReactNativeDeviceUtils.nitro.ts +54 -0
@@ -765,6 +765,35 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven
765
765
  spanningChangedListeners.removeIf { it.id == id }
766
766
  }
767
767
 
768
+ // Android has no window controls inside the app window: the value is always
769
+ // zero and never changes, so listeners are accepted but never called.
770
+ override fun getWindowControlsInsets(): WindowControlsInsets =
771
+ WindowControlsInsets(0.0, 0.0, 0.0, 0.0)
772
+
773
+ override fun addWindowControlsInsetsChangedListener(
774
+ callback: (insets: WindowControlsInsets) -> Unit
775
+ ): Double = synchronized(this) {
776
+ val id = nextListenerId
777
+ nextListenerId++
778
+ return@synchronized id
779
+ }
780
+
781
+ override fun removeWindowControlsInsetsChangedListener(id: Double) {}
782
+
783
+ // Android has no app-owned main menu bar: the config is ignored, and
784
+ // listeners are accepted but never called.
785
+ override fun setMainMenuConfig(config: MainMenuConfig) {}
786
+
787
+ override fun addMainMenuCommandListener(
788
+ callback: (command: MainMenuCommand) -> Unit
789
+ ): Double = synchronized(this) {
790
+ val id = nextListenerId
791
+ nextListenerId++
792
+ return@synchronized id
793
+ }
794
+
795
+ override fun removeMainMenuCommandListener(id: Double) {}
796
+
768
797
  private fun startObservingLayoutChanges() {
769
798
  if (isObservingLayoutChanges) {
770
799
  return
@@ -0,0 +1,170 @@
1
+ import Foundation
2
+ import UIKit
3
+ import ReactNativeNativeLogger
4
+
5
+ /// Detects foldable iPhones such as iPhone Duo.
6
+ ///
7
+ /// Detection runs in two steps so the answer is available synchronously at
8
+ /// launch and still covers models that are not listed yet:
9
+ /// 1. The model identifier is matched against known foldable iPhones.
10
+ /// 2. A `UIHingeInteraction` (iOS 27.1+) confirms the hinge at runtime.
11
+ /// A positive result is persisted, so later launches answer synchronously
12
+ /// even for models that only the hinge check recognized.
13
+ final class DualScreenDetector {
14
+ static let shared = DualScreenDetector()
15
+
16
+ private static let cacheKey = "1k_is_dual_screen_device"
17
+ private static let foldableModelIdentifiers: Set<String> = [
18
+ "iPhone19,4", // iPhone Duo
19
+ ]
20
+
21
+ private let lock = NSLock()
22
+ private var isDualScreen: Bool
23
+ private var spanning = false
24
+ private var listeners: [Double: (Bool) -> Void] = [:]
25
+ private var nextListenerId: Double = 1
26
+ private var hingeInteraction: UIInteraction?
27
+ private var keyWindowObserver: NSObjectProtocol?
28
+ private var resignKeyWindowObserver: NSObjectProtocol?
29
+
30
+ private init() {
31
+ let modelIdentifier = DualScreenDetector.modelIdentifier()
32
+ let isKnownModel = DualScreenDetector.foldableModelIdentifiers.contains(modelIdentifier)
33
+ let isCached = UserDefaults.standard.string(forKey: DualScreenDetector.cacheKey) == modelIdentifier
34
+ isDualScreen = isKnownModel || isCached
35
+ if isKnownModel && !isCached {
36
+ UserDefaults.standard.set(modelIdentifier, forKey: DualScreenDetector.cacheKey)
37
+ }
38
+ OneKeyLog.info(
39
+ "DeviceUtils",
40
+ "Dual screen detection: model=\(modelIdentifier), knownModel=\(isKnownModel), cached=\(isCached)"
41
+ )
42
+ installHingeObserver()
43
+ }
44
+
45
+ static func modelIdentifier() -> String {
46
+ #if targetEnvironment(simulator)
47
+ if let identifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"],
48
+ !identifier.isEmpty {
49
+ return identifier
50
+ }
51
+ #endif
52
+ var systemInfo = utsname()
53
+ uname(&systemInfo)
54
+ return withUnsafePointer(to: &systemInfo.machine) { pointer in
55
+ pointer.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: pointer.pointee)) {
56
+ String(cString: $0)
57
+ }
58
+ }
59
+ }
60
+
61
+ var isDualScreenDevice: Bool {
62
+ lock.lock()
63
+ defer { lock.unlock() }
64
+ return isDualScreen
65
+ }
66
+
67
+ var isSpanning: Bool {
68
+ lock.lock()
69
+ defer { lock.unlock() }
70
+ return spanning
71
+ }
72
+
73
+ func addSpanningChangedListener(_ callback: @escaping (Bool) -> Void) -> Double {
74
+ lock.lock()
75
+ defer { lock.unlock() }
76
+ let id = nextListenerId
77
+ nextListenerId += 1
78
+ listeners[id] = callback
79
+ return id
80
+ }
81
+
82
+ func removeSpanningChangedListener(_ id: Double) {
83
+ lock.lock()
84
+ defer { lock.unlock() }
85
+ listeners.removeValue(forKey: id)
86
+ }
87
+
88
+ private func installHingeObserver() {
89
+ // UIHinge ships in the iOS 27.1 SDK (Swift 6.4). Older toolchains keep
90
+ // the model identifier and cache only.
91
+ #if compiler(>=6.4)
92
+ guard #available(iOS 27.1, *) else { return }
93
+ DispatchQueue.main.async { [weak self] in
94
+ guard let self else { return }
95
+ self.keyWindowObserver = NotificationCenter.default.addObserver(
96
+ forName: UIWindow.didBecomeKeyNotification,
97
+ object: nil,
98
+ queue: .main
99
+ ) { [weak self] notification in
100
+ guard let window = notification.object as? UIWindow else { return }
101
+ self?.attachHingeInteraction(in: window)
102
+ }
103
+ self.resignKeyWindowObserver = NotificationCenter.default.addObserver(
104
+ forName: UIWindow.didResignKeyNotification,
105
+ object: nil,
106
+ queue: .main
107
+ ) { [weak self] notification in
108
+ guard let self, let window = notification.object as? UIWindow,
109
+ self.hingeInteraction?.view === window else { return }
110
+ self.attachHingeInteraction(in: nil)
111
+ }
112
+ self.attachHingeInteraction(in: DualScreenDetector.keyWindow())
113
+ }
114
+ #endif
115
+ }
116
+
117
+ #if compiler(>=6.4)
118
+ @available(iOS 27.1, *)
119
+ private func attachHingeInteraction(in window: UIWindow?) {
120
+ if let interaction = hingeInteraction, interaction.view === window { return }
121
+ let previousInteraction = hingeInteraction
122
+ hingeInteraction = nil
123
+ if let interaction = previousInteraction {
124
+ interaction.view?.removeInteraction(interaction)
125
+ }
126
+ handleHingeUpdate(nil)
127
+ // A listener can change the key window while spanning is reset.
128
+ guard let window, window.isKeyWindow, hingeInteraction == nil else { return }
129
+ let interaction = UIHingeInteraction { [weak self] interaction, update in
130
+ guard let self, self.hingeInteraction === interaction else { return }
131
+ self.handleHingeUpdate(update.hinge)
132
+ }
133
+ hingeInteraction = interaction
134
+ window.addInteraction(interaction)
135
+ }
136
+
137
+ @available(iOS 27.1, *)
138
+ private func handleHingeUpdate(_ hinge: UIHinge?) {
139
+ let nextSpanning = hinge?.status == .fullyOpen || hinge?.status == .partiallyOpen
140
+
141
+ lock.lock()
142
+ let isNewlyDetected = hinge != nil && !isDualScreen
143
+ if hinge != nil { isDualScreen = true }
144
+ let didChangeSpanning = spanning != nextSpanning
145
+ spanning = nextSpanning
146
+ let callbacks = Array(listeners.values)
147
+ lock.unlock()
148
+
149
+ if isNewlyDetected {
150
+ UserDefaults.standard.set(DualScreenDetector.modelIdentifier(), forKey: DualScreenDetector.cacheKey)
151
+ OneKeyLog.info(
152
+ "DeviceUtils",
153
+ "Dual screen detected by hinge: model=\(DualScreenDetector.modelIdentifier())"
154
+ )
155
+ }
156
+ // A newly detected device also notifies, so JS can react without
157
+ // waiting for the next fold.
158
+ if didChangeSpanning || isNewlyDetected {
159
+ callbacks.forEach { $0(nextSpanning) }
160
+ }
161
+ }
162
+ #endif
163
+
164
+ private static func keyWindow() -> UIWindow? {
165
+ UIApplication.shared.connectedScenes
166
+ .compactMap { $0 as? UIWindowScene }
167
+ .flatMap { $0.windows }
168
+ .first { $0.isKeyWindow }
169
+ }
170
+ }
@@ -0,0 +1,170 @@
1
+ import Foundation
2
+ import UIKit
3
+ import ReactNativeNativeLogger
4
+
5
+ /// Owns the app's custom main menu (the iPadOS menu bar).
6
+ ///
7
+ /// JS supplies localized titles and Help links through `setMainMenuConfig`.
8
+ /// The app delegate forwards `buildMenu(with:)` to `buildMenu(withBuilder:)`
9
+ /// (it reaches this class through `NSClassFromString`, because the app target
10
+ /// cannot import Nitro modules). App commands are `UIKeyCommand`s whose action
11
+ /// lives on `UIApplication`, which is always in the responder chain, so the
12
+ /// app delegate needs no command handler.
13
+ public final class MainMenuController: NSObject {
14
+ static let shared = MainMenuController()
15
+
16
+ private struct Link {
17
+ let title: String
18
+ let url: URL
19
+ }
20
+
21
+ private struct Config {
22
+ let aboutTitle: String
23
+ let preferencesTitle: String
24
+ let lockNowTitle: String
25
+ let helpLinks: [Link]
26
+ }
27
+
28
+ private static let aboutMenuIdentifier = UIMenu.Identifier("so.onekey.mainMenu.about")
29
+ private static let settingsMenuIdentifier = UIMenu.Identifier("so.onekey.mainMenu.settings")
30
+
31
+ private let lock = NSLock()
32
+ private var config: Config?
33
+ private var listeners: [Double: (MainMenuCommand) -> Void] = [:]
34
+ private var nextListenerId: Double = 1
35
+
36
+ func setConfig(_ config: MainMenuConfig) {
37
+ let links = config.helpLinks.compactMap { link -> Link? in
38
+ guard let url = URL(string: link.url) else {
39
+ OneKeyLog.warn("DeviceUtils", "Main menu: invalid help link URL")
40
+ return nil
41
+ }
42
+ return Link(title: link.title, url: url)
43
+ }
44
+ let next = Config(
45
+ aboutTitle: config.aboutTitle,
46
+ preferencesTitle: config.preferencesTitle,
47
+ lockNowTitle: config.lockNowTitle,
48
+ helpLinks: links
49
+ )
50
+ lock.lock()
51
+ self.config = next
52
+ lock.unlock()
53
+ DispatchQueue.main.async {
54
+ UIMenuSystem.main.setNeedsRebuild()
55
+ }
56
+ }
57
+
58
+ func addListener(_ callback: @escaping (MainMenuCommand) -> Void) -> Double {
59
+ lock.lock()
60
+ defer { lock.unlock() }
61
+ let id = nextListenerId
62
+ nextListenerId += 1
63
+ listeners[id] = callback
64
+ return id
65
+ }
66
+
67
+ func removeListener(_ id: Double) {
68
+ lock.lock()
69
+ defer { lock.unlock() }
70
+ listeners.removeValue(forKey: id)
71
+ }
72
+
73
+ /// Called by the app delegate's `buildMenu(with:)` on the main thread.
74
+ @objc(buildMenuWithBuilder:)
75
+ public static func buildMenu(withBuilder builder: UIMenuBuilder) {
76
+ shared.build(builder)
77
+ }
78
+
79
+ fileprivate func perform(commandName: String) {
80
+ guard let command = MainMenuCommand(fromString: commandName) else { return }
81
+ lock.lock()
82
+ let callbacks = Array(listeners.values)
83
+ lock.unlock()
84
+ OneKeyLog.info("DeviceUtils", "Main menu command: \(commandName), listeners=\(callbacks.count)")
85
+ callbacks.forEach { $0(command) }
86
+ }
87
+
88
+ private func build(_ builder: UIMenuBuilder) {
89
+ guard builder.system == .main else { return }
90
+
91
+ // A wallet has no documents to open or text to format.
92
+ builder.remove(menu: .file)
93
+ builder.remove(menu: .format)
94
+
95
+ lock.lock()
96
+ let config = self.config
97
+ lock.unlock()
98
+ // Keep the system app and Help menus until JS provides localized titles.
99
+ guard let config else { return }
100
+
101
+ let action = #selector(UIApplication.onekeyPerformMainMenuCommand(_:))
102
+ let about = UICommand(
103
+ title: config.aboutTitle,
104
+ action: action,
105
+ propertyList: MainMenuCommand.about.stringValue
106
+ )
107
+ let preferences = UIKeyCommand(
108
+ title: config.preferencesTitle,
109
+ action: action,
110
+ input: ",",
111
+ modifierFlags: .command,
112
+ propertyList: MainMenuCommand.preferences.stringValue
113
+ )
114
+ let lockNow = UIKeyCommand(
115
+ title: config.lockNowTitle,
116
+ action: action,
117
+ input: "l",
118
+ modifierFlags: [.command, .shift],
119
+ propertyList: MainMenuCommand.locknow.stringValue
120
+ )
121
+ let aboutMenu = UIMenu(
122
+ title: "",
123
+ identifier: MainMenuController.aboutMenuIdentifier,
124
+ options: .displayInline,
125
+ children: [about]
126
+ )
127
+ let settingsMenu = UIMenu(
128
+ title: "",
129
+ identifier: MainMenuController.settingsMenuIdentifier,
130
+ options: .displayInline,
131
+ children: [preferences, lockNow]
132
+ )
133
+
134
+ // The system Preferences item opens the app's page in the Settings app;
135
+ // replace it with the in-app preferences, which own the same shortcut.
136
+ if builder.menu(for: .preferences) != nil {
137
+ builder.replace(menu: .preferences, with: settingsMenu)
138
+ } else {
139
+ builder.insertChild(settingsMenu, atStartOfMenu: .application)
140
+ }
141
+ // An empty About title removes the item (Travel Mode hides About).
142
+ if config.aboutTitle.isEmpty {
143
+ builder.remove(menu: .about)
144
+ } else if builder.menu(for: .about) != nil {
145
+ builder.replace(menu: .about, with: aboutMenu)
146
+ } else {
147
+ builder.insertSibling(aboutMenu, beforeMenu: MainMenuController.settingsMenuIdentifier)
148
+ }
149
+
150
+ if config.helpLinks.isEmpty {
151
+ builder.remove(menu: .help)
152
+ } else if builder.menu(for: .help) != nil {
153
+ let links = config.helpLinks.map { link in
154
+ UIAction(title: link.title) { _ in
155
+ UIApplication.shared.open(link.url)
156
+ }
157
+ }
158
+ builder.replaceChildren(ofMenu: .help) { _ in links }
159
+ }
160
+ }
161
+ }
162
+
163
+ extension UIApplication {
164
+ /// Action of the main menu app commands; the command name is the
165
+ /// command's property list.
166
+ @objc func onekeyPerformMainMenuCommand(_ sender: UICommand) {
167
+ guard let name = sender.propertyList as? String else { return }
168
+ MainMenuController.shared.perform(commandName: name)
169
+ }
170
+ }
@@ -112,14 +112,14 @@ class ReactNativeDeviceUtils: HybridReactNativeDeviceUtilsSpec {
112
112
  }
113
113
 
114
114
  public func isDualScreenDevice() throws -> Bool {
115
- return false
115
+ return DualScreenDetector.shared.isDualScreenDevice
116
116
  }
117
117
 
118
118
  public func initEventListeners() throws -> Void {
119
119
  }
120
-
120
+
121
121
  public func isSpanning() throws -> Bool {
122
- return false
122
+ return DualScreenDetector.shared.isSpanning
123
123
  }
124
124
 
125
125
  public func getWindowRects() throws -> Promise<[DualScreenInfoRect]> {
@@ -132,10 +132,35 @@ class ReactNativeDeviceUtils: HybridReactNativeDeviceUtilsSpec {
132
132
 
133
133
 
134
134
  func addSpanningChangedListener(callback: @escaping (Bool) -> Void) throws -> Double {
135
- return 0
135
+ return DualScreenDetector.shared.addSpanningChangedListener(callback)
136
136
  }
137
137
 
138
138
  func removeSpanningChangedListener(id: Double) throws -> Void {
139
+ DualScreenDetector.shared.removeSpanningChangedListener(id)
140
+ }
141
+
142
+ func getWindowControlsInsets() throws -> WindowControlsInsets {
143
+ return WindowControlsInsetsObserver.shared.currentInsets
144
+ }
145
+
146
+ func addWindowControlsInsetsChangedListener(callback: @escaping (WindowControlsInsets) -> Void) throws -> Double {
147
+ return WindowControlsInsetsObserver.shared.addListener(callback)
148
+ }
149
+
150
+ func removeWindowControlsInsetsChangedListener(id: Double) throws -> Void {
151
+ WindowControlsInsetsObserver.shared.removeListener(id)
152
+ }
153
+
154
+ func setMainMenuConfig(config: MainMenuConfig) throws -> Void {
155
+ MainMenuController.shared.setConfig(config)
156
+ }
157
+
158
+ func addMainMenuCommandListener(callback: @escaping (MainMenuCommand) -> Void) throws -> Double {
159
+ return MainMenuController.shared.addListener(callback)
160
+ }
161
+
162
+ func removeMainMenuCommandListener(id: Double) throws -> Void {
163
+ MainMenuController.shared.removeListener(id)
139
164
  }
140
165
 
141
166
 
@@ -0,0 +1,151 @@
1
+ import Foundation
2
+ import UIKit
3
+ import ReactNativeNativeLogger
4
+
5
+ /// Tracks how much room the key window's system window controls (the iPadOS
6
+ /// window controls in the top-leading corner, for example) take beyond the
7
+ /// safe area.
8
+ ///
9
+ /// UIKit reports the controls through the safe area with horizontal corner
10
+ /// adaptation (iOS 26+). An invisible view spanning the window recomputes the
11
+ /// difference on every layout and safe area change, so window resizes and
12
+ /// entering or leaving full screen update the value.
13
+ final class WindowControlsInsetsObserver {
14
+ static let shared = WindowControlsInsetsObserver()
15
+
16
+ private let lock = NSLock()
17
+ private var insets = WindowControlsInsets(top: 0, left: 0, bottom: 0, right: 0)
18
+ private var listeners: [Double: (WindowControlsInsets) -> Void] = [:]
19
+ private var nextListenerId: Double = 1
20
+ private var keyWindowObserver: NSObjectProtocol?
21
+ private weak var trackingView: UIView?
22
+
23
+ private init() {
24
+ DispatchQueue.main.async { [weak self] in
25
+ self?.install()
26
+ }
27
+ }
28
+
29
+ var currentInsets: WindowControlsInsets {
30
+ lock.lock()
31
+ defer { lock.unlock() }
32
+ return insets
33
+ }
34
+
35
+ func addListener(_ callback: @escaping (WindowControlsInsets) -> Void) -> Double {
36
+ lock.lock()
37
+ defer { lock.unlock() }
38
+ let id = nextListenerId
39
+ nextListenerId += 1
40
+ listeners[id] = callback
41
+ return id
42
+ }
43
+
44
+ func removeListener(_ id: Double) {
45
+ lock.lock()
46
+ defer { lock.unlock() }
47
+ listeners.removeValue(forKey: id)
48
+ }
49
+
50
+ private func install(in keyWindow: UIWindow? = nil) {
51
+ // UIView.LayoutRegion ships in the iOS 26 SDK (Swift 6.2). Older
52
+ // toolchains and systems keep the all-zero value.
53
+ #if compiler(>=6.2)
54
+ guard #available(iOS 26.0, *) else { return }
55
+ if keyWindowObserver == nil {
56
+ keyWindowObserver = NotificationCenter.default.addObserver(
57
+ forName: UIWindow.didBecomeKeyNotification,
58
+ object: nil,
59
+ queue: .main
60
+ ) { [weak self] notification in
61
+ guard let window = notification.object as? UIWindow else { return }
62
+ self?.install(in: window)
63
+ }
64
+ }
65
+ guard let window = keyWindow ?? WindowControlsInsetsObserver.keyWindow() else { return }
66
+ if let view = trackingView, view.window === window {
67
+ update(from: view)
68
+ return
69
+ }
70
+ let view = WindowControlsTrackingView(frame: window.bounds) { [weak self] view in
71
+ self?.update(from: view)
72
+ }
73
+ view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
74
+ view.isUserInteractionEnabled = false
75
+ view.isAccessibilityElement = false
76
+ // Behind the root view controller's view, so it never draws or
77
+ // receives touches above app content.
78
+ let previousView = trackingView
79
+ trackingView = view
80
+ previousView?.removeFromSuperview()
81
+ window.insertSubview(view, at: 0)
82
+ update(from: view)
83
+ #endif
84
+ }
85
+
86
+ #if compiler(>=6.2)
87
+ @available(iOS 26.0, *)
88
+ private func update(from view: UIView) {
89
+ guard view === trackingView else { return }
90
+ let safeArea = view.safeAreaInsets
91
+ let adapted = view.edgeInsets(for: .safeArea(cornerAdaptation: .horizontal))
92
+ let next = WindowControlsInsets(
93
+ top: Double(max(0, adapted.top - safeArea.top)),
94
+ left: Double(max(0, adapted.left - safeArea.left)),
95
+ bottom: Double(max(0, adapted.bottom - safeArea.bottom)),
96
+ right: Double(max(0, adapted.right - safeArea.right))
97
+ )
98
+
99
+ lock.lock()
100
+ let previous = insets
101
+ let didChange = previous.top != next.top
102
+ || previous.left != next.left
103
+ || previous.bottom != next.bottom
104
+ || previous.right != next.right
105
+ if didChange {
106
+ insets = next
107
+ }
108
+ let callbacks = didChange ? Array(listeners.values) : []
109
+ lock.unlock()
110
+
111
+ guard didChange else { return }
112
+ OneKeyLog.debug(
113
+ "DeviceUtils",
114
+ "Window controls insets: top=\(next.top), left=\(next.left), bottom=\(next.bottom), right=\(next.right)"
115
+ )
116
+ callbacks.forEach { $0(next) }
117
+ }
118
+ #endif
119
+
120
+ private static func keyWindow() -> UIWindow? {
121
+ UIApplication.shared.connectedScenes
122
+ .compactMap { $0 as? UIWindowScene }
123
+ .flatMap { $0.windows }
124
+ .first { $0.isKeyWindow }
125
+ }
126
+ }
127
+
128
+ /// Invisible full-window view that reports layout and safe area changes.
129
+ private final class WindowControlsTrackingView: UIView {
130
+ private let onChange: (UIView) -> Void
131
+
132
+ init(frame: CGRect, onChange: @escaping (UIView) -> Void) {
133
+ self.onChange = onChange
134
+ super.init(frame: frame)
135
+ }
136
+
137
+ @available(*, unavailable)
138
+ required init?(coder: NSCoder) {
139
+ fatalError("init(coder:) has not been implemented")
140
+ }
141
+
142
+ override func layoutSubviews() {
143
+ super.layoutSubviews()
144
+ onChange(self)
145
+ }
146
+
147
+ override func safeAreaInsetsDidChange() {
148
+ super.safeAreaInsetsDidChange()
149
+ onChange(self)
150
+ }
151
+ }
@@ -9,6 +9,35 @@ export interface DualScreenInfoRect {
9
9
  width: number;
10
10
  height: number;
11
11
  }
12
+ /**
13
+ * Extra room, in points beyond the safe area, that content near the window
14
+ * corners must leave for system window controls (the iPadOS window controls in
15
+ * the top-leading corner, for example). All zero when no controls are shown.
16
+ */
17
+ export interface WindowControlsInsets {
18
+ top: number;
19
+ left: number;
20
+ bottom: number;
21
+ right: number;
22
+ }
23
+ /** App commands in the iPadOS menu bar that JS performs. */
24
+ export type MainMenuCommand = 'about' | 'preferences' | 'lockNow';
25
+ export interface MainMenuLink {
26
+ title: string;
27
+ url: string;
28
+ }
29
+ /**
30
+ * Localized content of the custom main menu (the iPadOS menu bar). Help links
31
+ * open in the system browser natively; the other items notify JS. An empty
32
+ * `aboutTitle` removes the About item and empty `helpLinks` remove the Help
33
+ * menu.
34
+ */
35
+ export interface MainMenuConfig {
36
+ aboutTitle: string;
37
+ preferencesTitle: string;
38
+ lockNowTitle: string;
39
+ helpLinks: MainMenuLink[];
40
+ }
12
41
  export interface LaunchOptions {
13
42
  launchType: string;
14
43
  deepLink?: string;
@@ -43,6 +72,23 @@ export interface ReactNativeDeviceUtils extends HybridObject<{
43
72
  addSpanningChangedListener(callback: (isSpanning: boolean) => void): number;
44
73
  removeSpanningChangedListener(id: number): void;
45
74
  setUserInterfaceStyle(style: UserInterfaceStyle): void;
75
+ /**
76
+ * Window-controls insets of the app's main window, for content that adapts
77
+ * horizontally (a header row beside the controls). iOS 26+ reads UIKit's
78
+ * safe area with horizontal corner adaptation; Android and older iOS return
79
+ * all zeros. Listeners fire on the main thread when the value changes.
80
+ */
81
+ getWindowControlsInsets(): WindowControlsInsets;
82
+ addWindowControlsInsetsChangedListener(callback: (insets: WindowControlsInsets) => void): number;
83
+ removeWindowControlsInsetsChangedListener(id: number): void;
84
+ /**
85
+ * Replaces the custom main menu content and rebuilds the menu bar. The menu
86
+ * only shows on iPadOS (and in windows with a menu bar); Android ignores it.
87
+ */
88
+ setMainMenuConfig(config: MainMenuConfig): void;
89
+ /** Listeners fire on the main thread when the user picks an app command. */
90
+ addMainMenuCommandListener(callback: (command: MainMenuCommand) => void): number;
91
+ removeMainMenuCommandListener(id: number): void;
46
92
  getLaunchOptions(): Promise<LaunchOptions>;
47
93
  clearLaunchOptions(): Promise<boolean>;
48
94
  getDeviceToken(): Promise<string>;