@webview-native-ui/engine 0.0.0-stage → 0.0.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 (47) hide show
  1. package/LICENSE +18 -0
  2. package/NOTICE +39 -0
  3. package/README.md +623 -2
  4. package/WebViewNativeUIEngine.podspec +17 -0
  5. package/dist/bridge.d.ts +4 -0
  6. package/dist/bridge.js +15 -0
  7. package/dist/geometry.d.ts +2 -0
  8. package/dist/geometry.js +25 -0
  9. package/dist/index.d.ts +10 -0
  10. package/dist/index.js +9 -0
  11. package/dist/ionic.d.ts +16 -0
  12. package/dist/ionic.js +31 -0
  13. package/dist/native-bootstrap.d.ts +8 -0
  14. package/dist/native-bootstrap.js +138 -0
  15. package/dist/native-session.d.ts +48 -0
  16. package/dist/native-session.js +519 -0
  17. package/dist/overlays.d.ts +42 -0
  18. package/dist/overlays.js +181 -0
  19. package/dist/projection-events.d.ts +12 -0
  20. package/dist/projection-events.js +44 -0
  21. package/dist/projection-placement.d.ts +14 -0
  22. package/dist/projection-placement.js +18 -0
  23. package/dist/runtime.d.ts +2 -0
  24. package/dist/runtime.js +11 -0
  25. package/dist/scroll-bindings.d.ts +11 -0
  26. package/dist/scroll-bindings.js +55 -0
  27. package/dist/scroll-placement.d.ts +5 -0
  28. package/dist/scroll-placement.js +85 -0
  29. package/dist/snapshot-styles.d.ts +2 -0
  30. package/dist/snapshot-styles.js +14 -0
  31. package/dist/types.d.ts +88 -0
  32. package/dist/types.js +1 -0
  33. package/dist/visibility.d.ts +6 -0
  34. package/dist/visibility.js +29 -0
  35. package/dist/web-projection-scope.d.ts +6 -0
  36. package/dist/web-projection-scope.js +8 -0
  37. package/dist/web-projection.d.ts +50 -0
  38. package/dist/web-projection.js +835 -0
  39. package/docs/cordova.md +125 -0
  40. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +16 -0
  41. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +256 -0
  42. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +15 -0
  43. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +311 -0
  44. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +14 -0
  45. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +27 -0
  46. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +117 -0
  47. package/package.json +64 -4
@@ -0,0 +1,125 @@
1
+ # Cordova plugin integration
2
+
3
+ The same npm package, Swift library and Android AAR support Capacitor and Cordova. Engine is a library used by plugin maintainers, not a separately registered Cordova plugin. A Cordova application does not need `@capacitor/core` or a Capacitor native SDK.
4
+
5
+ The consuming plugin still owns its native view and the corresponding DOM, including placement, size and stacking order. Supply an existing selector such as `native-ad` or `#player`. The DOM may be added after initialization. Image preparation, scroll handling, overlays and restoration use the common Engine implementation.
6
+
7
+ ## iOS
8
+
9
+ Use the same SwiftPM dependency described in the main README. In a Cordova SwiftPM plugin, declare both Cordova and Engine as dependencies of the plugin target; neither framework becomes a dependency of the Engine target.
10
+
11
+ ```swift
12
+ import Cordova
13
+ import WebKit
14
+ import WebViewNativeUIEngine
15
+
16
+ @objc(ExamplePlugin)
17
+ class ExamplePlugin: CDVPlugin {
18
+ private var projection: NativeUIProjection?
19
+ private var nativeView: UIView?
20
+
21
+ override func pluginInitialize() {
22
+ do {
23
+ projection = try NativeUIProjection(
24
+ webView: webView as? WKWebView,
25
+ plugin: "ExamplePlugin",
26
+ projectionSelector: "#player",
27
+ receiveListeners: .init(
28
+ update: ["contentChanged"],
29
+ projection: .init(native: ["shown"], web: ["covered"], none: ["hidden"])
30
+ ),
31
+ runtime: .cordova,
32
+ view: { [weak self] in self?.nativeView }
33
+ )
34
+ } catch {
35
+ // Keep the existing Web fallback when registration is unavailable.
36
+ NSLog("Native UI projection unavailable: %@", String(describing: error))
37
+ }
38
+ }
39
+
40
+ override func dispose() {
41
+ projection?.destroy()
42
+ projection = nil
43
+ super.dispose()
44
+ }
45
+ }
46
+ ```
47
+
48
+ Forward the plugin's existing presentation/content notifications with `projection?.receive("shown")`, `receive("hidden")` or `receive("contentChanged")`. Put this alongside the existing callback/event delivery, or in the plugin's shared notification method. `receive` updates the Engine session only: it does not deliver a second application callback. A matching destination emitted before JS startup is retained for the initial connection.
49
+
50
+ Cordova iOS's built-in `WkWebView.convertFilePath` serves the prepared PNGs. Engine resolves this internally. No file plugin, additional permission or author-provided URL converter is required. The default allowed origins include `app://localhost`; pass the actual trusted origin if the app changes its scheme or hostname.
51
+
52
+ ## Android
53
+
54
+ Declare the Maven dependency in the existing Cordova plugin Gradle configuration. Also declare `implementation 'androidx.webkit:webkit:1.14.0'` when using the `CordovaPluginPathHandler` signature; Cordova's internal WebKit dependency is not exposed on the plugin compile classpath. The plugin's `plugin.xml` must initialize it before the first WebView navigation:
55
+
56
+ ```xml
57
+ <feature name="ExamplePlugin">
58
+ <param name="android-package" value="com.example.ExamplePlugin" />
59
+ <param name="onload" value="true" />
60
+ </feature>
61
+ ```
62
+
63
+ ```kotlin
64
+ class ExamplePlugin : CordovaPlugin() {
65
+ private var projection: NativeUIProjection? = null
66
+ private var nativeView: View? = null
67
+
68
+ override fun pluginInitialize() {
69
+ val web = webView.view as WebView
70
+ projection = runCatching {
71
+ // One origin-restricted channel per WebView, shared with later registrations.
72
+ NativeUIProjection.install(web)
73
+ NativeUIProjection(
74
+ window = cordova.activity.window,
75
+ webView = web,
76
+ plugin = serviceName,
77
+ projectionSelector = "#player",
78
+ receiveListeners = NativeUIProjection.ReceiveListeners(
79
+ update = listOf("contentChanged"),
80
+ projection = NativeUIProjection.ReceiveListeners.Projection(
81
+ native = listOf("shown"), web = listOf("covered"), none = listOf("hidden")
82
+ )
83
+ ),
84
+ runtime = NativeUIProjection.Runtime.CORDOVA,
85
+ view = { nativeView }
86
+ )
87
+ }.onFailure {
88
+ android.util.Log.e(javaClass.simpleName, "Native UI projection unavailable", it)
89
+ }.getOrNull()
90
+ }
91
+
92
+ override fun getPathHandler() = CordovaPluginPathHandler { path ->
93
+ projection?.handleResource(path)
94
+ }
95
+
96
+ override fun onDestroy() {
97
+ projection?.close()
98
+ projection = null
99
+ super.onDestroy()
100
+ }
101
+ }
102
+ ```
103
+
104
+ Use `projection?.receive(eventName, data)` at the existing notification point, as on iOS. Native view creation and placement remain on the UI thread.
105
+
106
+ The `PathHandler` delegates same-origin image requests to Engine. It serves only the registration's PNG files in app-private cache storage; unrelated paths return `null`. If the plugin already has a handler, combine them with `projection?.handleResource(path) ?: existingHandler.handle(path)`. For multiple registrations, delegate to the matching owner. Keep the handler installed for the WebView lifetime. No WebViewClient replacement or storage permission is needed.
107
+
108
+ The defaults support Cordova's `https://localhost` and `http://localhost`. If the hostname changes, pass the same explicit origin set to `install` and each registration. Wildcard origins and `file://` app origins are not supported by this integration.
109
+
110
+ ## Lifetime and verification
111
+
112
+ Keep the registration for the WebView lifetime. Engine reconnects after a document reload and clears the old document's image state. If the plugin destroys its native UI in `onReset`, update its view provider and emit the appropriate destination; do not leave a registration pointing at a destroyed view. Dispose registrations when the plugin/WebView is destroyed.
113
+
114
+ Capacitor registrations retain their existing default. The Cordova integration requires the standard Cordova WebView resource handler. Running a Cordova plugin inside a Capacitor app is not covered by this integration; the host must use the Capacitor integration instead. Ionic overlay observation remains optional and shared through `enableSupportIonicOverlays`; plain Cordova apps need not enable it.
115
+
116
+ The integration fixture uses Cordova CLI 13, cordova-ios 8.1.1 and cordova-android 15.1.0 with their standard WebViews. It checks actual PNG handoff, native restoration and hidden-state retention across document reload, not merely JS bridge mocks. Older Cordova releases and custom WebView engines require separate verification.
117
+
118
+ Run the real consumer checks with an already booted simulator/emulator:
119
+
120
+ ```sh
121
+ npm run test:cordova -- ios SIMULATOR_UDID
122
+ npm run test:cordova -- android emulator-5554
123
+ ```
124
+
125
+ The script creates a disposable app, uses local SwiftPM/Maven Engine artifacts, and records the application result in the printed temporary directory. It never publishes to npm or Maven. Android requires JDK 21+, SDK 36 and `ANDROID_HOME`; iOS requires Xcode.
@@ -0,0 +1,16 @@
1
+ import UIKit
2
+ /// Keeps native content at full size while clipping hit testing and visible pixels.
3
+ public final class NativeUIClippedView: UIView {
4
+ public let content: UIView
5
+ public init(content: UIView) { self.content = content; super.init(frame: .zero); addSubview(content) }
6
+ @available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
7
+ public func place(_ frame: CGRect, clip: CGRect? = nil) {
8
+ clipsToBounds = clip != nil
9
+ let visible = clip.map { frame.intersection($0) } ?? frame
10
+ isHidden = visible.isNull || visible.isEmpty
11
+ guard !isHidden else { return }
12
+ if self.frame != visible { self.frame = visible }
13
+ let local = CGRect(x: frame.minX - visible.minX, y: frame.minY - visible.minY, width: frame.width, height: frame.height)
14
+ if content.frame != local { content.frame = local }
15
+ }
16
+ }
@@ -0,0 +1,256 @@
1
+ import UIKit
2
+
3
+ /// Immutable, locally served images of controls that will be covered by a Web overlay.
4
+ /// Native interaction invalidates in-flight captures before Web receives the activation.
5
+ public final class NativeUICoveredSnapshots {
6
+ private struct Part {
7
+ let ids: [String]
8
+ let frame: CGRect
9
+ let image: UIImage
10
+ let file: URL
11
+ }
12
+ private struct Capture {
13
+ let size: CGSize
14
+ let webFrame: CGRect
15
+ let parts: [Part]
16
+ }
17
+ private let snapshotView: (UIWindow) -> UIView
18
+ public convenience init() {
19
+ self.init(snapshotView: { $0.screen.snapshotView(afterScreenUpdates: false) })
20
+ }
21
+ init(snapshotView: @escaping (UIWindow) -> UIView) {
22
+ self.snapshotView = snapshotView
23
+ }
24
+ private var captures: [String: Capture] = [:]
25
+ private var restoration: (token: String, views: [UIImageView], entry: Bool)?
26
+ private var hiddenControls: (token: String, restore: [() -> Void])?
27
+ private var generation = 0
28
+ private var touching = false
29
+ private weak var window: UIWindow?
30
+ private var observer: SnapshotTouchObserver?
31
+ private let directory = FileManager.default.temporaryDirectory.appendingPathComponent("native-ui-snapshots-\(UUID().uuidString)")
32
+ public private(set) var coveringToken: String?
33
+ public var covering: Bool { coveringToken != nil }
34
+
35
+ public func observe(_ window: UIWindow, changed: @escaping (Bool) -> Void) {
36
+ guard self.window !== window else { return }
37
+ if let observer { self.window?.removeGestureRecognizer(observer) }
38
+ self.window = window
39
+ let observer = SnapshotTouchObserver { [weak self] active in
40
+ guard let self else { return }
41
+ self.touching = active
42
+ if active { self.generation += 1 }
43
+ changed(active)
44
+ }
45
+ window.addGestureRecognizer(observer)
46
+ self.observer = observer
47
+ }
48
+
49
+ // Gesture release can keep changing SwiftUI button bounds after touchesEnded.
50
+ // Wait off the presentation path for unchanged rendered geometry, not a guessed delay.
51
+ public func prepare(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat,
52
+ complete: @escaping ([String: Any]) -> Void) {
53
+ let stamp = generation
54
+ let start = CACurrentMediaTime()
55
+ func sample(_ previous: [String: CGRect], _ stable: Int) {
56
+ guard generation == stamp, !touching, CACurrentMediaTime() - start < 1 else { complete([:]); return }
57
+ let current = frames()
58
+ let count = !current.isEmpty && current == previous ? stable + 1 : 0
59
+ if count >= 3 {
60
+ capture(frames: current, in: webView, viewportWidth: viewportWidth, complete: complete)
61
+ } else {
62
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / 60.0) { sample(current, count) }
63
+ }
64
+ }
65
+ sample([:], 0)
66
+ }
67
+
68
+ private func capture(frames: [String: CGRect], in webView: UIView, viewportWidth: CGFloat,
69
+ complete: @escaping ([String: Any]) -> Void) {
70
+ guard !touching, let window = webView.window, !frames.isEmpty,
71
+ viewportWidth > 0, webView.bounds.width > 0 else { complete([:]); return }
72
+ let stamp = generation
73
+ let scale = window.screen.scale
74
+ let ratio = viewportWidth / webView.bounds.width
75
+ let size = window.bounds.size
76
+ let webFrame = webView.convert(webView.bounds, to: window)
77
+ // Each crop belongs to one source component. Overlapping crops share
78
+ // the same captured pixels and never combine unrelated components.
79
+ var regions: [(ids: [String], rect: CGRect)] = []
80
+ for (id, frame) in frames.sorted(by: { $0.key < $1.key }) {
81
+ var rect = webView.convert(frame, to: window).insetBy(dx: -12, dy: -12).intersection(window.bounds)
82
+ guard !rect.isNull, rect.width > 0, rect.height > 0 else { complete([:]); return }
83
+ rect = CGRect(x: floor(rect.minX * scale) / scale, y: floor(rect.minY * scale) / scale,
84
+ width: (ceil(rect.maxX * scale) - floor(rect.minX * scale)) / scale,
85
+ height: (ceil(rect.maxY * scale) - floor(rect.minY * scale)) / scale)
86
+ regions.append(([id], rect))
87
+ }
88
+ let bounds = regions.reduce(CGRect.null) { $0.union($1.rect) }
89
+ let format = UIGraphicsImageRendererFormat()
90
+ format.scale = scale
91
+ format.opaque = true
92
+ // Render the compositor's frozen screen, not UIKit's reconstruction of
93
+ // the hierarchy: the latter loses Liquid Glass background refraction.
94
+ // The replica remains detached and never changes visible UI or input.
95
+ let replica = snapshotView(window)
96
+ let screenBounds = window.convert(bounds, to: window.screen.coordinateSpace)
97
+ var drawn = false
98
+ let image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { context in
99
+ context.cgContext.translateBy(x: -screenBounds.minX, y: -screenBounds.minY)
100
+ drawn = replica.drawHierarchy(in: window.screen.bounds, afterScreenUpdates: true)
101
+ }
102
+ guard drawn, let pixels = image.cgImage else { complete([:]); return }
103
+ let token = UUID().uuidString
104
+ let directory = self.directory
105
+ let crops = regions.compactMap { region -> (ids: [String], frame: CGRect, image: UIImage)? in
106
+ let crop = CGRect(x: (region.rect.minX - bounds.minX) * scale, y: (region.rect.minY - bounds.minY) * scale,
107
+ width: region.rect.width * scale, height: region.rect.height * scale)
108
+ guard let part = pixels.cropping(to: crop) else { return nil }
109
+ return (region.ids, window.convert(region.rect, to: webView), UIImage(cgImage: part, scale: scale, orientation: .up))
110
+ }
111
+ guard crops.count == regions.count else { complete([:]); return }
112
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
113
+ var parts: [Part] = []
114
+ do {
115
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
116
+ for (index, crop) in crops.enumerated() {
117
+ guard let png = crop.image.pngData() else { throw CocoaError(.fileWriteUnknown) }
118
+ let file = directory.appendingPathComponent("\(token)-\(index).png")
119
+ try png.write(to: file, options: .atomic)
120
+ parts.append(Part(ids: crop.ids, frame: crop.frame, image: crop.image, file: file))
121
+ }
122
+ } catch {
123
+ parts.forEach { try? FileManager.default.removeItem(at: $0.file) }
124
+ parts = []
125
+ }
126
+ DispatchQueue.main.async {
127
+ guard let self, self.generation == stamp, !self.touching,
128
+ window.bounds.size == size, webView.convert(webView.bounds, to: window) == webFrame, parts.count == crops.count else {
129
+ parts.forEach { try? FileManager.default.removeItem(at: $0.file) }
130
+ complete([:]); return
131
+ }
132
+ self.captures[token] = Capture(size: size, webFrame: webFrame, parts: parts)
133
+ complete(["token": token, "parts": parts.map { part in
134
+ ["ids": part.ids, "url": part.file.absoluteString,
135
+ "x": part.frame.minX * ratio, "y": part.frame.minY * ratio,
136
+ "width": part.frame.width * ratio, "height": part.frame.height * ratio] as [String: Any]
137
+ }])
138
+ }
139
+ }
140
+ }
141
+
142
+ /// Install prepared pixels before hiding live views, within the same main-thread turn.
143
+ /// A transient (moving) surface does not replace background covering ownership.
144
+ @discardableResult public func cover(_ token: String, in webView: UIView, views: [UIView], transient: Bool = false) -> Bool {
145
+ guard restore(in: webView, token: token, entry: true) else { return false }
146
+ if !transient { coveringToken = token }
147
+ hide(views, for: token)
148
+ return true
149
+ }
150
+
151
+ public var hidesNativeControls: Bool { hiddenControls != nil }
152
+
153
+ private func hide(_ views: [UIView], for token: String) {
154
+ generation += 1
155
+ restoreControls()
156
+ hiddenControls = (token, views.map { view in
157
+ let hidden = view.isHidden
158
+ view.isHidden = true
159
+ return { [weak view] in view?.isHidden = hidden }
160
+ })
161
+ }
162
+
163
+ // Undo only the entry handoff's mutation; normal update owns subsequent visibility.
164
+ public func restoreControls() {
165
+ hiddenControls?.restore.forEach { $0() }
166
+ hiddenControls = nil
167
+ }
168
+
169
+ public func identifiers(for token: String) -> Set<String> { Set(captures[token]?.parts.flatMap { $0.ids } ?? []) }
170
+
171
+ // Bridge the entry or return with the already prepared pixels until Web retires the copy.
172
+ // No capture, encoding, or decoding runs here.
173
+ public func restore(in webView: UIView, token explicitToken: String? = nil, frames: [String: CGRect]? = nil, entry: Bool = false) -> Bool {
174
+ guard let token = explicitToken ?? coveringToken, let capture = captures[token], let window = webView.window,
175
+ window.bounds.size == capture.size, webView.convert(webView.bounds, to: window) == capture.webFrame else { return false }
176
+ if let frames, capture.parts.contains(where: { part in !part.ids.allSatisfy { frames[$0] != nil } }) { return false }
177
+ clearRestoration()
178
+ let views = capture.parts.map { part in
179
+ let view = UIImageView(image: part.image)
180
+ view.frame = webView.convert(frames?[part.ids[0]] ?? part.frame, to: window)
181
+ view.isUserInteractionEnabled = false
182
+ view.isAccessibilityElement = false
183
+ window.addSubview(view)
184
+ return view
185
+ }
186
+ restoration = (token, views, entry)
187
+ // A lost cleanup message must never leave an obsolete cover on live UI.
188
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
189
+ guard self?.restoration?.views.first === views.first else { return }
190
+ self?.clearRestoration()
191
+ }
192
+ return true
193
+ }
194
+
195
+ public func retireCopy(_ token: String, entry: Bool = true) {
196
+ // A late entry acknowledgement cannot remove the copy used on return.
197
+ guard restoration?.entry == entry, restoration?.token == token else { return }
198
+ clearRestoration()
199
+ }
200
+
201
+ private func clearRestoration() {
202
+ restoration?.views.forEach { $0.removeFromSuperview() }
203
+ restoration = nil
204
+ }
205
+
206
+ public func finishCovering() { coveringToken = nil }
207
+
208
+ public func release(_ token: String) {
209
+ if hiddenControls?.token == token { restoreControls() }
210
+ if coveringToken == token { coveringToken = nil }
211
+ if restoration?.token == token { clearRestoration() }
212
+ captures.removeValue(forKey: token)?.parts.forEach { try? FileManager.default.removeItem(at: $0.file) }
213
+ }
214
+
215
+ public func reset() {
216
+ restoreControls()
217
+ if let observer { window?.removeGestureRecognizer(observer) }
218
+ observer = nil
219
+ window = nil
220
+ touching = false
221
+ generation += 1
222
+ coveringToken = nil
223
+ Array(captures.keys).forEach(release)
224
+ }
225
+
226
+ deinit {
227
+ if let observer { window?.removeGestureRecognizer(observer) }
228
+ try? FileManager.default.removeItem(at: directory)
229
+ }
230
+ }
231
+
232
+ /// Observes the start of interaction without recognizing or delaying the app's gestures.
233
+ private final class SnapshotTouchObserver: UIGestureRecognizer {
234
+ private let changed: (Bool) -> Void
235
+ private var touches = Set<UITouch>()
236
+ init(changed: @escaping (Bool) -> Void) {
237
+ self.changed = changed
238
+ super.init(target: nil, action: nil)
239
+ cancelsTouchesInView = false
240
+ delaysTouchesBegan = false
241
+ delaysTouchesEnded = false
242
+ }
243
+ override func canPrevent(_ other: UIGestureRecognizer) -> Bool { false }
244
+ override func canBePrevented(by other: UIGestureRecognizer) -> Bool { false }
245
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
246
+ let first = self.touches.isEmpty
247
+ self.touches.formUnion(touches)
248
+ if first { changed(true) }
249
+ }
250
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) { finish(touches) }
251
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) { finish(touches) }
252
+ private func finish(_ touches: Set<UITouch>) {
253
+ self.touches.subtract(touches)
254
+ if self.touches.isEmpty { changed(false); state = .failed }
255
+ }
256
+ }
@@ -0,0 +1,15 @@
1
+ import CoreGraphics
2
+ public struct NativeUIFrame: Codable, Equatable {
3
+ public let x: Double
4
+ public let y: Double
5
+ public let width: Double
6
+ public let height: Double
7
+ public init(x: Double, y: Double, width: Double, height: Double) { self.x = x; self.y = y; self.width = width; self.height = height }
8
+ public var rect: CGRect { CGRect(x: x, y: y, width: width, height: height) }
9
+ public var isValid: Bool { [x, y, width, height].allSatisfy(\.isFinite) && width > 0 && height > 0 }
10
+ }
11
+ public struct NativeUIScrollNode: Decodable {
12
+ public let id: String
13
+ public let scroll: NativeUIScrollPlacement?
14
+ public init(id: String, scroll: NativeUIScrollPlacement?) { self.id = id; self.scroll = scroll }
15
+ }