diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 0e4699359..8b279b281 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -104,12 +104,12 @@ export default class APIClient { post(path, params, options) { this.init.method = 'POST'; - return this.fetch(path, params); + return this.fetch(path, params, undefined); } put(path, params, options) { this.init.method = 'PUT'; - return this.fetch(path, params); + return this.fetch(path, params, undefined); } delete(path, params, options) { diff --git a/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx b/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx index 00fdb9939..30d5ac08e 100644 --- a/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx +++ b/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { screenRecorder } from 'App/utils/screenRecorder'; -import { Tooltip } from 'react-tippy' -import { Button } from 'UI' -import { requestRecording, stopRecording, connectPlayer } from 'Player' -import { - SessionRecordingStatus, -} from 'Player/MessageDistributor/managers/AssistManager'; -let stopRecorderCb: () => void +import { Tooltip } from 'react-tippy'; +import { connect } from 'react-redux'; +import { Button } from 'UI'; +import { requestRecording, stopRecording, connectPlayer } from 'Player'; +import { SessionRecordingStatus } from 'Player/MessageDistributor/managers/AssistManager'; +let stopRecorderCb: () => void; +import { recordingsService } from 'App/services'; +import { toast } from 'react-toastify'; /** * "edge" || "edg/" chromium based edge (dev or canary) @@ -17,24 +18,44 @@ let stopRecorderCb: () => void * "safari" safari */ function isSupported() { - const agent = window.navigator.userAgent.toLowerCase() + const agent = window.navigator.userAgent.toLowerCase(); - if (agent.includes("edge") || agent.includes("edg/")) return true + if (agent.includes('edge') || agent.includes('edg/')) return true; // @ts-ignore - if (agent.includes("chrome") && !!window.chrome) return true + if (agent.includes('chrome') && !!window.chrome) return true; - return false + return false; } -const supportedBrowsers = ["Chrome v91+", "Edge v90+"] -const supportedMessage = `Supported Browsers: ${supportedBrowsers.join(', ')}` +const supportedBrowsers = ['Chrome v91+', 'Edge v90+']; +const supportedMessage = `Supported Browsers: ${supportedBrowsers.join(', ')}`; -function ScreenRecorder({ recordingState }: { recordingState: SessionRecordingStatus }) { +function ScreenRecorder({ + recordingState, + siteId, + sessionId, +}: { + recordingState: SessionRecordingStatus; + siteId: string; + sessionId: string; +}) { const [isRecording, setRecording] = React.useState(false); React.useEffect(() => { - return () => stopRecorderCb?.() - }, []) + return () => stopRecorderCb?.(); + }, []); + const onSave = async (saveObj: { name: string; duration: number }, blob: Blob) => { + try { + const url = await recordingsService.reserveUrl(siteId, saveObj); + const status = recordingsService.saveFile(url, blob); + + if (status) { + toast.success('Session recording saved'); + } + } catch (e) { + console.error(e); + } + }; React.useEffect(() => { if (!isRecording && recordingState === SessionRecordingStatus.Recording) { @@ -43,43 +64,51 @@ function ScreenRecorder({ recordingState }: { recordingState: SessionRecordingSt if (isRecording && recordingState !== SessionRecordingStatus.Recording) { stopRecordingHandler(); } - }, [recordingState, isRecording]) + }, [recordingState, isRecording]); const startRecording = async () => { - const stop = await screenRecorder(); - stopRecorderCb = stop; - setRecording(true); + const stop = await screenRecorder('test rec_' + new Date().getTime(), sessionId, onSave); + stopRecorderCb = stop; + setRecording(true); }; const stopRecordingHandler = () => { - stopRecording() + stopRecording(); stopRecorderCb?.(); setRecording(false); - } + }; const recordingRequest = () => { - requestRecording() + requestRecording(); // startRecording() - } + }; - if (!isSupported()) return ( -
- {/* @ts-ignore */} - - - -
- ) + if (!isSupported()) + return ( +
+ {/* @ts-ignore */} + + + +
+ ); return (
-
); } -// @ts-ignore -export default connectPlayer(state => ({ recordingState: state.recordingState}))(ScreenRecorder) +export default connectPlayer((state: any) => ({ recordingState: state.recordingState }))( + connect((state: any) => ({ + siteId: state.getIn(['site', 'siteId']), + sessionId: state.getIn(['sessions', 'current', 'sessionId']), + }))(ScreenRecorder) +); diff --git a/frontend/app/services/RecordingsService.ts b/frontend/app/services/RecordingsService.ts new file mode 100644 index 000000000..0b50fb943 --- /dev/null +++ b/frontend/app/services/RecordingsService.ts @@ -0,0 +1,41 @@ +import APIClient from 'App/api_client'; + +interface RecordingData { + name: string; + duration: number; +} + +export default class RecordingsService { + private client: APIClient; + + constructor(client?: APIClient) { + this.client = client ? client : new APIClient(); + } + + initClient(client?: APIClient) { + this.client = client || new APIClient(); + } + + reserveUrl(siteId: string, recordingData: RecordingData): Promise { + return this.client.put(`/${siteId}/assist/save`, recordingData) + .then(r => { + if (r.ok) { + return r.json().then(j => j.data.URL) + } else { + throw new Error("Can't reserve space for recording: " + r.status); + } + }) + } + + saveFile(url: string, file: Blob) { + return fetch(url, { method: 'PUT', headers: { 'Content-Type': 'video/webm' }, body: file }) + .then(r => { + if (r.ok) { + return true + } else { + throw new Error("Can't upload file: " + r.status) + } + }) + } + +} diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts index 6de2d300b..2bcf5981e 100644 --- a/frontend/app/services/index.ts +++ b/frontend/app/services/index.ts @@ -6,6 +6,7 @@ import UserService from "./UserService"; import AuditService from './AuditService'; import ErrorService from "./ErrorService"; import NotesService from "./NotesService"; +import RecordingsService from "./RecordingsService"; export const dashboardService = new DashboardService(); export const metricService = new MetricService(); @@ -15,3 +16,4 @@ export const funnelService = new FunnelService(); export const auditService = new AuditService(); export const errorService = new ErrorService(); export const notesService = new NotesService(); +export const recordingsService = new RecordingsService(); diff --git a/frontend/app/utils/screenRecorder.ts b/frontend/app/utils/screenRecorder.ts index 47acf6bca..fe68df4a5 100644 --- a/frontend/app/utils/screenRecorder.ts +++ b/frontend/app/utils/screenRecorder.ts @@ -1,44 +1,63 @@ const FILE_TYPE = 'video/webm'; const FRAME_RATE = 30; -function createFileRecorder (stream: MediaStream, mimeType: string) { +function createFileRecorder( + stream: MediaStream, + mimeType: string, + recName: string, + sessionId: string, + saveCb: Function +) { let ended = false; + const start = new Date().getTime(); + let recordedChunks: BlobPart[] = []; const SAVE_INTERVAL_MS = 200; const mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = function (e) { - if (e.data.size > 0) { + if (e.data.size > 0) { recordedChunks.push(e.data); - } + } }; function onEnd() { - if (ended) return; + if (ended) return; - ended = true; - saveFile(recordedChunks, mimeType); - recordedChunks = []; - }; + ended = true; + saveFile(recordedChunks, mimeType, start, recName, sessionId, saveCb); + recordedChunks = []; + } // sometimes we get race condition or the onstop won't trigger at all, // this is why we have to make it twice to make sure that stream is saved // plus we want to be able to handle both, native and custom button clicks - mediaRecorder.stream.getTracks().forEach(track => track.onended = onEnd); + mediaRecorder.stream.getTracks().forEach((track) => (track.onended = onEnd)); mediaRecorder.onstop = () => { - onEnd(); - mediaRecorder.stream.getTracks().forEach(track => track.stop()); - } + onEnd(); + mediaRecorder.stream.getTracks().forEach((track) => track.stop()); + }; mediaRecorder.start(SAVE_INTERVAL_MS); return mediaRecorder; } -function saveFile(recordedChunks: BlobPart[], mimeType: string) { +function saveFile( + recordedChunks: BlobPart[], + mimeType: string, + startDate: number, + recName: string, + sessionId: string, + saveCb: Function +) { + const saveObject = { name: recName, duration: new Date().getTime() - startDate, sessionId }; + const blob = new Blob(recordedChunks, { - type: mimeType, + type: mimeType, }); - const filename = +new Date() + '.' + mimeType.split('/')[1]; + saveCb(saveObject, blob); + + const filename = recName + '.' + mimeType.split('/')[1]; const downloadLink = document.createElement('a'); downloadLink.href = URL.createObjectURL(blob); downloadLink.download = filename; @@ -52,33 +71,33 @@ function saveFile(recordedChunks: BlobPart[], mimeType: string) { async function recordScreen() { return await navigator.mediaDevices.getDisplayMedia({ - audio: true, - video: { frameRate: FRAME_RATE }, - // potential chrome hack - // @ts-ignore - preferCurrentTab: true, + audio: true, + video: { frameRate: FRAME_RATE }, + // potential chrome hack + // @ts-ignore + preferCurrentTab: true, }); -}; +} /** -* Creates a screen recorder that sends the media stream to MediaRecorder -* which then saves the stream to a file. -* -* Supported Browsers: -* -* Windows: Chrome v91+, Edge v90+ - FULL SUPPORT; -* *Nix: Chrome v91+, Edge v90+ - LIMITED SUPPORT - (audio only captured from current tab) -* -* @returns a promise that resolves to a function that stops the recording -*/ -export async function screenRecorder() { + * Creates a screen recorder that sends the media stream to MediaRecorder + * which then saves the stream to a file. + * + * Supported Browsers: + * + * Windows: Chrome v91+, Edge v90+ - FULL SUPPORT; + * *Nix: Chrome v91+, Edge v90+ - LIMITED SUPPORT - (audio only captured from current tab) + * + * @returns a promise that resolves to a function that stops the recording + */ +export async function screenRecorder(recName: string, sessionId: string, saveCb: Function) { try { - const stream = await recordScreen(); - const mediaRecorder = createFileRecorder(stream, FILE_TYPE); + const stream = await recordScreen(); + const mediaRecorder = createFileRecorder(stream, FILE_TYPE, recName, sessionId, saveCb); - return () => mediaRecorder.stop(); + return () => mediaRecorder.stop(); } catch (e) { - console.log(e) + console.log(e); } } diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index c620a3af9..34d479f35 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -12,7 +12,7 @@ import AnnotationCanvas from './AnnotationCanvas.js' import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js' import { callConfirmDefault, } from './ConfirmWindow/defaults.js' import type { Options as ConfirmOptions, } from './ConfirmWindow/defaults.js' -import ScreenRecordingState, { RecordingState, } from './ScreenRecordingState' +import ScreenRecordingState from './ScreenRecordingState' // TODO: fully specified strict check with no-any (everywhere) @@ -208,7 +208,7 @@ export default class Assist { const onDenyRecording = () => { socket.emit('recording_denied') } - const recordingState = new ScreenRecordingState(onAcceptRecording, onDenyRecording, this.options) + const recordingState = new ScreenRecordingState(onAcceptRecording, onDenyRecording, this.options) // TODO: check incoming args socket.on('request_control', this.remoteControl.requestControl) @@ -259,11 +259,15 @@ export default class Assist { this.agents[id]?.onDisconnect?.() delete this.agents[id] + if (Object.keys(this.agents).length === 0 && recordingState.isActive) { + recordingState.denyRecording() + } endAgentCall(id) }) socket.on('NO_AGENT', () => { Object.values(this.agents).forEach(a => a.onDisconnect?.()) this.agents = {} + if (recordingState.isActive) recordingState.denyRecording() }) socket.on('call_end', (id) => { if (!callingAgents.has(id)) { @@ -281,13 +285,13 @@ export default class Assist { callUI?.toggleVideoStream(feedState) }) socket.on('request_recording', (id, agentData) => { - if (recordingState.status === RecordingState.Off) { + if (!recordingState.isActive) { this.options.onRecordingRequest?.(JSON.parse(agentData)) recordingState.requestRecording(id) } }) socket.on('stop_recording', (id) => { - if (recordingState.status !== RecordingState.Off) { + if (recordingState.isActive) { recordingState.denyRecording(id) } }) diff --git a/tracker/tracker-assist/src/ScreenRecordingState.ts b/tracker/tracker-assist/src/ScreenRecordingState.ts index 0ab99cc7a..521a80370 100644 --- a/tracker/tracker-assist/src/ScreenRecordingState.ts +++ b/tracker/tracker-assist/src/ScreenRecordingState.ts @@ -50,6 +50,10 @@ export default class ScreenRecordingState { private readonly options: AssistOptions ) {} + public get isActive() { + return this.status !== RecordingState.Off + } + private confirm: ConfirmWindow | null = null public requestRecording = (id: string) => {