@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.
- package/ChartWebview.podspec +29 -0
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +131 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt +331 -0
- package/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebviewPackage.kt +29 -0
- package/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +385 -0
- package/ios/ChartWebview.swift +747 -0
- package/lib/module/ChartWebview.nitro.js +4 -0
- package/lib/module/ChartWebview.nitro.js.map +1 -0
- package/lib/module/bridge.js +30 -0
- package/lib/module/bridge.js.map +1 -0
- package/lib/module/index.js +19 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/lib/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +186 -0
- package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +88 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +96 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.hpp +49 -0
- package/lib/nitrogen/generated/android/chartwebview+autolinking.cmake +83 -0
- package/lib/nitrogen/generated/android/chartwebview+autolinking.gradle +27 -0
- package/lib/nitrogen/generated/android/chartwebviewOnLoad.cpp +50 -0
- package/lib/nitrogen/generated/android/chartwebviewOnLoad.hpp +25 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void.kt +80 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void_std__string.kt +80 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +153 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/chartwebviewOnLoad.kt +35 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewManager.kt +50 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewStateUpdater.kt +23 -0
- package/lib/nitrogen/generated/ios/ChartWebview+autolinking.rb +60 -0
- package/lib/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.cpp +49 -0
- package/lib/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.hpp +158 -0
- package/lib/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Umbrella.hpp +46 -0
- package/lib/nitrogen/generated/ios/ChartWebviewAutolinking.mm +33 -0
- package/lib/nitrogen/generated/ios/ChartWebviewAutolinking.swift +25 -0
- package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.cpp +11 -0
- package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +164 -0
- package/lib/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +146 -0
- package/lib/nitrogen/generated/ios/swift/Func_void.swift +47 -0
- package/lib/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
- package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +68 -0
- package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +450 -0
- package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +45 -0
- package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +87 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +207 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +118 -0
- package/lib/nitrogen/generated/shared/json/ChartWebviewConfig.json +20 -0
- package/lib/nitrogen/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/lib/nitrogen/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +186 -0
- package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +88 -0
- package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +96 -0
- package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.hpp +49 -0
- package/lib/nitrogen/nitrogen/generated/android/chartwebview+autolinking.cmake +83 -0
- package/lib/nitrogen/nitrogen/generated/android/chartwebview+autolinking.gradle +27 -0
- package/lib/nitrogen/nitrogen/generated/android/chartwebviewOnLoad.cpp +50 -0
- package/lib/nitrogen/nitrogen/generated/android/chartwebviewOnLoad.hpp +25 -0
- package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void.kt +80 -0
- package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void_std__string.kt +80 -0
- package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +153 -0
- package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/chartwebviewOnLoad.kt +35 -0
- package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewManager.kt +50 -0
- package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewStateUpdater.kt +23 -0
- package/lib/nitrogen/nitrogen/generated/ios/ChartWebview+autolinking.rb +60 -0
- package/lib/nitrogen/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.cpp +49 -0
- package/lib/nitrogen/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.hpp +158 -0
- package/lib/nitrogen/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Umbrella.hpp +46 -0
- package/lib/nitrogen/nitrogen/generated/ios/ChartWebviewAutolinking.mm +33 -0
- package/lib/nitrogen/nitrogen/generated/ios/ChartWebviewAutolinking.swift +25 -0
- package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.cpp +11 -0
- package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +164 -0
- package/lib/nitrogen/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +146 -0
- package/lib/nitrogen/nitrogen/generated/ios/swift/Func_void.swift +47 -0
- package/lib/nitrogen/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
- package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +68 -0
- package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +450 -0
- package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +45 -0
- package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +87 -0
- package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +207 -0
- package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +118 -0
- package/lib/nitrogen/nitrogen/generated/shared/json/ChartWebviewConfig.json +20 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/ChartWebview.nitro.d.ts +21 -0
- package/lib/typescript/src/ChartWebview.nitro.d.ts.map +1 -0
- package/lib/typescript/src/bridge.d.ts +2 -0
- package/lib/typescript/src/bridge.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +145 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +17 -0
- package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +186 -0
- package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +88 -0
- package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +96 -0
- package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/chartwebview+autolinking.cmake +83 -0
- package/nitrogen/generated/android/chartwebview+autolinking.gradle +27 -0
- package/nitrogen/generated/android/chartwebviewOnLoad.cpp +50 -0
- package/nitrogen/generated/android/chartwebviewOnLoad.hpp +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/Func_void_std__string.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +153 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/chartwebviewOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewManager.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/views/HybridChartWebviewStateUpdater.kt +23 -0
- package/nitrogen/generated/ios/ChartWebview+autolinking.rb +60 -0
- package/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.cpp +49 -0
- package/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Bridge.hpp +158 -0
- package/nitrogen/generated/ios/ChartWebview-Swift-Cxx-Umbrella.hpp +46 -0
- package/nitrogen/generated/ios/ChartWebviewAutolinking.mm +33 -0
- package/nitrogen/generated/ios/ChartWebviewAutolinking.swift +25 -0
- package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +164 -0
- package/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +146 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +68 -0
- package/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +450 -0
- package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +45 -0
- package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +87 -0
- package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +207 -0
- package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +118 -0
- package/nitrogen/generated/shared/json/ChartWebviewConfig.json +20 -0
- package/package.json +170 -0
- package/src/ChartWebview.nitro.ts +59 -0
- package/src/bridge.ts +27 -0
- 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
|
+
}
|