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/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
+ }