@elizaos/capacitor-canvas 1.0.0
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/ElizaosCapacitorCanvas.podspec +18 -0
- package/android/build.gradle +48 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/eliza/plugins/canvas/CanvasPlugin.kt +2175 -0
- package/dist/esm/definitions.d.ts +473 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +154 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +857 -0
- package/dist/plugin.cjs.js +873 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +876 -0
- package/dist/plugin.js.map +1 -0
- package/electrobun/src/index.ts +872 -0
- package/electrobun/tsconfig.json +18 -0
- package/ios/Sources/CanvasPlugin/CanvasPlugin.swift +1933 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1933 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import UIKit
|
|
4
|
+
import CoreGraphics
|
|
5
|
+
import WebKit
|
|
6
|
+
|
|
7
|
+
// MARK: - Plugin Registration
|
|
8
|
+
|
|
9
|
+
@objc(ElizaCanvasPlugin)
|
|
10
|
+
public class ElizaCanvasPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
11
|
+
public let identifier = "ElizaCanvasPlugin"
|
|
12
|
+
public let jsName = "ElizaCanvas"
|
|
13
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
14
|
+
// Drawing canvas
|
|
15
|
+
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "attach", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "detach", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "resize", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "createLayer", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "updateLayer", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "deleteLayer", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "getLayers", returnType: CAPPluginReturnPromise),
|
|
25
|
+
CAPPluginMethod(name: "drawRect", returnType: CAPPluginReturnPromise),
|
|
26
|
+
CAPPluginMethod(name: "drawEllipse", returnType: CAPPluginReturnPromise),
|
|
27
|
+
CAPPluginMethod(name: "drawLine", returnType: CAPPluginReturnPromise),
|
|
28
|
+
CAPPluginMethod(name: "drawPath", returnType: CAPPluginReturnPromise),
|
|
29
|
+
CAPPluginMethod(name: "drawText", returnType: CAPPluginReturnPromise),
|
|
30
|
+
CAPPluginMethod(name: "drawImage", returnType: CAPPluginReturnPromise),
|
|
31
|
+
CAPPluginMethod(name: "drawBatch", returnType: CAPPluginReturnPromise),
|
|
32
|
+
CAPPluginMethod(name: "getPixelData", returnType: CAPPluginReturnPromise),
|
|
33
|
+
CAPPluginMethod(name: "toImage", returnType: CAPPluginReturnPromise),
|
|
34
|
+
CAPPluginMethod(name: "setTransform", returnType: CAPPluginReturnPromise),
|
|
35
|
+
CAPPluginMethod(name: "resetTransform", returnType: CAPPluginReturnPromise),
|
|
36
|
+
CAPPluginMethod(name: "setTouchEnabled", returnType: CAPPluginReturnPromise),
|
|
37
|
+
// Web canvas
|
|
38
|
+
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
|
|
39
|
+
CAPPluginMethod(name: "eval", returnType: CAPPluginReturnPromise),
|
|
40
|
+
CAPPluginMethod(name: "snapshot", returnType: CAPPluginReturnPromise),
|
|
41
|
+
CAPPluginMethod(name: "a2uiPush", returnType: CAPPluginReturnPromise),
|
|
42
|
+
CAPPluginMethod(name: "a2uiReset", returnType: CAPPluginReturnPromise),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
private var canvases: [String: ManagedCanvas] = [:]
|
|
46
|
+
private var nextCanvasId = 1
|
|
47
|
+
private var nextLayerId = 1
|
|
48
|
+
|
|
49
|
+
// MARK: - Create / Destroy
|
|
50
|
+
|
|
51
|
+
@objc func create(_ call: CAPPluginCall) {
|
|
52
|
+
guard let sizeObj = call.getObject("size"),
|
|
53
|
+
let width = sizeObj["width"] as? Int,
|
|
54
|
+
let height = sizeObj["height"] as? Int else {
|
|
55
|
+
call.reject("Missing size parameter")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let canvasId = "canvas_\(nextCanvasId)"
|
|
60
|
+
nextCanvasId += 1
|
|
61
|
+
|
|
62
|
+
let size = CGSize(width: width, height: height)
|
|
63
|
+
let canvas = ManagedCanvas(id: canvasId, size: size)
|
|
64
|
+
|
|
65
|
+
if let bgColorObj = call.getObject("backgroundColor") {
|
|
66
|
+
let color = colorFromObject(bgColorObj)
|
|
67
|
+
DispatchQueue.main.async {
|
|
68
|
+
canvas.view.backgroundColor = color
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
canvases[canvasId] = canvas
|
|
73
|
+
call.resolve(["canvasId": canvasId])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@objc func destroy(_ call: CAPPluginCall) {
|
|
77
|
+
guard let canvasId = call.getString("canvasId") else {
|
|
78
|
+
call.reject("Missing canvasId")
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
DispatchQueue.main.async { [weak self] in
|
|
83
|
+
guard let canvas = self?.canvases[canvasId] else {
|
|
84
|
+
call.resolve()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
canvas.webView?.removeFromSuperview()
|
|
88
|
+
canvas.view.removeFromSuperview()
|
|
89
|
+
self?.canvases.removeValue(forKey: canvasId)
|
|
90
|
+
call.resolve()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// MARK: - Attach / Detach
|
|
95
|
+
|
|
96
|
+
@objc func attach(_ call: CAPPluginCall) {
|
|
97
|
+
guard let canvasId = call.getString("canvasId"),
|
|
98
|
+
let canvas = canvases[canvasId] else {
|
|
99
|
+
call.reject("Canvas not found")
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
DispatchQueue.main.async { [weak self] in
|
|
104
|
+
guard let webView = self?.bridge?.webView else {
|
|
105
|
+
call.reject("WebView not found")
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
canvas.view.frame = webView.bounds
|
|
110
|
+
canvas.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
111
|
+
webView.superview?.insertSubview(canvas.view, belowSubview: webView)
|
|
112
|
+
webView.isOpaque = false
|
|
113
|
+
webView.backgroundColor = .clear
|
|
114
|
+
|
|
115
|
+
// If a web canvas exists, ensure it's also in the hierarchy.
|
|
116
|
+
if let wv = canvas.webView {
|
|
117
|
+
wv.frame = canvas.view.bounds
|
|
118
|
+
wv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
119
|
+
canvas.view.insertSubview(wv, at: 0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
canvas.view.touchHandler = { [weak self] type, touches in
|
|
123
|
+
guard let self = self, canvas.touchEnabled else { return }
|
|
124
|
+
let touchArray = touches.map { touch -> [String: Any] in
|
|
125
|
+
var dict: [String: Any] = [
|
|
126
|
+
"id": touch.id,
|
|
127
|
+
"x": touch.x,
|
|
128
|
+
"y": touch.y,
|
|
129
|
+
]
|
|
130
|
+
if let force = touch.force {
|
|
131
|
+
dict["force"] = force
|
|
132
|
+
}
|
|
133
|
+
return dict
|
|
134
|
+
}
|
|
135
|
+
self.notifyListeners("touch", data: [
|
|
136
|
+
"type": type,
|
|
137
|
+
"touches": touchArray,
|
|
138
|
+
"timestamp": Date().timeIntervalSince1970 * 1000,
|
|
139
|
+
])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
call.resolve()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@objc func detach(_ call: CAPPluginCall) {
|
|
147
|
+
guard let canvasId = call.getString("canvasId"),
|
|
148
|
+
let canvas = canvases[canvasId] else {
|
|
149
|
+
call.reject("Canvas not found")
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
DispatchQueue.main.async {
|
|
154
|
+
canvas.view.removeFromSuperview()
|
|
155
|
+
call.resolve()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// MARK: - Resize / Clear
|
|
160
|
+
|
|
161
|
+
@objc func resize(_ call: CAPPluginCall) {
|
|
162
|
+
guard let canvasId = call.getString("canvasId"),
|
|
163
|
+
let canvas = canvases[canvasId],
|
|
164
|
+
let sizeObj = call.getObject("size"),
|
|
165
|
+
let width = sizeObj["width"] as? Int,
|
|
166
|
+
let height = sizeObj["height"] as? Int else {
|
|
167
|
+
call.reject("Missing parameters")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
DispatchQueue.main.async {
|
|
172
|
+
canvas.size = CGSize(width: width, height: height)
|
|
173
|
+
canvas.view.frame.size = canvas.size
|
|
174
|
+
canvas.webView?.frame.size = canvas.size
|
|
175
|
+
call.resolve()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@objc func clear(_ call: CAPPluginCall) {
|
|
180
|
+
guard let canvasId = call.getString("canvasId"),
|
|
181
|
+
let canvas = canvases[canvasId] else {
|
|
182
|
+
call.reject("Canvas not found")
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let layerId = call.getString("layerId")
|
|
187
|
+
let rectObj = call.getObject("rect")
|
|
188
|
+
|
|
189
|
+
DispatchQueue.main.async {
|
|
190
|
+
let targetView: CanvasView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
191
|
+
|
|
192
|
+
if let rectObj = rectObj,
|
|
193
|
+
let x = rectObj["x"] as? CGFloat,
|
|
194
|
+
let y = rectObj["y"] as? CGFloat,
|
|
195
|
+
let width = rectObj["width"] as? CGFloat,
|
|
196
|
+
let height = rectObj["height"] as? CGFloat {
|
|
197
|
+
guard let ctx = targetView.createContext() else { return }
|
|
198
|
+
ctx.clear(CGRect(x: x, y: y, width: width, height: height))
|
|
199
|
+
targetView.commitContext()
|
|
200
|
+
} else {
|
|
201
|
+
targetView.setImage(nil)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
call.resolve()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// MARK: - Layer Operations
|
|
209
|
+
|
|
210
|
+
@objc func createLayer(_ call: CAPPluginCall) {
|
|
211
|
+
guard let canvasId = call.getString("canvasId"),
|
|
212
|
+
let canvas = canvases[canvasId],
|
|
213
|
+
let layerObj = call.getObject("layer") else {
|
|
214
|
+
call.reject("Missing parameters")
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let layerId = "layer_\(nextLayerId)"
|
|
219
|
+
nextLayerId += 1
|
|
220
|
+
|
|
221
|
+
let visible = layerObj["visible"] as? Bool ?? true
|
|
222
|
+
let opacity = layerObj["opacity"] as? Double ?? 1.0
|
|
223
|
+
let zIndex = layerObj["zIndex"] as? Int ?? 0
|
|
224
|
+
let name = layerObj["name"] as? String
|
|
225
|
+
|
|
226
|
+
DispatchQueue.main.async { [weak self] in
|
|
227
|
+
let layer = ManagedLayer(
|
|
228
|
+
id: layerId,
|
|
229
|
+
size: canvas.size,
|
|
230
|
+
visible: visible,
|
|
231
|
+
opacity: CGFloat(opacity),
|
|
232
|
+
zIndex: zIndex,
|
|
233
|
+
name: name
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
canvas.layers[layerId] = layer
|
|
237
|
+
canvas.view.addSubview(layer.view)
|
|
238
|
+
self?.sortLayers(canvas: canvas)
|
|
239
|
+
|
|
240
|
+
call.resolve(["layerId": layerId])
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@objc func updateLayer(_ call: CAPPluginCall) {
|
|
245
|
+
guard let canvasId = call.getString("canvasId"),
|
|
246
|
+
let canvas = canvases[canvasId],
|
|
247
|
+
let layerId = call.getString("layerId"),
|
|
248
|
+
let layer = canvas.layers[layerId],
|
|
249
|
+
let layerObj = call.getObject("layer") else {
|
|
250
|
+
call.reject("Layer not found")
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
DispatchQueue.main.async { [weak self] in
|
|
255
|
+
if let visible = layerObj["visible"] as? Bool {
|
|
256
|
+
layer.visible = visible
|
|
257
|
+
layer.view.isHidden = !visible
|
|
258
|
+
}
|
|
259
|
+
if let opacity = layerObj["opacity"] as? Double {
|
|
260
|
+
layer.opacity = CGFloat(opacity)
|
|
261
|
+
layer.view.alpha = layer.opacity
|
|
262
|
+
}
|
|
263
|
+
if let zIndex = layerObj["zIndex"] as? Int {
|
|
264
|
+
layer.zIndex = zIndex
|
|
265
|
+
self?.sortLayers(canvas: canvas)
|
|
266
|
+
}
|
|
267
|
+
if let name = layerObj["name"] as? String {
|
|
268
|
+
layer.name = name
|
|
269
|
+
}
|
|
270
|
+
call.resolve()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@objc func deleteLayer(_ call: CAPPluginCall) {
|
|
275
|
+
guard let canvasId = call.getString("canvasId"),
|
|
276
|
+
let canvas = canvases[canvasId],
|
|
277
|
+
let layerId = call.getString("layerId"),
|
|
278
|
+
let layer = canvas.layers[layerId] else {
|
|
279
|
+
call.reject("Layer not found")
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
DispatchQueue.main.async {
|
|
284
|
+
layer.view.removeFromSuperview()
|
|
285
|
+
canvas.layers.removeValue(forKey: layerId)
|
|
286
|
+
call.resolve()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@objc func getLayers(_ call: CAPPluginCall) {
|
|
291
|
+
guard let canvasId = call.getString("canvasId"),
|
|
292
|
+
let canvas = canvases[canvasId] else {
|
|
293
|
+
call.reject("Canvas not found")
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let layers = canvas.layers.values.map { layer -> [String: Any] in
|
|
298
|
+
var dict: [String: Any] = [
|
|
299
|
+
"id": layer.id,
|
|
300
|
+
"visible": layer.visible,
|
|
301
|
+
"opacity": layer.opacity,
|
|
302
|
+
"zIndex": layer.zIndex,
|
|
303
|
+
]
|
|
304
|
+
if let name = layer.name {
|
|
305
|
+
dict["name"] = name
|
|
306
|
+
}
|
|
307
|
+
return dict
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
call.resolve(["layers": layers])
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// MARK: - Drawing Operations
|
|
314
|
+
|
|
315
|
+
@objc func drawRect(_ call: CAPPluginCall) {
|
|
316
|
+
guard let canvasId = call.getString("canvasId"),
|
|
317
|
+
let canvas = canvases[canvasId],
|
|
318
|
+
let rectObj = call.getObject("rect") else {
|
|
319
|
+
call.reject("Missing parameters")
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let drawOpts = call.getObject("drawOptions")
|
|
324
|
+
let layerId = drawOpts?["layerId"] as? String
|
|
325
|
+
let fillObj = call.getObject("fill")
|
|
326
|
+
let strokeObj = call.getObject("stroke")
|
|
327
|
+
let cornerRadius = call.getFloat("cornerRadius") ?? 0
|
|
328
|
+
|
|
329
|
+
DispatchQueue.main.async {
|
|
330
|
+
let targetView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
331
|
+
guard let ctx = targetView.createContext() else {
|
|
332
|
+
call.reject("Failed to create context")
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
337
|
+
self.renderRect(ctx, rect: self.rectFromObject(rectObj), fill: fillObj, stroke: strokeObj, cornerRadius: CGFloat(cornerRadius))
|
|
338
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
339
|
+
|
|
340
|
+
targetView.commitContext()
|
|
341
|
+
call.resolve()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@objc func drawEllipse(_ call: CAPPluginCall) {
|
|
346
|
+
guard let canvasId = call.getString("canvasId"),
|
|
347
|
+
let canvas = canvases[canvasId],
|
|
348
|
+
let centerObj = call.getObject("center"),
|
|
349
|
+
let radiusX = call.getFloat("radiusX"),
|
|
350
|
+
let radiusY = call.getFloat("radiusY") else {
|
|
351
|
+
call.reject("Missing parameters")
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let drawOpts = call.getObject("drawOptions")
|
|
356
|
+
let layerId = drawOpts?["layerId"] as? String
|
|
357
|
+
let fillObj = call.getObject("fill")
|
|
358
|
+
let strokeObj = call.getObject("stroke")
|
|
359
|
+
|
|
360
|
+
DispatchQueue.main.async {
|
|
361
|
+
let targetView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
362
|
+
guard let ctx = targetView.createContext() else {
|
|
363
|
+
call.reject("Failed to create context")
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let cx = centerObj["x"] as? CGFloat ?? 0
|
|
368
|
+
let cy = centerObj["y"] as? CGFloat ?? 0
|
|
369
|
+
|
|
370
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
371
|
+
self.renderEllipse(ctx, center: CGPoint(x: cx, y: cy), radiusX: CGFloat(radiusX), radiusY: CGFloat(radiusY), fill: fillObj, stroke: strokeObj)
|
|
372
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
373
|
+
|
|
374
|
+
targetView.commitContext()
|
|
375
|
+
call.resolve()
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@objc func drawLine(_ call: CAPPluginCall) {
|
|
380
|
+
guard let canvasId = call.getString("canvasId"),
|
|
381
|
+
let canvas = canvases[canvasId],
|
|
382
|
+
let fromObj = call.getObject("from"),
|
|
383
|
+
let toObj = call.getObject("to"),
|
|
384
|
+
let strokeObj = call.getObject("stroke") else {
|
|
385
|
+
call.reject("Missing parameters")
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let drawOpts = call.getObject("drawOptions")
|
|
390
|
+
let layerId = drawOpts?["layerId"] as? String
|
|
391
|
+
|
|
392
|
+
DispatchQueue.main.async {
|
|
393
|
+
let targetView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
394
|
+
guard let ctx = targetView.createContext() else {
|
|
395
|
+
call.reject("Failed to create context")
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let from = CGPoint(x: fromObj["x"] as? CGFloat ?? 0, y: fromObj["y"] as? CGFloat ?? 0)
|
|
400
|
+
let to = CGPoint(x: toObj["x"] as? CGFloat ?? 0, y: toObj["y"] as? CGFloat ?? 0)
|
|
401
|
+
|
|
402
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
403
|
+
self.renderLine(ctx, from: from, to: to, stroke: strokeObj)
|
|
404
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
405
|
+
|
|
406
|
+
targetView.commitContext()
|
|
407
|
+
call.resolve()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
@objc func drawPath(_ call: CAPPluginCall) {
|
|
412
|
+
guard let canvasId = call.getString("canvasId"),
|
|
413
|
+
let canvas = canvases[canvasId],
|
|
414
|
+
let pathObj = call.getObject("path"),
|
|
415
|
+
let commands = pathObj["commands"] as? [[String: Any]] else {
|
|
416
|
+
call.reject("Missing parameters")
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let drawOpts = call.getObject("drawOptions")
|
|
421
|
+
let layerId = drawOpts?["layerId"] as? String
|
|
422
|
+
let fillObj = call.getObject("fill")
|
|
423
|
+
let strokeObj = call.getObject("stroke")
|
|
424
|
+
|
|
425
|
+
DispatchQueue.main.async {
|
|
426
|
+
let targetView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
427
|
+
guard let ctx = targetView.createContext() else {
|
|
428
|
+
call.reject("Failed to create context")
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
433
|
+
self.renderPath(ctx, commands: commands, fill: fillObj, stroke: strokeObj)
|
|
434
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
435
|
+
|
|
436
|
+
targetView.commitContext()
|
|
437
|
+
call.resolve()
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
@objc func drawText(_ call: CAPPluginCall) {
|
|
442
|
+
guard let canvasId = call.getString("canvasId"),
|
|
443
|
+
let canvas = canvases[canvasId],
|
|
444
|
+
let text = call.getString("text"),
|
|
445
|
+
let positionObj = call.getObject("position"),
|
|
446
|
+
let styleObj = call.getObject("style") else {
|
|
447
|
+
call.reject("Missing parameters")
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let drawOpts = call.getObject("drawOptions")
|
|
452
|
+
let layerId = drawOpts?["layerId"] as? String
|
|
453
|
+
|
|
454
|
+
DispatchQueue.main.async {
|
|
455
|
+
let targetView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
456
|
+
guard targetView.createContext() != nil else {
|
|
457
|
+
call.reject("Failed to create context")
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let ctx = UIGraphicsGetCurrentContext()!
|
|
462
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
463
|
+
self.renderText(ctx, text: text, position: positionObj, style: styleObj)
|
|
464
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
465
|
+
|
|
466
|
+
targetView.commitContext()
|
|
467
|
+
call.resolve()
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
@objc func drawImage(_ call: CAPPluginCall) {
|
|
472
|
+
guard let canvasId = call.getString("canvasId"),
|
|
473
|
+
let canvas = canvases[canvasId],
|
|
474
|
+
let destRectObj = call.getObject("destRect") else {
|
|
475
|
+
call.reject("Missing parameters")
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let drawOpts = call.getObject("drawOptions")
|
|
480
|
+
let layerId = drawOpts?["layerId"] as? String
|
|
481
|
+
let imageObj = call.getObject("image")
|
|
482
|
+
let imageString = call.getString("image")
|
|
483
|
+
let srcRectObj = call.getObject("srcRect")
|
|
484
|
+
|
|
485
|
+
DispatchQueue.main.async {
|
|
486
|
+
let targetView = layerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
487
|
+
guard let ctx = targetView.createContext() else {
|
|
488
|
+
call.reject("Failed to create context")
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
var image: UIImage?
|
|
493
|
+
|
|
494
|
+
if let imageObj = imageObj, let base64 = imageObj["base64"] as? String {
|
|
495
|
+
if let data = Data(base64Encoded: base64) {
|
|
496
|
+
image = UIImage(data: data)
|
|
497
|
+
}
|
|
498
|
+
} else if let imageString = imageString, let url = URL(string: imageString) {
|
|
499
|
+
if let data = try? Data(contentsOf: url) {
|
|
500
|
+
image = UIImage(data: data)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
guard let uiImage = image else {
|
|
505
|
+
targetView.commitContext()
|
|
506
|
+
call.reject("Failed to load image")
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
511
|
+
|
|
512
|
+
let destRect = self.rectFromObject(destRectObj)
|
|
513
|
+
|
|
514
|
+
if let srcRectObj = srcRectObj {
|
|
515
|
+
// Crop source image to srcRect, then draw into destRect.
|
|
516
|
+
let srcRect = self.rectFromObject(srcRectObj)
|
|
517
|
+
if let cgImage = uiImage.cgImage,
|
|
518
|
+
let cropped = cgImage.cropping(to: srcRect) {
|
|
519
|
+
UIImage(cgImage: cropped).draw(in: destRect)
|
|
520
|
+
} else {
|
|
521
|
+
uiImage.draw(in: destRect)
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
uiImage.draw(in: destRect)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
528
|
+
|
|
529
|
+
targetView.commitContext()
|
|
530
|
+
call.resolve()
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@objc func drawBatch(_ call: CAPPluginCall) {
|
|
535
|
+
guard let canvasId = call.getString("canvasId"),
|
|
536
|
+
let canvas = canvases[canvasId],
|
|
537
|
+
let commands = call.getArray("commands") as? [[String: Any]] else {
|
|
538
|
+
call.reject("Missing parameters")
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
DispatchQueue.main.async {
|
|
543
|
+
// Group consecutive commands by target view for efficiency.
|
|
544
|
+
var currentLayerId: String? = nil
|
|
545
|
+
var currentView: CanvasView = canvas.view
|
|
546
|
+
var contextOpen = false
|
|
547
|
+
|
|
548
|
+
let openContext = { (view: CanvasView) -> CGContext? in
|
|
549
|
+
let ctx = view.createContext()
|
|
550
|
+
return ctx
|
|
551
|
+
}
|
|
552
|
+
let closeContext = { (view: CanvasView) in
|
|
553
|
+
view.commitContext()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for command in commands {
|
|
557
|
+
guard let type = command["type"] as? String,
|
|
558
|
+
let args = command["args"] as? [String: Any] else {
|
|
559
|
+
continue
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let drawOpts = args["drawOptions"] as? [String: Any]
|
|
563
|
+
let targetLayerId = drawOpts?["layerId"] as? String
|
|
564
|
+
|
|
565
|
+
// Switch target view if layer changed.
|
|
566
|
+
if targetLayerId != currentLayerId {
|
|
567
|
+
if contextOpen {
|
|
568
|
+
closeContext(currentView)
|
|
569
|
+
contextOpen = false
|
|
570
|
+
}
|
|
571
|
+
currentLayerId = targetLayerId
|
|
572
|
+
currentView = targetLayerId.flatMap { canvas.layers[$0]?.view } ?? canvas.view
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if !contextOpen {
|
|
576
|
+
guard openContext(currentView) != nil else { continue }
|
|
577
|
+
contextOpen = true
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let ctx = UIGraphicsGetCurrentContext()!
|
|
581
|
+
self.applyDrawOptions(ctx, canvas: canvas, options: drawOpts)
|
|
582
|
+
|
|
583
|
+
switch type {
|
|
584
|
+
case "rect":
|
|
585
|
+
if let rectObj = args["rect"] as? [String: Any] {
|
|
586
|
+
let fill = args["fill"] as? [String: Any]
|
|
587
|
+
let stroke = args["stroke"] as? [String: Any]
|
|
588
|
+
let cr = args["cornerRadius"] as? CGFloat ?? 0
|
|
589
|
+
self.renderRect(ctx, rect: self.rectFromObject(rectObj), fill: fill, stroke: stroke, cornerRadius: cr)
|
|
590
|
+
}
|
|
591
|
+
case "ellipse":
|
|
592
|
+
if let centerObj = args["center"] as? [String: Any],
|
|
593
|
+
let rx = args["radiusX"] as? CGFloat,
|
|
594
|
+
let ry = args["radiusY"] as? CGFloat {
|
|
595
|
+
let cx = centerObj["x"] as? CGFloat ?? 0
|
|
596
|
+
let cy = centerObj["y"] as? CGFloat ?? 0
|
|
597
|
+
self.renderEllipse(ctx, center: CGPoint(x: cx, y: cy), radiusX: rx, radiusY: ry, fill: args["fill"] as? [String: Any], stroke: args["stroke"] as? [String: Any])
|
|
598
|
+
}
|
|
599
|
+
case "line":
|
|
600
|
+
if let fromObj = args["from"] as? [String: Any],
|
|
601
|
+
let toObj = args["to"] as? [String: Any],
|
|
602
|
+
let strokeObj = args["stroke"] as? [String: Any] {
|
|
603
|
+
let from = CGPoint(x: fromObj["x"] as? CGFloat ?? 0, y: fromObj["y"] as? CGFloat ?? 0)
|
|
604
|
+
let to = CGPoint(x: toObj["x"] as? CGFloat ?? 0, y: toObj["y"] as? CGFloat ?? 0)
|
|
605
|
+
self.renderLine(ctx, from: from, to: to, stroke: strokeObj)
|
|
606
|
+
}
|
|
607
|
+
case "path":
|
|
608
|
+
if let pathObj = args["path"] as? [String: Any],
|
|
609
|
+
let pathCommands = pathObj["commands"] as? [[String: Any]] {
|
|
610
|
+
self.renderPath(ctx, commands: pathCommands, fill: args["fill"] as? [String: Any], stroke: args["stroke"] as? [String: Any])
|
|
611
|
+
}
|
|
612
|
+
case "text":
|
|
613
|
+
if let text = args["text"] as? String,
|
|
614
|
+
let posObj = args["position"] as? [String: Any],
|
|
615
|
+
let styleObj = args["style"] as? [String: Any] {
|
|
616
|
+
self.renderText(ctx, text: text, position: posObj, style: styleObj)
|
|
617
|
+
}
|
|
618
|
+
case "image":
|
|
619
|
+
if let destRectObj = args["destRect"] as? [String: Any] {
|
|
620
|
+
let destRect = self.rectFromObject(destRectObj)
|
|
621
|
+
var img: UIImage?
|
|
622
|
+
if let imgObj = args["image"] as? [String: Any],
|
|
623
|
+
let b64 = imgObj["base64"] as? String,
|
|
624
|
+
let data = Data(base64Encoded: b64) {
|
|
625
|
+
img = UIImage(data: data)
|
|
626
|
+
} else if let imgStr = args["image"] as? String,
|
|
627
|
+
let url = URL(string: imgStr),
|
|
628
|
+
let data = try? Data(contentsOf: url) {
|
|
629
|
+
img = UIImage(data: data)
|
|
630
|
+
}
|
|
631
|
+
if let uiImage = img {
|
|
632
|
+
if let srcRectObj = args["srcRect"] as? [String: Any],
|
|
633
|
+
let cgImage = uiImage.cgImage,
|
|
634
|
+
let cropped = cgImage.cropping(to: self.rectFromObject(srcRectObj)) {
|
|
635
|
+
UIImage(cgImage: cropped).draw(in: destRect)
|
|
636
|
+
} else {
|
|
637
|
+
uiImage.draw(in: destRect)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
case "clear":
|
|
642
|
+
let clearRect = (args["rect"] as? [String: Any]).map { self.rectFromObject($0) }
|
|
643
|
+
if let clearRect = clearRect {
|
|
644
|
+
ctx.clear(clearRect)
|
|
645
|
+
} else {
|
|
646
|
+
// Close current context, clear the view, reopen if more commands follow.
|
|
647
|
+
closeContext(currentView)
|
|
648
|
+
contextOpen = false
|
|
649
|
+
currentView.setImage(nil)
|
|
650
|
+
}
|
|
651
|
+
default:
|
|
652
|
+
break
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
self.restoreDrawOptions(ctx, options: drawOpts)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if contextOpen {
|
|
659
|
+
closeContext(currentView)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
call.resolve()
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// MARK: - Pixel Data / Export
|
|
667
|
+
|
|
668
|
+
@objc func getPixelData(_ call: CAPPluginCall) {
|
|
669
|
+
guard let canvasId = call.getString("canvasId"),
|
|
670
|
+
let canvas = canvases[canvasId] else {
|
|
671
|
+
call.reject("Canvas not found")
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
let rectObj = call.getObject("rect")
|
|
676
|
+
|
|
677
|
+
DispatchQueue.main.async {
|
|
678
|
+
// Composite all layers into a single image.
|
|
679
|
+
let renderer = UIGraphicsImageRenderer(size: canvas.size)
|
|
680
|
+
let composited = renderer.image { _ in
|
|
681
|
+
canvas.view.drawHierarchy(in: CGRect(origin: .zero, size: canvas.size), afterScreenUpdates: true)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
guard let cgImage = composited.cgImage else {
|
|
685
|
+
call.reject("No image data")
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Determine the region to extract.
|
|
690
|
+
let region: CGRect
|
|
691
|
+
if let rectObj = rectObj {
|
|
692
|
+
region = self.rectFromObject(rectObj)
|
|
693
|
+
} else {
|
|
694
|
+
region = CGRect(origin: .zero, size: canvas.size)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
let pixelW = Int(region.width)
|
|
698
|
+
let pixelH = Int(region.height)
|
|
699
|
+
guard pixelW > 0, pixelH > 0 else {
|
|
700
|
+
call.reject("Invalid dimensions")
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Extract RGBA pixel data.
|
|
705
|
+
let bytesPerPixel = 4
|
|
706
|
+
let bytesPerRow = bytesPerPixel * pixelW
|
|
707
|
+
var pixelData = [UInt8](repeating: 0, count: bytesPerRow * pixelH)
|
|
708
|
+
|
|
709
|
+
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB),
|
|
710
|
+
let context = CGContext(
|
|
711
|
+
data: &pixelData,
|
|
712
|
+
width: pixelW,
|
|
713
|
+
height: pixelH,
|
|
714
|
+
bitsPerComponent: 8,
|
|
715
|
+
bytesPerRow: bytesPerRow,
|
|
716
|
+
space: colorSpace,
|
|
717
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
718
|
+
) else {
|
|
719
|
+
call.reject("Failed to create bitmap context")
|
|
720
|
+
return
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Draw the cropped region into our pixel buffer.
|
|
724
|
+
let drawRect = CGRect(x: -region.origin.x, y: -region.origin.y,
|
|
725
|
+
width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
|
|
726
|
+
context.draw(cgImage, in: drawRect)
|
|
727
|
+
|
|
728
|
+
let base64 = Data(pixelData).base64EncodedString()
|
|
729
|
+
|
|
730
|
+
call.resolve([
|
|
731
|
+
"data": base64,
|
|
732
|
+
"width": pixelW,
|
|
733
|
+
"height": pixelH,
|
|
734
|
+
])
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
@objc func toImage(_ call: CAPPluginCall) {
|
|
739
|
+
guard let canvasId = call.getString("canvasId"),
|
|
740
|
+
let canvas = canvases[canvasId] else {
|
|
741
|
+
call.reject("Canvas not found")
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let imageFormat = call.getString("format") ?? "png"
|
|
746
|
+
let quality = call.getFloat("quality") ?? 100
|
|
747
|
+
let layerIds = call.getArray("layerIds") as? [String]
|
|
748
|
+
|
|
749
|
+
DispatchQueue.main.async {
|
|
750
|
+
let renderer = UIGraphicsImageRenderer(size: canvas.size)
|
|
751
|
+
let image = renderer.image { _ in
|
|
752
|
+
if let layerIds = layerIds {
|
|
753
|
+
// Only render specified layers.
|
|
754
|
+
for lid in layerIds {
|
|
755
|
+
if let layer = canvas.layers[lid] {
|
|
756
|
+
layer.view.drawHierarchy(in: CGRect(origin: .zero, size: canvas.size), afterScreenUpdates: true)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
canvas.view.drawHierarchy(in: CGRect(origin: .zero, size: canvas.size), afterScreenUpdates: true)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
var data: Data?
|
|
765
|
+
var outputFormat = imageFormat
|
|
766
|
+
|
|
767
|
+
switch imageFormat {
|
|
768
|
+
case "jpeg":
|
|
769
|
+
data = image.jpegData(compressionQuality: CGFloat(quality / 100))
|
|
770
|
+
case "webp":
|
|
771
|
+
// WebP not natively supported; fall back to PNG.
|
|
772
|
+
data = image.pngData()
|
|
773
|
+
outputFormat = "png"
|
|
774
|
+
default:
|
|
775
|
+
data = image.pngData()
|
|
776
|
+
outputFormat = "png"
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
guard let imageData = data else {
|
|
780
|
+
call.reject("Failed to encode image")
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
call.resolve([
|
|
785
|
+
"base64": imageData.base64EncodedString(),
|
|
786
|
+
"format": outputFormat,
|
|
787
|
+
"width": Int(canvas.size.width),
|
|
788
|
+
"height": Int(canvas.size.height),
|
|
789
|
+
])
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// MARK: - Transform
|
|
794
|
+
|
|
795
|
+
@objc func setTransform(_ call: CAPPluginCall) {
|
|
796
|
+
guard let canvasId = call.getString("canvasId"),
|
|
797
|
+
let canvas = canvases[canvasId],
|
|
798
|
+
let transformObj = call.getObject("transform") else {
|
|
799
|
+
call.reject("Missing parameters")
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
canvas.globalTransform = affineTransformFromObject(transformObj)
|
|
804
|
+
call.resolve()
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
@objc func resetTransform(_ call: CAPPluginCall) {
|
|
808
|
+
guard let canvasId = call.getString("canvasId"),
|
|
809
|
+
let canvas = canvases[canvasId] else {
|
|
810
|
+
call.reject("Canvas not found")
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
canvas.globalTransform = .identity
|
|
815
|
+
call.resolve()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// MARK: - Touch
|
|
819
|
+
|
|
820
|
+
@objc func setTouchEnabled(_ call: CAPPluginCall) {
|
|
821
|
+
guard let canvasId = call.getString("canvasId"),
|
|
822
|
+
let canvas = canvases[canvasId],
|
|
823
|
+
let enabled = call.getBool("enabled") else {
|
|
824
|
+
call.reject("Missing parameters")
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
DispatchQueue.main.async {
|
|
829
|
+
canvas.touchEnabled = enabled
|
|
830
|
+
canvas.view.isUserInteractionEnabled = enabled
|
|
831
|
+
call.resolve()
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// MARK: - Web Canvas: Navigate
|
|
836
|
+
|
|
837
|
+
@objc func navigate(_ call: CAPPluginCall) {
|
|
838
|
+
guard let canvasId = call.getString("canvasId"),
|
|
839
|
+
let canvas = canvases[canvasId],
|
|
840
|
+
let urlString = call.getString("url") else {
|
|
841
|
+
call.reject("Missing parameters")
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
let placement = call.getObject("placement")
|
|
846
|
+
|
|
847
|
+
DispatchQueue.main.async { [weak self] in
|
|
848
|
+
let wv = self?.ensureWebView(for: canvas) ?? canvas.webView!
|
|
849
|
+
|
|
850
|
+
// Apply placement if provided, otherwise fill the canvas.
|
|
851
|
+
if let placement = placement {
|
|
852
|
+
let x = placement["x"] as? CGFloat ?? 0
|
|
853
|
+
let y = placement["y"] as? CGFloat ?? 0
|
|
854
|
+
let w = placement["width"] as? CGFloat ?? canvas.size.width
|
|
855
|
+
let h = placement["height"] as? CGFloat ?? canvas.size.height
|
|
856
|
+
wv.frame = CGRect(x: x, y: y, width: w, height: h)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
860
|
+
guard let url = URL(string: trimmed) else {
|
|
861
|
+
call.reject("Invalid URL: \(trimmed)")
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if url.isFileURL {
|
|
866
|
+
wv.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
|
867
|
+
} else {
|
|
868
|
+
wv.load(URLRequest(url: url))
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
call.resolve(["url": trimmed])
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// MARK: - Web Canvas: Eval
|
|
876
|
+
|
|
877
|
+
@objc func eval(_ call: CAPPluginCall) {
|
|
878
|
+
guard let canvasId = call.getString("canvasId"),
|
|
879
|
+
let canvas = canvases[canvasId],
|
|
880
|
+
let script = call.getString("script") else {
|
|
881
|
+
call.reject("Missing parameters")
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
guard let wv = canvas.webView else {
|
|
886
|
+
call.reject("No web view - call navigate() first")
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
DispatchQueue.main.async {
|
|
891
|
+
wv.evaluateJavaScript(script) { result, error in
|
|
892
|
+
if let error = error {
|
|
893
|
+
call.reject("eval failed: \(error.localizedDescription)")
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
if let result = result {
|
|
897
|
+
call.resolve(["result": String(describing: result)])
|
|
898
|
+
} else {
|
|
899
|
+
call.resolve(["result": ""])
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// MARK: - Web Canvas: Snapshot
|
|
906
|
+
|
|
907
|
+
@objc func snapshot(_ call: CAPPluginCall) {
|
|
908
|
+
guard let canvasId = call.getString("canvasId"),
|
|
909
|
+
let canvas = canvases[canvasId] else {
|
|
910
|
+
call.reject("Canvas not found")
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
guard let wv = canvas.webView else {
|
|
915
|
+
call.reject("No web view - call navigate() first")
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
let maxWidth = call.getFloat("maxWidth").flatMap { CGFloat($0) }
|
|
920
|
+
let quality = call.getDouble("quality") ?? 0.82
|
|
921
|
+
let formatStr = call.getString("format") ?? "png"
|
|
922
|
+
|
|
923
|
+
DispatchQueue.main.async {
|
|
924
|
+
let config = WKSnapshotConfiguration()
|
|
925
|
+
if let maxWidth = maxWidth {
|
|
926
|
+
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
wv.takeSnapshot(with: config) { image, error in
|
|
930
|
+
if let error = error {
|
|
931
|
+
call.reject("snapshot failed: \(error.localizedDescription)")
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
guard let image = image else {
|
|
935
|
+
call.reject("snapshot returned nil")
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let data: Data?
|
|
940
|
+
var outputFormat = formatStr
|
|
941
|
+
switch formatStr {
|
|
942
|
+
case "jpeg":
|
|
943
|
+
let q = min(max(quality, 0.1), 1.0)
|
|
944
|
+
data = image.jpegData(compressionQuality: q)
|
|
945
|
+
default:
|
|
946
|
+
data = image.pngData()
|
|
947
|
+
outputFormat = "png"
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
guard let encoded = data else {
|
|
951
|
+
call.reject("snapshot encode failed")
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
call.resolve([
|
|
956
|
+
"base64": encoded.base64EncodedString(),
|
|
957
|
+
"format": outputFormat,
|
|
958
|
+
"width": Int(image.size.width),
|
|
959
|
+
"height": Int(image.size.height),
|
|
960
|
+
])
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// MARK: - Web Canvas: A2UI Push
|
|
966
|
+
|
|
967
|
+
@objc func a2uiPush(_ call: CAPPluginCall) {
|
|
968
|
+
guard let canvasId = call.getString("canvasId"),
|
|
969
|
+
let canvas = canvases[canvasId] else {
|
|
970
|
+
call.reject("Canvas not found")
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
guard let wv = canvas.webView else {
|
|
975
|
+
call.reject("No web view - call navigate() first")
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Accept either "messages" (JSON array) or "jsonl" (newline-delimited JSON string).
|
|
980
|
+
let messagesJSON: String
|
|
981
|
+
if let messages = call.getArray("messages") {
|
|
982
|
+
guard let data = try? JSONSerialization.data(withJSONObject: messages),
|
|
983
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
984
|
+
call.reject("Failed to serialize messages")
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
messagesJSON = json
|
|
988
|
+
} else if let jsonl = call.getString("jsonl") {
|
|
989
|
+
// Parse JSONL: each non-empty line is a JSON object; collect into an array.
|
|
990
|
+
var parsed: [Any] = []
|
|
991
|
+
for rawLine in jsonl.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
|
|
992
|
+
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
993
|
+
if line.isEmpty { continue }
|
|
994
|
+
guard let lineData = line.data(using: .utf8),
|
|
995
|
+
let obj = try? JSONSerialization.jsonObject(with: lineData) else {
|
|
996
|
+
call.reject("Invalid JSONL at line: \(line.prefix(80))")
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
parsed.append(obj)
|
|
1000
|
+
}
|
|
1001
|
+
guard let data = try? JSONSerialization.data(withJSONObject: parsed),
|
|
1002
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
1003
|
+
call.reject("Failed to serialize parsed JSONL")
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
messagesJSON = json
|
|
1007
|
+
} else if let payload = call.getObject("payload") {
|
|
1008
|
+
// Single payload object wrapped in array.
|
|
1009
|
+
guard let data = try? JSONSerialization.data(withJSONObject: [payload]),
|
|
1010
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
1011
|
+
call.reject("Failed to serialize payload")
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
messagesJSON = json
|
|
1015
|
+
} else {
|
|
1016
|
+
call.reject("Missing messages, jsonl, or payload parameter")
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Escape the JSON for embedding in a JS string literal.
|
|
1021
|
+
let escapedJSON = Self.jsStringLiteral(messagesJSON)
|
|
1022
|
+
|
|
1023
|
+
let js = """
|
|
1024
|
+
(() => {
|
|
1025
|
+
try {
|
|
1026
|
+
const host = globalThis.elizaA2UI;
|
|
1027
|
+
if (host && typeof host.applyMessages === 'function') {
|
|
1028
|
+
host.applyMessages(JSON.parse(\(escapedJSON)));
|
|
1029
|
+
return 'ok';
|
|
1030
|
+
}
|
|
1031
|
+
return 'a2ui_not_ready';
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
return 'error:' + e.message;
|
|
1034
|
+
}
|
|
1035
|
+
})()
|
|
1036
|
+
"""
|
|
1037
|
+
|
|
1038
|
+
DispatchQueue.main.async {
|
|
1039
|
+
wv.evaluateJavaScript(js) { result, error in
|
|
1040
|
+
if let error = error {
|
|
1041
|
+
call.reject("a2uiPush failed: \(error.localizedDescription)")
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
let resultStr = (result as? String) ?? ""
|
|
1045
|
+
if resultStr == "a2ui_not_ready" {
|
|
1046
|
+
call.reject("A2UI host not ready - ensure the canvas page includes the A2UI runtime")
|
|
1047
|
+
} else if resultStr.hasPrefix("error:") {
|
|
1048
|
+
call.reject("a2uiPush JS error: \(resultStr)")
|
|
1049
|
+
} else {
|
|
1050
|
+
call.resolve()
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// MARK: - Web Canvas: A2UI Reset
|
|
1057
|
+
|
|
1058
|
+
@objc func a2uiReset(_ call: CAPPluginCall) {
|
|
1059
|
+
guard let canvasId = call.getString("canvasId"),
|
|
1060
|
+
let canvas = canvases[canvasId] else {
|
|
1061
|
+
call.reject("Canvas not found")
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
guard let wv = canvas.webView else {
|
|
1066
|
+
call.reject("No web view - call navigate() first")
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
let js = """
|
|
1071
|
+
(() => {
|
|
1072
|
+
try {
|
|
1073
|
+
const host = globalThis.elizaA2UI;
|
|
1074
|
+
if (host && typeof host.reset === 'function') {
|
|
1075
|
+
host.reset();
|
|
1076
|
+
return 'ok';
|
|
1077
|
+
}
|
|
1078
|
+
return 'no_reset';
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
return 'error:' + e.message;
|
|
1081
|
+
}
|
|
1082
|
+
})()
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
DispatchQueue.main.async {
|
|
1086
|
+
wv.evaluateJavaScript(js) { result, error in
|
|
1087
|
+
if let error = error {
|
|
1088
|
+
call.reject("a2uiReset failed: \(error.localizedDescription)")
|
|
1089
|
+
return
|
|
1090
|
+
}
|
|
1091
|
+
call.resolve()
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// MARK: - Web View Management
|
|
1097
|
+
|
|
1098
|
+
@MainActor
|
|
1099
|
+
@discardableResult
|
|
1100
|
+
private func ensureWebView(for canvas: ManagedCanvas) -> WKWebView {
|
|
1101
|
+
if let existing = canvas.webView { return existing }
|
|
1102
|
+
|
|
1103
|
+
let config = WKWebViewConfiguration()
|
|
1104
|
+
config.websiteDataStore = .nonPersistent()
|
|
1105
|
+
|
|
1106
|
+
let ucc = WKUserContentController()
|
|
1107
|
+
let handler = CanvasA2UIMessageHandler(plugin: self, canvasId: canvas.id)
|
|
1108
|
+
ucc.add(handler, name: CanvasA2UIMessageHandler.messageName)
|
|
1109
|
+
config.userContentController = ucc
|
|
1110
|
+
|
|
1111
|
+
let navDelegate = CanvasNavigationDelegate(plugin: self, canvasId: canvas.id)
|
|
1112
|
+
|
|
1113
|
+
let wv = WKWebView(frame: CGRect(origin: .zero, size: canvas.size), configuration: config)
|
|
1114
|
+
wv.isOpaque = true
|
|
1115
|
+
wv.backgroundColor = .black
|
|
1116
|
+
wv.scrollView.backgroundColor = .black
|
|
1117
|
+
wv.scrollView.contentInsetAdjustmentBehavior = .never
|
|
1118
|
+
wv.scrollView.contentInset = .zero
|
|
1119
|
+
wv.scrollView.isScrollEnabled = true
|
|
1120
|
+
wv.scrollView.bounces = true
|
|
1121
|
+
wv.navigationDelegate = navDelegate
|
|
1122
|
+
|
|
1123
|
+
canvas.webView = wv
|
|
1124
|
+
canvas.navigationDelegate = navDelegate
|
|
1125
|
+
canvas.a2uiHandler = handler
|
|
1126
|
+
|
|
1127
|
+
// Insert the web view behind drawing layers in the canvas view hierarchy.
|
|
1128
|
+
if canvas.view.superview != nil {
|
|
1129
|
+
wv.frame = canvas.view.bounds
|
|
1130
|
+
wv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1131
|
+
canvas.view.insertSubview(wv, at: 0)
|
|
1132
|
+
// Make drawing view transparent so web content shows through.
|
|
1133
|
+
canvas.view.backgroundColor = .clear
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return wv
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// MARK: - Internal Render Helpers
|
|
1140
|
+
|
|
1141
|
+
/// Apply draw options (blend mode, opacity, shadow, transform) to the context.
|
|
1142
|
+
private func applyDrawOptions(_ ctx: CGContext, canvas: ManagedCanvas, options: [String: Any]?) {
|
|
1143
|
+
ctx.saveGState()
|
|
1144
|
+
|
|
1145
|
+
// Global canvas transform.
|
|
1146
|
+
if canvas.globalTransform != .identity {
|
|
1147
|
+
ctx.concatenate(canvas.globalTransform)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
guard let options = options else { return }
|
|
1151
|
+
|
|
1152
|
+
// Per-operation transform.
|
|
1153
|
+
if let transformObj = options["transform"] as? [String: Any] {
|
|
1154
|
+
ctx.concatenate(affineTransformFromObject(transformObj))
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Blend mode.
|
|
1158
|
+
if let blendStr = options["blendMode"] as? String {
|
|
1159
|
+
ctx.setBlendMode(blendModeFromString(blendStr))
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Opacity.
|
|
1163
|
+
if let opacity = options["opacity"] as? Double {
|
|
1164
|
+
ctx.setAlpha(CGFloat(opacity))
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Shadow.
|
|
1168
|
+
if let shadowObj = options["shadow"] as? [String: Any] {
|
|
1169
|
+
let blur = shadowObj["blur"] as? CGFloat ?? 0
|
|
1170
|
+
let offsetX = shadowObj["offsetX"] as? CGFloat ?? 0
|
|
1171
|
+
let offsetY = shadowObj["offsetY"] as? CGFloat ?? 0
|
|
1172
|
+
let color: CGColor
|
|
1173
|
+
if let colorObj = shadowObj["color"] as? [String: Any] {
|
|
1174
|
+
color = colorFromObject(colorObj).cgColor
|
|
1175
|
+
} else if let colorStr = shadowObj["color"] as? String {
|
|
1176
|
+
color = (UIColor(hex: colorStr) ?? .black).cgColor
|
|
1177
|
+
} else {
|
|
1178
|
+
color = UIColor.black.withAlphaComponent(0.5).cgColor
|
|
1179
|
+
}
|
|
1180
|
+
ctx.setShadow(offset: CGSize(width: offsetX, height: offsetY), blur: blur, color: color)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private func restoreDrawOptions(_ ctx: CGContext, options: [String: Any]?) {
|
|
1185
|
+
ctx.restoreGState()
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private func renderRect(_ ctx: CGContext, rect: CGRect, fill: [String: Any]?, stroke: [String: Any]?, cornerRadius: CGFloat) {
|
|
1189
|
+
let path: UIBezierPath
|
|
1190
|
+
if cornerRadius > 0 {
|
|
1191
|
+
path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
|
|
1192
|
+
} else {
|
|
1193
|
+
path = UIBezierPath(rect: rect)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if let fill = fill {
|
|
1197
|
+
if let gradient = extractGradient(fill) {
|
|
1198
|
+
ctx.saveGState()
|
|
1199
|
+
path.addClip()
|
|
1200
|
+
drawGradient(ctx, gradient: gradient)
|
|
1201
|
+
ctx.restoreGState()
|
|
1202
|
+
} else {
|
|
1203
|
+
let color = colorFromFillOrStroke(fill)
|
|
1204
|
+
color.setFill()
|
|
1205
|
+
path.fill()
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if let stroke = stroke {
|
|
1210
|
+
let color = colorFromFillOrStroke(stroke)
|
|
1211
|
+
let width = stroke["width"] as? CGFloat ?? 1
|
|
1212
|
+
color.setStroke()
|
|
1213
|
+
path.lineWidth = width
|
|
1214
|
+
applyStrokeStyle(path, style: stroke)
|
|
1215
|
+
path.stroke()
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
private func renderEllipse(_ ctx: CGContext, center: CGPoint, radiusX: CGFloat, radiusY: CGFloat, fill: [String: Any]?, stroke: [String: Any]?) {
|
|
1220
|
+
let rect = CGRect(
|
|
1221
|
+
x: center.x - radiusX,
|
|
1222
|
+
y: center.y - radiusY,
|
|
1223
|
+
width: radiusX * 2,
|
|
1224
|
+
height: radiusY * 2
|
|
1225
|
+
)
|
|
1226
|
+
let path = UIBezierPath(ovalIn: rect)
|
|
1227
|
+
|
|
1228
|
+
if let fill = fill {
|
|
1229
|
+
if let gradient = extractGradient(fill) {
|
|
1230
|
+
ctx.saveGState()
|
|
1231
|
+
path.addClip()
|
|
1232
|
+
drawGradient(ctx, gradient: gradient)
|
|
1233
|
+
ctx.restoreGState()
|
|
1234
|
+
} else {
|
|
1235
|
+
let color = colorFromFillOrStroke(fill)
|
|
1236
|
+
color.setFill()
|
|
1237
|
+
path.fill()
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if let stroke = stroke {
|
|
1242
|
+
let color = colorFromFillOrStroke(stroke)
|
|
1243
|
+
let width = stroke["width"] as? CGFloat ?? 1
|
|
1244
|
+
color.setStroke()
|
|
1245
|
+
path.lineWidth = width
|
|
1246
|
+
applyStrokeStyle(path, style: stroke)
|
|
1247
|
+
path.stroke()
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
private func renderLine(_ ctx: CGContext, from: CGPoint, to: CGPoint, stroke: [String: Any]) {
|
|
1252
|
+
let path = UIBezierPath()
|
|
1253
|
+
path.move(to: from)
|
|
1254
|
+
path.addLine(to: to)
|
|
1255
|
+
|
|
1256
|
+
let color = colorFromFillOrStroke(stroke)
|
|
1257
|
+
let width = stroke["width"] as? CGFloat ?? 1
|
|
1258
|
+
color.setStroke()
|
|
1259
|
+
path.lineWidth = width
|
|
1260
|
+
applyStrokeStyle(path, style: stroke)
|
|
1261
|
+
path.stroke()
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
private func renderPath(_ ctx: CGContext, commands: [[String: Any]], fill: [String: Any]?, stroke: [String: Any]?) {
|
|
1265
|
+
let cgPath = CGMutablePath()
|
|
1266
|
+
|
|
1267
|
+
for cmd in commands {
|
|
1268
|
+
guard let type = cmd["type"] as? String else { continue }
|
|
1269
|
+
let args = cmd["args"] as? [Double] ?? []
|
|
1270
|
+
|
|
1271
|
+
switch type {
|
|
1272
|
+
case "moveTo" where args.count >= 2:
|
|
1273
|
+
cgPath.move(to: CGPoint(x: args[0], y: args[1]))
|
|
1274
|
+
|
|
1275
|
+
case "lineTo" where args.count >= 2:
|
|
1276
|
+
cgPath.addLine(to: CGPoint(x: args[0], y: args[1]))
|
|
1277
|
+
|
|
1278
|
+
case "quadraticCurveTo" where args.count >= 4:
|
|
1279
|
+
cgPath.addQuadCurve(
|
|
1280
|
+
to: CGPoint(x: args[2], y: args[3]),
|
|
1281
|
+
control: CGPoint(x: args[0], y: args[1])
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
case "bezierCurveTo" where args.count >= 6:
|
|
1285
|
+
cgPath.addCurve(
|
|
1286
|
+
to: CGPoint(x: args[4], y: args[5]),
|
|
1287
|
+
control1: CGPoint(x: args[0], y: args[1]),
|
|
1288
|
+
control2: CGPoint(x: args[2], y: args[3])
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
case "arcTo" where args.count >= 5:
|
|
1292
|
+
cgPath.addArc(
|
|
1293
|
+
tangent1End: CGPoint(x: args[0], y: args[1]),
|
|
1294
|
+
tangent2End: CGPoint(x: args[2], y: args[3]),
|
|
1295
|
+
radius: CGFloat(args[4])
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
case "arc" where args.count >= 5:
|
|
1299
|
+
let cx = args[0], cy = args[1], radius = args[2]
|
|
1300
|
+
let startAngle = args[3], endAngle = args[4]
|
|
1301
|
+
// In UIKit's flipped coordinate system, CGPath clockwise parameter
|
|
1302
|
+
// is inverted visually. Canvas 2D counterclockwise maps to CGPath clockwise.
|
|
1303
|
+
let counterclockwise = args.count > 5 ? (args[5] != 0) : false
|
|
1304
|
+
cgPath.addArc(
|
|
1305
|
+
center: CGPoint(x: cx, y: cy),
|
|
1306
|
+
radius: CGFloat(radius),
|
|
1307
|
+
startAngle: CGFloat(startAngle),
|
|
1308
|
+
endAngle: CGFloat(endAngle),
|
|
1309
|
+
clockwise: counterclockwise
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
case "ellipse" where args.count >= 7:
|
|
1313
|
+
let cx = args[0], cy = args[1]
|
|
1314
|
+
let rx = args[2], ry = args[3]
|
|
1315
|
+
let rotation = args[4]
|
|
1316
|
+
let startAngle = args[5], endAngle = args[6]
|
|
1317
|
+
let counterclockwise = args.count > 7 ? (args[7] != 0) : false
|
|
1318
|
+
// Use transform to draw an elliptical arc.
|
|
1319
|
+
var t = CGAffineTransform(translationX: CGFloat(cx), y: CGFloat(cy))
|
|
1320
|
+
t = t.rotated(by: CGFloat(rotation))
|
|
1321
|
+
t = t.scaledBy(x: CGFloat(rx), y: CGFloat(ry))
|
|
1322
|
+
cgPath.addArc(
|
|
1323
|
+
center: .zero,
|
|
1324
|
+
radius: 1.0,
|
|
1325
|
+
startAngle: CGFloat(startAngle),
|
|
1326
|
+
endAngle: CGFloat(endAngle),
|
|
1327
|
+
clockwise: counterclockwise,
|
|
1328
|
+
transform: t
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
case "rect" where args.count >= 4:
|
|
1332
|
+
cgPath.addRect(CGRect(x: args[0], y: args[1], width: args[2], height: args[3]))
|
|
1333
|
+
|
|
1334
|
+
case "closePath":
|
|
1335
|
+
cgPath.closeSubpath()
|
|
1336
|
+
|
|
1337
|
+
default:
|
|
1338
|
+
break
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
let bezierPath = UIBezierPath(cgPath: cgPath)
|
|
1343
|
+
|
|
1344
|
+
if let fill = fill {
|
|
1345
|
+
if let gradient = extractGradient(fill) {
|
|
1346
|
+
ctx.saveGState()
|
|
1347
|
+
bezierPath.addClip()
|
|
1348
|
+
drawGradient(ctx, gradient: gradient)
|
|
1349
|
+
ctx.restoreGState()
|
|
1350
|
+
} else {
|
|
1351
|
+
let color = colorFromFillOrStroke(fill)
|
|
1352
|
+
color.setFill()
|
|
1353
|
+
bezierPath.fill()
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if let stroke = stroke {
|
|
1358
|
+
let color = colorFromFillOrStroke(stroke)
|
|
1359
|
+
let width = stroke["width"] as? CGFloat ?? 1
|
|
1360
|
+
color.setStroke()
|
|
1361
|
+
bezierPath.lineWidth = width
|
|
1362
|
+
applyStrokeStyle(bezierPath, style: stroke)
|
|
1363
|
+
bezierPath.stroke()
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
private func renderText(_ ctx: CGContext, text: String, position: [String: Any], style: [String: Any]) {
|
|
1368
|
+
let x = position["x"] as? CGFloat ?? 0
|
|
1369
|
+
let y = position["y"] as? CGFloat ?? 0
|
|
1370
|
+
|
|
1371
|
+
let fontName = style["font"] as? String ?? "Helvetica"
|
|
1372
|
+
let fontSize = style["size"] as? CGFloat ?? 14
|
|
1373
|
+
let color = colorFromFillOrStroke(style)
|
|
1374
|
+
let align = style["align"] as? String ?? "left"
|
|
1375
|
+
let maxWidth = style["maxWidth"] as? CGFloat
|
|
1376
|
+
|
|
1377
|
+
let uiFont = UIFont(name: fontName, size: fontSize) ?? UIFont.systemFont(ofSize: fontSize)
|
|
1378
|
+
|
|
1379
|
+
var attributes: [NSAttributedString.Key: Any] = [
|
|
1380
|
+
.font: uiFont,
|
|
1381
|
+
.foregroundColor: color,
|
|
1382
|
+
]
|
|
1383
|
+
|
|
1384
|
+
let paragraph = NSMutableParagraphStyle()
|
|
1385
|
+
switch align {
|
|
1386
|
+
case "center": paragraph.alignment = .center
|
|
1387
|
+
case "right": paragraph.alignment = .right
|
|
1388
|
+
default: paragraph.alignment = .left
|
|
1389
|
+
}
|
|
1390
|
+
attributes[.paragraphStyle] = paragraph
|
|
1391
|
+
|
|
1392
|
+
let nsText = text as NSString
|
|
1393
|
+
let textSize = nsText.size(withAttributes: attributes)
|
|
1394
|
+
|
|
1395
|
+
// Adjust position based on alignment.
|
|
1396
|
+
var drawPoint = CGPoint(x: x, y: y)
|
|
1397
|
+
switch align {
|
|
1398
|
+
case "center":
|
|
1399
|
+
drawPoint.x -= textSize.width / 2
|
|
1400
|
+
case "right":
|
|
1401
|
+
drawPoint.x -= textSize.width
|
|
1402
|
+
default:
|
|
1403
|
+
break
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Handle baseline adjustment.
|
|
1407
|
+
if let baseline = style["baseline"] as? String {
|
|
1408
|
+
switch baseline {
|
|
1409
|
+
case "top":
|
|
1410
|
+
break // default
|
|
1411
|
+
case "middle":
|
|
1412
|
+
drawPoint.y -= textSize.height / 2
|
|
1413
|
+
case "bottom", "alphabetic":
|
|
1414
|
+
drawPoint.y -= textSize.height
|
|
1415
|
+
default:
|
|
1416
|
+
break
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if let maxWidth = maxWidth {
|
|
1421
|
+
let drawRect = CGRect(x: drawPoint.x, y: drawPoint.y, width: maxWidth, height: textSize.height * 2)
|
|
1422
|
+
nsText.draw(in: drawRect, withAttributes: attributes)
|
|
1423
|
+
} else {
|
|
1424
|
+
nsText.draw(at: drawPoint, withAttributes: attributes)
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// MARK: - Gradient Support
|
|
1429
|
+
|
|
1430
|
+
private func extractGradient(_ obj: [String: Any]) -> [String: Any]? {
|
|
1431
|
+
guard let type = obj["type"] as? String,
|
|
1432
|
+
(type == "linear" || type == "radial") else {
|
|
1433
|
+
return nil
|
|
1434
|
+
}
|
|
1435
|
+
return obj
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private func drawGradient(_ ctx: CGContext, gradient gradientObj: [String: Any]) {
|
|
1439
|
+
guard let type = gradientObj["type"] as? String,
|
|
1440
|
+
let stops = gradientObj["stops"] as? [[String: Any]] else { return }
|
|
1441
|
+
|
|
1442
|
+
var colors: [CGColor] = []
|
|
1443
|
+
var locations: [CGFloat] = []
|
|
1444
|
+
for stop in stops {
|
|
1445
|
+
let offset = stop["offset"] as? CGFloat ?? 0
|
|
1446
|
+
locations.append(offset)
|
|
1447
|
+
if let colorObj = stop["color"] as? [String: Any] {
|
|
1448
|
+
colors.append(colorFromObject(colorObj).cgColor)
|
|
1449
|
+
} else if let colorStr = stop["color"] as? String {
|
|
1450
|
+
colors.append((UIColor(hex: colorStr) ?? .black).cgColor)
|
|
1451
|
+
} else {
|
|
1452
|
+
colors.append(UIColor.black.cgColor)
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB),
|
|
1457
|
+
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else {
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
switch type {
|
|
1462
|
+
case "linear":
|
|
1463
|
+
let x0 = gradientObj["x0"] as? CGFloat ?? 0
|
|
1464
|
+
let y0 = gradientObj["y0"] as? CGFloat ?? 0
|
|
1465
|
+
let x1 = gradientObj["x1"] as? CGFloat ?? 0
|
|
1466
|
+
let y1 = gradientObj["y1"] as? CGFloat ?? 0
|
|
1467
|
+
ctx.drawLinearGradient(gradient, start: CGPoint(x: x0, y: y0), end: CGPoint(x: x1, y: y1),
|
|
1468
|
+
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
|
|
1469
|
+
case "radial":
|
|
1470
|
+
let x0 = gradientObj["x0"] as? CGFloat ?? 0
|
|
1471
|
+
let y0 = gradientObj["y0"] as? CGFloat ?? 0
|
|
1472
|
+
let r0 = gradientObj["r0"] as? CGFloat ?? 0
|
|
1473
|
+
let x1 = gradientObj["x1"] as? CGFloat ?? 0
|
|
1474
|
+
let y1 = gradientObj["y1"] as? CGFloat ?? 0
|
|
1475
|
+
let r1 = gradientObj["r1"] as? CGFloat ?? 0
|
|
1476
|
+
ctx.drawRadialGradient(gradient,
|
|
1477
|
+
startCenter: CGPoint(x: x0, y: y0), startRadius: r0,
|
|
1478
|
+
endCenter: CGPoint(x: x1, y: y1), endRadius: r1,
|
|
1479
|
+
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
|
|
1480
|
+
default:
|
|
1481
|
+
break
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// MARK: - Stroke Style
|
|
1486
|
+
|
|
1487
|
+
private func applyStrokeStyle(_ path: UIBezierPath, style: [String: Any]) {
|
|
1488
|
+
if let lineCap = style["lineCap"] as? String {
|
|
1489
|
+
switch lineCap {
|
|
1490
|
+
case "round": path.lineCapStyle = .round
|
|
1491
|
+
case "square": path.lineCapStyle = .square
|
|
1492
|
+
default: path.lineCapStyle = .butt
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if let lineJoin = style["lineJoin"] as? String {
|
|
1496
|
+
switch lineJoin {
|
|
1497
|
+
case "round": path.lineJoinStyle = .round
|
|
1498
|
+
case "bevel": path.lineJoinStyle = .bevel
|
|
1499
|
+
default: path.lineJoinStyle = .miter
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if let dashPattern = style["dashPattern"] as? [Double] {
|
|
1503
|
+
let pattern = dashPattern.map { CGFloat($0) }
|
|
1504
|
+
path.setLineDash(pattern, count: pattern.count, phase: 0)
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// MARK: - Utility Helpers
|
|
1509
|
+
|
|
1510
|
+
private func sortLayers(canvas: ManagedCanvas) {
|
|
1511
|
+
let sorted = canvas.layers.values.sorted { $0.zIndex < $1.zIndex }
|
|
1512
|
+
for (index, layer) in sorted.enumerated() {
|
|
1513
|
+
// Offset by 1 if web view is at index 0.
|
|
1514
|
+
let insertIdx = canvas.webView != nil ? index + 1 : index
|
|
1515
|
+
canvas.view.insertSubview(layer.view, at: insertIdx)
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
private func colorFromObject(_ obj: [String: Any]?) -> UIColor {
|
|
1520
|
+
guard let obj = obj else { return .black }
|
|
1521
|
+
|
|
1522
|
+
let r = obj["r"] as? Int ?? 0
|
|
1523
|
+
let g = obj["g"] as? Int ?? 0
|
|
1524
|
+
let b = obj["b"] as? Int ?? 0
|
|
1525
|
+
let a = obj["a"] as? Double ?? 1.0
|
|
1526
|
+
|
|
1527
|
+
return UIColor(
|
|
1528
|
+
red: CGFloat(r) / 255.0,
|
|
1529
|
+
green: CGFloat(g) / 255.0,
|
|
1530
|
+
blue: CGFloat(b) / 255.0,
|
|
1531
|
+
alpha: CGFloat(a)
|
|
1532
|
+
)
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/// Extract a color from a fill or stroke style object. Handles both `{ color: ... }` wrappers
|
|
1536
|
+
/// and direct color objects, as well as hex string colors.
|
|
1537
|
+
private func colorFromFillOrStroke(_ obj: [String: Any]) -> UIColor {
|
|
1538
|
+
if let colorObj = obj["color"] as? [String: Any] {
|
|
1539
|
+
return colorFromObject(colorObj)
|
|
1540
|
+
}
|
|
1541
|
+
if let colorStr = obj["color"] as? String {
|
|
1542
|
+
return UIColor(hex: colorStr) ?? .black
|
|
1543
|
+
}
|
|
1544
|
+
// Maybe the object itself is a color.
|
|
1545
|
+
if obj["r"] != nil {
|
|
1546
|
+
return colorFromObject(obj)
|
|
1547
|
+
}
|
|
1548
|
+
return .black
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
private func rectFromObject(_ obj: [String: Any]) -> CGRect {
|
|
1552
|
+
let x = obj["x"] as? CGFloat ?? 0
|
|
1553
|
+
let y = obj["y"] as? CGFloat ?? 0
|
|
1554
|
+
let width = obj["width"] as? CGFloat ?? 0
|
|
1555
|
+
let height = obj["height"] as? CGFloat ?? 0
|
|
1556
|
+
return CGRect(x: x, y: y, width: width, height: height)
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
private func affineTransformFromObject(_ obj: [String: Any]) -> CGAffineTransform {
|
|
1560
|
+
var t = CGAffineTransform.identity
|
|
1561
|
+
if let tx = obj["translateX"] as? CGFloat {
|
|
1562
|
+
t = t.translatedBy(x: tx, y: 0)
|
|
1563
|
+
}
|
|
1564
|
+
if let ty = obj["translateY"] as? CGFloat {
|
|
1565
|
+
t = t.translatedBy(x: 0, y: ty)
|
|
1566
|
+
}
|
|
1567
|
+
if let sx = obj["scaleX"] as? CGFloat, let sy = obj["scaleY"] as? CGFloat {
|
|
1568
|
+
t = t.scaledBy(x: sx, y: sy)
|
|
1569
|
+
} else if let sx = obj["scaleX"] as? CGFloat {
|
|
1570
|
+
t = t.scaledBy(x: sx, y: 1)
|
|
1571
|
+
} else if let sy = obj["scaleY"] as? CGFloat {
|
|
1572
|
+
t = t.scaledBy(x: 1, y: sy)
|
|
1573
|
+
}
|
|
1574
|
+
if let rotation = obj["rotation"] as? CGFloat {
|
|
1575
|
+
t = t.rotated(by: rotation)
|
|
1576
|
+
}
|
|
1577
|
+
if let skewX = obj["skewX"] as? CGFloat {
|
|
1578
|
+
t = t.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(skewX), d: 1, tx: 0, ty: 0))
|
|
1579
|
+
}
|
|
1580
|
+
if let skewY = obj["skewY"] as? CGFloat {
|
|
1581
|
+
t = t.concatenating(CGAffineTransform(a: 1, b: tan(skewY), c: 0, d: 1, tx: 0, ty: 0))
|
|
1582
|
+
}
|
|
1583
|
+
return t
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
private func blendModeFromString(_ str: String) -> CGBlendMode {
|
|
1587
|
+
switch str {
|
|
1588
|
+
case "multiply": return .multiply
|
|
1589
|
+
case "screen": return .screen
|
|
1590
|
+
case "overlay": return .overlay
|
|
1591
|
+
case "darken": return .darken
|
|
1592
|
+
case "lighten": return .lighten
|
|
1593
|
+
case "color-dodge": return .colorDodge
|
|
1594
|
+
case "color-burn": return .colorBurn
|
|
1595
|
+
default: return .normal
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/// Produce a properly JSON-escaped JS string literal (single-quoted).
|
|
1600
|
+
static func jsStringLiteral(_ value: String) -> String {
|
|
1601
|
+
if let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
1602
|
+
let encoded = String(data: data, encoding: .utf8),
|
|
1603
|
+
encoded.count >= 2 {
|
|
1604
|
+
// encoded is '["..."]'; extract the inner string literal including quotes.
|
|
1605
|
+
let inner = encoded.dropFirst(1).dropLast(1)
|
|
1606
|
+
return String(inner)
|
|
1607
|
+
}
|
|
1608
|
+
// Fallback: manual escape.
|
|
1609
|
+
let escaped = value
|
|
1610
|
+
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
1611
|
+
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
1612
|
+
.replacingOccurrences(of: "\n", with: "\\n")
|
|
1613
|
+
.replacingOccurrences(of: "\r", with: "\\r")
|
|
1614
|
+
.replacingOccurrences(of: "\t", with: "\\t")
|
|
1615
|
+
return "\"\(escaped)\""
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// MARK: - ManagedCanvas
|
|
1620
|
+
|
|
1621
|
+
extension ElizaCanvasPlugin {
|
|
1622
|
+
class ManagedCanvas {
|
|
1623
|
+
let id: String
|
|
1624
|
+
var view: CanvasView
|
|
1625
|
+
var webView: WKWebView?
|
|
1626
|
+
var layers: [String: ManagedLayer] = [:]
|
|
1627
|
+
var size: CGSize
|
|
1628
|
+
var touchEnabled = false
|
|
1629
|
+
var globalTransform: CGAffineTransform = .identity
|
|
1630
|
+
var navigationDelegate: CanvasNavigationDelegate?
|
|
1631
|
+
var a2uiHandler: CanvasA2UIMessageHandler?
|
|
1632
|
+
|
|
1633
|
+
init(id: String, size: CGSize) {
|
|
1634
|
+
self.id = id
|
|
1635
|
+
self.size = size
|
|
1636
|
+
self.view = CanvasView(frame: CGRect(origin: .zero, size: size))
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
class ManagedLayer {
|
|
1641
|
+
let id: String
|
|
1642
|
+
var name: String?
|
|
1643
|
+
var visible: Bool
|
|
1644
|
+
var opacity: CGFloat
|
|
1645
|
+
var zIndex: Int
|
|
1646
|
+
var view: CanvasView
|
|
1647
|
+
|
|
1648
|
+
init(id: String, size: CGSize, visible: Bool, opacity: CGFloat, zIndex: Int, name: String?) {
|
|
1649
|
+
self.id = id
|
|
1650
|
+
self.name = name
|
|
1651
|
+
self.visible = visible
|
|
1652
|
+
self.opacity = opacity
|
|
1653
|
+
self.zIndex = zIndex
|
|
1654
|
+
self.view = CanvasView(frame: CGRect(origin: .zero, size: size))
|
|
1655
|
+
self.view.alpha = opacity
|
|
1656
|
+
self.view.isHidden = !visible
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// MARK: - CanvasView
|
|
1662
|
+
|
|
1663
|
+
extension ElizaCanvasPlugin {
|
|
1664
|
+
class CanvasView: UIView {
|
|
1665
|
+
private var drawingImage: UIImage?
|
|
1666
|
+
var touchHandler: ((String, [TouchInfo]) -> Void)?
|
|
1667
|
+
|
|
1668
|
+
struct TouchInfo {
|
|
1669
|
+
let id: Int
|
|
1670
|
+
let x: CGFloat
|
|
1671
|
+
let y: CGFloat
|
|
1672
|
+
let force: CGFloat?
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
override func draw(_ rect: CGRect) {
|
|
1676
|
+
drawingImage?.draw(in: bounds)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
func setImage(_ image: UIImage?) {
|
|
1680
|
+
drawingImage = image
|
|
1681
|
+
setNeedsDisplay()
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
func getImage() -> UIImage? {
|
|
1685
|
+
return drawingImage
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
func createContext() -> CGContext? {
|
|
1689
|
+
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 1.0)
|
|
1690
|
+
if let currentImage = drawingImage {
|
|
1691
|
+
currentImage.draw(in: bounds)
|
|
1692
|
+
}
|
|
1693
|
+
return UIGraphicsGetCurrentContext()
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
func commitContext() {
|
|
1697
|
+
drawingImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
1698
|
+
UIGraphicsEndImageContext()
|
|
1699
|
+
setNeedsDisplay()
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// MARK: Touch Handling
|
|
1703
|
+
|
|
1704
|
+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
1705
|
+
handleTouches(touches, type: "start")
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
1709
|
+
handleTouches(touches, type: "move")
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
1713
|
+
handleTouches(touches, type: "end")
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
1717
|
+
handleTouches(touches, type: "cancel")
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
private func handleTouches(_ touches: Set<UITouch>, type: String) {
|
|
1721
|
+
let touchInfos = touches.map { touch -> TouchInfo in
|
|
1722
|
+
let location = touch.location(in: self)
|
|
1723
|
+
return TouchInfo(
|
|
1724
|
+
id: touch.hash,
|
|
1725
|
+
x: location.x,
|
|
1726
|
+
y: location.y,
|
|
1727
|
+
force: touch.force > 0 ? touch.force : nil
|
|
1728
|
+
)
|
|
1729
|
+
}
|
|
1730
|
+
touchHandler?(type, touchInfos)
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// MARK: - WKNavigationDelegate
|
|
1736
|
+
|
|
1737
|
+
/// Handles navigation policy for the canvas web view: intercepts eliza:// deep links,
|
|
1738
|
+
/// reports load errors, and emits navigation events to the Capacitor layer.
|
|
1739
|
+
// `@MainActor` on the whole delegate class lets us invoke
|
|
1740
|
+
// `decisionHandler` (which WebKit now types as `@MainActor @Sendable`
|
|
1741
|
+
// in modern SDKs) synchronously from the nonisolated
|
|
1742
|
+
// `webView(_:decidePolicyFor:decisionHandler:)` method. Without the
|
|
1743
|
+
// isolation, Swift 6 strict concurrency rejects the direct call with:
|
|
1744
|
+
// error: call to main actor-isolated parameter 'decisionHandler'
|
|
1745
|
+
// in a synchronous nonisolated context
|
|
1746
|
+
// WKNavigationDelegate callbacks are always invoked by WebKit on the
|
|
1747
|
+
// main thread, so this matches the actual runtime contract.
|
|
1748
|
+
@MainActor
|
|
1749
|
+
final class CanvasNavigationDelegate: NSObject, WKNavigationDelegate {
|
|
1750
|
+
weak var plugin: ElizaCanvasPlugin?
|
|
1751
|
+
let canvasId: String
|
|
1752
|
+
|
|
1753
|
+
init(plugin: ElizaCanvasPlugin, canvasId: String) {
|
|
1754
|
+
self.plugin = plugin
|
|
1755
|
+
self.canvasId = canvasId
|
|
1756
|
+
super.init()
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
func webView(
|
|
1760
|
+
_ webView: WKWebView,
|
|
1761
|
+
decidePolicyFor navigationAction: WKNavigationAction,
|
|
1762
|
+
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void
|
|
1763
|
+
) {
|
|
1764
|
+
guard let url = navigationAction.request.url else {
|
|
1765
|
+
decisionHandler(.allow)
|
|
1766
|
+
return
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Intercept eliza:// deep links.
|
|
1770
|
+
if url.scheme?.lowercased() == "eliza" {
|
|
1771
|
+
decisionHandler(.cancel)
|
|
1772
|
+
plugin?.notifyListeners("deepLink", data: [
|
|
1773
|
+
"canvasId": canvasId,
|
|
1774
|
+
"url": url.absoluteString,
|
|
1775
|
+
])
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
decisionHandler(.allow)
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
1783
|
+
plugin?.notifyListeners("webViewReady", data: [
|
|
1784
|
+
"canvasId": canvasId,
|
|
1785
|
+
"url": webView.url?.absoluteString ?? "",
|
|
1786
|
+
])
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
1790
|
+
plugin?.notifyListeners("navigationError", data: [
|
|
1791
|
+
"canvasId": canvasId,
|
|
1792
|
+
"error": error.localizedDescription,
|
|
1793
|
+
])
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
1797
|
+
plugin?.notifyListeners("navigationError", data: [
|
|
1798
|
+
"canvasId": canvasId,
|
|
1799
|
+
"error": error.localizedDescription,
|
|
1800
|
+
])
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// MARK: - A2UI Message Handler
|
|
1805
|
+
|
|
1806
|
+
/// Receives A2UI action messages from the canvas web view (e.g. button taps in A2UI components)
|
|
1807
|
+
/// and forwards them as Capacitor events.
|
|
1808
|
+
final class CanvasA2UIMessageHandler: NSObject, WKScriptMessageHandler {
|
|
1809
|
+
static let messageName = "elizaCanvasA2UIAction"
|
|
1810
|
+
|
|
1811
|
+
weak var plugin: ElizaCanvasPlugin?
|
|
1812
|
+
let canvasId: String
|
|
1813
|
+
|
|
1814
|
+
init(plugin: ElizaCanvasPlugin, canvasId: String) {
|
|
1815
|
+
self.plugin = plugin
|
|
1816
|
+
self.canvasId = canvasId
|
|
1817
|
+
super.init()
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
1821
|
+
guard message.name == Self.messageName else { return }
|
|
1822
|
+
|
|
1823
|
+
// Only accept actions from local/trusted URLs.
|
|
1824
|
+
guard let webView = message.webView, let url = webView.url else { return }
|
|
1825
|
+
if !url.isFileURL, !Self.isLocalNetworkURL(url) {
|
|
1826
|
+
return
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
guard let body = parseBody(message.body) else { return }
|
|
1830
|
+
|
|
1831
|
+
let userAction = (body["userAction"] as? [String: Any]) ?? body
|
|
1832
|
+
guard !userAction.isEmpty else { return }
|
|
1833
|
+
|
|
1834
|
+
let actionName = extractActionName(userAction)
|
|
1835
|
+
let actionId = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
|
1836
|
+
let surfaceId = (userAction["surfaceId"] as? String) ?? "main"
|
|
1837
|
+
|
|
1838
|
+
plugin?.notifyListeners("a2uiAction", data: [
|
|
1839
|
+
"canvasId": canvasId,
|
|
1840
|
+
"actionId": actionId,
|
|
1841
|
+
"actionName": actionName ?? "",
|
|
1842
|
+
"surfaceId": surfaceId,
|
|
1843
|
+
"userAction": userAction,
|
|
1844
|
+
])
|
|
1845
|
+
|
|
1846
|
+
// Dispatch action status acknowledgement back to the web view.
|
|
1847
|
+
let statusJS = """
|
|
1848
|
+
(() => {
|
|
1849
|
+
const detail = { id: \(ElizaCanvasPlugin.jsStringLiteral(actionId)), ok: true, error: '' };
|
|
1850
|
+
window.dispatchEvent(new CustomEvent('eliza:a2ui-action-status', { detail }));
|
|
1851
|
+
})();
|
|
1852
|
+
"""
|
|
1853
|
+
webView.evaluateJavaScript(statusJS) { _, _ in }
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
private func parseBody(_ body: Any) -> [String: Any]? {
|
|
1857
|
+
if let dict = body as? [String: Any] { return dict.isEmpty ? nil : dict }
|
|
1858
|
+
if let str = body as? String,
|
|
1859
|
+
let data = str.data(using: .utf8),
|
|
1860
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
1861
|
+
return json.isEmpty ? nil : json
|
|
1862
|
+
}
|
|
1863
|
+
return nil
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
private func extractActionName(_ userAction: [String: Any]) -> String? {
|
|
1867
|
+
for key in ["name", "action"] {
|
|
1868
|
+
if let raw = userAction[key] as? String {
|
|
1869
|
+
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1870
|
+
if !trimmed.isEmpty { return trimmed }
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
return nil
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
static func isLocalNetworkURL(_ url: URL) -> Bool {
|
|
1877
|
+
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
|
1878
|
+
return false
|
|
1879
|
+
}
|
|
1880
|
+
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
|
1881
|
+
return false
|
|
1882
|
+
}
|
|
1883
|
+
if host == "localhost" { return true }
|
|
1884
|
+
if host.hasSuffix(".local") { return true }
|
|
1885
|
+
if host.hasSuffix(".ts.net") { return true }
|
|
1886
|
+
if host.hasSuffix(".tailscale.net") { return true }
|
|
1887
|
+
// Allow bare hostnames (no dots, no colons) as LAN names.
|
|
1888
|
+
if !host.contains("."), !host.contains(":") { return true }
|
|
1889
|
+
// Check for private IPv4 ranges.
|
|
1890
|
+
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
|
1891
|
+
if parts.count == 4 {
|
|
1892
|
+
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
|
1893
|
+
if bytes.count == 4 {
|
|
1894
|
+
let (a, b) = (bytes[0], bytes[1])
|
|
1895
|
+
if a == 10 { return true }
|
|
1896
|
+
if a == 172, (16...31).contains(Int(b)) { return true }
|
|
1897
|
+
if a == 192, b == 168 { return true }
|
|
1898
|
+
if a == 127 { return true }
|
|
1899
|
+
if a == 169, b == 254 { return true }
|
|
1900
|
+
if a == 100, (64...127).contains(Int(b)) { return true }
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return false
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// MARK: - UIColor Hex Extension
|
|
1908
|
+
|
|
1909
|
+
extension UIColor {
|
|
1910
|
+
convenience init?(hex: String) {
|
|
1911
|
+
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1912
|
+
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
|
1913
|
+
|
|
1914
|
+
var rgb: UInt64 = 0
|
|
1915
|
+
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
|
1916
|
+
|
|
1917
|
+
switch hexSanitized.count {
|
|
1918
|
+
case 6:
|
|
1919
|
+
let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
|
1920
|
+
let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
|
1921
|
+
let b = CGFloat(rgb & 0x0000FF) / 255.0
|
|
1922
|
+
self.init(red: r, green: g, blue: b, alpha: 1.0)
|
|
1923
|
+
case 8:
|
|
1924
|
+
let r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
|
|
1925
|
+
let g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
|
|
1926
|
+
let b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
|
|
1927
|
+
let a = CGFloat(rgb & 0x000000FF) / 255.0
|
|
1928
|
+
self.init(red: r, green: g, blue: b, alpha: a)
|
|
1929
|
+
default:
|
|
1930
|
+
return nil
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|