@onekeyfe/react-native-device-utils 6.7.0-alpha.385 → 6.7.0-alpha.388

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 +158 -0
  3. package/ios/MainMenuController.swift +170 -0
  4. package/ios/ReactNativeDeviceUtils.swift +29 -4
  5. package/ios/WindowControlsInsetsObserver.swift +149 -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,158 @@
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 hingeObserver: AnyObject?
27
+
28
+ private init() {
29
+ let modelIdentifier = DualScreenDetector.modelIdentifier()
30
+ let isKnownModel = DualScreenDetector.foldableModelIdentifiers.contains(modelIdentifier)
31
+ let isCached = UserDefaults.standard.bool(forKey: DualScreenDetector.cacheKey)
32
+ isDualScreen = isKnownModel || isCached
33
+ if isKnownModel && !isCached {
34
+ UserDefaults.standard.set(true, forKey: DualScreenDetector.cacheKey)
35
+ }
36
+ OneKeyLog.info(
37
+ "DeviceUtils",
38
+ "Dual screen detection: model=\(modelIdentifier), knownModel=\(isKnownModel), cached=\(isCached)"
39
+ )
40
+ installHingeObserver()
41
+ }
42
+
43
+ static func modelIdentifier() -> String {
44
+ #if targetEnvironment(simulator)
45
+ if let identifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"],
46
+ !identifier.isEmpty {
47
+ return identifier
48
+ }
49
+ #endif
50
+ var systemInfo = utsname()
51
+ uname(&systemInfo)
52
+ return withUnsafePointer(to: &systemInfo.machine) { pointer in
53
+ pointer.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: pointer.pointee)) {
54
+ String(cString: $0)
55
+ }
56
+ }
57
+ }
58
+
59
+ var isDualScreenDevice: Bool {
60
+ lock.lock()
61
+ defer { lock.unlock() }
62
+ return isDualScreen
63
+ }
64
+
65
+ var isSpanning: Bool {
66
+ lock.lock()
67
+ defer { lock.unlock() }
68
+ return spanning
69
+ }
70
+
71
+ func addSpanningChangedListener(_ callback: @escaping (Bool) -> Void) -> Double {
72
+ lock.lock()
73
+ defer { lock.unlock() }
74
+ let id = nextListenerId
75
+ nextListenerId += 1
76
+ listeners[id] = callback
77
+ return id
78
+ }
79
+
80
+ func removeSpanningChangedListener(_ id: Double) {
81
+ lock.lock()
82
+ defer { lock.unlock() }
83
+ listeners.removeValue(forKey: id)
84
+ }
85
+
86
+ private func installHingeObserver() {
87
+ // UIHinge ships in the iOS 27.1 SDK (Swift 6.4). Older toolchains keep
88
+ // the model identifier and cache only.
89
+ #if compiler(>=6.4)
90
+ guard #available(iOS 27.1, *) else { return }
91
+ DispatchQueue.main.async { [weak self] in
92
+ self?.attachHingeInteraction()
93
+ }
94
+ #endif
95
+ }
96
+
97
+ #if compiler(>=6.4)
98
+ @available(iOS 27.1, *)
99
+ private func attachHingeInteraction() {
100
+ guard hingeObserver == nil else { return }
101
+ guard let window = DualScreenDetector.keyWindow() else {
102
+ // Native modules can initialize before the first window exists.
103
+ hingeObserver = NotificationCenter.default.addObserver(
104
+ forName: UIWindow.didBecomeKeyNotification,
105
+ object: nil,
106
+ queue: .main
107
+ ) { [weak self] _ in
108
+ guard let self, let observer = self.hingeObserver else { return }
109
+ NotificationCenter.default.removeObserver(observer)
110
+ self.hingeObserver = nil
111
+ self.attachHingeInteraction()
112
+ }
113
+ return
114
+ }
115
+ let interaction = UIHingeInteraction { [weak self] _, update in
116
+ self?.handleHingeUpdate(update.hinge)
117
+ }
118
+ window.addInteraction(interaction)
119
+ hingeObserver = interaction
120
+ }
121
+
122
+ @available(iOS 27.1, *)
123
+ private func handleHingeUpdate(_ hinge: UIHinge?) {
124
+ // A nil hinge means the hierarchy no longer provides hinge updates;
125
+ // keep the last known state.
126
+ guard let hinge else { return }
127
+ let nextSpanning = hinge.status == .fullyOpen || hinge.status == .partiallyOpen
128
+
129
+ lock.lock()
130
+ let isNewlyDetected = !isDualScreen
131
+ isDualScreen = true
132
+ let didChangeSpanning = spanning != nextSpanning
133
+ spanning = nextSpanning
134
+ let callbacks = Array(listeners.values)
135
+ lock.unlock()
136
+
137
+ if isNewlyDetected {
138
+ UserDefaults.standard.set(true, forKey: DualScreenDetector.cacheKey)
139
+ OneKeyLog.info(
140
+ "DeviceUtils",
141
+ "Dual screen detected by hinge: model=\(DualScreenDetector.modelIdentifier())"
142
+ )
143
+ }
144
+ // A newly detected device also notifies, so JS can react without
145
+ // waiting for the next fold.
146
+ if didChangeSpanning || isNewlyDetected {
147
+ callbacks.forEach { $0(nextSpanning) }
148
+ }
149
+ }
150
+ #endif
151
+
152
+ private static func keyWindow() -> UIWindow? {
153
+ UIApplication.shared.connectedScenes
154
+ .compactMap { $0 as? UIWindowScene }
155
+ .flatMap { $0.windows }
156
+ .first { $0.isKeyWindow }
157
+ }
158
+ }
@@ -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,149 @@
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() {
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
+ guard trackingView == nil else { return }
56
+ guard let window = WindowControlsInsetsObserver.keyWindow() else {
57
+ // Native modules can initialize before the first window exists.
58
+ guard keyWindowObserver == nil else { return }
59
+ keyWindowObserver = NotificationCenter.default.addObserver(
60
+ forName: UIWindow.didBecomeKeyNotification,
61
+ object: nil,
62
+ queue: .main
63
+ ) { [weak self] _ in
64
+ guard let self, let observer = self.keyWindowObserver else { return }
65
+ NotificationCenter.default.removeObserver(observer)
66
+ self.keyWindowObserver = nil
67
+ self.install()
68
+ }
69
+ return
70
+ }
71
+ let view = WindowControlsTrackingView(frame: window.bounds) { [weak self] view in
72
+ self?.update(from: view)
73
+ }
74
+ view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
75
+ view.isUserInteractionEnabled = false
76
+ view.isAccessibilityElement = false
77
+ // Behind the root view controller's view, so it never draws or
78
+ // receives touches above app content.
79
+ window.insertSubview(view, at: 0)
80
+ trackingView = view
81
+ update(from: view)
82
+ #endif
83
+ }
84
+
85
+ #if compiler(>=6.2)
86
+ @available(iOS 26.0, *)
87
+ private func update(from view: UIView) {
88
+ let safeArea = view.safeAreaInsets
89
+ let adapted = view.edgeInsets(for: .safeArea(cornerAdaptation: .horizontal))
90
+ let next = WindowControlsInsets(
91
+ top: Double(max(0, adapted.top - safeArea.top)),
92
+ left: Double(max(0, adapted.left - safeArea.left)),
93
+ bottom: Double(max(0, adapted.bottom - safeArea.bottom)),
94
+ right: Double(max(0, adapted.right - safeArea.right))
95
+ )
96
+
97
+ lock.lock()
98
+ let previous = insets
99
+ let didChange = previous.top != next.top
100
+ || previous.left != next.left
101
+ || previous.bottom != next.bottom
102
+ || previous.right != next.right
103
+ if didChange {
104
+ insets = next
105
+ }
106
+ let callbacks = didChange ? Array(listeners.values) : []
107
+ lock.unlock()
108
+
109
+ guard didChange else { return }
110
+ OneKeyLog.debug(
111
+ "DeviceUtils",
112
+ "Window controls insets: top=\(next.top), left=\(next.left), bottom=\(next.bottom), right=\(next.right)"
113
+ )
114
+ callbacks.forEach { $0(next) }
115
+ }
116
+ #endif
117
+
118
+ private static func keyWindow() -> UIWindow? {
119
+ UIApplication.shared.connectedScenes
120
+ .compactMap { $0 as? UIWindowScene }
121
+ .flatMap { $0.windows }
122
+ .first { $0.isKeyWindow }
123
+ }
124
+ }
125
+
126
+ /// Invisible full-window view that reports layout and safe area changes.
127
+ private final class WindowControlsTrackingView: UIView {
128
+ private let onChange: (UIView) -> Void
129
+
130
+ init(frame: CGRect, onChange: @escaping (UIView) -> Void) {
131
+ self.onChange = onChange
132
+ super.init(frame: frame)
133
+ }
134
+
135
+ @available(*, unavailable)
136
+ required init?(coder: NSCoder) {
137
+ fatalError("init(coder:) has not been implemented")
138
+ }
139
+
140
+ override func layoutSubviews() {
141
+ super.layoutSubviews()
142
+ onChange(self)
143
+ }
144
+
145
+ override func safeAreaInsetsDidChange() {
146
+ super.safeAreaInsetsDidChange()
147
+ onChange(self)
148
+ }
149
+ }
@@ -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>;