cuoral-ionic 0.0.1
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/LICENSE +21 -0
- package/README.md +273 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/com/cuoral/ionic/CuoralPlugin.java +291 -0
- package/assets/icon/favicon.png +0 -0
- package/dist/bridge.d.ts +51 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +159 -0
- package/dist/cuoral.d.ts +51 -0
- package/dist/cuoral.d.ts.map +1 -0
- package/dist/cuoral.js +173 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +819 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +825 -0
- package/dist/index.js.map +1 -0
- package/dist/modal.d.ts +46 -0
- package/dist/modal.d.ts.map +1 -0
- package/dist/modal.js +220 -0
- package/dist/plugin.d.ts +76 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +134 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +24 -0
- package/dist/web.d.ts +27 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +107 -0
- package/ios/Plugin/CuoralPlugin.m +11 -0
- package/ios/Plugin/CuoralPlugin.swift +246 -0
- package/package.json +59 -0
- package/src/bridge.ts +195 -0
- package/src/cuoral.ts +208 -0
- package/src/index.ts +5 -0
- package/src/modal.ts +257 -0
- package/src/plugin.ts +190 -0
- package/src/types.ts +129 -0
- package/src/web.ts +135 -0
package/dist/web.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
/**
|
|
3
|
+
* Web implementation (for testing in browser)
|
|
4
|
+
*/
|
|
5
|
+
export class CuoralPluginWeb extends WebPlugin {
|
|
6
|
+
constructor() {
|
|
7
|
+
super(...arguments);
|
|
8
|
+
this.isRecording = false;
|
|
9
|
+
}
|
|
10
|
+
async startRecording(options) {
|
|
11
|
+
console.log('[Cuoral Web] Start recording', options);
|
|
12
|
+
// Check if Screen Capture API is available
|
|
13
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
14
|
+
throw new Error('Screen recording is not supported in this browser');
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
// Request screen capture
|
|
18
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
19
|
+
video: true,
|
|
20
|
+
audio: options?.includeAudio || false,
|
|
21
|
+
});
|
|
22
|
+
this.isRecording = true;
|
|
23
|
+
this.recordingStartTime = Date.now();
|
|
24
|
+
// Store stream for later stopping
|
|
25
|
+
window.__cuoralRecordingStream = stream;
|
|
26
|
+
return { success: true };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error('[Cuoral Web] Failed to start recording:', error);
|
|
30
|
+
return { success: false };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async stopRecording() {
|
|
34
|
+
console.log('[Cuoral Web] Stop recording');
|
|
35
|
+
if (!this.isRecording) {
|
|
36
|
+
return { success: false };
|
|
37
|
+
}
|
|
38
|
+
const stream = window.__cuoralRecordingStream;
|
|
39
|
+
if (stream) {
|
|
40
|
+
stream.getTracks().forEach(track => track.stop());
|
|
41
|
+
delete window.__cuoralRecordingStream;
|
|
42
|
+
}
|
|
43
|
+
const duration = this.recordingStartTime
|
|
44
|
+
? Math.floor((Date.now() - this.recordingStartTime) / 1000)
|
|
45
|
+
: 0;
|
|
46
|
+
this.isRecording = false;
|
|
47
|
+
this.recordingStartTime = undefined;
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
duration,
|
|
51
|
+
filePath: 'web-recording-not-saved',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async getRecordingState() {
|
|
55
|
+
return {
|
|
56
|
+
isRecording: this.isRecording,
|
|
57
|
+
duration: this.recordingStartTime
|
|
58
|
+
? Math.floor((Date.now() - this.recordingStartTime) / 1000)
|
|
59
|
+
: undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async takeScreenshot(options) {
|
|
63
|
+
console.log('[Cuoral Web] Take screenshot', options);
|
|
64
|
+
try {
|
|
65
|
+
// Use Screen Capture API
|
|
66
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
67
|
+
video: { width: 1920, height: 1080 },
|
|
68
|
+
});
|
|
69
|
+
// Capture frame from video stream
|
|
70
|
+
const video = document.createElement('video');
|
|
71
|
+
video.srcObject = stream;
|
|
72
|
+
video.play();
|
|
73
|
+
await new Promise(resolve => {
|
|
74
|
+
video.onloadedmetadata = resolve;
|
|
75
|
+
});
|
|
76
|
+
const canvas = document.createElement('canvas');
|
|
77
|
+
canvas.width = video.videoWidth;
|
|
78
|
+
canvas.height = video.videoHeight;
|
|
79
|
+
const ctx = canvas.getContext('2d');
|
|
80
|
+
ctx?.drawImage(video, 0, 0);
|
|
81
|
+
// Stop stream
|
|
82
|
+
stream.getTracks().forEach(track => track.stop());
|
|
83
|
+
// Convert to base64
|
|
84
|
+
const format = options?.format || 'png';
|
|
85
|
+
const quality = options?.quality || 0.92;
|
|
86
|
+
const base64 = canvas.toDataURL(`image/${format}`, quality);
|
|
87
|
+
return {
|
|
88
|
+
base64: base64.split(',')[1],
|
|
89
|
+
mimeType: `image/${format}`,
|
|
90
|
+
width: canvas.width,
|
|
91
|
+
height: canvas.height,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.error('[Cuoral Web] Failed to take screenshot:', error);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async isRecordingSupported() {
|
|
100
|
+
const supported = !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
|
|
101
|
+
return { supported };
|
|
102
|
+
}
|
|
103
|
+
async requestPermissions() {
|
|
104
|
+
// Web doesn't require explicit permissions - they're requested on demand
|
|
105
|
+
return { granted: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <Capacitor/Capacitor.h>
|
|
3
|
+
|
|
4
|
+
CAP_PLUGIN(CuoralPlugin, "CuoralPlugin",
|
|
5
|
+
CAP_PLUGIN_METHOD(startRecording, CAPPluginReturnPromise);
|
|
6
|
+
CAP_PLUGIN_METHOD(stopRecording, CAPPluginReturnPromise);
|
|
7
|
+
CAP_PLUGIN_METHOD(getRecordingState, CAPPluginReturnPromise);
|
|
8
|
+
CAP_PLUGIN_METHOD(takeScreenshot, CAPPluginReturnPromise);
|
|
9
|
+
CAP_PLUGIN_METHOD(isRecordingSupported, CAPPluginReturnPromise);
|
|
10
|
+
CAP_PLUGIN_METHOD(requestPermissions, CAPPluginReturnPromise);
|
|
11
|
+
)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import ReplayKit
|
|
4
|
+
import AVFoundation
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cuoral Plugin for iOS
|
|
8
|
+
* Handles screen recording and screenshot capture
|
|
9
|
+
*/
|
|
10
|
+
@objc(CuoralPlugin)
|
|
11
|
+
public class CuoralPlugin: CAPPlugin {
|
|
12
|
+
private var isRecording = false
|
|
13
|
+
private var recordingStartTime: Date?
|
|
14
|
+
private var videoOutputURL: URL?
|
|
15
|
+
private let recorder = RPScreenRecorder.shared()
|
|
16
|
+
private var assetWriter: AVAssetWriter?
|
|
17
|
+
private var videoInput: AVAssetWriterInput?
|
|
18
|
+
private var firstFrameTime: CMTime?
|
|
19
|
+
|
|
20
|
+
@objc public func startRecording(_ call: CAPPluginCall) {
|
|
21
|
+
// Force cleanup any stale state first
|
|
22
|
+
if isRecording {
|
|
23
|
+
recorder.stopCapture { _ in }
|
|
24
|
+
isRecording = false
|
|
25
|
+
assetWriter?.cancelWriting()
|
|
26
|
+
assetWriter = nil
|
|
27
|
+
videoInput = nil
|
|
28
|
+
videoOutputURL = nil
|
|
29
|
+
recordingStartTime = nil
|
|
30
|
+
firstFrameTime = nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
guard !isRecording else {
|
|
34
|
+
call.resolve(["success": false, "error": "Already recording"])
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check if recording is available
|
|
39
|
+
guard recorder.isAvailable else {
|
|
40
|
+
call.reject("Screen recording is not available")
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Get options
|
|
45
|
+
let includeAudio = call.getBool("includeAudio") ?? false
|
|
46
|
+
|
|
47
|
+
// Setup output URL
|
|
48
|
+
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
49
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
50
|
+
videoOutputURL = documentsPath.appendingPathComponent("cuoral_recording_\(timestamp).mp4")
|
|
51
|
+
|
|
52
|
+
guard let outputURL = videoOutputURL else {
|
|
53
|
+
call.reject("Failed to create output URL")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Setup asset writer
|
|
58
|
+
do {
|
|
59
|
+
assetWriter = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
|
60
|
+
|
|
61
|
+
let videoSettings: [String: Any] = [
|
|
62
|
+
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
63
|
+
AVVideoWidthKey: UIScreen.main.bounds.width * UIScreen.main.scale,
|
|
64
|
+
AVVideoHeightKey: UIScreen.main.bounds.height * UIScreen.main.scale
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
|
68
|
+
videoInput?.expectsMediaDataInRealTime = true
|
|
69
|
+
|
|
70
|
+
if let videoInput = videoInput, assetWriter?.canAdd(videoInput) == true {
|
|
71
|
+
assetWriter?.add(videoInput)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Don't start session yet - wait for first frame
|
|
75
|
+
assetWriter?.startWriting()
|
|
76
|
+
firstFrameTime = nil // Reset first frame time
|
|
77
|
+
|
|
78
|
+
} catch {
|
|
79
|
+
call.reject("Failed to setup video writer: \(error.localizedDescription)")
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Start recording
|
|
84
|
+
recorder.isMicrophoneEnabled = includeAudio
|
|
85
|
+
|
|
86
|
+
recorder.startCapture(handler: { [weak self] sampleBuffer, bufferType, error in
|
|
87
|
+
guard let self = self, self.isRecording else { return }
|
|
88
|
+
|
|
89
|
+
if let error = error {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if bufferType == .video, let videoInput = self.videoInput, videoInput.isReadyForMoreMediaData {
|
|
94
|
+
// Start session on first frame
|
|
95
|
+
if self.firstFrameTime == nil {
|
|
96
|
+
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
97
|
+
self.firstFrameTime = presentationTime
|
|
98
|
+
self.assetWriter?.startSession(atSourceTime: presentationTime)
|
|
99
|
+
}
|
|
100
|
+
videoInput.append(sampleBuffer)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
}) { [weak self] error in
|
|
104
|
+
guard let self = self else { return }
|
|
105
|
+
|
|
106
|
+
if let error = error {
|
|
107
|
+
call.reject("Failed to start recording: \(error.localizedDescription)")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
self.isRecording = true
|
|
112
|
+
self.recordingStartTime = Date()
|
|
113
|
+
|
|
114
|
+
call.resolve(["success": true])
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@objc public func stopRecording(_ call: CAPPluginCall) {
|
|
119
|
+
guard isRecording else {
|
|
120
|
+
// Try to cleanup any stale state
|
|
121
|
+
recorder.stopCapture { _ in }
|
|
122
|
+
assetWriter?.cancelWriting()
|
|
123
|
+
assetWriter = nil
|
|
124
|
+
videoInput = nil
|
|
125
|
+
videoOutputURL = nil
|
|
126
|
+
recordingStartTime = nil
|
|
127
|
+
firstFrameTime = nil
|
|
128
|
+
|
|
129
|
+
call.resolve(["success": false, "error": "Not recording"])
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
isRecording = false
|
|
134
|
+
|
|
135
|
+
recorder.stopCapture { [weak self] error in
|
|
136
|
+
guard let self = self else { return }
|
|
137
|
+
|
|
138
|
+
if let error = error {
|
|
139
|
+
// Cleanup even on error
|
|
140
|
+
self.assetWriter?.cancelWriting()
|
|
141
|
+
self.assetWriter = nil
|
|
142
|
+
self.videoInput = nil
|
|
143
|
+
self.videoOutputURL = nil
|
|
144
|
+
self.recordingStartTime = nil
|
|
145
|
+
self.firstFrameTime = nil
|
|
146
|
+
|
|
147
|
+
call.reject("Failed to stop recording: \(error.localizedDescription)")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Finalize the video file
|
|
152
|
+
self.videoInput?.markAsFinished()
|
|
153
|
+
self.assetWriter?.finishWriting { [weak self] in
|
|
154
|
+
guard let self = self else { return }
|
|
155
|
+
|
|
156
|
+
let duration = self.recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0
|
|
157
|
+
let filePath = self.videoOutputURL?.path ?? ""
|
|
158
|
+
|
|
159
|
+
call.resolve([
|
|
160
|
+
"success": true,
|
|
161
|
+
"filePath": filePath,
|
|
162
|
+
"duration": Int(duration)
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
// Clean up
|
|
166
|
+
self.assetWriter = nil
|
|
167
|
+
self.videoInput = nil
|
|
168
|
+
self.videoOutputURL = nil
|
|
169
|
+
self.recordingStartTime = nil
|
|
170
|
+
self.firstFrameTime = nil
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@objc func getRecordingState(_ call: CAPPluginCall) {
|
|
176
|
+
let duration = recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0
|
|
177
|
+
|
|
178
|
+
call.resolve([
|
|
179
|
+
"isRecording": isRecording,
|
|
180
|
+
"duration": Int(duration),
|
|
181
|
+
"filePath": videoOutputURL?.path ?? ""
|
|
182
|
+
])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@objc public func takeScreenshot(_ call: CAPPluginCall) {
|
|
186
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
187
|
+
call.reject("Failed to get window")
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let quality = call.getFloat("quality") ?? 0.92
|
|
192
|
+
let format = call.getString("format") ?? "png"
|
|
193
|
+
|
|
194
|
+
// Capture screenshot
|
|
195
|
+
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
|
|
196
|
+
let image = renderer.image { context in
|
|
197
|
+
window.layer.render(in: context.cgContext)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Convert to data
|
|
201
|
+
var imageData: Data?
|
|
202
|
+
var mimeType = "image/png"
|
|
203
|
+
|
|
204
|
+
if format == "jpeg" {
|
|
205
|
+
imageData = image.jpegData(compressionQuality: CGFloat(quality))
|
|
206
|
+
mimeType = "image/jpeg"
|
|
207
|
+
} else {
|
|
208
|
+
imageData = image.pngData()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
guard let data = imageData else {
|
|
212
|
+
call.reject("Failed to convert image to data")
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let base64 = data.base64EncodedString()
|
|
217
|
+
|
|
218
|
+
call.resolve([
|
|
219
|
+
"base64": base64,
|
|
220
|
+
"mimeType": mimeType,
|
|
221
|
+
"width": Int(image.size.width),
|
|
222
|
+
"height": Int(image.size.height)
|
|
223
|
+
])
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@objc public func isRecordingSupported(_ call: CAPPluginCall) {
|
|
227
|
+
call.resolve([
|
|
228
|
+
"supported": recorder.isAvailable
|
|
229
|
+
])
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@objc override public func requestPermissions(_ call: CAPPluginCall) {
|
|
233
|
+
// iOS doesn't require explicit permissions for screen recording
|
|
234
|
+
// The system will show a prompt when recording starts
|
|
235
|
+
call.resolve([
|
|
236
|
+
"granted": true
|
|
237
|
+
])
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// MARK: - RPPreviewViewControllerDelegate
|
|
242
|
+
extension CuoralPlugin: RPPreviewViewControllerDelegate {
|
|
243
|
+
public func previewControllerDidFinish(_ previewController: RPPreviewViewController) {
|
|
244
|
+
previewController.dismiss(animated: true, completion: nil)
|
|
245
|
+
}
|
|
246
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cuoral-ionic",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Cuoral Ionic Framework Library - Proactive customer success platform with support ticketing, customer intelligence, screen recording, and engagement tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.esm.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"src",
|
|
12
|
+
"assets",
|
|
13
|
+
"ios",
|
|
14
|
+
"android",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc && rollup -c",
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"prepare": "npm run build",
|
|
21
|
+
"test": "jest"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"cuoral",
|
|
25
|
+
"ionic",
|
|
26
|
+
"capacitor",
|
|
27
|
+
"screen-recording",
|
|
28
|
+
"support",
|
|
29
|
+
"widget",
|
|
30
|
+
"bridge"
|
|
31
|
+
],
|
|
32
|
+
"author": "Cuoral",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"capacitor": {
|
|
35
|
+
"ios": {
|
|
36
|
+
"src": "ios"
|
|
37
|
+
},
|
|
38
|
+
"android": {
|
|
39
|
+
"src": "android"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@capacitor/core": "^5.0.0 || ^6.0.0",
|
|
44
|
+
"@ionic/angular": "^7.0.0 || ^8.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@capacitor/core": "^6.0.0",
|
|
48
|
+
"@ionic/angular": "^8.0.0",
|
|
49
|
+
"@types/node": "^20.0.0",
|
|
50
|
+
"rollup": "^4.0.0",
|
|
51
|
+
"rollup-plugin-typescript2": "^0.36.0",
|
|
52
|
+
"typescript": "^5.0.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {},
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "https://github.com/cuoral/cuoral-ionic.git"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { CuoralMessage, CuoralMessageType, CuoralConfig } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bridge for bidirectional communication between WebView and Native code
|
|
5
|
+
*/
|
|
6
|
+
export class CuoralBridge {
|
|
7
|
+
private config: CuoralConfig;
|
|
8
|
+
private messageHandlers: Map<CuoralMessageType, Array<(payload: any) => void>> = new Map();
|
|
9
|
+
private isInitialized: boolean = false;
|
|
10
|
+
private widgetIframe: HTMLIFrameElement | null = null;
|
|
11
|
+
|
|
12
|
+
constructor(config: CuoralConfig) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.setupMessageListener();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialize the bridge
|
|
19
|
+
*/
|
|
20
|
+
public initialize(): void {
|
|
21
|
+
if (this.isInitialized) {
|
|
22
|
+
this.log('Bridge already initialized');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.isInitialized = true;
|
|
27
|
+
this.log('Bridge initialized');
|
|
28
|
+
|
|
29
|
+
// Notify widget that bridge is ready
|
|
30
|
+
this.sendToWidget({
|
|
31
|
+
type: CuoralMessageType.WIDGET_READY,
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send message to native code
|
|
38
|
+
*/
|
|
39
|
+
public sendToNative(message: CuoralMessage): void {
|
|
40
|
+
if (!message.timestamp) {
|
|
41
|
+
message.timestamp = Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.log('Sending to native:', message);
|
|
45
|
+
|
|
46
|
+
// For Capacitor - use window.postMessage
|
|
47
|
+
if (window.webkit?.messageHandlers?.cuoral) {
|
|
48
|
+
// iOS WKWebView
|
|
49
|
+
window.webkit.messageHandlers.cuoral.postMessage(message);
|
|
50
|
+
} else if ((window as any).CuoralAndroid) {
|
|
51
|
+
// Android JavascriptInterface
|
|
52
|
+
(window as any).CuoralAndroid.postMessage(JSON.stringify(message));
|
|
53
|
+
} else {
|
|
54
|
+
// Fallback - use postMessage for any other WebView
|
|
55
|
+
window.postMessage(message, '*');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Send message to widget iframe
|
|
61
|
+
*/
|
|
62
|
+
public sendToWidget(message: CuoralMessage): void {
|
|
63
|
+
if (!message.timestamp) {
|
|
64
|
+
message.timestamp = Date.now();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.log('Sending to widget:', message);
|
|
68
|
+
|
|
69
|
+
// Find the iframe element
|
|
70
|
+
const iframe = document.getElementById('cuoral-widget-iframe') as HTMLIFrameElement;
|
|
71
|
+
|
|
72
|
+
if (iframe?.contentWindow) {
|
|
73
|
+
try {
|
|
74
|
+
// Use postMessage for cross-origin communication
|
|
75
|
+
iframe.contentWindow.postMessage(message, '*');
|
|
76
|
+
this.log('Message sent via postMessage');
|
|
77
|
+
return;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
this.log('Error using postMessage:', error);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
this.log('Iframe not found or not ready');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback: Use localStorage for same-origin
|
|
86
|
+
try {
|
|
87
|
+
const storageKey = `cuoral_message_${Date.now()}`;
|
|
88
|
+
localStorage.setItem(storageKey, JSON.stringify(message));
|
|
89
|
+
|
|
90
|
+
// Clean up after 5 seconds
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
localStorage.removeItem(storageKey);
|
|
93
|
+
}, 5000);
|
|
94
|
+
|
|
95
|
+
// Trigger a storage event by updating a counter
|
|
96
|
+
const counter = parseInt(localStorage.getItem('cuoral_message_counter') || '0');
|
|
97
|
+
localStorage.setItem('cuoral_message_counter', (counter + 1).toString());
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.log('Error using localStorage:', error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register message handler
|
|
105
|
+
*/
|
|
106
|
+
public on(type: CuoralMessageType, handler: (payload: any) => void): () => void {
|
|
107
|
+
if (!this.messageHandlers.has(type)) {
|
|
108
|
+
this.messageHandlers.set(type, []);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const handlers = this.messageHandlers.get(type)!;
|
|
112
|
+
handlers.push(handler);
|
|
113
|
+
|
|
114
|
+
this.log(`Registered handler for ${type}`);
|
|
115
|
+
|
|
116
|
+
// Return unsubscribe function
|
|
117
|
+
return () => {
|
|
118
|
+
const index = handlers.indexOf(handler);
|
|
119
|
+
if (index > -1) {
|
|
120
|
+
handlers.splice(index, 1);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Setup window message listener
|
|
127
|
+
*/
|
|
128
|
+
private setupMessageListener(): void {
|
|
129
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
130
|
+
// Only process messages FROM the iframe
|
|
131
|
+
const widgetFrame = document.querySelector('iframe[src*="mobile.html"]') as HTMLIFrameElement;
|
|
132
|
+
if (widgetFrame && event.source === widgetFrame.contentWindow) {
|
|
133
|
+
this.widgetIframe = widgetFrame;
|
|
134
|
+
} else if (widgetFrame) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Validate message structure
|
|
139
|
+
if (!event.data || !event.data.type) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const message = event.data as CuoralMessage;
|
|
144
|
+
|
|
145
|
+
// Check if it's a Cuoral message
|
|
146
|
+
if (!Object.values(CuoralMessageType).includes(message.type)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.log('Cuoral message received:', message);
|
|
151
|
+
|
|
152
|
+
// Call custom handler if provided
|
|
153
|
+
if (this.config.onMessage) {
|
|
154
|
+
this.config.onMessage(message);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Call registered handlers
|
|
158
|
+
const handlers = this.messageHandlers.get(message.type);
|
|
159
|
+
if (handlers) {
|
|
160
|
+
handlers.forEach(handler => handler(message.payload));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Debug logging
|
|
167
|
+
*/
|
|
168
|
+
private log(...args: any[]): void {
|
|
169
|
+
if (this.config.debug) {
|
|
170
|
+
console.log('[CuoralBridge]', ...args);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Destroy the bridge
|
|
176
|
+
*/
|
|
177
|
+
public destroy(): void {
|
|
178
|
+
this.messageHandlers.clear();
|
|
179
|
+
this.isInitialized = false;
|
|
180
|
+
this.log('Bridge destroyed');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Extend Window interface for TypeScript
|
|
185
|
+
declare global {
|
|
186
|
+
interface Window {
|
|
187
|
+
webkit?: {
|
|
188
|
+
messageHandlers?: {
|
|
189
|
+
cuoral?: {
|
|
190
|
+
postMessage: (message: any) => void;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|