@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.
@@ -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
+ }