@rdlabo/capacitor-native-ui 0.0.0-stage → 1.2.1

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 (255) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +53 -2
  4. package/RdlaboCapacitorNativeUi.podspec +17 -0
  5. package/android/build.gradle +69 -0
  6. package/android/proguard-rules.pro +0 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
  9. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
  10. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/core.d.ts +4 -0
  13. package/dist/core.js +3 -0
  14. package/dist/native/activation.d.ts +14 -0
  15. package/dist/native/activation.d.ts.map +1 -0
  16. package/dist/native/activation.js +305 -0
  17. package/dist/native/activation.js.map +1 -0
  18. package/dist/native/components/index.d.ts +19 -0
  19. package/dist/native/components/index.d.ts.map +1 -0
  20. package/dist/native/components/index.js +38 -0
  21. package/dist/native/components/index.js.map +1 -0
  22. package/dist/native/components/ion-back-button.d.ts +10 -0
  23. package/dist/native/components/ion-back-button.d.ts.map +1 -0
  24. package/dist/native/components/ion-back-button.js +21 -0
  25. package/dist/native/components/ion-back-button.js.map +1 -0
  26. package/dist/native/components/ion-button.d.ts +10 -0
  27. package/dist/native/components/ion-button.d.ts.map +1 -0
  28. package/dist/native/components/ion-button.js +25 -0
  29. package/dist/native/components/ion-button.js.map +1 -0
  30. package/dist/native/components/ion-buttons.d.ts +11 -0
  31. package/dist/native/components/ion-buttons.d.ts.map +1 -0
  32. package/dist/native/components/ion-buttons.js +30 -0
  33. package/dist/native/components/ion-buttons.js.map +1 -0
  34. package/dist/native/components/ion-fab.d.ts +10 -0
  35. package/dist/native/components/ion-fab.d.ts.map +1 -0
  36. package/dist/native/components/ion-fab.js +132 -0
  37. package/dist/native/components/ion-fab.js.map +1 -0
  38. package/dist/native/components/ion-menu-button.d.ts +13 -0
  39. package/dist/native/components/ion-menu-button.d.ts.map +1 -0
  40. package/dist/native/components/ion-menu-button.js +44 -0
  41. package/dist/native/components/ion-menu-button.js.map +1 -0
  42. package/dist/native/components/ion-segment.d.ts +11 -0
  43. package/dist/native/components/ion-segment.d.ts.map +1 -0
  44. package/dist/native/components/ion-segment.js +30 -0
  45. package/dist/native/components/ion-segment.js.map +1 -0
  46. package/dist/native/components/ion-tab-bar.d.ts +11 -0
  47. package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
  48. package/dist/native/components/ion-tab-bar.js +50 -0
  49. package/dist/native/components/ion-tab-bar.js.map +1 -0
  50. package/dist/native/components/searchable-tabs.d.ts +20 -0
  51. package/dist/native/components/searchable-tabs.d.ts.map +1 -0
  52. package/dist/native/components/searchable-tabs.js +413 -0
  53. package/dist/native/components/searchable-tabs.js.map +1 -0
  54. package/dist/native/definitions.d.ts +255 -0
  55. package/dist/native/definitions.d.ts.map +1 -0
  56. package/dist/native/definitions.js +0 -0
  57. package/dist/native/extensions.d.ts +31 -0
  58. package/dist/native/extensions.d.ts.map +1 -0
  59. package/dist/native/extensions.js +122 -0
  60. package/dist/native/extensions.js.map +1 -0
  61. package/dist/native/foldable/fold-state.d.ts +5 -0
  62. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  63. package/dist/native/foldable/fold-state.js +15 -0
  64. package/dist/native/foldable/fold-state.js.map +1 -0
  65. package/dist/native/foldable/types.d.ts +12 -0
  66. package/dist/native/foldable/types.d.ts.map +1 -0
  67. package/dist/native/foldable/types.js +0 -0
  68. package/dist/native/html.d.ts +9 -0
  69. package/dist/native/html.d.ts.map +1 -0
  70. package/dist/native/html.js +188 -0
  71. package/dist/native/html.js.map +1 -0
  72. package/dist/native/index.d.ts +10 -0
  73. package/dist/native/index.d.ts.map +1 -0
  74. package/dist/native/index.js +28 -0
  75. package/dist/native/index.js.map +1 -0
  76. package/dist/native/legacy-layout.d.ts +5 -0
  77. package/dist/native/legacy-layout.d.ts.map +1 -0
  78. package/dist/native/legacy-layout.js +27 -0
  79. package/dist/native/legacy-layout.js.map +1 -0
  80. package/dist/native/overlays/content.d.ts +4 -0
  81. package/dist/native/overlays/content.d.ts.map +1 -0
  82. package/dist/native/overlays/content.js +51 -0
  83. package/dist/native/overlays/content.js.map +1 -0
  84. package/dist/native/overlays/controller.d.ts +8 -0
  85. package/dist/native/overlays/controller.d.ts.map +1 -0
  86. package/dist/native/overlays/controller.js +285 -0
  87. package/dist/native/overlays/controller.js.map +1 -0
  88. package/dist/native/overlays/dialog-animation.d.ts +9 -0
  89. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  90. package/dist/native/overlays/dialog-animation.js +53 -0
  91. package/dist/native/overlays/dialog-animation.js.map +1 -0
  92. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  93. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  94. package/dist/native/overlays/dismiss-animation.js +18 -0
  95. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  96. package/dist/native/overlays/focus.d.ts +5 -0
  97. package/dist/native/overlays/focus.d.ts.map +1 -0
  98. package/dist/native/overlays/focus.js +59 -0
  99. package/dist/native/overlays/focus.js.map +1 -0
  100. package/dist/native/overlays/popover.d.ts +12 -0
  101. package/dist/native/overlays/popover.d.ts.map +1 -0
  102. package/dist/native/overlays/popover.js +110 -0
  103. package/dist/native/overlays/popover.js.map +1 -0
  104. package/dist/native/overlays/styles.d.ts +11 -0
  105. package/dist/native/overlays/styles.d.ts.map +1 -0
  106. package/dist/native/overlays/styles.js +167 -0
  107. package/dist/native/overlays/styles.js.map +1 -0
  108. package/dist/native/prehide.d.ts +8 -0
  109. package/dist/native/prehide.d.ts.map +1 -0
  110. package/dist/native/prehide.js +238 -0
  111. package/dist/native/prehide.js.map +1 -0
  112. package/dist/native/runtime.d.ts +7 -0
  113. package/dist/native/runtime.d.ts.map +1 -0
  114. package/dist/native/runtime.js +788 -0
  115. package/dist/native/runtime.js.map +1 -0
  116. package/dist/native/shared/bridge.d.ts +6 -0
  117. package/dist/native/shared/bridge.d.ts.map +1 -0
  118. package/dist/native/shared/bridge.js +18 -0
  119. package/dist/native/shared/bridge.js.map +1 -0
  120. package/dist/native/shared/candidate.d.ts +25 -0
  121. package/dist/native/shared/candidate.d.ts.map +1 -0
  122. package/dist/native/shared/candidate.js +106 -0
  123. package/dist/native/shared/candidate.js.map +1 -0
  124. package/dist/native/shared/clipping.d.ts +5 -0
  125. package/dist/native/shared/clipping.d.ts.map +1 -0
  126. package/dist/native/shared/clipping.js +26 -0
  127. package/dist/native/shared/clipping.js.map +1 -0
  128. package/dist/native/shared/crossfade.d.ts +12 -0
  129. package/dist/native/shared/crossfade.d.ts.map +1 -0
  130. package/dist/native/shared/crossfade.js +67 -0
  131. package/dist/native/shared/crossfade.js.map +1 -0
  132. package/dist/native/shared/diagnostics.d.ts +4 -0
  133. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  134. package/dist/native/shared/diagnostics.js +18 -0
  135. package/dist/native/shared/diagnostics.js.map +1 -0
  136. package/dist/native/shared/dom.d.ts +46 -0
  137. package/dist/native/shared/dom.d.ts.map +1 -0
  138. package/dist/native/shared/dom.js +180 -0
  139. package/dist/native/shared/dom.js.map +1 -0
  140. package/dist/native/shared/events.d.ts +15 -0
  141. package/dist/native/shared/events.d.ts.map +1 -0
  142. package/dist/native/shared/events.js +17 -0
  143. package/dist/native/shared/events.js.map +1 -0
  144. package/dist/native/shared/icons.d.ts +8 -0
  145. package/dist/native/shared/icons.d.ts.map +1 -0
  146. package/dist/native/shared/icons.js +61 -0
  147. package/dist/native/shared/icons.js.map +1 -0
  148. package/dist/native/shared/modal.d.ts +10 -0
  149. package/dist/native/shared/modal.d.ts.map +1 -0
  150. package/dist/native/shared/modal.js +160 -0
  151. package/dist/native/shared/modal.js.map +1 -0
  152. package/dist/native/shared/projection-id.d.ts +4 -0
  153. package/dist/native/shared/projection-id.d.ts.map +1 -0
  154. package/dist/native/shared/projection-id.js +7 -0
  155. package/dist/native/shared/projection-id.js.map +1 -0
  156. package/dist/native/shared/toolbar-options.d.ts +9 -0
  157. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  158. package/dist/native/shared/toolbar-options.js +36 -0
  159. package/dist/native/shared/toolbar-options.js.map +1 -0
  160. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  161. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  162. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  163. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  164. package/dist/native/startup.d.ts +8 -0
  165. package/dist/native/startup.d.ts.map +1 -0
  166. package/dist/native/startup.js +86 -0
  167. package/dist/native/startup.js.map +1 -0
  168. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  169. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  170. package/dist/native/vertical-bars-web-search.js +90 -0
  171. package/dist/native/vertical-bars-web-search.js.map +1 -0
  172. package/dist/native/vertical-bars-web.d.ts +5 -0
  173. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web.js +421 -0
  175. package/dist/native/vertical-bars-web.js.map +1 -0
  176. package/dist/native-integration/index.d.ts +25 -0
  177. package/dist/native-integration/index.d.ts.map +1 -0
  178. package/dist/native-integration/index.js +33 -0
  179. package/dist/native-integration/index.js.map +1 -0
  180. package/dist/vertical-bars.d.ts +6 -0
  181. package/dist/vertical-bars.js +5 -0
  182. package/docs/configuration.md +187 -0
  183. package/docs/framework-independent.md +82 -0
  184. package/docs/migration.md +206 -0
  185. package/docs/native-mini-player.md +47 -0
  186. package/docs/native-ui-extensions.md +365 -0
  187. package/docs/native-ui.md +409 -0
  188. package/docs/setup.md +205 -0
  189. package/docs/vertical-bars.md +379 -0
  190. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  191. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  192. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  193. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  194. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  199. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  200. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  201. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  202. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  203. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  204. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  205. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  206. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  207. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  208. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  209. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  210. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  211. package/package.json +89 -4
  212. package/src/core.ts +15 -0
  213. package/src/native/activation.ts +394 -0
  214. package/src/native/components/index.ts +45 -0
  215. package/src/native/components/ion-back-button.ts +22 -0
  216. package/src/native/components/ion-button.ts +25 -0
  217. package/src/native/components/ion-buttons.ts +37 -0
  218. package/src/native/components/ion-fab.ts +151 -0
  219. package/src/native/components/ion-menu-button.ts +37 -0
  220. package/src/native/components/ion-segment.ts +27 -0
  221. package/src/native/components/ion-tab-bar.ts +50 -0
  222. package/src/native/components/searchable-tabs.ts +499 -0
  223. package/src/native/definitions.ts +324 -0
  224. package/src/native/extensions.ts +176 -0
  225. package/src/native/foldable/fold-state.ts +10 -0
  226. package/src/native/foldable/types.ts +4 -0
  227. package/src/native/html.ts +193 -0
  228. package/src/native/index.ts +41 -0
  229. package/src/native/legacy-layout.ts +21 -0
  230. package/src/native/overlays/content.ts +51 -0
  231. package/src/native/overlays/controller.ts +349 -0
  232. package/src/native/overlays/dialog-animation.ts +53 -0
  233. package/src/native/overlays/dismiss-animation.ts +15 -0
  234. package/src/native/overlays/focus.ts +64 -0
  235. package/src/native/overlays/popover.ts +120 -0
  236. package/src/native/overlays/styles.ts +181 -0
  237. package/src/native/prehide.ts +285 -0
  238. package/src/native/runtime.ts +988 -0
  239. package/src/native/shared/bridge.ts +18 -0
  240. package/src/native/shared/candidate.ts +162 -0
  241. package/src/native/shared/clipping.ts +24 -0
  242. package/src/native/shared/crossfade.ts +67 -0
  243. package/src/native/shared/diagnostics.ts +13 -0
  244. package/src/native/shared/dom.ts +304 -0
  245. package/src/native/shared/events.ts +14 -0
  246. package/src/native/shared/icons.ts +57 -0
  247. package/src/native/shared/modal.ts +157 -0
  248. package/src/native/shared/projection-id.ts +2 -0
  249. package/src/native/shared/toolbar-options.ts +53 -0
  250. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  251. package/src/native/startup.ts +84 -0
  252. package/src/native/vertical-bars-web-search.ts +86 -0
  253. package/src/native/vertical-bars-web.ts +498 -0
  254. package/src/native-integration/index.ts +49 -0
  255. package/src/vertical-bars.ts +11 -0
@@ -0,0 +1,119 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// Intercepts only a prepared blank window. Other requests keep Capacitor's behavior.
5
+ final class NativeUIOverlayController: NSObject, WKUIDelegate {
6
+ private weak var source: WKWebView?
7
+ private weak var owner: UIViewController?
8
+ private let original: WKUIDelegate?
9
+ private let automaticallyOpensWindows: Bool
10
+ private var prepared: String?
11
+ private let event: (String, String) -> Void
12
+ private var id: String?
13
+ private var host: NativeUIOverlayHost?
14
+
15
+ init(source: WKWebView, owner: UIViewController, event: @escaping (String, String) -> Void) {
16
+ self.source = source
17
+ self.owner = owner
18
+ self.event = event
19
+ original = source.uiDelegate
20
+ automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically
21
+ super.init()
22
+ source.uiDelegate = self
23
+ }
24
+
25
+ override func responds(to selector: Selector!) -> Bool {
26
+ super.responds(to: selector) || original?.responds(to: selector) == true
27
+ }
28
+
29
+ override func forwardingTarget(for selector: Selector!) -> Any? {
30
+ original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector)
31
+ }
32
+
33
+ func prepare(_ id: String) -> Bool {
34
+ guard prepared == nil, host == nil else { return false }
35
+ prepared = id
36
+ source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
37
+ return true
38
+ }
39
+
40
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
41
+ for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
42
+ // WebKit percent-encodes '#' inside about: URLs; compare the decoded form.
43
+ guard webView === source, navigationAction.sourceFrame.isMainFrame,
44
+ let prepared, navigationAction.targetFrame == nil,
45
+ navigationAction.request.url?.absoluteString.removingPercentEncoding == "about:blank#\(prepared)" else {
46
+ return original?.webView?(webView, createWebViewWith: configuration,
47
+ for: navigationAction, windowFeatures: windowFeatures)
48
+ }
49
+ self.prepared = nil
50
+ source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
51
+ id = prepared
52
+ let host = NativeUIOverlayHost(configuration: configuration, options: nil) { [weak self] action in
53
+ self?.event(prepared, action)
54
+ }
55
+ host.webView.uiDelegate = self
56
+ host.webView.frame = source?.bounds ?? .zero
57
+ self.host = host
58
+ return host.webView
59
+ }
60
+
61
+ func present(_ id: String, options: [String: Any]?, anchored: (String, NativeUIOverlayHost, @escaping (Bool) -> Void) -> Bool,
62
+ completion: @escaping (Bool) -> Void) {
63
+ guard self.id == id, let host, let owner,
64
+ owner.presentedViewController == nil else { completion(false); return }
65
+ host.apply(options)
66
+ var settled = false
67
+ var timeout: DispatchWorkItem?
68
+ let complete: (Bool) -> Void = { presented in
69
+ guard !settled else { return }
70
+ settled = true
71
+ timeout?.cancel()
72
+ timeout = nil
73
+ completion(presented)
74
+ }
75
+ timeout = DispatchWorkItem { [weak host] in
76
+ host?.cancelAnchored?()
77
+ complete(false)
78
+ }
79
+ DispatchQueue.main.asyncAfter(deadline: .now() + NativeUIOverlayHost.completionTimeout, execute: timeout!)
80
+ if host.options?["kind"] as? String == "popover",
81
+ let anchor = host.options?["anchorId"] as? String,
82
+ anchored(anchor, host, complete) { return }
83
+ complete(false)
84
+ }
85
+
86
+ // Keep the WebView alive until JavaScript has restored the adopted nodes and listeners.
87
+ func dismiss(_ id: String, animated: Bool, completion: @escaping () -> Void) {
88
+ guard self.id == id, let host else { completion(); return }
89
+ host.dismissPopover(animated: animated, completion: completion)
90
+ }
91
+
92
+ func close(_ id: String, completion: @escaping () -> Void) {
93
+ if prepared == id {
94
+ prepared = nil
95
+ source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
96
+ }
97
+ guard self.id == id, let host else { completion(); return }
98
+ let finish = { [self] in
99
+ host.webView.uiDelegate = nil
100
+ self.host = nil
101
+ self.id = nil
102
+ completion()
103
+ }
104
+ host.dismissPopover(animated: false, completion: finish)
105
+ }
106
+
107
+ func stop(completion: @escaping () -> Void) {
108
+ let finish = { [self] in
109
+ prepared = nil
110
+ if let source, source.uiDelegate === self {
111
+ source.uiDelegate = original
112
+ source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
113
+ }
114
+ completion()
115
+ }
116
+ if let id { close(id, completion: finish) }
117
+ else { finish() }
118
+ }
119
+ }
@@ -0,0 +1,75 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// Hosts only the live content of a Vertical Bars popover.
5
+ final class NativeUIOverlayHost: UIViewController {
6
+ let webView: WKWebView
7
+ private(set) var options: [String: Any]?
8
+ let event: (String) -> Void
9
+ // Settle native presentation before the JS bridge's five-second deadline.
10
+ static let completionTimeout: TimeInterval = 4
11
+ var dismissAnchored: (() -> Void)?
12
+ var cancelAnchored: (() -> Void)?
13
+ var anchoredVisible = false
14
+ private var dismissalCompletions: [() -> Void]?
15
+ private var dismissalTimeout: DispatchWorkItem?
16
+
17
+ func completeDismissal() {
18
+ dismissalTimeout?.cancel()
19
+ dismissalTimeout = nil
20
+ anchoredVisible = false
21
+ let completions = dismissalCompletions ?? []
22
+ dismissalCompletions = nil
23
+ completions.forEach { $0() }
24
+ }
25
+
26
+ // Dismiss, close and stop may overlap while SwiftUI is still disappearing.
27
+ // Start dismissal once and deliver its completion to every waiting caller.
28
+ func dismissPopover(animated: Bool, completion: @escaping () -> Void) {
29
+ if dismissalCompletions != nil {
30
+ dismissalCompletions?.append(completion)
31
+ return
32
+ }
33
+ dismissalCompletions = [completion]
34
+ let finish = { [self] in completeDismissal() }
35
+ let timeout = DispatchWorkItem { [weak self] in
36
+ guard let self, self.dismissalCompletions != nil else { return }
37
+ // Remove the owning rail if SwiftUI never reports disappearance.
38
+ // Its detach path settles waiters before any replacement can attach.
39
+ self.cancelAnchored?()
40
+ self.completeDismissal()
41
+ }
42
+ dismissalTimeout = timeout
43
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.completionTimeout, execute: timeout)
44
+ if let dismissAnchored {
45
+ if anchoredVisible { dismissAnchored() }
46
+ else { dismissAnchored(); finish() }
47
+ } else if presentingViewController != nil {
48
+ dismiss(animated: animated, completion: finish)
49
+ } else { finish() }
50
+ }
51
+
52
+ init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String) -> Void) {
53
+ webView = WKWebView(frame: .zero, configuration: configuration)
54
+ self.event = event
55
+ super.init(nibName: nil, bundle: nil)
56
+ apply(options)
57
+ }
58
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
59
+
60
+ func apply(_ options: [String: Any]?) {
61
+ guard let options else { return }
62
+ self.options = options
63
+ preferredContentSize = CGSize(width: options["width"] as? Double ?? 280, height: options["height"] as? Double ?? 200)
64
+ overrideUserInterfaceStyle = options["dark"] as? Bool == true ? .dark : .light
65
+ }
66
+
67
+ override func loadView() {
68
+ webView.isOpaque = false
69
+ webView.backgroundColor = .clear
70
+ webView.scrollView.backgroundColor = .clear
71
+ webView.scrollView.contentInsetAdjustmentBehavior = .never
72
+ view = webView
73
+ view.accessibilityViewIsModal = true
74
+ }
75
+ }
@@ -0,0 +1,21 @@
1
+ import UIKit
2
+
3
+ final class NativeUIControlRendering {
4
+ private var images: [String: UIImage] = [:]
5
+ func clear() { images.removeAll() }
6
+
7
+ func image(_ item: NativeUIItemContent) -> UIImage? {
8
+ guard let data = item.icon, let width = item.iconWidth else { return nil }
9
+ let template = item.iconTemplate == true
10
+ let key = "\(width):" + (template ? "template:" : "original:") + data
11
+ if let cached = images[key] { return cached }
12
+ guard let result = NativeUIRendering.image(NativeUIImage(data: data, width: width, height: item.iconHeight ?? width, template: template)) else { return nil }
13
+ if images.count >= 128 { images.removeAll() }
14
+ images[key] = result
15
+ return result
16
+ }
17
+
18
+ func color(_ css: String?) -> UIColor {
19
+ NativeUIRendering.color(css)
20
+ }
21
+ }
@@ -0,0 +1,35 @@
1
+ import UIKit
2
+
3
+ @MainActor enum NativeUICrossfade {
4
+ static func duration(_ milliseconds: Double?) -> TimeInterval {
5
+ UIAccessibility.isReduceMotionEnabled ? 0 : (milliseconds ?? 0) / 1000
6
+ }
7
+
8
+ static func enter(_ view: UIView, duration: TimeInterval) {
9
+ guard duration > 0 else { return }
10
+ view.alpha = 0
11
+ UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
12
+ view.alpha = 1
13
+ }
14
+ }
15
+
16
+ static func retire(_ view: UIView, duration: TimeInterval) {
17
+ // The outgoing copy is visual only: it cannot keep accepting actions or
18
+ // VoiceOver focus once the Web source has been restored.
19
+ if duration > 0, let host = view.superview, let parent = host.superview,
20
+ !view.isHidden, !host.isHidden, let copy = view.snapshotView(afterScreenUpdates: false) {
21
+ copy.frame = host.convert(view.frame, to: parent)
22
+ copy.alpha = CGFloat(view.layer.presentation()?.opacity ?? Float(view.alpha))
23
+ copy.isUserInteractionEnabled = false
24
+ copy.isAccessibilityElement = false
25
+ copy.accessibilityElementsHidden = true
26
+ parent.insertSubview(copy, aboveSubview: host)
27
+ UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut]) {
28
+ copy.alpha = 0
29
+ } completion: { _ in
30
+ copy.removeFromSuperview()
31
+ }
32
+ }
33
+ view.removeFromSuperview()
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ import UIKit
2
+
3
+ final class NativeUIHost: UIView {
4
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5
+ let hit = super.hitTest(point, with: event)
6
+ return hit === self ? nil : hit
7
+ }
8
+ }
@@ -0,0 +1,161 @@
1
+ import Foundation
2
+ import CoreGraphics
3
+
4
+ // Standard renderer names are independent of DOM tags and Web frameworks.
5
+ enum NativeUIComponent: String, Decodable {
6
+ case `extension`
7
+ case button, buttons = "button-group"
8
+ case backButton = "back-button", menuButton = "menu-button"
9
+ case tabBar = "tab-bar", segment, fab = "floating-action-button"
10
+
11
+ init(from decoder: Decoder) throws {
12
+ let container = try decoder.singleValueContainer()
13
+ let value = try container.decode(String.self)
14
+ // Accept previous clients during native/Web bundle upgrades.
15
+ let legacy: [String: NativeUIComponent] = [
16
+ "ion-button": .button, "ion-buttons": .buttons,
17
+ "ion-back-button": .backButton, "ion-menu-button": .menuButton,
18
+ "ion-tab-bar": .tabBar, "ion-segment": .segment, "ion-fab": .fab
19
+ ]
20
+ guard let kind = NativeUIComponent(rawValue: value) ?? legacy[value] else {
21
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unknown native control kind")
22
+ }
23
+ self = kind
24
+ }
25
+ }
26
+
27
+ struct NativeUISnapshot: Decodable {
28
+ let revision: Int
29
+ let transitionDuration: Double?
30
+ let viewportWidth: Double
31
+ let verticalBarFrame: NativeUIFrame?
32
+ let verticalBarEdge: String?
33
+ let controls: [NativeUIControl]
34
+
35
+ var isValid: Bool {
36
+ revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
37
+ (verticalBarFrame.map { $0.isValid } ?? true) &&
38
+ (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
39
+ Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
40
+ }
41
+ }
42
+
43
+ // Frames and content decode from the same flat wire object. Keeping them separate
44
+ // lets FABs compare artwork without treating movement or visibility as new content.
45
+ struct NativeUIFrame: Decodable, Equatable {
46
+ let x: Double
47
+ let y: Double
48
+ let width: Double
49
+ let height: Double
50
+ var rect: CGRect { CGRect(x: x, y: y, width: width, height: height) }
51
+ var isValid: Bool { [x, y, width, height].allSatisfy(\.isFinite) && width > 0 && height > 0 }
52
+ }
53
+
54
+ struct NativeUIControl: Decodable, Equatable {
55
+ enum Placement: String, Decodable { case verticalBars = "vertical-bars" }
56
+ enum ToolbarSlot: String, Decodable { case start, end }
57
+ let id: String
58
+ let kind: NativeUIComponent
59
+ let placement: Placement?
60
+ let toolbarSlot: ToolbarSlot?
61
+ let frame: NativeUIFrame
62
+ let items: [NativeUIItem]
63
+ let dark: Bool
64
+ let rtl: Bool
65
+ let tabBarAnchor: NativeUITabBar.Anchor?
66
+ let search: NativeUISearch?
67
+ let extensionContent: NativeUIExtensionContent?
68
+
69
+ private enum CodingKeys: String, CodingKey {
70
+ case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search
71
+ case extensionContent = "extension"
72
+ }
73
+
74
+ init(from decoder: Decoder) throws {
75
+ let values = try decoder.container(keyedBy: CodingKeys.self)
76
+ id = try values.decode(String.self, forKey: .id)
77
+ kind = try values.decode(NativeUIComponent.self, forKey: .kind)
78
+ placement = try values.decodeIfPresent(Placement.self, forKey: .placement)
79
+ toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot)
80
+ frame = try NativeUIFrame(from: decoder)
81
+ items = try values.decode([NativeUIItem].self, forKey: .items)
82
+ dark = try values.decode(Bool.self, forKey: .dark)
83
+ rtl = try values.decode(Bool.self, forKey: .rtl)
84
+ tabBarAnchor = try values.decodeIfPresent(NativeUITabBar.Anchor.self, forKey: .tabBarAnchor)
85
+ search = try values.decodeIfPresent(NativeUISearch.self, forKey: .search)
86
+ extensionContent = try values.decodeIfPresent(NativeUIExtensionContent.self, forKey: .extensionContent)
87
+ }
88
+
89
+ var isValid: Bool {
90
+ if kind == .extension {
91
+ return !id.isEmpty && frame.isValid && items.isEmpty && placement == nil && toolbarSlot == nil &&
92
+ search == nil && tabBarAnchor == nil && extensionContent?.isValid == true
93
+ }
94
+ return extensionContent == nil && NativeUIComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 &&
95
+ (!NativeUIButton.kinds.contains(kind) || items.count == 1) &&
96
+ (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) &&
97
+ Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) &&
98
+ (tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) &&
99
+ (search.map { kind == .tabBar && $0.isValid } ?? true)
100
+ }
101
+ }
102
+
103
+ struct NativeUIItem: Decodable, Equatable {
104
+ let frame: NativeUIFrame
105
+ let content: NativeUIItemContent
106
+ let visible: Bool?
107
+ var id: String { content.id }
108
+
109
+ private enum CodingKeys: String, CodingKey { case visible }
110
+
111
+ init(from decoder: Decoder) throws {
112
+ frame = try NativeUIFrame(from: decoder)
113
+ content = try NativeUIItemContent(from: decoder)
114
+ visible = try decoder.container(keyedBy: CodingKeys.self).decodeIfPresent(Bool.self, forKey: .visible)
115
+ }
116
+
117
+ var isValid: Bool { frame.isValid && content.isValid }
118
+ }
119
+
120
+ struct NativeUIBadge: Decodable, Equatable {
121
+ let value: String
122
+ let color: String
123
+ let textColor: String
124
+ }
125
+
126
+ struct NativeUIItemContent: Decodable, Equatable {
127
+ enum IconPosition: String, Decodable { case leading, trailing, top }
128
+ enum ButtonFill: String, Decodable { case clear, solid, outline }
129
+ let id: String
130
+ var label: String
131
+ let accessibilityLabel: String
132
+ let disabled: Bool
133
+ let selected: Bool
134
+ let fontSize: Double
135
+ let fontWeight: Double
136
+ let color: String
137
+ let buttonFill: ButtonFill?
138
+ let backgroundColor: String?
139
+ let borderColor: String?
140
+ let borderWidth: CGFloat?
141
+ let badge: NativeUIBadge?
142
+ var icon: String?
143
+ var iconWidth: Double?
144
+ var iconHeight: Double?
145
+ let iconPosition: IconPosition?
146
+ let imagePadding: Double?
147
+ let contentInsetLeading: Double?
148
+ let contentInsetTrailing: Double?
149
+ let iconTemplate: Bool?
150
+ let closeIcon: String?
151
+ let closeIconWidth: Double?
152
+ let closeIconHeight: Double?
153
+ let iconTransition: Double?
154
+
155
+ var isValid: Bool {
156
+ !id.isEmpty && fontSize.isFinite && fontSize >= 0 && fontWeight.isFinite &&
157
+ [iconWidth, iconHeight, closeIconWidth, closeIconHeight].compactMap { $0 }.allSatisfy { $0.isFinite && $0 > 0 } &&
158
+ [imagePadding, contentInsetLeading, contentInsetTrailing].compactMap { $0 }.allSatisfy { $0.isFinite } &&
159
+ (iconTransition.map { $0.isFinite && $0 >= 0 } ?? true)
160
+ }
161
+ }
@@ -0,0 +1,52 @@
1
+ import UIKit
2
+
3
+ /// Reads public accessibility geometry after the system has laid out its rail.
4
+ /// IDs bind measurements to current DOM controls without private class names.
5
+ /// Compact square ancestors approximate the system button surface; this remains
6
+ /// a layout heuristic and invalid measurements fall back to Web CSS.
7
+ enum NativeUIVerticalBarsGeometry {
8
+ static func measure(in root: UIView, relativeTo webView: UIView, ids: Set<String>) -> [String: CGRect] {
9
+ guard let window = root.window, window === webView.window else { return [:] }
10
+ root.layoutIfNeeded()
11
+ var result: [String: CGRect] = [:]
12
+ var visited = Set<ObjectIdentifier>()
13
+ func visit(_ object: NSObject, depth: Int) {
14
+ guard depth < 40, visited.count < 2000, visited.insert(ObjectIdentifier(object)).inserted else { return }
15
+ if let id = (object as? UIView)?.accessibilityIdentifier ?? (object as? UIAccessibilityElement)?.accessibilityIdentifier, ids.contains(id) {
16
+ let screenFrame = object.accessibilityFrame
17
+ let frame = (object as? UIView).map { view -> CGRect in
18
+ var surface = view
19
+ var ancestor = view.superview
20
+ while let current = ancestor, current !== root,
21
+ current.bounds.width < 100, current.bounds.height < 100 {
22
+ if current.bounds.width >= max(surface.bounds.width, 1), current.bounds.height >= max(surface.bounds.height, 1),
23
+ abs(current.bounds.width - current.bounds.height) < 1 { surface = current }
24
+ ancestor = current.superview
25
+ }
26
+ return surface.convert(surface.bounds, to: webView)
27
+ } ?? webView.convert(screenFrame, from: window.screen.coordinateSpace)
28
+ if !frame.isNull, !frame.isInfinite, frame.width > 0, frame.height > 0,
29
+ webView.bounds.intersects(frame), frame.width < webView.bounds.width * 0.75 {
30
+ // UIKit may expose a label and its larger button with the same ID.
31
+ if result[id] == nil || frame.width * frame.height > result[id]!.width * result[id]!.height {
32
+ result[id] = frame
33
+ }
34
+ }
35
+ }
36
+ if let view = object as? UIView { view.subviews.forEach { visit($0, depth: depth + 1) } }
37
+ if object === webView { return }
38
+ if let elements = object.accessibilityElements {
39
+ elements.compactMap { $0 as? NSObject }.forEach { visit($0, depth: depth + 1) }
40
+ } else {
41
+ let count = object.accessibilityElementCount()
42
+ if count > 0 && count < 200 {
43
+ for index in 0..<count {
44
+ if let element = object.accessibilityElement(at: index) as? NSObject { visit(element, depth: depth + 1) }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ visit(root, depth: 0)
50
+ return result
51
+ }
52
+ }
@@ -0,0 +1,165 @@
1
+ import XCTest
2
+ import SwiftUI
3
+ import WebKit
4
+ @testable import NativeUIPlugin
5
+
6
+ final class NativeUIExtensionTests: XCTestCase {
7
+ struct Props: Decodable { let title: String }
8
+
9
+ private func context(width: Double = 120, dark: Bool = false, accessory: Bool = false) throws -> NativeUIContext {
10
+ var content: [String: Any] = ["name": "fixture", "props": "{}", "actions": [:]]
11
+ if accessory { content["tabBarId"] = "tabs" }
12
+ let json: [String: Any] = ["id": "source", "kind": "extension", "x": -4, "y": 30,
13
+ "width": width, "height": 44, "dark": dark, "rtl": dark, "items": [], "extension": content]
14
+ let node = try JSONDecoder().decode(NativeUIControl.self, from: JSONSerialization.data(withJSONObject: json))
15
+ return NativeUIContext(node: node, viewportWidth: 600, pointsPerCSSPixel: 0.5)
16
+ }
17
+
18
+ @MainActor func testContextUpdatesGeometryEnvironmentAndAccessoryPlacement() throws {
19
+ let name = UUID().uuidString
20
+ var received: [NativeUIContext] = []
21
+ try NativeUI.register(name) { (props: Props, actions: NativeUIActions, context: NativeUIContext) in
22
+ received.append(context)
23
+ XCTAssertTrue(actions.isAvailable("open"))
24
+ XCTAssertFalse(actions.isAvailable("disabled"))
25
+ return Text(props.title)
26
+ }
27
+ let data = NativeUIExtensionContent(name: name, props: "{\"title\":\"Ready\"}", actions: ["open": "button"], tabBarId: nil)
28
+ let actions = NativeUIActions(availableNames: ["open"]) { _ in }
29
+ _ = try NativeUI.content(data, actions: actions, context: context())
30
+ _ = try NativeUI.content(data, actions: actions, context: context(width: 220, dark: true, accessory: true))
31
+ XCTAssertEqual(received.count, 2)
32
+ XCTAssertEqual(received[0].sourceID, "source")
33
+ XCTAssertEqual(received[0].sourceFrame, CGRect(x: -4, y: 30, width: 120, height: 44))
34
+ XCTAssertEqual(received[0].sourceFrameInWebView, CGRect(x: -2, y: 15, width: 60, height: 22))
35
+ XCTAssertEqual(received[0].placement, .element)
36
+ XCTAssertEqual(received[0].colorScheme, .light)
37
+ XCTAssertEqual(received[0].layoutDirection, .leftToRight)
38
+ XCTAssertEqual(received[1].sourceFrameInWebView.width, 110)
39
+ XCTAssertEqual(received[1].viewportWidth, 600)
40
+ XCTAssertEqual(received[1].pointsPerCSSPixel, 0.5)
41
+ XCTAssertEqual(received[1].placement, .tabAccessory)
42
+ XCTAssertEqual(received[1].colorScheme, .dark)
43
+ XCTAssertEqual(received[1].layoutDirection, .rightToLeft)
44
+ XCTAssertThrowsError(try NativeUI.register(name) { (_: Props, _: NativeUIActions) in Text("Duplicate") })
45
+ }
46
+
47
+ @MainActor func testPublicRenderingSharesArtworkAndComputedColors() throws {
48
+ let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16)).image { context in
49
+ UIColor.red.setFill()
50
+ context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
51
+ }
52
+ let data = try XCTUnwrap(image.pngData()).base64EncodedString()
53
+ let artwork = NativeUIImage(data: data, width: 16, height: 16, template: true)
54
+ let decoded = try XCTUnwrap(NativeUIRendering.image(artwork))
55
+ XCTAssertEqual(decoded.size, CGSize(width: 16, height: 16))
56
+ XCTAssertEqual(decoded.renderingMode, .alwaysTemplate)
57
+ let legacy: [String: Any] = ["id": "icon", "label": "Icon", "accessibilityLabel": "Icon",
58
+ "disabled": false, "selected": false, "fontSize": 16, "fontWeight": 400,
59
+ "color": "rgb(0, 0, 0)", "icon": data, "iconWidth": 16]
60
+ let item = try JSONDecoder().decode(NativeUIItemContent.self, from: JSONSerialization.data(withJSONObject: legacy))
61
+ XCTAssertEqual(try XCTUnwrap(NativeUIControlRendering().image(item)).size, CGSize(width: 16, height: 16))
62
+ XCTAssertNil(NativeUIRendering.image(NativeUIImage(data: "invalid", width: 16, height: 16)))
63
+ XCTAssertNil(NativeUIRendering.image(NativeUIImage(data: data, width: .nan, height: 16)))
64
+ let color = NativeUIRendering.color("rgba(255, 0, 128, 0.5)")
65
+ var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
66
+ XCTAssertTrue(color.getRed(&red, green: &green, blue: &blue, alpha: &alpha))
67
+ XCTAssertEqual(red, 1)
68
+ XCTAssertEqual(blue, 128.0 / 255.0, accuracy: 0.001)
69
+ XCTAssertEqual(alpha, 0.5)
70
+ XCTAssertEqual(NativeUIRendering.color("currentColor", fallback: .red), .red)
71
+ XCTAssertEqual(NativeUIControlRendering().color("rgba(255, 0, 128, 0.5)"), color)
72
+ }
73
+
74
+ @MainActor func testLocalRegistrationDecodesPropsAndRejectsDuplicateAndMissingRenderers() throws {
75
+ let name = UUID().uuidString
76
+ var title = ""
77
+ try NativeUI.register(name) { (props: Props, _: NativeUIActions) in
78
+ title = props.title
79
+ return Text(props.title)
80
+ }
81
+ let actions = NativeUIActions { _ in }
82
+ let context = try context()
83
+ _ = try NativeUI.content(NativeUIExtensionContent(name: name, props: "{\"title\":\"Local View\"}", actions: [:], tabBarId: nil), actions: actions, context: context)
84
+ XCTAssertEqual(title, "Local View")
85
+ XCTAssertThrowsError(try NativeUI.register(name) { (props: Props, _: NativeUIActions) in Text(props.title) })
86
+ XCTAssertThrowsError(try NativeUI.content(NativeUIExtensionContent(name: name, props: "{}", actions: [:], tabBarId: nil), actions: actions, context: context))
87
+ XCTAssertThrowsError(try NativeUI.content(NativeUIExtensionContent(name: "missing", props: "{}", actions: [:], tabBarId: nil), actions: actions, context: context))
88
+ }
89
+
90
+ @MainActor func testExtensionSnapshotAcceptsEmptyActionsButRejectsInvalidProps() throws {
91
+ func decode(_ props: String) throws -> NativeUIControl {
92
+ let json: [String: Any] = ["id": "counter", "kind": "extension", "x": 0, "y": 0, "width": 100, "height": 44,
93
+ "dark": false, "rtl": false, "items": [], "extension": ["name": "app.counter", "props": props, "actions": [:]]]
94
+ return try JSONDecoder().decode(NativeUIControl.self, from: JSONSerialization.data(withJSONObject: json))
95
+ }
96
+ XCTAssertTrue(try decode("{\"title\":\"Ready\"}").isValid)
97
+ XCTAssertFalse(try decode("[]").isValid)
98
+ XCTAssertFalse(try decode("broken").isValid)
99
+ }
100
+
101
+ @MainActor func testActionWaitsForWebAcknowledgementAndIgnoresDuplicateReplies() async throws {
102
+ let broker = NativeUIExtensionActions()
103
+ var request: String?
104
+ var completed = false
105
+ let sent = expectation(description: "Web action sent")
106
+ let task = Task { @MainActor in
107
+ try await broker.perform(owner: "source") { request = $0; sent.fulfill() }
108
+ completed = true
109
+ }
110
+ defer { task.cancel() }
111
+ await fulfillment(of: [sent], timeout: 2)
112
+ let requestID = try XCTUnwrap(request)
113
+ XCTAssertFalse(completed)
114
+ broker.finish(requestID)
115
+ broker.finish(requestID, error: NativeUIError.rejected)
116
+ try await task.value
117
+ XCTAssertTrue(completed)
118
+ }
119
+
120
+ @MainActor func testRetirementRejectsPendingCallsOnlyForThatSource() async throws {
121
+ let broker = NativeUIExtensionActions()
122
+ var requests: [String: String] = [:]
123
+ let sent = expectation(description: "Both actions sent")
124
+ sent.expectedFulfillmentCount = 2
125
+ let first = Task { @MainActor in try await broker.perform(owner: "first") { requests["first"] = $0; sent.fulfill() } }
126
+ let second = Task { @MainActor in try await broker.perform(owner: "second") { requests["second"] = $0; sent.fulfill() } }
127
+ defer { first.cancel(); second.cancel() }
128
+ await fulfillment(of: [sent], timeout: 2)
129
+ broker.cancel(owner: "first")
130
+ do { try await first.value; XCTFail("Retired source must reject") } catch NativeUIError.unavailable {} catch { XCTFail("\(error)") }
131
+ broker.finish(try XCTUnwrap(requests["second"]))
132
+ try await second.value
133
+ }
134
+
135
+ @MainActor func testBridgeLossAndTaskCancellationAlwaysSettle() async {
136
+ let broker = NativeUIExtensionActions()
137
+ do {
138
+ try await broker.perform(owner: "lost", timeout: 0.01) { _ in }
139
+ XCTFail("Lost bridge must time out")
140
+ } catch NativeUIError.timedOut {} catch { XCTFail("\(error)") }
141
+ let sent = expectation(description: "Cancelable action sent")
142
+ let task = Task { @MainActor in try await broker.perform(owner: "canceled") { _ in sent.fulfill() } }
143
+ await fulfillment(of: [sent], timeout: 2)
144
+ task.cancel()
145
+ do { try await task.value; XCTFail("Canceled task must reject") } catch is CancellationError {} catch { XCTFail("\(error)") }
146
+ broker.cancel()
147
+ }
148
+
149
+ @MainActor func testHostedViewUsesControllerContainmentAndDetaches() {
150
+ let owner = UIViewController()
151
+ owner.view = WKWebView()
152
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
153
+ window.rootViewController = owner
154
+ window.makeKeyAndVisible()
155
+ defer { window.isHidden = true }
156
+ let host = NativeUIExtensionController(content: AnyView(Text("Local content")), owner: owner)
157
+ XCTAssertTrue(host.view.superview === owner.view)
158
+ XCTAssertTrue(host.controller.parent === owner)
159
+ host.update(AnyView(Text("Updated")))
160
+ host.detach(duration: 0)
161
+ XCTAssertNil(host.controller.parent)
162
+ XCTAssertNil(host.view.superview)
163
+ XCTAssertTrue(owner.children.isEmpty)
164
+ }
165
+ }