@onekeyfe/react-native-chart-webview 3.0.39

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 (132) hide show
  1. package/ChartWebview.podspec +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +31 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +131 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  8. package/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt +331 -0
  9. package/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebviewPackage.kt +29 -0
  10. package/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +385 -0
  11. package/ios/ChartWebview.swift +747 -0
  12. package/lib/module/ChartWebview.nitro.js +4 -0
  13. package/lib/module/ChartWebview.nitro.js.map +1 -0
  14. package/lib/module/bridge.js +30 -0
  15. package/lib/module/bridge.js.map +1 -0
  16. package/lib/module/index.js +19 -0
  17. package/lib/module/index.js.map +1 -0
  18. package/lib/module/package.json +1 -0
  19. package/lib/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  20. package/lib/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  21. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +186 -0
  22. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +88 -0
  23. package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +96 -0
  24. package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.hpp +49 -0
  25. package/lib/nitrogen/generated/android/chartwebview+autolinking.cmake +83 -0
  26. package/lib/nitrogen/generated/android/chartwebview+autolinking.gradle +27 -0
  27. package/lib/nitrogen/generated/android/chartwebviewOnLoad.cpp +50 -0
  28. package/lib/nitrogen/generated/android/chartwebviewOnLoad.hpp +25 -0
  29. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void.kt +80 -0
  30. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void_std__string.kt +80 -0
  31. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +153 -0
  32. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/chartwebviewOnLoad.kt +35 -0
  33. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewManager.kt +50 -0
  34. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewStateUpdater.kt +23 -0
  35. package/lib/nitrogen/generated/ios/ChartWebview+autolinking.rb +60 -0
  36. package/lib/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.cpp +49 -0
  37. package/lib/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.hpp +158 -0
  38. package/lib/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Umbrella.hpp +46 -0
  39. package/lib/nitrogen/generated/ios/ChartWebviewAutolinking.mm +33 -0
  40. package/lib/nitrogen/generated/ios/ChartWebviewAutolinking.swift +25 -0
  41. package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.cpp +11 -0
  42. package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +164 -0
  43. package/lib/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +146 -0
  44. package/lib/nitrogen/generated/ios/swift/Func_void.swift +47 -0
  45. package/lib/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
  46. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +68 -0
  47. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +450 -0
  48. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +45 -0
  49. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +87 -0
  50. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +207 -0
  51. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +118 -0
  52. package/lib/nitrogen/generated/shared/json/ChartWebviewConfig.json +20 -0
  53. package/lib/nitrogen/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  54. package/lib/nitrogen/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  55. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +186 -0
  56. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +88 -0
  57. package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +96 -0
  58. package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.hpp +49 -0
  59. package/lib/nitrogen/nitrogen/generated/android/chartwebview+autolinking.cmake +83 -0
  60. package/lib/nitrogen/nitrogen/generated/android/chartwebview+autolinking.gradle +27 -0
  61. package/lib/nitrogen/nitrogen/generated/android/chartwebviewOnLoad.cpp +50 -0
  62. package/lib/nitrogen/nitrogen/generated/android/chartwebviewOnLoad.hpp +25 -0
  63. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void.kt +80 -0
  64. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void_std__string.kt +80 -0
  65. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +153 -0
  66. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/chartwebviewOnLoad.kt +35 -0
  67. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewManager.kt +50 -0
  68. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewStateUpdater.kt +23 -0
  69. package/lib/nitrogen/nitrogen/generated/ios/ChartWebview+autolinking.rb +60 -0
  70. package/lib/nitrogen/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.cpp +49 -0
  71. package/lib/nitrogen/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.hpp +158 -0
  72. package/lib/nitrogen/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Umbrella.hpp +46 -0
  73. package/lib/nitrogen/nitrogen/generated/ios/ChartWebviewAutolinking.mm +33 -0
  74. package/lib/nitrogen/nitrogen/generated/ios/ChartWebviewAutolinking.swift +25 -0
  75. package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.cpp +11 -0
  76. package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +164 -0
  77. package/lib/nitrogen/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +146 -0
  78. package/lib/nitrogen/nitrogen/generated/ios/swift/Func_void.swift +47 -0
  79. package/lib/nitrogen/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
  80. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +68 -0
  81. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +450 -0
  82. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +45 -0
  83. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +87 -0
  84. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +207 -0
  85. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +118 -0
  86. package/lib/nitrogen/nitrogen/generated/shared/json/ChartWebviewConfig.json +20 -0
  87. package/lib/typescript/package.json +1 -0
  88. package/lib/typescript/src/ChartWebview.nitro.d.ts +21 -0
  89. package/lib/typescript/src/ChartWebview.nitro.d.ts.map +1 -0
  90. package/lib/typescript/src/bridge.d.ts +2 -0
  91. package/lib/typescript/src/bridge.d.ts.map +1 -0
  92. package/lib/typescript/src/index.d.ts +145 -0
  93. package/lib/typescript/src/index.d.ts.map +1 -0
  94. package/nitro.json +17 -0
  95. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  96. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  97. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +186 -0
  98. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +88 -0
  99. package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +96 -0
  100. package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.hpp +49 -0
  101. package/nitrogen/generated/android/chartwebview+autolinking.cmake +83 -0
  102. package/nitrogen/generated/android/chartwebview+autolinking.gradle +27 -0
  103. package/nitrogen/generated/android/chartwebviewOnLoad.cpp +50 -0
  104. package/nitrogen/generated/android/chartwebviewOnLoad.hpp +25 -0
  105. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void.kt +80 -0
  106. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void_std__string.kt +80 -0
  107. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +153 -0
  108. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/chartwebviewOnLoad.kt +35 -0
  109. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewManager.kt +50 -0
  110. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewStateUpdater.kt +23 -0
  111. package/nitrogen/generated/ios/ChartWebview+autolinking.rb +60 -0
  112. package/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.cpp +49 -0
  113. package/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.hpp +158 -0
  114. package/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Umbrella.hpp +46 -0
  115. package/nitrogen/generated/ios/ChartWebviewAutolinking.mm +33 -0
  116. package/nitrogen/generated/ios/ChartWebviewAutolinking.swift +25 -0
  117. package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.cpp +11 -0
  118. package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +164 -0
  119. package/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +146 -0
  120. package/nitrogen/generated/ios/swift/Func_void.swift +47 -0
  121. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
  122. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +68 -0
  123. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +450 -0
  124. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +45 -0
  125. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +87 -0
  126. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +207 -0
  127. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +118 -0
  128. package/nitrogen/generated/shared/json/ChartWebviewConfig.json +20 -0
  129. package/package.json +170 -0
  130. package/src/ChartWebview.nitro.ts +59 -0
  131. package/src/bridge.ts +27 -0
  132. package/src/index.tsx +20 -0
@@ -0,0 +1,747 @@
1
+ import Foundation
2
+ import UIKit
3
+ import WebKit
4
+
5
+ // MARK: - Constants (shared)
6
+
7
+ private enum ChartWebviewConst {
8
+ /// Custom scheme used for the offline virtual same-origin.
9
+ static let customScheme = "onekey-chart"
10
+ /// Virtual host used when serving offline content.
11
+ static let virtualHost = "chart"
12
+ /// Name of the page -> native message handler.
13
+ static let messageHandlerName = "onekeyChart"
14
+ }
15
+
16
+ // MARK: - ChartContainerView (window-attach detection)
17
+
18
+ /// The host's `view`. Reports when it is attached to / detached from a window so
19
+ /// the host can claim / release the shared WebView at the right time.
20
+ final class ChartContainerView: UIView {
21
+ var onWindowChange: ((_ attached: Bool) -> Void)?
22
+
23
+ override func didMoveToWindow() {
24
+ super.didMoveToWindow()
25
+ onWindowChange?(window != nil)
26
+ }
27
+ }
28
+
29
+ // MARK: - HybridChartWebview (thin host)
30
+
31
+ /// Thin host for a chart WebView. It owns only a container view; the real
32
+ /// WKWebView lives in a `PooledChartWebView` which can be **shared** across
33
+ /// hosts that use the same `reuseKey` (singleton via reparenting).
34
+ ///
35
+ /// The host claims the backing WebView into its container when it is attached
36
+ /// AND active (`active != false`), releases it otherwise, forwards page events
37
+ /// to its Nitro callbacks while it is the active owner, and delegates
38
+ /// `postMessage` / `reload` to the backing WebView.
39
+ class HybridChartWebview: HybridChartWebviewSpec {
40
+
41
+ private static var instanceIds = 0
42
+ private let instanceId: Int = {
43
+ HybridChartWebview.instanceIds += 1
44
+ return HybridChartWebview.instanceIds
45
+ }()
46
+
47
+ // Substring of the chart's render-ready bridge message (method
48
+ // 'tradingview_renderReady'); cheaper than JSON-parsing every page message.
49
+ private static let renderReadyMarker = "tradingview_renderReady"
50
+ // Safety-net: reveal the live chart even if the render-ready signal is missed.
51
+ private static let revealFallback: TimeInterval = 2.0
52
+ // The one host currently holding a snapshot over a switch, waiting for the
53
+ // render-ready signal. Shared so render-ready (which arrives on whichever host
54
+ // owns the WebView at that instant — not necessarily the awaiting one) reveals
55
+ // the correct host regardless of the owner-handoff timing.
56
+ private static weak var pendingRevealHost: HybridChartWebview?
57
+
58
+ // MARK: - HybridView
59
+
60
+ private let container = ChartContainerView()
61
+ var view: UIView { container }
62
+
63
+ /// The WebView backing this host (a shared pool entry, or a private one).
64
+ private var backing: PooledChartWebView?
65
+ private var attached = false
66
+
67
+ override init() {
68
+ super.init()
69
+ container.onWindowChange = { [weak self] attached in
70
+ self?.attached = attached
71
+ self?.scheduleReconcile()
72
+ }
73
+ }
74
+
75
+ // MARK: - Props (source)
76
+
77
+ var uri: String? { didSet { applySourceIfOwner() } }
78
+ var localBundle: String? { didSet { applySourceIfOwner() } }
79
+ var entry: String? { didSet { applySourceIfOwner() } }
80
+ var paramsJson: String? { didSet { applySourceIfOwner() } }
81
+
82
+ // Document-start bridge JS (single source of truth in the TS layer). Handed to
83
+ // the pooled WebView when we claim it, before its first load. Re-applying the
84
+ // source on change triggers a deferred load if this prop lands after the source.
85
+ var bridgeScript: String? { didSet { applySourceIfOwner() } }
86
+
87
+ // MARK: - Props (singleton)
88
+
89
+ // `pooled` + non-empty `reuseKey` => the backing WebView is shared (keyed by
90
+ // reuseKey) across hosts; otherwise the host owns a private WebView.
91
+ var reuseKey: String? { didSet { scheduleReconcile() } }
92
+ var pooled: Bool? { didSet { scheduleReconcile() } }
93
+ // `active` (JS useIsFocused) decides which host, among those sharing a key,
94
+ // owns the single WebView. nil is treated as active (single-host case).
95
+ var active: Bool? {
96
+ didSet {
97
+ // Becoming active: cover with our last frame NOW (synchronously, before the
98
+ // JS symbol-change fires this commit) and wait for the chart's render-ready,
99
+ // so the switch holds the snapshot until the new content paints. Doing it
100
+ // here rather than in the coalesced reconcile is essential — a cached symbol
101
+ // can render + signal before the async reconcile even runs. ownSnapshot is
102
+ // non-nil only for pooled hosts shown before (first show reveals live).
103
+ let wasActive = (oldValue ?? true) != false
104
+ if active != false, !wasActive, let snapshot = ownSnapshot {
105
+ showPlaceholder(snapshot)
106
+ awaitContentReveal()
107
+ }
108
+ scheduleReconcile()
109
+ }
110
+ }
111
+
112
+ // MARK: - Props (events)
113
+
114
+ var onMessage: ((_ message: String) -> Void)?
115
+ var onLoadEnd: (() -> Void)?
116
+ var onError: ((_ message: String) -> Void)?
117
+
118
+ // Called by the backing PooledChartWebView while this host is the owner.
119
+ func handleMessage(_ message: String) {
120
+ // The chart reports it has painted the new symbol after a switch; that's our
121
+ // cue to drop the snapshot we held over the switch and reveal the live chart.
122
+ if message.contains(HybridChartWebview.renderReadyMarker) { onContentRendered() }
123
+ onMessage?(message)
124
+ }
125
+ func handleLoadEnd() { onLoadEnd?() }
126
+ func handleError(_ message: String) { onError?(message) }
127
+
128
+ // MARK: - Methods
129
+
130
+ func postMessage(message: String) throws { backing?.postMessage(message) }
131
+ func reload() throws { backing?.reload() }
132
+ func clearSnapshot() throws { backing?.clearSnapshot() }
133
+
134
+ // MARK: - Ownership reconciliation
135
+
136
+ private func isPooled() -> Bool {
137
+ if let key = reuseKey, !key.isEmpty, pooled == true { return true }
138
+ return false
139
+ }
140
+
141
+ private func effectiveKey() -> String {
142
+ isPooled() ? reuseKey! : "private:\(instanceId)"
143
+ }
144
+
145
+ private func wantsOwnership() -> Bool { attached && (active != false) }
146
+
147
+ // A single React commit applies props one at a time, so the intermediate
148
+ // states are inconsistent (e.g. `pooled` re-applied while `active` is still
149
+ // stale). Reacting to each setter synchronously makes the wrong host claim
150
+ // mid-commit. Coalesce to ONE reconcile at the end of the run-loop, by which
151
+ // point every prop in the commit holds its final value.
152
+ private var reconcileScheduled = false
153
+
154
+ private func scheduleReconcile() {
155
+ if reconcileScheduled { return }
156
+ reconcileScheduled = true
157
+ DispatchQueue.main.async { [weak self] in
158
+ self?.reconcileScheduled = false
159
+ self?.reconcile()
160
+ }
161
+ }
162
+
163
+ private func reconcile() {
164
+ if isPooled() { reconcilePooled() } else { reconcilePrivate() }
165
+ }
166
+
167
+ // Pooled: every host sharing the key references the one entry. The active host
168
+ // displays the live WebView; inactive hosts display the cached frame snapshot
169
+ // so their slot isn't blank (and the live WebView reparents on hand-off).
170
+ private func reconcilePooled() {
171
+ let pooled = ChartWebviewPool.shared.acquireShared(key: effectiveKey())
172
+ backing = pooled
173
+ if wantsOwnership() {
174
+ pooled.owner = self
175
+ // Register the document-start bridge before the first load (the prop is set
176
+ // by now; the pool may have been created earlier by a window-attach reconcile).
177
+ pooled.setBridgeScript(bridgeScript ?? "")
178
+ pooled.attach(to: container)
179
+ pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson)
180
+ // Keep a fresh frame of OUR content while we own the WebView, so that when
181
+ // we later go inactive (and the shared WebView reloads the other slot's
182
+ // chart) our slot freezes to its own last frame, not the other content.
183
+ startOwnCapture()
184
+ // The snapshot hold/reveal over a switch is driven by the `active` setter +
185
+ // render-ready, not here. Just keep a held snapshot on top of the freshly
186
+ // attached WebView, or clear a stale one if we're not holding.
187
+ if awaitingReveal {
188
+ if let ph = placeholder { container.bringSubviewToFront(ph) }
189
+ } else {
190
+ removePlaceholder()
191
+ }
192
+ } else {
193
+ // Inactive: give up ownership only if we still hold it, and freeze to our
194
+ // own last captured frame. We do NOT detach (that races a rapid re-claim).
195
+ let wasOwner = pooled.owner === self
196
+ if wasOwner { pooled.owner = nil }
197
+ stopOwnCapture()
198
+ showPlaceholder(ownSnapshot)
199
+ }
200
+ }
201
+
202
+ // OUR content's last frame. Because the WebView is shared and reloads other
203
+ // slots' charts, we keep our own snapshot, captured periodically while we own
204
+ // the WebView (late results dropped once we yield, so it only holds our frame).
205
+ private var ownSnapshot: UIImage?
206
+ private var capturing = false
207
+
208
+ private func startOwnCapture() {
209
+ if capturing { return }
210
+ capturing = true
211
+ scheduleOwnCapture()
212
+ }
213
+
214
+ private func stopOwnCapture() { capturing = false }
215
+
216
+ private func scheduleOwnCapture() {
217
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
218
+ guard let self = self, self.capturing,
219
+ let entry = self.backing, entry.owner === self else { return }
220
+ entry.captureNow { [weak self] image in
221
+ guard let self = self else { return }
222
+ if let image = image, entry.owner === self { self.ownSnapshot = image }
223
+ }
224
+ if self.capturing { self.scheduleOwnCapture() }
225
+ }
226
+ }
227
+
228
+ // Non-pooled: a private WebView, claimed when active, kept on this host so a
229
+ // later re-claim reuses it without a reload. No placeholder (single host).
230
+ private func reconcilePrivate() {
231
+ guard wantsOwnership() else { return }
232
+ let pooled = backing ?? PooledChartWebView(key: effectiveKey())
233
+ backing = pooled
234
+ pooled.owner = self
235
+ pooled.setBridgeScript(bridgeScript ?? "")
236
+ pooled.attach(to: container)
237
+ pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson)
238
+ }
239
+
240
+ private func applySourceIfOwner() {
241
+ guard let pooled = backing, pooled.owner === self else { return }
242
+ // Register the bridge before any load setSource may trigger (setSource defers
243
+ // loading until the bridge is registered).
244
+ pooled.setBridgeScript(bridgeScript ?? "")
245
+ pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson)
246
+ }
247
+
248
+ // MARK: - Snapshot placeholder (shown while this host is inactive)
249
+
250
+ private var placeholder: UIImageView?
251
+
252
+ private func showPlaceholder(_ image: UIImage?) {
253
+ guard let image = image else { return }
254
+ let iv: UIImageView
255
+ if let existing = placeholder {
256
+ iv = existing
257
+ } else {
258
+ iv = UIImageView()
259
+ iv.contentMode = .scaleToFill
260
+ iv.frame = container.bounds
261
+ iv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
262
+ container.addSubview(iv)
263
+ placeholder = iv
264
+ }
265
+ iv.image = image
266
+ container.bringSubviewToFront(iv)
267
+ }
268
+
269
+ private func removePlaceholder() {
270
+ placeholder?.removeFromSuperview()
271
+ placeholder = nil
272
+ }
273
+
274
+ // MARK: - Reveal coordination (hold the snapshot over a switch until the chart
275
+ // reports the new symbol has painted, with a safety-net timeout)
276
+
277
+ private var awaitingReveal = false
278
+ private var revealFallbackWork: DispatchWorkItem?
279
+
280
+ private func awaitContentReveal() {
281
+ awaitingReveal = true
282
+ HybridChartWebview.pendingRevealHost = self
283
+ revealFallbackWork?.cancel()
284
+ let work = DispatchWorkItem { [weak self] in self?.doReveal() }
285
+ revealFallbackWork = work
286
+ // Safety net: reveal even if the render-ready signal is missed.
287
+ DispatchQueue.main.asyncAfter(
288
+ deadline: .now() + HybridChartWebview.revealFallback, execute: work)
289
+ }
290
+
291
+ // The chart signalled the post-switch render is done (see handleMessage).
292
+ // Reveal whichever host is holding a snapshot — render-ready may land on a
293
+ // different host than the one waiting, depending on owner-handoff timing.
294
+ private func onContentRendered() {
295
+ HybridChartWebview.pendingRevealHost?.doReveal()
296
+ }
297
+
298
+ private func doReveal() {
299
+ guard awaitingReveal else { return }
300
+ awaitingReveal = false
301
+ if HybridChartWebview.pendingRevealHost === self {
302
+ HybridChartWebview.pendingRevealHost = nil
303
+ }
304
+ revealFallbackWork?.cancel()
305
+ revealFallbackWork = nil
306
+ removePlaceholder()
307
+ }
308
+ }
309
+
310
+ // MARK: - PooledChartWebView (owns the WKWebView)
311
+
312
+ /// Owns one real `WKWebView` together with its message bridge, custom-scheme
313
+ /// handler and load state. A single instance can be **reparented** across
314
+ /// multiple hosts that share a `reuseKey`, so N mount points are backed by ONE
315
+ /// WebView (state preserved, no reload on hand-off). Page events are routed to
316
+ /// the current `owner`.
317
+ final class PooledChartWebView {
318
+ // Live WebView count, logged on create/destroy — the signal the example uses
319
+ // to verify the singleton (N hosts sharing a key => one "CREATED" line).
320
+ private static var liveCount = 0
321
+
322
+ let key: String
323
+ weak var owner: HybridChartWebview?
324
+
325
+ // The page's user-content controller, kept so the document-start bridge can be
326
+ // added lazily (see setBridgeScript) once the host has the prop value.
327
+ private var userContent: WKUserContentController?
328
+ private var bridgeRegistered = false
329
+
330
+ private var webView: WKWebView!
331
+ private var proxy: ChartWebViewProxy!
332
+ private var lastLoadedUrl: String?
333
+
334
+ /// Read by the scheme handler to resolve offline files.
335
+ fileprivate var currentLocalBundle: String?
336
+
337
+ // Last rendered frame, used to mask the brief blank frame the reparent (move)
338
+ // can produce. WKWebView renders out-of-process, so a *synchronous* capture
339
+ // returns blank — we keep an asynchronously-captured snapshot fresh instead
340
+ // (after load and after each move) and show it as an overlay on the next move.
341
+ private var cachedSnapshot: UIImage?
342
+ private var overlay: UIImageView?
343
+
344
+ init(key: String) {
345
+ self.key = key
346
+ PooledChartWebView.liveCount += 1
347
+ NSLog("[ChartWebviewPool] WebView CREATED key=\(key) liveCount=\(PooledChartWebView.liveCount)")
348
+ setupWebView()
349
+ }
350
+
351
+ // Called by the host before the first load. Registers the document-start bridge
352
+ // (shim + the shared TS bridgeScript) once a non-empty script is available;
353
+ // subsequent calls are no-ops. Done lazily because the first reconcile
354
+ // (window-attach) can run before the bridgeScript prop is applied.
355
+ func setBridgeScript(_ bridgeScript: String) {
356
+ guard !bridgeRegistered, !bridgeScript.isEmpty, let userContent = userContent else { return }
357
+ bridgeRegistered = true
358
+ let handlerName = ChartWebviewConst.messageHandlerName
359
+ let shim = "(function(){window.__chartNativePost=function(s){"
360
+ + "window.webkit.messageHandlers.\(handlerName).postMessage(s);};})();"
361
+ let userScript = WKUserScript(
362
+ source: shim + "\n" + bridgeScript,
363
+ injectionTime: .atDocumentStart,
364
+ forMainFrameOnly: false
365
+ )
366
+ userContent.addUserScript(userScript)
367
+ }
368
+
369
+ private func setupWebView() {
370
+ let proxy = ChartWebViewProxy(pooled: self)
371
+ self.proxy = proxy
372
+
373
+ let config = WKWebViewConfiguration()
374
+ let preferences = WKPreferences()
375
+ preferences.javaScriptCanOpenWindowsAutomatically = false
376
+ config.preferences = preferences
377
+ if #available(iOS 14.0, *) {
378
+ let pagePrefs = WKWebpagePreferences()
379
+ pagePrefs.allowsContentJavaScript = true
380
+ config.defaultWebpagePreferences = pagePrefs
381
+ } else {
382
+ config.preferences.javaScriptEnabled = true
383
+ }
384
+
385
+ config.setURLSchemeHandler(proxy, forURLScheme: ChartWebviewConst.customScheme)
386
+
387
+ let userContent = WKUserContentController()
388
+ userContent.add(proxy, name: ChartWebviewConst.messageHandlerName)
389
+ // The document-start bridge is added later via setBridgeScript (the TS prop
390
+ // isn't available yet at construction).
391
+ self.userContent = userContent
392
+ config.userContentController = userContent
393
+
394
+ let webView = WKWebView(frame: .zero, configuration: config)
395
+ webView.navigationDelegate = proxy
396
+ webView.uiDelegate = proxy
397
+ webView.scrollView.bounces = false
398
+ if #available(iOS 16.4, *) {
399
+ webView.isInspectable = true
400
+ }
401
+ self.webView = webView
402
+ }
403
+
404
+ // MARK: - Reparenting
405
+
406
+ /// Move the WebView into `container`, detaching it from any previous parent.
407
+ func attach(to container: UIView) {
408
+ runOnMain { [weak self] in
409
+ guard let self = self, let webView = self.webView else { return }
410
+ guard webView.superview !== container else { return }
411
+ webView.removeFromSuperview()
412
+ webView.frame = container.bounds
413
+ webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
414
+ container.addSubview(webView)
415
+ self.showSnapshotOverlay(in: container)
416
+ // Capture a fresh snapshot (async) so the NEXT move has a current frame.
417
+ self.refreshSnapshotSoon()
418
+ }
419
+ }
420
+
421
+ /// Remove the WebView from its current parent (keeps it alive, warm).
422
+ func detachFromParent() {
423
+ runOnMain { [weak self] in
424
+ self?.removeOverlay()
425
+ self?.webView?.removeFromSuperview()
426
+ }
427
+ }
428
+
429
+ /// Drop the cached snapshot and remove any visible overlay.
430
+ func clearSnapshot() {
431
+ runOnMain { [weak self] in
432
+ self?.removeOverlay()
433
+ self?.cachedSnapshot = nil
434
+ }
435
+ }
436
+
437
+ /// The last captured frame (shown by inactive hosts as a placeholder).
438
+ func snapshot() -> UIImage? { cachedSnapshot }
439
+
440
+ /// Asynchronously capture the current frame shortly after things settle.
441
+ func refreshSnapshotSoon() {
442
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
443
+ self?.takeSnapshotNow(nil)
444
+ }
445
+ }
446
+
447
+ /// Capture now and deliver the fresh frame (so the host losing ownership can
448
+ /// upgrade its placeholder to its very last frame).
449
+ func captureNow(_ callback: @escaping (UIImage?) -> Void) {
450
+ takeSnapshotNow(callback)
451
+ }
452
+
453
+ private func takeSnapshotNow(_ callback: ((UIImage?) -> Void)?) {
454
+ // Don't capture mid-reparent (overlay visible): the page may be blank for a
455
+ // frame and we'd cache a black image. Keep the last good frame instead.
456
+ guard overlay == nil, let webView = webView, webView.superview != nil,
457
+ webView.bounds.width > 0, webView.bounds.height > 0 else {
458
+ callback?(cachedSnapshot)
459
+ return
460
+ }
461
+ let config = WKSnapshotConfiguration()
462
+ config.afterScreenUpdates = false
463
+ webView.takeSnapshot(with: config) { [weak self] image, _ in
464
+ if let image = image { self?.cachedSnapshot = image }
465
+ callback?(self?.cachedSnapshot)
466
+ }
467
+ }
468
+
469
+ private func showSnapshotOverlay(in container: UIView) {
470
+ guard let snap = cachedSnapshot else { return }
471
+ removeOverlay()
472
+ let iv = UIImageView(image: snap)
473
+ iv.frame = container.bounds
474
+ iv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
475
+ iv.contentMode = .scaleToFill
476
+ container.addSubview(iv) // added last => on top of the WebView
477
+ overlay = iv
478
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
479
+ self?.removeOverlay()
480
+ }
481
+ }
482
+
483
+ private func removeOverlay() {
484
+ overlay?.removeFromSuperview()
485
+ overlay = nil
486
+ }
487
+
488
+ // MARK: - Loading
489
+
490
+ /// Apply the source props and (re)load only when the effective URL changes —
491
+ /// so reparenting / redundant prop re-applies never reload (which would lose
492
+ /// chart state, the whole point of pooling).
493
+ func setSource(uri: String?, localBundle: String?, entry: String?, paramsJson: String?) {
494
+ // Never load before the document-start bridge is registered — otherwise the
495
+ // page boots without the bridge and its first $private requests are lost. The
496
+ // host re-calls setSource once the bridgeScript prop arrives.
497
+ guard bridgeRegistered else { return }
498
+ currentLocalBundle = localBundle
499
+ guard let urlString = computeTargetUrl(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) else { return }
500
+ guard urlString != lastLoadedUrl else { return }
501
+ guard let url = URL(string: urlString) else {
502
+ owner?.handleError("Invalid url: \(urlString)")
503
+ return
504
+ }
505
+ lastLoadedUrl = urlString
506
+ runOnMain { [weak self] in self?.webView?.load(URLRequest(url: url)) }
507
+ }
508
+
509
+ private func computeTargetUrl(uri: String?, localBundle: String?, entry: String?, paramsJson: String?) -> String? {
510
+ if let uri = uri, !uri.isEmpty { return uri }
511
+ if let localBundle = localBundle, !localBundle.isEmpty {
512
+ let entryFile = (entry?.isEmpty == false) ? entry! : "index.html"
513
+ let query = buildQueryString(fromParamsJson: paramsJson)
514
+ var urlString = "\(ChartWebviewConst.customScheme)://\(ChartWebviewConst.virtualHost)/\(entryFile)"
515
+ if !query.isEmpty { urlString += "?\(query)" }
516
+ return urlString
517
+ }
518
+ return nil
519
+ }
520
+
521
+ private func buildQueryString(fromParamsJson json: String?) -> String {
522
+ guard let json = json, !json.isEmpty,
523
+ let data = json.data(using: .utf8),
524
+ let obj = try? JSONSerialization.jsonObject(with: data),
525
+ let dict = obj as? [String: Any] else {
526
+ return ""
527
+ }
528
+ var allowed = CharacterSet.urlQueryAllowed
529
+ allowed.remove(charactersIn: "&=+?#")
530
+ var pairs: [String] = []
531
+ for (key, value) in dict {
532
+ let stringValue: String
533
+ switch value {
534
+ case let s as String: stringValue = s
535
+ case let b as Bool: stringValue = b ? "true" : "false"
536
+ case let n as NSNumber: stringValue = n.stringValue
537
+ default: stringValue = "\(value)"
538
+ }
539
+ let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
540
+ let encodedValue = stringValue.addingPercentEncoding(withAllowedCharacters: allowed) ?? stringValue
541
+ pairs.append("\(encodedKey)=\(encodedValue)")
542
+ }
543
+ return pairs.sorted().joined(separator: "&")
544
+ }
545
+
546
+ // MARK: - Bridge methods
547
+
548
+ func postMessage(_ message: String) {
549
+ runOnMain { [weak self] in
550
+ guard let self = self, let webView = self.webView else { return }
551
+ let jsStringLiteral = self.jsStringLiteral(from: message)
552
+ let js = "window.postMessage(JSON.parse(\(jsStringLiteral)), '*')"
553
+ webView.evaluateJavaScript(js, completionHandler: nil)
554
+ }
555
+ }
556
+
557
+ func reload() {
558
+ runOnMain { [weak self] in self?.webView?.reload() }
559
+ }
560
+
561
+ private func jsStringLiteral(from raw: String) -> String {
562
+ if let data = try? JSONSerialization.data(withJSONObject: [raw], options: []),
563
+ let arrStr = String(data: data, encoding: .utf8) {
564
+ var s = arrStr
565
+ if s.hasPrefix("[") { s.removeFirst() }
566
+ if s.hasSuffix("]") { s.removeLast() }
567
+ return s.trimmingCharacters(in: .whitespacesAndNewlines)
568
+ }
569
+ let escaped = raw
570
+ .replacingOccurrences(of: "\\", with: "\\\\")
571
+ .replacingOccurrences(of: "\"", with: "\\\"")
572
+ .replacingOccurrences(of: "\n", with: "\\n")
573
+ .replacingOccurrences(of: "\r", with: "\\r")
574
+ return "\"\(escaped)\""
575
+ }
576
+
577
+ private func runOnMain(_ block: @escaping () -> Void) {
578
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
579
+ }
580
+ }
581
+
582
+ // MARK: - ChartWebViewProxy (NSObject host for the WebKit conformances)
583
+
584
+ /// Owns every WebKit delegate / handler conformance that requires `NSObject`,
585
+ /// forwarding back to the pooled WebView's current owner. Held strongly by the
586
+ /// PooledChartWebView; references it weakly to avoid a retain cycle.
587
+ private final class ChartWebViewProxy: NSObject {
588
+ private weak var pooled: PooledChartWebView?
589
+
590
+ init(pooled: PooledChartWebView) {
591
+ self.pooled = pooled
592
+ super.init()
593
+ }
594
+ }
595
+
596
+ // MARK: - WKScriptMessageHandler (page -> native)
597
+
598
+ extension ChartWebViewProxy: WKScriptMessageHandler {
599
+ func userContentController(
600
+ _ userContentController: WKUserContentController,
601
+ didReceive message: WKScriptMessage
602
+ ) {
603
+ guard message.name == ChartWebviewConst.messageHandlerName else { return }
604
+ let owner = pooled?.owner
605
+ if let body = message.body as? String {
606
+ owner?.handleMessage(body)
607
+ } else if let data = try? JSONSerialization.data(withJSONObject: message.body, options: []),
608
+ let str = String(data: data, encoding: .utf8) {
609
+ owner?.handleMessage(str)
610
+ } else {
611
+ owner?.handleMessage("\(message.body)")
612
+ }
613
+ }
614
+ }
615
+
616
+ // MARK: - WKNavigationDelegate (load/error events)
617
+
618
+ extension ChartWebViewProxy: WKNavigationDelegate {
619
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
620
+ pooled?.owner?.handleLoadEnd()
621
+ // Prime the snapshot so the first move already has a frame to mask with.
622
+ pooled?.refreshSnapshotSoon()
623
+ }
624
+
625
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
626
+ pooled?.owner?.handleError(error.localizedDescription)
627
+ }
628
+
629
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
630
+ pooled?.owner?.handleError(error.localizedDescription)
631
+ }
632
+ }
633
+
634
+ // MARK: - WKUIDelegate
635
+
636
+ extension ChartWebViewProxy: WKUIDelegate {}
637
+
638
+ // MARK: - WKURLSchemeHandler (offline virtual same-origin)
639
+
640
+ extension ChartWebViewProxy: WKURLSchemeHandler {
641
+ func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
642
+ guard let url = urlSchemeTask.request.url else {
643
+ urlSchemeTask.didFailWithError(
644
+ NSError(domain: "ChartWebview", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing url"])
645
+ )
646
+ return
647
+ }
648
+
649
+ guard let localBundle = pooled?.currentLocalBundle, !localBundle.isEmpty else {
650
+ respondNotFound(url: url, task: urlSchemeTask)
651
+ return
652
+ }
653
+
654
+ var relativePath = url.path
655
+ if relativePath.hasPrefix("/") { relativePath.removeFirst() }
656
+ if relativePath.isEmpty { relativePath = "index.html" }
657
+
658
+ guard let fileURL = resolveBundleFileURL(localBundle: localBundle, relativePath: relativePath),
659
+ let data = try? Data(contentsOf: fileURL) else {
660
+ respondNotFound(url: url, task: urlSchemeTask)
661
+ return
662
+ }
663
+
664
+ let headers: [String: String] = [
665
+ "Content-Type": mimeTypeForPath(fileURL.pathExtension),
666
+ "Content-Length": "\(data.count)",
667
+ "Access-Control-Allow-Origin": "*",
668
+ "Cache-Control": "no-cache",
669
+ ]
670
+ guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers) else {
671
+ respondNotFound(url: url, task: urlSchemeTask)
672
+ return
673
+ }
674
+
675
+ urlSchemeTask.didReceive(response)
676
+ urlSchemeTask.didReceive(data)
677
+ urlSchemeTask.didFinish()
678
+ }
679
+
680
+ func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
681
+
682
+ private func resolveBundleFileURL(localBundle: String, relativePath: String) -> URL? {
683
+ let resourceURL = Bundle.main.resourceURL ?? Bundle.main.bundleURL
684
+ let baseDir = resourceURL.appendingPathComponent(localBundle, isDirectory: true)
685
+ let candidate = baseDir.appendingPathComponent(relativePath).standardizedFileURL
686
+ let basePath = baseDir.standardizedFileURL.path
687
+ guard candidate.path == basePath || candidate.path.hasPrefix(basePath + "/") else { return nil }
688
+ let fm = FileManager.default
689
+ var isDir: ObjCBool = false
690
+ guard fm.fileExists(atPath: candidate.path, isDirectory: &isDir), !isDir.boolValue else { return nil }
691
+ return candidate
692
+ }
693
+
694
+ private func respondNotFound(url: URL, task: WKURLSchemeTask) {
695
+ let body = "Not Found".data(using: .utf8) ?? Data()
696
+ let response = HTTPURLResponse(
697
+ url: url,
698
+ statusCode: 404,
699
+ httpVersion: "HTTP/1.1",
700
+ headerFields: ["Content-Type": "text/plain", "Access-Control-Allow-Origin": "*"]
701
+ )!
702
+ task.didReceive(response)
703
+ task.didReceive(body)
704
+ task.didFinish()
705
+ }
706
+
707
+ private func mimeTypeForPath(_ ext: String) -> String {
708
+ switch ext.lowercased() {
709
+ case "html", "htm": return "text/html; charset=utf-8"
710
+ case "js", "mjs": return "application/javascript; charset=utf-8"
711
+ case "css": return "text/css; charset=utf-8"
712
+ case "json", "map": return "application/json; charset=utf-8"
713
+ case "wasm": return "application/wasm"
714
+ case "woff2": return "font/woff2"
715
+ case "woff": return "font/woff"
716
+ case "ttf": return "font/ttf"
717
+ case "otf": return "font/otf"
718
+ case "eot": return "application/vnd.ms-fontobject"
719
+ case "svg": return "image/svg+xml"
720
+ case "png": return "image/png"
721
+ case "jpg", "jpeg": return "image/jpeg"
722
+ case "gif": return "image/gif"
723
+ case "webp": return "image/webp"
724
+ case "ico": return "image/x-icon"
725
+ case "txt": return "text/plain; charset=utf-8"
726
+ default: return "application/octet-stream"
727
+ }
728
+ }
729
+ }
730
+
731
+ // MARK: - ChartWebviewPool (warm pool keyed by reuseKey)
732
+
733
+ /// Warm pool of `PooledChartWebView`s keyed by `reuseKey`. Entries are created
734
+ /// on first use and kept warm (a single static chart instance, OKX-style), so
735
+ /// the next mount point that claims the same key reuses the live WebView instead
736
+ /// of recreating it. Non-pooled hosts don't use this — they own a private one.
737
+ final class ChartWebviewPool {
738
+ static let shared = ChartWebviewPool()
739
+ private var entries: [String: PooledChartWebView] = [:]
740
+
741
+ func acquireShared(key: String) -> PooledChartWebView {
742
+ if let existing = entries[key] { return existing }
743
+ let created = PooledChartWebView(key: key)
744
+ entries[key] = created
745
+ return created
746
+ }
747
+ }