@webview-native-ui/engine 0.0.0-stage → 0.0.2

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -2
  3. package/WebViewNativeUIEngine.podspec +17 -0
  4. package/dist/dom/geometry.d.ts +2 -0
  5. package/dist/dom/geometry.js +25 -0
  6. package/dist/dom/overlays.d.ts +42 -0
  7. package/dist/dom/overlays.js +181 -0
  8. package/dist/dom/scroll-bindings.d.ts +11 -0
  9. package/dist/dom/scroll-bindings.js +55 -0
  10. package/dist/dom/scroll-placement.d.ts +5 -0
  11. package/dist/dom/scroll-placement.js +85 -0
  12. package/dist/dom/visibility.d.ts +6 -0
  13. package/dist/dom/visibility.js +29 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.js +9 -0
  16. package/dist/ionic.d.ts +16 -0
  17. package/dist/ionic.js +31 -0
  18. package/dist/native/bootstrap.d.ts +8 -0
  19. package/dist/native/bootstrap.js +138 -0
  20. package/dist/native/bridge.d.ts +4 -0
  21. package/dist/native/bridge.js +15 -0
  22. package/dist/native/resource-url.d.ts +2 -0
  23. package/dist/native/resource-url.js +11 -0
  24. package/dist/native/session.d.ts +49 -0
  25. package/dist/native/session.js +520 -0
  26. package/dist/projection/events.d.ts +12 -0
  27. package/dist/projection/events.js +44 -0
  28. package/dist/projection/placement.d.ts +14 -0
  29. package/dist/projection/placement.js +18 -0
  30. package/dist/projection/scope.d.ts +6 -0
  31. package/dist/projection/scope.js +8 -0
  32. package/dist/projection/styles.d.ts +2 -0
  33. package/dist/projection/styles.js +14 -0
  34. package/dist/projection/web.d.ts +52 -0
  35. package/dist/projection/web.js +838 -0
  36. package/dist/types.d.ts +88 -0
  37. package/dist/types.js +1 -0
  38. package/docs/assets/hero.png +0 -0
  39. package/docs/capacitor-plugin.md +279 -0
  40. package/docs/cordova-plugin.md +198 -0
  41. package/docs/cordova.md +3 -0
  42. package/docs/maintenance.md +52 -0
  43. package/docs/projection.md +340 -0
  44. package/ios/Sources/WebViewNativeUIEngine/ClippedView.swift +16 -0
  45. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +256 -0
  46. package/ios/Sources/WebViewNativeUIEngine/Frame.swift +15 -0
  47. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +314 -0
  48. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +14 -0
  49. package/ios/Sources/WebViewNativeUIEngine/ScrollBindings.swift +27 -0
  50. package/ios/Sources/WebViewNativeUIEngine/ScrollTracker.swift +117 -0
  51. package/package.json +63 -4
@@ -0,0 +1,314 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// One fixed DOM fallback and its native view. The plugin retains this registration.
5
+ /// Existing notifyListeners events drive projection; no public plugin method is intercepted.
6
+ public final class NativeUIProjection: NSObject, WKScriptMessageHandler {
7
+ public enum Runtime: String { case capacitor, cordova }
8
+ public enum FailedFallback: String { case web, native }
9
+
10
+ public struct ReceiveListeners {
11
+ public struct Projection {
12
+ public let native: [String]
13
+ public let web: [String]
14
+ public let none: [String]
15
+ public init(native: [String] = [], web: [String] = [], none: [String] = []) {
16
+ self.native = native; self.web = web; self.none = none
17
+ }
18
+ }
19
+ public let update: [String]
20
+ public let projection: Projection
21
+ public let match: [String: Any]
22
+ public init(update: [String] = [], projection: Projection = .init(), match: [String: Any] = [:]) {
23
+ self.update = update; self.projection = projection; self.match = match
24
+ }
25
+ fileprivate var configuration: [String: Any] {
26
+ ["update": update, "projection": ["native": projection.native, "web": projection.web, "none": projection.none], "match": match]
27
+ }
28
+ }
29
+ private weak var webView: WKWebView?
30
+ private let views: () -> [String: UIView]
31
+ private let snapshots: NativeUICoveredSnapshots
32
+ private let scrolling = NativeUIScrollBindings()
33
+ private var masks: [String: CAShapeLayer] = [:]
34
+ private let id = "nativeUI" + UUID().uuidString.replacingOccurrences(of: "-", with: "")
35
+ private let origins: Set<String>
36
+ private let allowedOriginsJSON: String
37
+ private let canCapture: () -> Bool
38
+ private let runtime: Runtime
39
+ private let receiveListeners: ReceiveListeners
40
+ private let notificationLock = NSLock()
41
+ private var notificationDestination: String?
42
+ private var recordedDestination: String? {
43
+ notificationLock.lock(); defer { notificationLock.unlock() }
44
+ return notificationDestination
45
+ }
46
+ private var script: WKUserScript?
47
+ private var documentID: String?
48
+ private var destination = "native"
49
+ private var connected = false
50
+ private var restoreAccepted = true
51
+ private var hiddenByProjection: [UIView] = []
52
+ private var visible = true
53
+ private var closed = false
54
+ private struct Anchor { let native: CGRect; let image: CGRect }
55
+ private var anchors: [String: [String: Anchor]] = [:]
56
+ private var captured: [String: [String: UIView]] = [:]
57
+
58
+ public convenience init(webView: WKWebView?, plugin: String, projectionSelector: String,
59
+ receiveListeners: ReceiveListeners = .init(),
60
+ allowedOrigins: Set<String> = ["capacitor://localhost", "app://localhost", "https://localhost", "http://localhost"],
61
+ failedFallback: FailedFallback = .web, runtime: Runtime = .capacitor,
62
+ captureDelayMs: Double = 50,
63
+ canCapture: @escaping () -> Bool = { true }, view: @escaping () -> UIView?) throws {
64
+ try self.init(webView: webView, plugin: plugin, projectionSelector: projectionSelector,
65
+ receiveListeners: receiveListeners, allowedOrigins: allowedOrigins, failedFallback: failedFallback, runtime: runtime,
66
+ captureDelayMs: captureDelayMs, canCapture: canCapture, snapshots: NativeUICoveredSnapshots(), view: view)
67
+ }
68
+ init(webView: WKWebView?, plugin: String, projectionSelector: String,
69
+ receiveListeners: ReceiveListeners = .init(),
70
+ allowedOrigins: Set<String> = ["capacitor://localhost", "app://localhost", "https://localhost", "http://localhost"],
71
+ failedFallback: FailedFallback = .web, runtime: Runtime = .capacitor,
72
+ captureDelayMs: Double = 50,
73
+ canCapture: @escaping () -> Bool = { true }, snapshots: NativeUICoveredSnapshots,
74
+ view: @escaping () -> UIView?) throws {
75
+ self.allowedOriginsJSON = String(data: try JSONSerialization.data(withJSONObject: Array(allowedOrigins)), encoding: .utf8)!
76
+ self.runtime = runtime
77
+ self.snapshots = snapshots
78
+ self.receiveListeners = receiveListeners
79
+ guard let webView else { throw ProjectionError.missingWebView }
80
+ self.webView = webView; self.views = { view().map { ["view": $0] } ?? [:] }; self.origins = allowedOrigins; self.canCapture = canCapture
81
+ super.init()
82
+ precondition(Thread.isMainThread)
83
+ guard captureDelayMs.isFinite, captureDelayMs >= 0, captureDelayMs <= 2147483647,
84
+ !projectionSelector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ProjectionError.invalidConfiguration }
85
+ let config: [String: Any] = ["id": id, "plugin": plugin, "projectionSelector": projectionSelector,
86
+ "captureDelayMs": captureDelayMs, "receiveListeners": receiveListeners.configuration, "failedFallback": failedFallback.rawValue, "runtime": runtime.rawValue]
87
+ let json = String(data: try JSONSerialization.data(withJSONObject: config), encoding: .utf8)!
88
+ let allowed = allowedOriginsJSON
89
+ let runtime = try Self.runtime()
90
+ let source = "if (!globalThis.WebViewNativeUIEngineRuntime) { \(runtime) } WebViewNativeUIEngineRuntime.start(\(json)).catch(console.error);"
91
+ let script = WKUserScript(source: "if (\(allowed).includes(location.origin)) { \(source) }", injectionTime: .atDocumentEnd, forMainFrameOnly: true)
92
+ self.script = script
93
+ webView.configuration.userContentController.add(WeakProjectionHandler(self), name: id)
94
+ webView.configuration.userContentController.addUserScript(script)
95
+ evaluate(source)
96
+ }
97
+ /// Observe the plugin's existing notification before forwarding it to Capacitor.
98
+ /// Cordova also forwards notifications to the Engine session, never to application listeners.
99
+ public func receive(_ eventName: String, data: [String: Any]? = nil) {
100
+ guard receiveListeners.match.allSatisfy({ key, expected in
101
+ guard let actual = data?[key] else { return false }
102
+ if let a = actual as? NSNumber, let b = expected as? NSNumber {
103
+ return CFGetTypeID(a) == CFGetTypeID(b) && a == b
104
+ }
105
+ return (actual as? String).map { $0 == expected as? String } ?? false
106
+ }) else { return }
107
+ let routes = receiveListeners.projection
108
+ let matches = [("native", routes.native), ("web", routes.web), ("none", routes.none)]
109
+ .filter { $0.1.contains(eventName) }
110
+ if matches.count == 1 {
111
+ notificationLock.lock()
112
+ notificationDestination = matches[0].0
113
+ notificationLock.unlock()
114
+ }
115
+ guard runtime == .cordova, !matches.isEmpty || receiveListeners.update.contains(eventName),
116
+ let json = try? JSONSerialization.data(withJSONObject: [id, eventName, data ?? [:]]),
117
+ let args = String(data: json, encoding: .utf8) else { return }
118
+ DispatchQueue.main.async { [weak self] in
119
+ guard let self, !self.closed else { return }
120
+ self.evaluate("globalThis.WebViewNativeUIEngineRuntime?.notify(...\(args))")
121
+ }
122
+ }
123
+ // Check in the executing document: a native URL check can race navigation.
124
+ private func evaluate(_ source: String) {
125
+ webView?.evaluateJavaScript("if (\(allowedOriginsJSON).includes(location.origin)) { \(source) }", completionHandler: nil)
126
+ }
127
+ private enum ProjectionError: Error { case missingWebView, invalidConfiguration, missingRuntime }
128
+ private static func runtime() throws -> String {
129
+ #if SWIFT_PACKAGE
130
+ let bundle = Bundle.module
131
+ #else
132
+ let containing = Bundle(for: NativeUIProjection.self)
133
+ let bundle = containing.url(forResource: "WebViewNativeUIEngine", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? containing
134
+ #endif
135
+ guard let url = bundle.url(forResource: "native-ui-engine", withExtension: "js") else { throw ProjectionError.missingRuntime }
136
+ return try String(contentsOf: url, encoding: .utf8)
137
+ }
138
+ public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
139
+ let origin = message.frameInfo.securityOrigin
140
+ let defaultPort = origin.port == 0 || (origin.protocol == "https" && origin.port == 443) || (origin.protocol == "http" && origin.port == 80)
141
+ guard origins.contains("\(origin.protocol)://\(origin.host)" + (defaultPort ? "" : ":\(origin.port)")),
142
+ !closed, message.frameInfo.isMainFrame, let webView,
143
+ let text = message.body as? String, let data = text.data(using: .utf8),
144
+ let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
145
+ let key = message["key"] as? String, key.hasPrefix(id + ":"),
146
+ let request = message["request"] as? [String: Any] else { return }
147
+ let page = key.split(separator: ":").dropFirst().first.map(String.init)
148
+ if documentID != page { snapshots.reset(); scrolling.reset(); captured.removeAll(); anchors.removeAll(); documentID = page }
149
+ let complete: ([String: Any]) -> Void = { [weak self] value in
150
+ guard let self, !self.closed, self.documentID == page else { return }
151
+ self.reply(key, value)
152
+ }
153
+ let token = request["token"] as? String ?? ""
154
+ switch request["action"] as? String {
155
+ case "connect":
156
+ if !connected { destination = (views().values.contains(where: { !$0.isHidden }) ? "native" : "none"); connected = true }
157
+ complete(["destination": recordedDestination ?? destination])
158
+ case "destination":
159
+ if let next = request["destination"] as? String, ["native", "web", "none"].contains(next) { destination = next; connected = true }
160
+ complete([:])
161
+ case "track":
162
+ scrolling.reset()
163
+ for (id, mask) in masks { if views()[id]?.layer.mask === mask { views()[id]?.layer.mask = nil } }
164
+ masks.removeAll()
165
+ guard let raw = request["scroll"] as? [String: Any], let width = request["viewportWidth"] as? Double, width.isFinite, width > 0,
166
+ let data = try? JSONSerialization.data(withJSONObject: raw),
167
+ let placement = try? JSONDecoder().decode(NativeUIScrollPlacement.self, from: data), placement.isValid else {
168
+ complete(["tracked": false]); return
169
+ }
170
+ let scale = webView.bounds.width / width
171
+ let current = views()
172
+ guard !current.isEmpty, current.values.allSatisfy({ $0.layer.mask == nil }) else { complete(["tracked": false]); return }
173
+ let nodes = current.map { id, view -> NativeUIScrollNode in
174
+ let frame = view.convert(view.bounds, to: webView)
175
+ return NativeUIScrollNode(id: id, scroll: NativeUIScrollPlacement(id: placement.id,
176
+ offsetX: placement.offsetX, offsetY: placement.offsetY, viewport: placement.viewport, clip: placement.clip,
177
+ contentWidth: placement.contentWidth, contentHeight: placement.contentHeight,
178
+ frame: NativeUIFrame(x: frame.minX/scale-placement.viewport.x+placement.offsetX,
179
+ y: frame.minY/scale-placement.viewport.y+placement.offsetY, width: frame.width/scale, height: frame.height/scale)))
180
+ }
181
+ let rejected = scrolling.replace(nodes, in: webView, scale: scale) { [weak self, weak webView] id, frame, clip in
182
+ guard let self, let webView, self.visible, !self.snapshots.hidesNativeControls,
183
+ let view = self.views()[id], current[id] === view, let parent = view.superview else { return }
184
+ let old = view.convert(view.bounds, to: webView)
185
+ let from = parent.convert(CGPoint(x: old.midX, y: old.midY), from: webView)
186
+ let to = parent.convert(CGPoint(x: frame.midX, y: frame.midY), from: webView)
187
+ view.transform.tx += to.x-from.x; view.transform.ty += to.y-from.y
188
+ let mask = self.masks[id] ?? CAShapeLayer()
189
+ self.masks[id] = mask
190
+ mask.frame = view.bounds
191
+ mask.path = UIBezierPath(rect: clip.isNull ? .zero : view.convert(clip, from: webView).intersection(view.bounds)).cgPath
192
+ view.layer.mask = mask
193
+ }
194
+ complete(["tracked": rejected.isEmpty])
195
+ case "prepare":
196
+ guard visible, canCapture(), let ids = request["ids"] as? [String], let width = request["viewportWidth"] as? Double, width.isFinite, width > 0,
197
+ let window = webView.window else { complete([:]); return }
198
+ let selected = views().filter { ids.contains($0.key) }
199
+ guard selected.count == ids.count else { complete([:]); return }
200
+ snapshots.observe(window) { [weak self] active in self?.reply(nil, ["active": active]) }
201
+ snapshots.prepare(frames: { [weak self, weak webView] in
202
+ guard let self, let webView, self.visible, self.canCapture() else { return [:] }
203
+ let current = self.views()
204
+ guard selected.allSatisfy({ current[$0.key] === $0.value && !$0.value.isHidden && $0.value.window != nil }) else { return [:] }
205
+ return selected.mapValues { $0.convert($0.bounds, to: webView) }
206
+ }, in: webView, viewportWidth: width) { [weak self] result in
207
+ if let self, let token = result["token"] as? String, let parts = result["parts"] as? [[String: Any]] {
208
+ self.captured[token] = selected
209
+ let scale = webView.bounds.width / width
210
+ var entries: [String: Anchor] = [:]
211
+ for part in parts {
212
+ guard let ids = part["ids"] as? [String], let x = part["x"] as? CGFloat, let y = part["y"] as? CGFloat,
213
+ let w = part["width"] as? CGFloat, let h = part["height"] as? CGFloat else { continue }
214
+ let image = CGRect(x: x*scale, y: y*scale, width: w*scale, height: h*scale)
215
+ for id in ids { if let view = selected[id] { entries[id] = Anchor(native: view.convert(view.bounds, to: webView), image: image) } }
216
+ }
217
+ self.anchors[token] = entries
218
+ }
219
+ complete(result)
220
+ }
221
+ case "cover":
222
+ let selected = captured[token] ?? [:], current = views()
223
+ let valid = visible && canCapture() && !selected.isEmpty && selected.allSatisfy { current[$0.key] === $0.value }
224
+ restoreAccepted = true
225
+ complete(["shown": valid && snapshots.cover(token, in: webView, views: Array(Dictionary(grouping: selected.values, by: { ObjectIdentifier($0) }).values.compactMap { $0.first }), transient: true)])
226
+ case "restore":
227
+ restoreAccepted = false
228
+ guard let width = request["viewportWidth"] as? Double, width.isFinite, width > 0,
229
+ let parts = request["parts"] as? [[String: Any]] else { complete(["shown": false]); return }
230
+ var frames: [String: CGRect] = [:]
231
+ let scale = webView.bounds.width / width
232
+ for part in parts {
233
+ guard let ids = part["ids"] as? [String], let x = part["x"] as? Double, let y = part["y"] as? Double,
234
+ let w = part["width"] as? Double, let h = part["height"] as? Double,
235
+ [x,y,w,h].allSatisfy({ $0.isFinite }), w > 0, h > 0 else { complete(["shown": false]); return }
236
+ for id in ids { frames[id] = CGRect(x: x*scale, y: y*scale, width: w*scale, height: h*scale) }
237
+ }
238
+ guard Set(frames.keys) == snapshots.identifiers(for: token), !frames.isEmpty else { complete(["shown": false]); return }
239
+ let current = views()
240
+ // Preserve renderer-owned constraints. Translation follows the corresponding DOM's movement.
241
+ for (id, frame) in frames {
242
+ guard let view = current[id], captured[token]?[id] === view, let anchor = anchors[token]?[id] else { complete(["shown": false]); return }
243
+ let old = view.convert(view.bounds, to: webView)
244
+ guard abs(old.width-anchor.native.width) < 0.1, abs(old.height-anchor.native.height) < 0.1,
245
+ abs(frame.width-anchor.image.width) < 0.1, abs(frame.height-anchor.image.height) < 0.1 else { complete(["shown": false]); return }
246
+ }
247
+ for (id, frame) in frames {
248
+ guard let view = current[id], let parent = view.superview, let anchor = anchors[token]?[id] else { continue }
249
+ let old = view.convert(view.bounds, to: webView)
250
+ let from = parent.convert(CGPoint(x: old.midX, y: old.midY), from: webView)
251
+ let to = parent.convert(CGPoint(x: anchor.native.midX+frame.minX-anchor.image.minX,
252
+ y: anchor.native.midY+frame.minY-anchor.image.minY), from: webView)
253
+ view.transform.tx += to.x-from.x; view.transform.ty += to.y-from.y
254
+ }
255
+ restoreAccepted = snapshots.restore(in: webView, token: token, frames: frames)
256
+ complete(["shown": restoreAccepted])
257
+ case "resume":
258
+ guard restoreAccepted else { reply(key, [:], error: "Native UI geometry changed"); return }
259
+ snapshots.restoreControls(); snapshots.finishCovering()
260
+ webView.window?.layoutIfNeeded()
261
+ CATransaction.begin()
262
+ CATransaction.setCompletionBlock { complete([:]) }
263
+ views().values.forEach { $0.setNeedsDisplay() }
264
+ CATransaction.commit()
265
+ case "visibility":
266
+ visible = request["visible"] as? Bool ?? false
267
+ snapshots.restoreControls()
268
+ if visible {
269
+ hiddenByProjection.forEach { $0.isHidden = false }; hiddenByProjection.removeAll()
270
+ } else {
271
+ for view in views().values where !view.isHidden {
272
+ hiddenByProjection.append(view); view.isHidden = true
273
+ }
274
+ }
275
+ complete([:])
276
+ case "painted": snapshots.retireCopy(token); complete([:])
277
+ case "restored": snapshots.retireCopy(token, entry: false); complete([:])
278
+ case "release": snapshots.release(token); captured.removeValue(forKey: token); anchors.removeValue(forKey: token); complete([:])
279
+ case "reset": snapshots.reset(); captured.removeAll(); anchors.removeAll(); complete([:])
280
+ default: complete([:])
281
+ }
282
+ }
283
+ private func reply(_ key: String?, _ value: [String: Any], error: String? = nil) {
284
+ guard !closed else { return }
285
+ let values: [Any] = [id, key as Any? ?? NSNull(), value, error as Any? ?? NSNull()]
286
+ guard let data = try? JSONSerialization.data(withJSONObject: values), let json = String(data: data, encoding: .utf8) else { return }
287
+ evaluate("globalThis.WebViewNativeUIEngineRuntime?.receive(...\(json))")
288
+ }
289
+ /// Call with the plugin's teardown. Does not destroy the renderer's views or DOM.
290
+ deinit { destroy() }
291
+ public func destroy() {
292
+ guard !closed else { return }
293
+ closed = true
294
+ evaluate("globalThis.WebViewNativeUIEngineRuntime?.stop('\(id)')")
295
+ if let controller = webView?.configuration.userContentController {
296
+ controller.removeScriptMessageHandler(forName: id)
297
+ let remaining = controller.userScripts.filter { $0 !== script }
298
+ controller.removeAllUserScripts()
299
+ remaining.forEach { controller.addUserScript($0) }
300
+ }
301
+ snapshots.reset(); scrolling.reset(); captured.removeAll(); anchors.removeAll()
302
+ if destination != "none" { hiddenByProjection.forEach { $0.isHidden = false } }
303
+ hiddenByProjection.removeAll()
304
+ for (id, mask) in masks { if views()[id]?.layer.mask === mask { views()[id]?.layer.mask = nil } }
305
+ masks.removeAll()
306
+ }
307
+ }
308
+ private final class WeakProjectionHandler: NSObject, WKScriptMessageHandler {
309
+ weak var owner: NativeUIProjection?
310
+ init(_ owner: NativeUIProjection) { self.owner = owner }
311
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
312
+ owner?.userContentController(controller, didReceive: message)
313
+ }
314
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";var WebViewNativeUIEngineRuntime=(()=>{var ze=Object.defineProperty;var it=Object.getOwnPropertyDescriptor;var st=Object.getOwnPropertyNames;var at=Object.prototype.hasOwnProperty;var ct=(e,o)=>{for(var n in o)ze(e,n,{get:o[n],enumerable:!0})},lt=(e,o,n,f)=>{if(o&&typeof o=="object"||typeof o=="function")for(let v of st(o))!at.call(e,v)&&v!==n&&ze(e,v,{get:()=>o[v],enumerable:!(f=it(o,v))||f.enumerable});return e};var dt=e=>lt(ze({},"__esModule",{value:!0}),e);var jt={};ct(jt,{hide:()=>Lt,notify:()=>wt,receive:()=>Pt,show:()=>Tt,start:()=>St,stop:()=>Mt});var ut=Symbol.for("webview-native-ui-engine.projection-placement");var $e=e=>e?.[ut];var pt=(e,o)=>{if(e.matches("ion-toast, ion-menu"))return!1;let n=e;if(n.showBackdrop===!1)return!1;if(!e.matches("ion-modal"))return!0;let f=o??n.initialBreakpoint;return f===void 0||f>(n.backdropBreakpoint??0)},ft=Symbol.for("@webview-native-ui/engine/overlays/v1"),mt=e=>{let o=new Set,n=new Set,f=new Set,v=new WeakMap,c=new WeakSet,l=new Map,w=[],u={},S=new Map,j,M=m=>{for(let p of[...f])try{p(m)}catch(i){console.error("Native UI overlay listener failed",i)}},O=(m,p,i)=>{p==="willPresent"||p==="didPresent"?(c.delete(m),o.add(m)):p==="didDismiss"&&(c.add(m),o.delete(m),n.delete(m),v.delete(m));let T=i?.detail?.breakpoint;typeof T=="number"&&T>0&&v.set(m,T),M({element:m,phase:p,event:i})},R=(m=!1)=>{let p=!1;for(let i of o)(!i.isConnected||i.ownerDocument!==e||!w.some(({selector:T})=>i.matches(T)))&&(o.delete(i),n.delete(i),p=!0);for(let i of e.querySelectorAll(w.map(({selector:T})=>T).join(",")))!o.has(i)&&!c.has(i)&&(m||!i.matches("ion-popover"))&&(i.presented||i.classList.contains("show-menu"))&&(o.add(i),p=!0);p&&M({phase:"change"})};return{get active(){return o},subscribe(m){return f.add(m),()=>{f.delete(m)}},setNative(m,p){n.has(m)!==p&&(p?n.add(m):n.delete(m),M({element:m,phase:"change"}))},isExcluded(m){return!!u.exclude&&m.matches(u.exclude)},isNative(m){return n.has(m)},get stack(){let m=[...o].filter(i=>i.isConnected&&i.ownerDocument===e),p=i=>{let T=Number.parseFloat(e.defaultView.getComputedStyle(i).zIndex);return Number.isFinite(T)?T:i.overlayIndex??0};return m.sort((i,T)=>p(i)-p(T)),m},isCovered(m){let p=this.stack,i=p.reduce((T,N,d)=>N.contains(m)?d:T,-1);return p.some((T,N)=>{if(n.has(T)||u.exclude&&T.matches(u.exclude))return!1;let d=u.covers?.(T,m);return d!==void 0?d:N>i&&!T.contains(m)&&(T.matches("ion-menu")||pt(T,v.get(T)))})},enable(m,p){let i={...u,...p},T=[...m,...i.additional??[]];i.exclude&&e.querySelector(i.exclude);let N=new Map;for(let W of T){e.querySelector(W.selector);for(let[$,A]of Object.entries(W.events)){let F=N.get(A);if(F&&F.phase!==$)throw new TypeError(`Overlay event "${A}" has conflicting phases`);N.set(A,{phase:$,selector:F?`${F.selector}, ${W.selector}`:W.selector})}}u=i,w=T,S=N;for(let W of S.keys()){if(l.has(W))continue;let $=A=>{let F=S.get(W),C=A.composedPath().find(X=>X instanceof e.defaultView.HTMLElement&&X.matches(F.selector));C&&O(C,F.phase,A)};e.addEventListener(W,$,!0),l.set(W,()=>e.removeEventListener(W,$,!0))}for(let[W,$]of l)S.has(W)||($(),l.delete(W));let d=!j;j||(j=new MutationObserver(()=>R()),j.observe(e.documentElement,{childList:!0,subtree:!0}),e.addEventListener("nativeUIOverlayAdopted",W=>{let $=W.detail;$?.ownerDocument===e&&O($,"willPresent",W)})),R(d),(d||p)&&M({phase:"change"})}}},_e=e=>{let o=e;return o[ft]??=mt(e)};function*ae(e){let o=e;for(;o;){let n=o.getRootNode();o=o.assignedSlot??o.parentElement??(n instanceof ShadowRoot?n.host:null),o&&(yield o)}}var vt=({x:e,y:o,width:n,height:f})=>({x:e,y:o,width:n,height:f}),Ke=e=>{let o=e.getBoundingClientRect(),n=getComputedStyle(e),f=parseFloat(n.borderLeftWidth)||0,v=parseFloat(n.borderTopWidth)||0;return{x:o.left+f,y:o.top+v,width:o.width-f-(parseFloat(n.borderRightWidth)||0),height:o.height-v-(parseFloat(n.borderBottomWidth)||0)}},Ve=(e,o,n)=>{let f=[e,...ae(e)],v=f.indexOf(o);if(v<=0||o===e.ownerDocument.scrollingElement)return;let c=e.ownerDocument.defaultView;if(c.visualViewport&&(c.visualViewport.scale!==1||c.visualViewport.offsetLeft||c.visualViewport.offsetTop))return;let l=0,w=0,u=c.innerWidth,S=c.innerHeight;for(let[R,m]of f.entries()){let p=getComputedStyle(m);if(p.transform!=="none"&&!new DOMMatrixReadOnly(p.transform).isIdentity||p.zoom&&p.zoom!=="normal"&&Number(p.zoom)!==1||[p.translate,p.rotate,p.scale,p.perspective].some(d=>d&&d!=="none")||p.writingMode&&p.writingMode!=="horizontal-tb"||R<v&&/^(fixed|sticky)$/.test(p.position))return;if(R===0)continue;if(p.scrollbarGutter&&p.scrollbarGutter!=="auto")return;let i=/^(auto|scroll|hidden|clip)$/.test(p.overflowX),T=/^(auto|scroll|hidden|clip)$/.test(p.overflowY);if(R<v&&(i||T))return;let N=m===e.ownerDocument.scrollingElement;if(R>v&&((i||N)&&m.scrollWidth>m.clientWidth||(T||N)&&m.scrollHeight>m.clientHeight)||(i||T)&&[p.borderTopLeftRadius,p.borderTopRightRadius,p.borderBottomLeftRadius,p.borderBottomRightRadius].some(d=>parseFloat(d)>0)||p.clipPath&&p.clipPath!=="none"||p.maskImage&&p.maskImage!=="none")return;if(R>=v){let d=Ke(m);i&&(l=Math.max(l,d.x),u=Math.min(u,d.x+d.width)),T&&(w=Math.max(w,d.y),S=Math.min(S,d.y+d.height))}}let j=getComputedStyle(o);if(!/^(auto|scroll)$/.test(j.overflowX)&&!/^(auto|scroll)$/.test(j.overflowY))return;let M=Ke(o);if(u<=l||S<=w||!M.width||!M.height)return;let O=vt(e.getBoundingClientRect());return{id:n(o),offsetX:o.scrollLeft,offsetY:o.scrollTop,viewport:M,clip:{x:l,y:w,width:u-l,height:S-w},contentWidth:o.scrollWidth,contentHeight:o.scrollHeight,frame:{...O,x:O.x-M.x+o.scrollLeft,y:O.y-M.y+o.scrollTop}}};var Ge=e=>{let o=globalThis;if(o.Capacitor?.convertFileSrc)return o.Capacitor.convertFileSrc(e);if(!e.startsWith("file:"))return e;if(o.WkWebView?.convertFilePath)return o.WkWebView.convertFilePath(e);throw new Error("Native UI file URL requires a host resource handler")};var ce=e=>new Promise((o,n)=>{let f=setTimeout(()=>n(new Error("Native UI bridge timed out")),5e3);e.then(o,n).finally(()=>clearTimeout(f))}),Ne=async e=>{try{return await ce(e)}catch(o){throw e.then(n=>n.remove()).catch(()=>{}),o}};var Ue=async(e,o)=>{let n=new Map;for(let w of e.update??[])n.set(w,{updated:!0});for(let w of["native","web","none"])for(let u of e.projection?.[w]??[]){let S=n.get(u)??{updated:!1};if(S.destination&&S.destination!==w)throw new TypeError(`Projection event "${u}" has conflicting destinations`);n.set(u,{...S,destination:w})}let f=!1,v=[],c=async()=>{f=!0;let u=(await Promise.allSettled(v.splice(0).map(S=>Promise.resolve().then(()=>S.remove())))).find(S=>S.status==="rejected");if(u?.status==="rejected")throw u.reason},l=[...n].map(async([w,u])=>{let S=await Ne(Promise.resolve().then(()=>e.addListener(w,j=>{!f&&(!e.accepts||e.accepts(j))&&o(u.destination,u.updated)})));f?await S.remove():v.push(S)});try{return await Promise.all(l),{remove:c}}catch(w){throw await c().catch(()=>{}),w}};var Je=`
2
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]),
3
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]) *,
4
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::after,
5
+ :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::part(native) { visibility: hidden !important; }
6
+ [data-native-ui-snapshot][data-native-ui-snapshot]::before {
7
+ all:initial!important;content:""!important;display:block!important;position:absolute!important;
8
+ visibility:visible!important;opacity:0!important;will-change:opacity!important;
9
+ pointer-events:none!important;image-rendering:pixelated!important;
10
+ }
11
+ [data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }
12
+ [data-native-ui-snapshot-visible][data-native-ui-snapshot][data-native-ui-snapshot]::before { opacity:1!important; }
13
+ `;var ht=(e,o=!1)=>JSON.stringify(e.map(({control:n})=>[n.id,n.kind,n.placement,o?0:n.x,o?0:n.y,n.width,n.height,n.dark,n.rtl])),Ze=(e,o,n=o.getComputedStyle(e))=>{let f=e.getBoundingClientRect(),v=(j,M)=>{let O=parseFloat(j);return n.display!=="inline"&&Number.isFinite(O)?O+(n.boxSizing==="border-box"?0:M.reduce((R,m)=>R+(parseFloat(n.getPropertyValue(m))||0),0)):NaN},c=v(n.width,["padding-left","padding-right","border-left-width","border-right-width"]),l=v(n.height,["padding-top","padding-bottom","border-top-width","border-bottom-width"]),w=1;if(!(c>0&&l>0))for(let j of[e,...ae(e)]){let M=(j===e?n:o.getComputedStyle(j)).getPropertyValue("zoom");w*=(parseFloat(M)||1)/(M.endsWith("%")?100:1)}let u=c>0?f.width/c:w,S=l>0?f.height/l:w;return{x:f.x,y:f.y,scaleX:u||1,scaleY:S||1,left:(parseFloat(n.borderLeftWidth)||0)-e.scrollLeft,top:(parseFloat(n.borderTopWidth)||0)-e.scrollTop,position:n.position}},yt=async(e,o,n)=>{let f=n.captureDelayMs??50;if(!Number.isFinite(f)||f<0||f>2147483647)throw new RangeError("captureDelayMs must be a finite, nonnegative timer interval");let v="native",c=e.element.ownerDocument,l=c.defaultView,w=Array.from(l.crypto.getRandomValues(new Uint32Array(4)),t=>t.toString(16)).join("-"),u=t=>t.isConnected&&t.ownerDocument===c,S=t=>!!t?.isConnected&&t.ownerDocument===c,j=o.send.bind(o),M="data-native-ui-snapshot",O=`${e.namespace}:sources-change`,R=new Set,m=new Set,p=new Set,i=new WeakMap,T=0,N=t=>JSON.stringify(t.map(a=>(i.has(a.scope)||i.set(a.scope,++T),[i.get(a.scope),ht([a],!!n.opaqueUnderlay)]))),d=new Map,W=t=>{let a=t.getRootNode();return a.nodeType===11&&"host"in a?a:c},$=t=>{let a=d.get(t);if(!a){let y=c.createElement("style"),r=c.createElement("style");y.setAttribute("data-native-ui-snapshot-base",""),r.setAttribute(M,""),y.textContent=Je,(t.nodeType===11?t:t.head).append(y,r),a={base:y,image:r},d.set(t,a)}return a.image},A=()=>`${l.innerWidth}:${l.innerHeight}:${l.devicePixelRatio}`,F=!1,C=!1,X=0,ue=0,P,g,ie=!1,U=!1,pe=0,ge=0,D=0,re=0,B=0,Me=-1,J=!1,we=!1,Y,be=new Set,_=(t,a,y)=>{let r=()=>{be.delete(r)&&j({action:t,token:a}).catch(()=>{})};be.add(r),y&&!J?l.requestAnimationFrame(()=>l.requestAnimationFrame(r)):r()},se=new Set,Ee=()=>{se.forEach(t=>p.delete(t)),se.clear()},Z=(t,a=!1)=>{!t||t===P||t===g||(t.images.forEach(y=>{y.src=""}),_("release",t.data.token,a))},Se=t=>t.filter(a=>{if(!S(a.scope.element)||![a.element,...a.sources??[]].every(u))return!1;if(a.control.scroll){let y=a.element.getBoundingClientRect(),r=a.control.scroll.clip;if(y.right<=r.x||y.bottom<=r.y||y.left>=r.x+r.width||y.top>=r.y+r.height)return!1}return n.eligible?.(a)??!0}),fe=async()=>{if(re=0,J||v!=="native"||we||ie||g||n.blocked()||!Y||P?.revision===Y.revision&&Me===B)return;let{revision:t,candidates:a}=Y,y=D,r=B,L=A(),x=Se(a);if(!x.length)return;we=!0;let k,G=!1,ee=[];try{let te=!1,he=j({action:"prepare",revision:n.captureRevision?.()??t,viewportWidth:l.innerWidth,ids:x.map(b=>b.control.id)});he.then(b=>{te&&b.token&&j({action:"release",token:b.token}).catch(()=>{})}).catch(()=>{});try{k=await ce(he)}finally{te=!0}if(!k.token||!k.parts?.length)return;let ye=new Set(x.map(b=>b.control.id));if(k.parts.some(b=>!b.url.startsWith("file:")&&!/^\/__native_ui_engine__\/nativeUI[a-fA-F0-9]+\/[a-fA-F0-9-]+-[0-9]+\.png$/.test(b.url)||!b.ids.length||b.ids.some(V=>!ye.has(V))||![b.x,b.y,b.width,b.height].every(Number.isFinite)||b.width<=0||b.height<=0)||new Set(k.parts.flatMap(b=>b.ids)).size!==ye.size||k.parts.some(b=>new Set(b.ids.map(V=>x.find(Ie=>Ie.control.id===V).scope)).size!==1)||(await ce(Promise.all(k.parts.map(async b=>{let V=c.createElement("img");return ee.push(V),V.src=Ge(b.url),await V.decode(),V}))),J||v!=="native"||ie||g||n.blocked()||D!==y||A()!==L||Y?.revision!==t||x.some(b=>!S(b.scope.element)||![b.element,...b.sources??[]].every(u))))return;let Le=P;P={data:k,images:ee,sources:Array.from(new Set(x.flatMap(b=>[b.element,...b.sources??[]]))),targets:k.parts.map(b=>x.find(V=>b.ids.includes(V.control.id)).element),scopes:k.parts.map(b=>x.find(V=>b.ids.includes(V.control.id)).scope),parents:k.parts.map(b=>x.find(V=>b.ids.includes(V.control.id)).scope.element.parentNode),frames:[],viewport:L,revision:t,layout:N(x)},Ee(),P.sources.forEach(b=>{p.add(b),se.add(b)}),Re(),n.opaqueUnderlay&&j({action:"prepared",token:k.token}).catch(()=>{}),l.dispatchEvent(new Event(O)),Me=r,G=!0,Z(Le)}catch{}finally{G||ee.forEach(te=>{te.src=""}),k?.token&&!G&&j({action:"release",token:k.token}).catch(()=>{}),we=!1,Y&&(Y.revision!==t||B!==r)&&ne()}},ne=()=>{l.clearTimeout(re),!J&&v==="native"&&!g&&!ie&&!n.blocked()&&(re=l.setTimeout(()=>{fe()},f))},Pe,Ce=(t,a)=>{if(a.some(r=>r.scope.element.ownerDocument!==c||[r.element,...r.sources??[]].some(L=>L.ownerDocument!==c)))throw new TypeError("All projection sources and fallback scopes must share one Document");if(n.blocked())return;!g&&P&&P.layout!==N(Se(a))&&ve();let y=N(a);Pe!==y&&(Pe=y,B++,D++),Y?.revision!==t&&D++,Y={revision:t,candidates:a},(P?.revision!==t||Me!==B)&&ne()},me=t=>{!g&&P&&t&&Y?.candidates.some(a=>a.scrollElement===t&&P.sources.includes(a.element))&&ve(),g&&(He(),De()),B++,D++,ne()},Oe=await Ne(o.listen(({active:t})=>{ie=t,D++,l.clearTimeout(re),t||ne()})),je=new Set,Te=t=>{je.delete(t)&&t.removeAttribute("data-native-ui-snapshot-prepared"),t.removeAttribute("data-native-ui-snapshot-visible"),t.removeAttribute(M),t.removeAttribute("data-native-ui-snapshot-source")},s=()=>{ue++,l.cancelAnimationFrame(X),l.cancelAnimationFrame(xe),xe=0,q.clear(),X=0,F=!1,C=!1,Ee(),g?.sources.forEach(t=>{R.delete(t),m.delete(t)}),z.forEach(Te),z.clear(),d.forEach(({base:t,image:a})=>{t.remove(),a.remove()}),d.clear()},h=(t=g,a=U)=>{if(g!==t)return;let y=new Set([g,P]);s(),g=void 0,P=void 0,U=!1,l.clearTimeout(pe),y.forEach(r=>Z(r,a)),n.wake()},E=()=>{if(!g)return;if(P!==g){h();return}let t=g;s(),g=void 0,U=!1,l.clearTimeout(pe),_("restored",t.data.token,!0),t.sources.forEach(a=>{p.add(a),se.add(a)}),Re(),n.wake()},K=()=>{U&&E(),be.forEach(t=>t())},I=t=>t.scopes.map(a=>S(a.element)?a.element:void 0),z=new Set,Q=new Map,q=new Map,xe=0,He=()=>{for(let[t,a]of q){if(!t.isConnected)continue;let y=l.getComputedStyle(t),r=`${a.x-(parseFloat(y.borderLeftWidth)||0)+t.scrollLeft}px`,L=`${a.y-(parseFloat(y.borderTopWidth)||0)+t.scrollTop}px`;a.style.left!==r&&a.style.setProperty("left",r,"important"),a.style.top!==L&&a.style.setProperty("top",L,"important")}},De=()=>{if(xe||!g)return;[...q.keys()].some(a=>a.getAnimations?.().some(y=>y.playState==="running"&&y.effect?.getKeyframes().some(r=>["borderLeftWidth","borderTopWidth","borderWidth"].some(L=>L in r))))&&(xe=l.requestAnimationFrame(()=>{xe=0,He(),De()}))},Re=()=>{let t=g??P;if(!t||U)return;if(!t.sources.every(u)||I(t).some(r=>!r)){ve();return}let a=I(t);if(g){if(a.some(L=>!L?.isConnected||!d.has(W(L)))){h(g,!1);return}let r=new Set;for(let[L,x]of t.targets.entries()){let k=Q.get(x),G=a[L];!k||!G?.isConnected||(G.setAttribute(M,k),G.setAttribute("data-native-ui-snapshot-source",""),G.toggleAttribute("data-native-ui-snapshot-visible",C),r.add(G))}for(let L of z)r.has(L)||Te(L);z.clear(),r.forEach(L=>z.add(L));return}Q.clear(),q.clear(),z.forEach(Te),z.clear();let y=new Map;for(let r of new Set(a)){if(!r)continue;z.add(r);let L=$(W(r)),x=y.get(L);x||(x={rules:[],positions:[]},y.set(L,x));let{rules:k,positions:G}=x;r!==t.targets[a.indexOf(r)]&&!r.hasAttribute("data-native-ui-snapshot-prepared")&&(r.setAttribute("data-native-ui-snapshot-prepared",""),je.add(r)),r.toggleAttribute("data-native-ui-snapshot-visible",!!g||!!n.opaqueUnderlay),g&&r.setAttribute("data-native-ui-snapshot-source","");let ee=a.flatMap((H,oe)=>H===r?[oe]:[]),te=l.getComputedStyle(r);{let H=Ze(r,l,te);ee.forEach(oe=>{let Fe=t.data.parts[oe];n.opaqueUnderlay&&t.frames[oe]||(t.frames[oe]={x:n.opaqueUnderlay?0:(Fe.x-H.x)/H.scaleX,y:n.opaqueUnderlay?0:(Fe.y-H.y)/H.scaleY,width:Fe.width/H.scaleX,height:Fe.height/H.scaleY})})}let he=ee.map(H=>t.frames[H]),ye=Math.min(...he.map(H=>H.x)),Le=Math.min(...he.map(H=>H.y)),b=Math.max(...he.map(H=>H.x+H.width)),V=Math.max(...he.map(H=>H.y+H.height)),Ie=(parseFloat(te.borderLeftWidth)||0)-r.scrollLeft,ot=(parseFloat(te.borderTopWidth)||0)-r.scrollTop,Ae=`${w}-${ee[0]}`;ee.forEach(H=>Q.set(t.targets[H],Ae)),r.setAttribute(M,Ae),k.push(`[${M}="${Ae}"][data-native-ui-snapshot-visible] { ${te.position==="static"?"position:relative!important;":""} }`);let rt=ee.map(H=>{let oe=t.frames[H];return`url(${JSON.stringify(t.images[H].src)}) ${oe.x-ye}px ${oe.y-Le}px / ${oe.width}px ${oe.height}px no-repeat`}),ke=c.createElement("span").style;ke.cssText=`left:${ye-Ie}px;top:${Le-ot}px;width:${b-ye}px;height:${V-Le}px;background:${rt.join(",")};`;for(let H of Array.from(ke))ke.setProperty(H,ke.getPropertyValue(H),"important");k.push(`[${M}="${Ae}"][${M}][${M}]::before { ${ke.cssText} }`),G.push({target:r,x:ye,y:Le})}t.parents=t.scopes.map(r=>r.element.parentNode);for(let[r,{rules:L,positions:x}]of y)r.textContent=L.join(`
14
+ `),x.forEach(({target:k,x:G,y:ee},te)=>q.set(k,{x:G,y:ee,style:r.sheet.cssRules[te*2+1].style}))},Xe=()=>{if(K(),J||v==="none"||g)return;let t=P;if(!t||t.viewport!==A()||!t.sources.every(u)||n.current&&!n.current(t.targets)||t.targets.some(r=>!Q.has(r))||I(t).some(r=>!r?.isConnected)||[...z].some(r=>!r.isConnected))return;t.scopes.some((r,L)=>r.element.parentNode!==t.parents[L])&&Re(),He();let a=++ue,y=()=>ue===a;F=!0,C=!!n.opaqueUnderlay,g=t,D++,l.clearTimeout(re),t.sources.forEach(r=>{R.add(r)}),z.forEach(r=>{r.setAttribute("data-native-ui-snapshot-source",""),r.toggleAttribute("data-native-ui-snapshot-visible",C)}),X=l.requestAnimationFrame(()=>{X=0,y()&&(F=!1,n.wake())}),ce(j({action:"cover",token:t.data.token,...n.transient?{transient:!0}:{}})).then(r=>{if(y()){if(F=!1,r?.shown!==!0){h(t,!1);return}U||(C=!0,t.sources.forEach(L=>m.add(L)),Re(),He(),De()),l.cancelAnimationFrame(X),X=l.requestAnimationFrame(()=>{X=0,y()&&j({action:"painted",token:t.data.token}).catch(()=>{y()&&h(t,!1)})}),n.wake()}}).catch(()=>{y()&&h(t,!1)})},Ye=()=>{if(!(!g||J||U)){if(!g.sources.every(u)||I(g).some(t=>!t)||n.current&&!n.current(g.targets)){h(g,!1);return}if(!n.blocked()){if(n.transient){let t=g,a=I(t);if(a.some(r=>!r?.isConnected)){h(t,!1);return}let y=a.map((r,L)=>{let x=Ze(r,l),k=t.frames[L];return{...t.data.parts[L],x:x.x+k.x*x.scaleX,y:x.y+k.y*x.scaleY,width:k.width*x.scaleX,height:k.height*x.scaleY}});y.some((r,L)=>["x","y","width","height"].some(x=>r[x]!==t.data.parts[L][x]))&&(n.opaqueUnderlay?t.data={...t.data,parts:y}:P=void 0),j({action:"restore",token:t.data.token,viewportWidth:l.innerWidth,parts:y}).catch(()=>{})}ge=n.revision?.()??Y?.revision??0,U=!0,g.sources.forEach(t=>R.delete(t)),pe=l.setTimeout(()=>{U&&h(g,!1)},250),n.wake()}}},nt=(t,a,y)=>{!U||!g||y===void 0||y<=ge||!n.transient&&t||g.data.parts.every(r=>r.ids.every(L=>a?.includes(L)))&&(E(),ne())},ve=()=>{be.forEach(a=>a()),D++,l.clearTimeout(re);let t=P;P=void 0,Z(t),g?h(g,!1):(s(),Q.clear(),l.dispatchEvent(new Event(O)))},qe;try{n.events&&(qe=await Ue(n.events,(t,a)=>{if(!J){if(t&&(v=t),v==="none"){ve();return}t==="web"&&Xe(),t==="native"&&(Ye(),ne()),a&&me()}}))}catch(t){throw J=!0,ve(),await ce(Promise.resolve(Oe.remove())).catch(()=>{}),t}return{get owners(){return[...R]},get visibleOwners(){return[...m]},get preparedOwners(){return[...p]},consider:Ce,changed:me,moved:()=>{He(),De()},get token(){return U?void 0:g?.data.token},begin:Xe,get restoring(){return U},get handoffPending(){return F},retireRestoration:K,end:Ye,updated:nt,invalidate:ve,async destroy(){J=!0,ve(),await Promise.allSettled([ce(Promise.resolve().then(()=>Oe?.remove())),ce(Promise.resolve().then(()=>qe?.remove()))])}}},Qe=async(e,o,n)=>{let f=await yt(e,o,n),v=f.consider;return Object.assign(f,{consider:(c,l)=>v(c,l.map(w=>({...w,scope:e})))})};var et=(e,o)=>{if(e.nodeType!==1||e.namespaceURI!=="http://www.w3.org/1999/xhtml"||!e.ownerDocument||!("style"in e))throw new TypeError("A Web projection scope requires an HTMLElement fallback");if(e.matches("img, input, textarea, select, video, audio, canvas, iframe, embed, object, br, hr, area, base, link, meta, source, track, wbr"))throw new TypeError("A Web projection fallback must support ::before; use its existing container element");return Object.freeze({element:e,namespace:o})};var tt=async(e,o,n={addListener:async(f,v)=>globalThis.Capacitor.addListener(e.plugin,f,v)})=>{let f="view",v=e.receiveListeners,c=!1,l=!1,w="native",u="native",S=_e(document),j=()=>w==="native"&&d&&S.isCovered(d)?"web":w,M=!1,O=!1,R=!1,m,p,i,T=!1,N=()=>{let s=T;T=!0;try{i?.begin()}finally{T=s}},d,W={},$,A=0,F=()=>{A++,$=void 0;let s=$e(d);s?.owner===W&&(s.projected=!1,s.owner=void 0)},C={listen:s=>o.listen(s),send:s=>{if(!e.placementHandoff)return o.send(s);let h=$e(d);if(s.action==="cover"){let Q=++A;return $=s.token,h&&(h.owner=W,h.projected=!1),o.send(s).then(q=>(Q===A&&h===$e(d)&&h?.owner===W&&(h.projected=q?.shown===!0),q)).catch(q=>{throw Q===A&&h?.owner===W&&(F(),h.invalidate()),q})}let E=s.action==="release"&&s.token===$,K=$&&(s.action==="restore"||E||s.action==="reset"||s.action==="visibility"&&s.visible);K&&A++;let I=A,z=()=>{I!==A||h?.owner!==W||(F(),h.invalidate())};try{let Q=K&&h?.owner===W?{...s,token:"token"in s?s.token:$,placement:h.measure()}:s;return o.send(Q).then(q=>(I===A&&(s.action==="resume"||E||s.action==="reset"||s.action==="visibility")&&F(),q)).catch(q=>{throw z(),q})}catch(Q){return z(),Promise.reject(Q)}}},X=0,ue=0,P=0,g=!1,ie=!1,U=!1,pe=!1,ge=document.createElement("style"),D=`data-native-ui-session-${e.id.replace(/[^a-zA-Z0-9-]/g,"")}`;ge.textContent=`[${D}="native"], [${D}="native"] *, [${D}="none"], [${D}="none"] * {visibility:hidden!important}`,document.documentElement.append(ge);let re=()=>{let s=document.querySelectorAll(e.projectionSelector);return s.length===1&&s[0]instanceof HTMLElement?s[0]:void 0},B=()=>{d&&(u==="web"||u==="native"&&O?d.removeAttribute(D):d.setAttribute(D,u))},Me=()=>{if(!d?.isConnected)return[];let s=d.getBoundingClientRect(),h={x:s.x,y:s.y,width:s.width,height:s.height},E=m&&Ve(d,m,()=>e.id);return[{element:d,scrollElement:m,control:{id:f,...h,...E?{scroll:E}:{}}}]},J=async()=>{if(c)return;let s=P,h=e.failedFallback==="native"&&j()==="native"&&!!d?.isConnected;await C.send({action:"visibility",visible:h}).catch(()=>{}),!(c||s!==P)&&(h||u==="none"?B():d?.removeAttribute(D))},we=()=>{if(!M||u!=="native"||!d?.isConnected||i?.token||i?.restoring)return;let s=e.failedFallback==="native";C.send({action:"visibility",visible:s}).catch(()=>{}),s?B():d.removeAttribute(D)},Y=()=>{if(c||T)return;if(we(),u==="web"&&i&&!i.token&&!i.restoring){C.send({action:"visibility",visible:!1}).catch(()=>{}),d?.removeAttribute(D);return}if(g||!i?.restoring||u!=="native")return;g=!0;let s=P,h=i;C.send({action:"resume"}).then(()=>{c||s!==P||h!==i||(O=!1,h.updated(void 0,[f],++X),B(),_())}).catch(()=>{s!==P||c||(h.invalidate(),J())}).finally(()=>{g=!1,Y()})},be=async()=>{if(c||ie||M)return;if(i?.restoring){Y();return}let s=re();if(s!==d){if(P++,F(),d?.removeAttribute(D),await i?.destroy(),i=void 0,c)return;d=s,O=!1,Ee.disconnect(),d&&Ee.observe(d)}if(ne(),u=j(),!d){await C.send({action:"visibility",visible:!1});return}if(!i){ie=!0;let h=P;try{let E=await Qe(et(d,e.id),C,{wake:Y,captureDelayMs:e.captureDelayMs,blocked:()=>u!=="native"||M||O&&!i?.token,revision:()=>X,captureRevision:e.captureMode==="view"?()=>ue:void 0,transient:!0,opaqueUnderlay:e.captureMode==="view"});if(c||h!==P){await E.destroy();return}i=E}finally{ie=!1}}if(!c){if(u==="native"&&O){await C.send({action:"visibility",visible:!1}),B();return}if(u==="native"){let h=P;if(await C.send({action:"visibility",visible:!0}),c||h!==P||u!=="native")return;B(),m=[...ae(d)].find(I=>{let z=getComputedStyle(I);return/auto|scroll/.test(z.overflowX+z.overflowY)&&(I.scrollHeight>I.clientHeight||I.scrollWidth>I.clientWidth)});let E=m&&Ve(d,m,()=>e.id),K=await C.send({action:"track",viewportWidth:innerWidth,...E?{scroll:E}:{}});if(c||h!==P)return;R=K?.tracked===!0,i.consider(e.captureMode==="view"?ue:++X,Me())}else await C.send({action:"visibility",visible:!1}),B()}},_=()=>{c||(U=!0,!(!l||pe)&&(pe=!0,Promise.resolve().then(async()=>{try{for(;U&&!c;)U=!1,await be()}catch{await J()}finally{pe=!1,U&&_()}})))},se=()=>{i?.invalidate(),_()},Ee=new ResizeObserver(se);window.addEventListener("resize",se);let Z=new Set,Se=new WeakSet,fe=s=>{let h=s.composedPath()[0]??s.target;if(!(c||u!=="native"||!d||Se.has(s)||h!==document&&![...ae(d)].includes(h))){if(Se.add(s),R&&h===m){i?.changed(h);return}M?e.captureMode==="view"?i?.moved():i?.changed():((g||i?.restoring)&&P++,M=!0,N(),O=e.failedFallback!=="native",i?.token?d.removeAttribute(D):we()),clearTimeout(p),p=setTimeout(()=>{M=!1,!(c||u!=="native")&&(i?.end(),i?.restoring||_())},100)}},ne=()=>{let s=new Set(d?ae(d):[]),h=s.size!==Z.size||[...s].some(E=>!Z.has(E));for(let E of Z)s.has(E)||(E.removeEventListener("scroll",fe),E.removeEventListener("slotchange",Pe),Z.delete(E));for(let E of s)Z.has(E)||(E.addEventListener("scroll",fe,{passive:!0}),E.addEventListener("slotchange",Pe),Z.add(E));h&&(R=!1,m=void 0,i?.invalidate(),_())},Pe=()=>{ne(),_()};document.addEventListener("scroll",fe,!0);let Ce=new MutationObserver(s=>{ne(),re()!==d?_():d&&s.some(h=>h.type==="childList")&&me(j())});Ce.observe(document.documentElement,{childList:!0,attributes:!0,subtree:!0});let me=(s,h=!1)=>{if(c||!h&&s===u)return;M=!1,clearTimeout(p),u=s,P++;let E=P;u==="none"?C.send({action:"visibility",visible:!1}).then(()=>{u!=="none"||E!==P||c||(i?.invalidate(),B())}).catch(()=>{u!=="none"||E!==P||c||(i?.invalidate(),B())}):u==="web"?(N(),i?.token||C.send({action:"visibility",visible:!1}).catch(()=>{}),B()):u==="native"&&(i?.end(),i?.restoring||_())},Oe=S.subscribe(()=>queueMicrotask(()=>me(j()))),je,Te=async()=>{if(!c){c=!0,F(),P++,Oe(),Ce.disconnect(),Ee.disconnect(),window.removeEventListener("resize",se),document.removeEventListener("scroll",fe,!0);for(let s of Z)s.removeEventListener("scroll",fe),s.removeEventListener("slotchange",Pe);Z.clear(),clearTimeout(p),d?.removeAttribute(D),ge.remove(),await Promise.allSettled([Promise.resolve(je?.remove()),i?.destroy()])}};try{let s=P;je=await Ue({addListener:(E,K)=>n.addListener(E,K),update:v?.update,projection:v?.projection,accepts:E=>!v?.match||Object.entries(v?.match).every(([K,I])=>typeof E=="object"&&E!==null&&E[K]===I)},(E,K)=>{c||(E&&(E==="native"&&(O=!1),w=E,C.send({action:"destination",destination:E}).catch(()=>{}),me(j(),!0)),K&&(ue++,i?.changed(),u==="native"&&_()))});let h=await C.send({action:"connect"});return P===s&&["native","web","none"].includes(h?.destination)&&(w=h.destination),u=j(),l=!0,_(),{destroy:Te,show:()=>{O=!1,w="native",me(j(),!0)},hide:()=>{w=u="none",P++,M=!1,clearTimeout(p),i?.invalidate(),B()}}}catch(s){throw await Te(),s}};var le=new Map,de=new Map,We=new Map,gt=Array.from(crypto.getRandomValues(new Uint32Array(4)),e=>e.toString(16)).join("-"),Be=new Map,wt=(e,o,n)=>Be.get(e)?.get(o)?.(n),bt=0,Et=(e,o)=>new Promise((n,f)=>{let v=`${e}:${gt}:${++bt}`,c=setTimeout(()=>{de.delete(v),f(new Error("Native UI session timed out"))},5e3);de.set(v,{resolve:n,reject:f,timer:c});let l=JSON.stringify({key:v,request:o}),w=globalThis,u=w.webkit?.messageHandlers?.[e]??w[e]??w.nativeUIEngine;if(!u){clearTimeout(c),de.delete(v),f(new Error("Native UI session unavailable"));return}try{u.postMessage(l)}catch(S){clearTimeout(c),de.delete(v),f(S)}}),Pt=(e,o,n,f)=>{if(o===null){We.get(e)?.(n);return}let v=de.get(o);!v||!o.startsWith(`${e}:`)||(de.delete(o),clearTimeout(v.timer),f?v.reject(new Error(f)):v.resolve(n))},Tt=e=>{le.get(e)?.show()},Lt=e=>(le.get(e)?.hide(),!0),Mt=async e=>{let o=le.get(e);le.delete(e),Be.delete(e),We.delete(e);for(let[n,f]of de)n.startsWith(`${e}:`)&&(de.delete(n),clearTimeout(f.timer),f.reject(new Error("Native UI session closed")));await o?.destroy()},St=async e=>{if(le.has(e.id))return;let o,n=!1,f,v={show:()=>{f="native",o?.show()},hide:()=>{f="none",o?.hide()},destroy:async()=>{n=!0,await o?.destroy()}};le.set(e.id,v);try{if(document.readyState==="loading"&&await new Promise(l=>document.addEventListener("DOMContentLoaded",()=>l(),{once:!0})),n)return;let c=new Map;e.runtime==="cordova"&&Be.set(e.id,c),o=await tt(e,{send:l=>Et(e.id,l),listen:async l=>(We.set(e.id,l),{remove:()=>{We.get(e.id)===l&&We.delete(e.id)}})},e.runtime==="cordova"?{addListener:async(l,w)=>(c.set(l,w),{remove:()=>{c.delete(l)}})}:void 0),f==="none"?o.hide():f==="native"&&o.show(),n&&await o.destroy()}catch(c){throw le.get(e.id)===v&&(le.delete(e.id),Be.delete(e.id)),c}};return dt(jt);})();
@@ -0,0 +1,27 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// One tracker per DOM scroll owner. A lost established mapping is rejected;
5
+ /// the host's normal acknowledgement path can then request a fresh acquisition.
6
+ public final class NativeUIScrollBindings {
7
+ private var trackers: [String: NativeUIScrollTracker] = [:]
8
+ public init() {}
9
+ public func replace(_ nodes: [NativeUIScrollNode], in webView: WKWebView, scale: CGFloat,
10
+ render: @escaping (String, CGRect, CGRect) -> Void) -> [String] {
11
+ let previous = trackers
12
+ trackers.removeAll()
13
+ var rejected: [String] = []
14
+ for (id, group) in Dictionary(grouping: nodes.filter { $0.scroll != nil }, by: { $0.scroll!.id }) {
15
+ if group.contains(where: { $0.scroll?.reuse == true }) {
16
+ if let tracker = previous[id], tracker.replace(group, webView: webView, scale: scale) {
17
+ trackers[id] = tracker
18
+ } else { rejected.append(contentsOf: group.map(\.id)) }
19
+ } else if let tracker = NativeUIScrollTracker(webView: webView, nodes: group, scale: scale, render: render) {
20
+ trackers[id] = tracker
21
+ } else { rejected.append(contentsOf: group.map(\.id)) }
22
+ }
23
+ return rejected
24
+ }
25
+ public func update() { trackers.values.forEach { $0.update() } }
26
+ public func reset() { trackers.removeAll() }
27
+ }
@@ -0,0 +1,117 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ public struct NativeUIScrollPlacement: Decodable, Equatable {
5
+ public let id: String
6
+ public let reuse: Bool?
7
+ public let offsetX: Double
8
+ public let offsetY: Double
9
+ public let viewport: NativeUIFrame
10
+ public let clip: NativeUIFrame
11
+ public let contentWidth: Double
12
+ public let contentHeight: Double
13
+ public let frame: NativeUIFrame
14
+
15
+ public init(id: String, reuse: Bool? = nil, offsetX: Double, offsetY: Double,
16
+ viewport: NativeUIFrame, clip: NativeUIFrame, contentWidth: Double, contentHeight: Double, frame: NativeUIFrame) {
17
+ self.id = id; self.reuse = reuse; self.offsetX = offsetX; self.offsetY = offsetY
18
+ self.viewport = viewport; self.clip = clip; self.contentWidth = contentWidth; self.contentHeight = contentHeight; self.frame = frame
19
+ }
20
+
21
+ public var isValid: Bool {
22
+ !id.isEmpty && viewport.isValid && clip.isValid && frame.isValid &&
23
+ offsetX.isFinite && offsetY.isFinite && contentWidth.isFinite && contentHeight.isFinite && contentWidth > 0 && contentHeight > 0
24
+ }
25
+ }
26
+
27
+ /// One observer per overflow container. Web measures layout; native owns scrolling.
28
+ /// WKWebView's overflow hierarchy is not a public DOM mapping: only bind a unique match.
29
+ public final class NativeUIScrollTracker {
30
+ private weak var webView: WKWebView?
31
+ private weak var scrollView: UIScrollView?
32
+ private let placement: NativeUIScrollPlacement
33
+ private let scale: CGFloat
34
+ private let contentOrigin: CGPoint
35
+ private let viewportCorrection: CGPoint
36
+ private var frames: [String: CGRect] = [:]
37
+ private let render: (String, CGRect, CGRect) -> Void
38
+ private var observation: NSKeyValueObservation?
39
+
40
+ public init?(webView: WKWebView, nodes: [NativeUIScrollNode], scale: CGFloat,
41
+ render: @escaping (String, CGRect, CGRect) -> Void) {
42
+ guard let placement = nodes.first?.scroll, placement.isValid,
43
+ nodes.allSatisfy({ $0.scroll?.id == placement.id && $0.scroll?.viewport == placement.viewport &&
44
+ $0.scroll?.clip == placement.clip && $0.scroll?.contentWidth == placement.contentWidth &&
45
+ $0.scroll?.contentHeight == placement.contentHeight }), scale.isFinite, scale > 0 else { return nil }
46
+ let transform = CGAffineTransform(scaleX: scale, y: scale)
47
+ let viewport = placement.viewport.rect.applying(transform)
48
+ let candidates = Self.scrollViews(in: webView).filter { scroll in
49
+ let frame = scroll.convert(scroll.bounds, to: webView)
50
+ return scroll !== webView.scrollView && !scroll.isHidden && scroll.window != nil &&
51
+ abs(scroll.zoomScale - 1) < 0.001 &&
52
+ abs(frame.minX - viewport.minX) < 2 && abs(frame.minY - viewport.minY) < 2 &&
53
+ abs(frame.width - viewport.width) < 2 && abs(frame.height - viewport.height) < 2 &&
54
+ abs(scroll.contentSize.width - placement.contentWidth * scale) < 3 &&
55
+ abs(scroll.contentSize.height - placement.contentHeight * scale) < 3
56
+ }
57
+ guard candidates.count == 1, let scroll = candidates.first, !scroll.isDragging, !scroll.isDecelerating else { return nil }
58
+ self.webView = webView
59
+ self.scrollView = scroll
60
+ self.placement = placement
61
+ self.scale = scale
62
+ // Calibrate coordinate origins once while acquiring a resting scroller.
63
+ // CSS RTL scroll origins and UIKit dimensions can differ by rounded pixels.
64
+ // Keep this mapping for content updates during motion; never recalibrate per tick.
65
+ self.contentOrigin = CGPoint(x: scroll.contentOffset.x - placement.offsetX * scale,
66
+ y: scroll.contentOffset.y - placement.offsetY * scale)
67
+ let actual = scroll.convert(scroll.bounds, to: webView)
68
+ self.viewportCorrection = CGPoint(x: viewport.minX - actual.minX, y: viewport.minY - actual.minY)
69
+ self.render = render
70
+ setFrames(nodes)
71
+ observation = scroll.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in self?.update() }
72
+ }
73
+
74
+ /// Reuse the observer and coordinate origin when only source content/layout changes.
75
+ public func replace(_ nodes: [NativeUIScrollNode], webView: WKWebView, scale: CGFloat) -> Bool {
76
+ guard self.webView === webView, let scrollView, scrollView.isDescendant(of: webView),
77
+ !scrollView.isHidden, scrollView.window != nil, self.scale == scale,
78
+ nodes.allSatisfy({ node in
79
+ guard let next = node.scroll else { return false }
80
+ return next.id == placement.id && next.viewport == placement.viewport && next.clip == placement.clip &&
81
+ next.contentWidth == placement.contentWidth && next.contentHeight == placement.contentHeight
82
+ }) else { return false }
83
+ setFrames(nodes)
84
+ return true
85
+ }
86
+
87
+ private func setFrames(_ nodes: [NativeUIScrollNode]) {
88
+ frames = Dictionary(uniqueKeysWithValues: nodes.map {
89
+ ($0.id, $0.scroll!.frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale))
90
+ .offsetBy(dx: contentOrigin.x, dy: contentOrigin.y))
91
+ })
92
+ }
93
+
94
+ public func update() {
95
+ guard let webView, let scrollView, scrollView.isDescendant(of: webView) else {
96
+ frames.forEach { render($0.key, $0.value, .null) }
97
+ return
98
+ }
99
+ let origin = scrollView.convert(scrollView.bounds, to: webView).origin
100
+ let viewport = CGRect(x: origin.x + viewportCorrection.x, y: origin.y + viewportCorrection.y,
101
+ width: placement.viewport.width * scale, height: placement.viewport.height * scale)
102
+ let visible = placement.clip.rect.applying(CGAffineTransform(scaleX: scale, y: scale))
103
+ .intersection(viewport).intersection(webView.bounds)
104
+ UIView.performWithoutAnimation {
105
+ for (id, frame) in frames {
106
+ render(id, frame.offsetBy(dx: viewport.minX - scrollView.contentOffset.x,
107
+ dy: viewport.minY - scrollView.contentOffset.y), visible)
108
+ }
109
+ }
110
+ }
111
+
112
+ private static func scrollViews(in view: UIView) -> [UIScrollView] {
113
+ view.subviews.flatMap { child in
114
+ ((child as? UIScrollView).map { [$0] } ?? []) + scrollViews(in: child)
115
+ }
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@webview-native-ui/engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Native UI placement, scroll tracking, and Web projection for Capacitor and Cordova plugin maintainers.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/webview-native-ui/engine.git"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./ionic": {
17
+ "types": "./dist/ionic.d.ts",
18
+ "import": "./dist/ionic.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "ios/Sources",
24
+ "WebViewNativeUIEngine.podspec",
25
+ "README.md",
26
+ "LICENSE",
27
+ "docs"
28
+ ],
29
+ "scripts": {
30
+ "build": "rm -rf dist && tsc -p tsconfig.json && node scripts/build-runtime.mjs",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "playwright test",
33
+ "lint": "npm run typecheck && eslint . --max-warnings 0 && npm run format:check",
34
+ "format": "prettier --write \"**/*.{ts,js,mjs,cjs,json,md,yml,yaml,html}\"",
35
+ "prepublishOnly": "npm run build",
36
+ "test:release": "node --test scripts/native-ui-engine-release/publish.test.mjs",
37
+ "test:package": "node scripts/verify-package.mjs",
38
+ "release": "np --no-publish --no-tests --branch main",
39
+ "test:cordova": "node scripts/verify-cordova.mjs",
40
+ "format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,json,md,yml,yaml,html}\""
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^10.0.1",
44
+ "@playwright/test": "^1.58.0",
45
+ "esbuild": "^0.27.0",
46
+ "eslint": "^10.13.0",
47
+ "globals": "^17.13.0",
48
+ "np": "^10.2.0",
49
+ "prettier": "^3.6.2",
50
+ "typescript": "^5.9.3",
51
+ "typescript-eslint": "^8.72.1"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "peerDependencies": {
57
+ "@capacitor/core": ">=8.0.0 <9"
58
+ },
59
+ "types": "./dist/index.d.ts",
60
+ "peerDependenciesMeta": {
61
+ "@capacitor/core": {
62
+ "optional": true
63
+ }
64
+ }
65
+ }