From a24d99f75c01f985c41270c799e8f5b640bfdb10 Mon Sep 17 00:00:00 2001 From: Delirium Date: Fri, 12 May 2023 15:38:43 +0200 Subject: [PATCH] feat(player): player file loader refactoring (#1203) * change(ui): refactor mob loading * refactor(player): split message loader into separate file, remove toast dependency out of player lib, fix types, fix inspector and screen context * refactor(player): simplify file loading, add safe error throws * refactor(player): move loading status changers to the end of the flow --- .../app/components/Session/LivePlayer.tsx | 49 +++--- .../Player/ClickMapRenderer/ThinPlayer.tsx | 8 +- frontend/app/components/Session/WebPlayer.tsx | 7 +- .../Session_/Player/Controls/Timeline.tsx | 3 +- frontend/app/player/common/types.ts | 18 ++ frontend/app/player/create.ts | 27 ++- frontend/app/player/guards.ts | 3 + frontend/app/player/player/Animator.ts | 3 +- frontend/app/player/player/Player.ts | 5 +- frontend/app/player/web/MessageLoader.ts | 135 +++++++++++++++ frontend/app/player/web/MessageManager.ts | 158 ++++-------------- frontend/app/player/web/Screen/Screen.ts | 20 +-- frontend/app/player/web/WebLivePlayer.ts | 26 +-- frontend/app/player/web/WebPlayer.ts | 28 +++- .../player/web/addons/InspectorController.ts | 15 +- .../app/player/web/assist/AssistManager.ts | 2 + .../app/player/web/assist/ScreenRecording.ts | 5 +- .../app/player/web/managers/DOM/VirtualDOM.ts | 4 +- .../app/player/web/managers/PagesManager.ts | 2 +- frontend/app/player/web/network/crypto.ts | 2 +- frontend/app/player/web/network/loadFiles.ts | 29 +++- 21 files changed, 330 insertions(+), 219 deletions(-) create mode 100644 frontend/app/player/guards.ts create mode 100644 frontend/app/player/web/MessageLoader.ts diff --git a/frontend/app/components/Session/LivePlayer.tsx b/frontend/app/components/Session/LivePlayer.tsx index 0e1d50f3a..03c8860d5 100644 --- a/frontend/app/components/Session/LivePlayer.tsx +++ b/frontend/app/components/Session/LivePlayer.tsx @@ -8,10 +8,11 @@ import { createLiveWebPlayer } from 'Player'; import PlayerBlockHeader from './Player/LivePlayer/LivePlayerBlockHeader'; import PlayerBlock from './Player/LivePlayer/LivePlayerBlock'; import styles from '../Session_/session.module.css'; -import Session from 'App/mstore/types/session'; +import Session from 'App/types/session'; import withLocationHandlers from 'HOCs/withLocationHandlers'; import APIClient from 'App/api_client'; import { useLocation } from 'react-router-dom'; +import { toast } from 'react-toastify' interface Props { session: Session; @@ -58,15 +59,21 @@ function LivePlayer({ if (isEnterprise) { new APIClient().get('/config/assist/credentials').then(r => r.json()) .then(({ data }) => { - const [player, store] = createLiveWebPlayer(sessionWithAgentData, data, (state) => - makeAutoObservable(state) + const [player, store] = createLiveWebPlayer( + sessionWithAgentData, + data, + (state) => makeAutoObservable(state), + toast ); setContextValue({ player, store }); playerInst = player; }) } else { - const [player, store] = createLiveWebPlayer(sessionWithAgentData, null, (state) => - makeAutoObservable(state) + const [player, store] = createLiveWebPlayer( + sessionWithAgentData, + null, + (state) => makeAutoObservable(state), + toast ); setContextValue({ player, store }); playerInst = player; @@ -117,19 +124,19 @@ function LivePlayer({ } export default withPermissions( - ['ASSIST_LIVE'], - '', - true - )( - connect( - (state: any) => { - return { - session: state.getIn(['sessions', 'current']), - showAssist: state.getIn(['sessions', 'showChatWindow']), - isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', - userEmail: state.getIn(['user', 'account', 'email']), - userName: state.getIn(['user', 'account', 'name']), - }; - } - )(withLocationHandlers()(React.memo(LivePlayer))) - ) + ['ASSIST_LIVE'], + '', + true +)( + connect( + (state: any) => { + return { + session: state.getIn(['sessions', 'current']), + showAssist: state.getIn(['sessions', 'showChatWindow']), + isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', + userEmail: state.getIn(['user', 'account', 'email']), + userName: state.getIn(['user', 'account', 'name']), + }; + } + )(withLocationHandlers()(React.memo(LivePlayer))) +) diff --git a/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx b/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx index 1855a5046..51f1224c0 100644 --- a/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx +++ b/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx @@ -6,7 +6,7 @@ import withLocationHandlers from 'HOCs/withLocationHandlers'; import PlayerContent from './ThinPlayerContent'; import { IPlayerContext, PlayerContext, defaultContextValue } from '../../playerContext'; import { observer } from 'mobx-react-lite'; - +import { toast } from 'react-toastify' function WebPlayer(props: any) { const { @@ -20,8 +20,10 @@ function WebPlayer(props: any) { const [contextValue, setContextValue] = useState(defaultContextValue); useEffect(() => { - const [WebPlayerInst, PlayerStore] = createClickMapPlayer(customSession, (state) => - makeAutoObservable(state) + const [WebPlayerInst, PlayerStore] = createClickMapPlayer( + customSession, + (state) => makeAutoObservable(state), + toast, ); setContextValue({ player: WebPlayerInst, store: PlayerStore }); diff --git a/frontend/app/components/Session/WebPlayer.tsx b/frontend/app/components/Session/WebPlayer.tsx index a51fca718..923b2efd7 100644 --- a/frontend/app/components/Session/WebPlayer.tsx +++ b/frontend/app/components/Session/WebPlayer.tsx @@ -14,6 +14,7 @@ import { IPlayerContext, PlayerContext, defaultContextValue } from './playerCont import { observer } from 'mobx-react-lite'; import { Note } from "App/services/NotesService"; import { useParams } from 'react-router-dom' +import { toast } from 'react-toastify' const TABS = { EVENTS: 'User Events', @@ -44,8 +45,10 @@ function WebPlayer(props: any) { if (!session.sessionId || contextValue.player !== undefined) return; fetchList('issues'); - const [WebPlayerInst, PlayerStore] = createWebPlayer(session, (state) => - makeAutoObservable(state) + const [WebPlayerInst, PlayerStore] = createWebPlayer( + session, + (state) => makeAutoObservable(state), + toast, ); setContextValue({ player: WebPlayerInst, store: PlayerStore }); playerInst = WebPlayerInst; diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.tsx b/frontend/app/components/Session_/Player/Controls/Timeline.tsx index a69189b49..e456df08a 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.tsx +++ b/frontend/app/components/Session_/Player/Controls/Timeline.tsx @@ -41,6 +41,7 @@ function Timeline(props: IProps) { ready, endTime, devtoolsLoading, + domLoading, } = store.get() const { issues } = props; const notes = notesStore.sessionNotes @@ -170,7 +171,7 @@ function Timeline(props: IProps) { /> )) : null}
- {devtoolsLoading || !ready ?
: null} + {devtoolsLoading || domLoading || !ready ?
: null}
{events.map((e) => ( diff --git a/frontend/app/player/common/types.ts b/frontend/app/player/common/types.ts index 308ec0659..20cfa2a58 100644 --- a/frontend/app/player/common/types.ts +++ b/frontend/app/player/common/types.ts @@ -25,3 +25,21 @@ export interface Store { update(state: Partial): void } + +export interface SessionFilesInfo { + startedAt: number + sessionId: string + isMobile: boolean + agentToken?: string + duration: number + domURL: string[] + devtoolsURL: string[] + /** deprecated */ + mobsUrl: string[] + fileKey: string | null + events: Record[] + stackEvents: Record[] + frustrations: Record[] + errors: Record[] + agentInfo?: { email: string, name: string } +} \ No newline at end of file diff --git a/frontend/app/player/create.ts b/frontend/app/player/create.ts index 2d46c5b0e..31b1300f0 100644 --- a/frontend/app/player/create.ts +++ b/frontend/app/player/create.ts @@ -1,5 +1,5 @@ import SimpleStore from './common/SimpleStore' -import type { Store } from './common/types' +import type { Store, SessionFilesInfo } from './common/types' import WebPlayer from './web/WebPlayer' import WebLivePlayer from './web/WebLivePlayer' @@ -14,7 +14,11 @@ type WebLivePlayerStore = Store export type IWebLivePlayer = WebLivePlayer export type IWebLivePlayerStore = WebLivePlayerStore -export function createWebPlayer(session: Record, wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore): [IWebPlayer, IWebPlayerStore] { +export function createWebPlayer( + session: SessionFilesInfo, + wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore, + uiErrorHandler?: { error: (msg: string) => void } +): [IWebPlayer, IWebPlayerStore] { let store: WebPlayerStore = new SimpleStore({ ...WebPlayer.INITIAL_STATE, }) @@ -22,12 +26,16 @@ export function createWebPlayer(session: Record, wrapStore?: (s:IWe store = wrapStore(store) } - const player = new WebPlayer(store, session, false) + const player = new WebPlayer(store, session, false, false, uiErrorHandler) return [player, store] } -export function createClickMapPlayer(session: Record, wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore): [IWebPlayer, IWebPlayerStore] { +export function createClickMapPlayer( + session: SessionFilesInfo, + wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore, + uiErrorHandler?: { error: (msg: string) => void } +): [IWebPlayer, IWebPlayerStore] { let store: WebPlayerStore = new SimpleStore({ ...WebPlayer.INITIAL_STATE, }) @@ -35,11 +43,16 @@ export function createClickMapPlayer(session: Record, wrapStore?: ( store = wrapStore(store) } - const player = new WebPlayer(store, session, false, true) + const player = new WebPlayer(store, session, false, true, uiErrorHandler) return [player, store] } -export function createLiveWebPlayer(session: Record, config: RTCIceServer[] | null, wrapStore?: (s:IWebLivePlayerStore) => IWebLivePlayerStore): [IWebLivePlayer, IWebLivePlayerStore] { +export function createLiveWebPlayer( + session: SessionFilesInfo, + config: RTCIceServer[] | null, + wrapStore?: (s:IWebLivePlayerStore) => IWebLivePlayerStore, + uiErrorHandler?: { error: (msg: string) => void } +): [IWebLivePlayer, IWebLivePlayerStore] { let store: WebLivePlayerStore = new SimpleStore({ ...WebLivePlayer.INITIAL_STATE, }) @@ -47,6 +60,6 @@ export function createLiveWebPlayer(session: Record, config: RTCIce store = wrapStore(store) } - const player = new WebLivePlayer(store, session, config) + const player = new WebLivePlayer(store, session, config, uiErrorHandler) return [player, store] } diff --git a/frontend/app/player/guards.ts b/frontend/app/player/guards.ts new file mode 100644 index 000000000..a2d18f714 --- /dev/null +++ b/frontend/app/player/guards.ts @@ -0,0 +1,3 @@ +export function isRootNode(node: Node): node is Document { + return node.nodeType === Node.DOCUMENT_NODE || node instanceof Document +} \ No newline at end of file diff --git a/frontend/app/player/player/Animator.ts b/frontend/app/player/player/Animator.ts index 55d38432c..6199f0c96 100644 --- a/frontend/app/player/player/Animator.ts +++ b/frontend/app/player/player/Animator.ts @@ -1,6 +1,7 @@ -import type { Store, Moveable, Interval } from '../common/types'; +import type { Store, Interval } from '../common/types'; import MessageManager from 'App/player/web/MessageManager' + const fps = 60 const performance: { now: () => number } = window.performance || { now: Date.now.bind(Date) } const requestAnimationFrame: typeof window.requestAnimationFrame = diff --git a/frontend/app/player/player/Player.ts b/frontend/app/player/player/Player.ts index 4973a98a3..e3520191a 100644 --- a/frontend/app/player/player/Player.ts +++ b/frontend/app/player/player/Player.ts @@ -1,8 +1,9 @@ import * as typedLocalStorage from './localStorage'; -import type { Moveable, Cleanable, Store } from '../common/types'; +import type { Store } from '../common/types'; import Animator from './Animator'; import type { GetState as AnimatorGetState } from './Animator'; +import MessageManager from "Player/web/MessageManager"; export const SPEED_OPTIONS = [0.5, 1, 2, 4, 8, 16] @@ -34,7 +35,7 @@ export default class Player extends Animator { speed: initialSpeed, } as const - constructor(private pState: Store, private manager: Moveable & Cleanable) { + constructor(private pState: Store, private manager: MessageManager) { super(pState, manager) // Autoplay diff --git a/frontend/app/player/web/MessageLoader.ts b/frontend/app/player/web/MessageLoader.ts new file mode 100644 index 000000000..0acb282dd --- /dev/null +++ b/frontend/app/player/web/MessageLoader.ts @@ -0,0 +1,135 @@ +import type { Store, SessionFilesInfo } from 'Player'; +import { decryptSessionBytes } from './network/crypto'; +import MFileReader from './messages/MFileReader'; +import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles'; +import type { + Message, +} from './messages'; +import logger from 'App/logger'; +import MessageManager from "Player/web/MessageManager"; + + +interface State { + firstFileLoading: boolean, + domLoading: boolean, + devtoolsLoading: boolean, + error: boolean, +} + +export default class MessageLoader { + static INITIAL_STATE: State = { + firstFileLoading: false, + domLoading: false, + devtoolsLoading: false, + error: false, + } + + constructor( + private readonly session: SessionFilesInfo, + private store: Store, + private messageManager: MessageManager, + private isClickmap: boolean, + ) {} + + createNewParser(shouldDecrypt = true, file?: string, toggleStatus?: (isLoading: boolean) => void) { + const decrypt = shouldDecrypt && this.session.fileKey + ? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey!) + : (b: Uint8Array) => Promise.resolve(b) + // Each time called - new fileReader created + const fileReader = new MFileReader(new Uint8Array(), this.session.startedAt) + return (b: Uint8Array) => decrypt(b).then(b => { + toggleStatus?.(true); + fileReader.append(b) + fileReader.checkForIndexes() + const msgs: Array = [] + for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) { + msgs.push(msg) + } + const sorted = msgs.sort((m1, m2) => { + return m1.time - m2.time + }) + + sorted.forEach(msg => { + this.messageManager.distributeMessage(msg) + }) + logger.info("Messages count: ", msgs.length, sorted, file) + + this.messageManager._sortMessagesHack(sorted) + toggleStatus?.(false); + this.messageManager.setMessagesLoading(false) + }) + } + + loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise) { + if (urls.length > 0) { + this.store.update({ domLoading: true }) + return loadFiles(urls, parser, true).then(() => this.store.update({ domLoading: false })) + } else { + return Promise.resolve() + } + } + + loadDevtools() { + if (!this.isClickmap) { + this.store.update({ devtoolsLoading: true }) + return loadFiles(this.session.devtoolsURL, this.createNewParser(true, 'devtools')) + // TODO: also in case of dynamic update through assist + .then(() => { + // @ts-ignore ? + this.store.update({ ...this.messageManager.getListsFullState(), devtoolsLoading: false }); + }) + } else { + return Promise.resolve() + } + } + + async loadFiles() { + this.messageManager.startLoading() + + const loadMethod = this.session.domURL && this.session.domURL.length > 0 + ? { url: this.session.domURL, parser: () => this.createNewParser(true, 'dom') } + : { url: this.session.mobsUrl, parser: () => this.createNewParser(false, 'dom') } + + const parser = loadMethod.parser() + /** + * We load first dom mob file before the rest + * to speed up time to replay + * but as a tradeoff we have to have some copy-paste + * for the devtools file + * */ + try { + await loadFiles([loadMethod.url[0]], parser) + const restDomFilesPromise = this.loadDomFiles([...loadMethod.url.slice(1)], parser) + const restDevtoolsFilesPromise = this.loadDevtools() + + await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]) + this.messageManager.onFileReadSuccess() + } catch (e) { + try { + this.store.update({ domLoading: true, devtoolsLoading: true }) + const efsDomFilePromise = requestEFSDom(this.session.sessionId) + const efsDevtoolsFilePromise = requestEFSDevtools(this.session.sessionId) + + const [domData, devtoolsData] = await Promise.allSettled([efsDomFilePromise, efsDevtoolsFilePromise]) + const domParser = this.createNewParser(false, 'domEFS') + const devtoolsParser = this.createNewParser(false, 'devtoolsEFS') + const parseDomPromise: Promise = domData.status === 'fulfilled' + ? domParser(domData.value) : Promise.reject('No dom file in EFS') + const parseDevtoolsPromise: Promise = devtoolsData.status === 'fulfilled' + ? devtoolsParser(devtoolsData.value) : Promise.reject('No devtools file in EFS') + + await Promise.all([parseDomPromise, parseDevtoolsPromise]) + this.messageManager.onFileReadSuccess() + } catch (e2) { + this.messageManager.onFileReadFailed(e) + } + } finally { + this.messageManager.onFileReadFinally() + this.store.update({ domLoading: false, devtoolsLoading: false }) + } + } + + clean() { + this.store.update(MessageLoader.INITIAL_STATE); + } +} \ No newline at end of file diff --git a/frontend/app/player/web/MessageManager.ts b/frontend/app/player/web/MessageManager.ts index 08d9b1505..82c294875 100644 --- a/frontend/app/player/web/MessageManager.ts +++ b/frontend/app/player/web/MessageManager.ts @@ -3,12 +3,14 @@ import { Decoder } from "syncod"; import logger from 'App/logger'; import { TYPES as EVENT_TYPES } from 'Types/session/event'; -import { Log } from './types/log'; -import { Resource, ResourceType, getResourceFromResourceTiming, getResourceFromNetworkRequest } from './types/resource' +import { Log } from 'Player'; +import { + ResourceType, + getResourceFromResourceTiming, + getResourceFromNetworkRequest +} from 'Player' -import { toast } from 'react-toastify'; - -import type { Store, Timed } from '../common/types'; +import type { Store } from 'Player'; import ListWalker from '../common/ListWalker'; import PagesManager from './managers/PagesManager'; @@ -18,7 +20,6 @@ import PerformanceTrackManager from './managers/PerformanceTrackManager'; import WindowNodeCounter from './managers/WindowNodeCounter'; import ActivityManager from './managers/ActivityManager'; -import MFileReader from './messages/MFileReader'; import { MouseThrashing, MType } from "./messages"; import { isDOMType } from './messages/filters.gen'; import type { @@ -30,9 +31,6 @@ import type { MouseClick, } from './messages'; -import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles'; -import { decryptSessionBytes } from './network/crypto'; - import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from './Lists'; import Screen, { @@ -44,7 +42,6 @@ import type { InitialLists } from './Lists' import type { PerformanceChartPoint } from './managers/PerformanceTrackManager'; import type { SkipInterval } from './managers/ActivityManager'; - export interface State extends ScreenState, ListsState { performanceChartData: PerformanceChartPoint[], skipIntervals: SkipInterval[], @@ -58,8 +55,6 @@ export interface State extends ScreenState, ListsState { domBuildingTime?: number, loadTime?: { time: number, value: number }, error: boolean, - devtoolsLoading: boolean, - messagesLoading: boolean, cssLoading: boolean, @@ -87,14 +82,12 @@ export default class MessageManager { performanceChartData: [], skipIntervals: [], error: false, - devtoolsLoading: false, - - messagesLoading: false, cssLoading: false, ready: false, lastMessageTime: 0, firstVisualEvent: 0, messagesProcessed: false, + messagesLoading: false, } private locationEventManager: ListWalker/**/ = new ListWalker(); @@ -117,7 +110,7 @@ export default class MessageManager { private activityManager: ActivityManager | null = null; - private sessionStart: number; + private readonly sessionStart: number; private navigationStartOffset: number = 0; private lastMessageTime: number = 0; private firstVisualEventSet = false; @@ -126,7 +119,8 @@ export default class MessageManager { private readonly session: any /*Session*/, private readonly state: Store, private readonly screen: Screen, - initialLists?: Partial + initialLists?: Partial, + private readonly uiErrorHandler?: { error: (error: string) => void, }, ) { this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading) this.mouseMoveManager = new MouseMoveManager(screen) @@ -134,7 +128,7 @@ export default class MessageManager { this.sessionStart = this.session.startedAt this.lists = new Lists(initialLists) - initialLists?.event?.forEach((e: Record) => { // TODO: to one of "Moveable" module + initialLists?.event?.forEach((e: Record) => { // TODO: to one of "Movable" module if (e.type === EVENT_TYPES.LOCATION) { this.locationEventManager.append(e); } @@ -143,6 +137,10 @@ export default class MessageManager { this.activityManager = new ActivityManager(this.session.duration.milliseconds) // only if not-live } + public getListsFullState = () => { + return this.lists.getFullListsState() + } + public updateLists(lists: Partial) { Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => { const currentList = this.lists.lists[key] @@ -162,7 +160,7 @@ export default class MessageManager { this.state.update({ cssLoading, ready: !this.state.get().messagesLoading && !cssLoading }) } - private _sortMessagesHack(msgs: Message[]) { + public _sortMessagesHack = (msgs: Message[]) => { // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); this.pagesManager.sortPages((m1, m2) => { @@ -190,7 +188,7 @@ export default class MessageManager { } private waitingForFiles: boolean = false - private onFileReadSuccess = () => { + public onFileReadSuccess = () => { const stateToUpdate : Partial= { performanceChartData: this.performanceTrackManager.chartData, performanceAvailability: this.performanceTrackManager.availability, @@ -202,108 +200,22 @@ export default class MessageManager { } this.state.update(stateToUpdate) } - private onFileReadFailed = (e: any) => { + + public onFileReadFailed = (e: any) => { logger.error(e) this.state.update({ error: true }) - toast.error('Error requesting a session file') + this.uiErrorHandler?.error('Error requesting a session file') } - private onFileReadFinally = () => { + + public onFileReadFinally = () => { this.waitingForFiles = false this.state.update({ messagesProcessed: true }) - // this.setMessagesLoading(false) - // this.state.update({ filesLoaded: true }) } - async loadMessages(isClickmap: boolean = false) { + public startLoading = () => { + this.waitingForFiles = true this.state.update({ messagesProcessed: false }) this.setMessagesLoading(true) - // TODO: reusable decryptor instance - const createNewParser = (shouldDecrypt = true, file?: string) => { - const decrypt = shouldDecrypt && this.session.fileKey - ? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey) - : (b: Uint8Array) => Promise.resolve(b) - // Each time called - new fileReader created - const fileReader = new MFileReader(new Uint8Array(), this.sessionStart) - return (b: Uint8Array) => decrypt(b).then(b => { - fileReader.append(b) - fileReader.checkForIndexes() - const msgs: Array = [] - for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) { - msgs.push(msg) - } - const sorted = msgs.sort((m1, m2) => { - return m1.time - m2.time - }) - - let outOfOrderCounter = 0 - sorted.forEach(msg => { - this.distributeMessage(msg) - }) - - if (outOfOrderCounter > 0) console.warn("Unsorted mob file, error count: ", outOfOrderCounter) - logger.info("Messages count: ", msgs.length, sorted, file) - - this._sortMessagesHack(msgs) - this.setMessagesLoading(false) - }) - } - - this.waitingForFiles = true - - // TODO: refactor this stuff; split everything to async/await - - const loadMethod = this.session.domURL && this.session.domURL.length > 0 - ? { url: this.session.domURL, parser: () => createNewParser(true, 'dom') } - : { url: this.session.mobsUrl, parser: () => createNewParser(false, 'dom')} - - const parser = loadMethod.parser() - - /** - * We load first dom mobfile before the rest - * to speed up time to replay - * but as a tradeoff we have to have some copy-paste - * for the devtools file - * */ - loadFiles([loadMethod.url[0]], parser) - .then(() => { - const domPromise = loadMethod.url.length > 1 - ? loadFiles([loadMethod.url[1]], parser, true) - : Promise.resolve() - const devtoolsPromise = !isClickmap - ? this.loadDevtools(createNewParser) - : Promise.resolve() - return Promise.all([domPromise, devtoolsPromise]) - }) - /** - * EFS fallback for unprocessed sessions (which are live) - * */ - .catch(() => { - requestEFSDom(this.session.sessionId) - .then(createNewParser(false, 'domEFS')) - .catch(this.onFileReadFailed) - if (!isClickmap) { - this.loadDevtools(createNewParser) - } - } - ) - .then(this.onFileReadSuccess) - .finally(this.onFileReadFinally); - } - - loadDevtools(createNewParser: (shouldDecrypt: boolean, file: string) => (b: Uint8Array) => Promise) { - this.state.update({ devtoolsLoading: true }) - return loadFiles(this.session.devtoolsURL, createNewParser(true, 'devtools')) - // EFS fallback - .catch(() => - requestEFSDevtools(this.session.sessionId) - .then(createNewParser(false, 'devtoolsEFS')) - ) - // TODO: also in case of dynamic update through assist - .then(() => { - this.state.update({ ...this.lists.getFullListsState() }) - }) - .catch(e => logger.error("Can not download the devtools file", e)) - .finally(() => this.state.update({ devtoolsLoading: false })) } resetMessageManagers() { @@ -395,29 +307,14 @@ export default class MessageManager { } } - private decodeStateMessage(msg: any, keys: Array) { - const decoded = {}; - try { - keys.forEach(key => { - // @ts-ignore TODO: types for decoder - decoded[key] = this.decoder.decode(msg[key]); - }); - } catch (e) { - logger.error("Error on message decoding: ", e, msg); - return null; - } - return { ...msg, ...decoded }; - } - distributeMessage(msg: Message): void { + distributeMessage = (msg: Message): void => { const lastMessageTime = Math.max(msg.time, this.lastMessageTime) this.lastMessageTime = lastMessageTime this.state.update({ lastMessageTime }) if (visualChanges.includes(msg.tp)) { this.activityManager?.updateAcctivity(msg.time); } - let decoded; - const time = msg.time; switch (msg.tp) { case MType.SetPageLocation: this.locationManager.append(msg); @@ -464,6 +361,7 @@ export default class MessageManager { case MType.ResourceTiming: // TODO: merge `resource` and `fetch` lists into one here instead of UI if (msg.initiator !== ResourceType.FETCH && msg.initiator !== ResourceType.XHR) { + // @ts-ignore TODO: typing for lists this.lists.lists.resource.insert(getResourceFromResourceTiming(msg, this.sessionStart)) } break; @@ -523,7 +421,7 @@ export default class MessageManager { } } - setMessagesLoading(messagesLoading: boolean) { + setMessagesLoading = (messagesLoading: boolean) => { this.screen.display(!messagesLoading); this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading }); } diff --git a/frontend/app/player/web/Screen/Screen.ts b/frontend/app/player/web/Screen/Screen.ts index 185b7c769..3eff4a6a0 100644 --- a/frontend/app/player/web/Screen/Screen.ts +++ b/frontend/app/player/web/Screen/Screen.ts @@ -97,20 +97,6 @@ export default class Screen { parentElement.appendChild(this.screen); this.parentElement = parentElement; - - /* == For the Inspecting Document content == */ - this.overlay.addEventListener('contextmenu', () => { - this.overlay.style.display = 'none' - const doc = this.document - if (!doc) { return } - const returnOverlay = () => { - this.overlay.style.display = 'block' - doc.removeEventListener('mousemove', returnOverlay) - doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection - } - doc.addEventListener('mousemove', returnOverlay) - doc.addEventListener('mouseclick', returnOverlay) - }) } getParentElement(): HTMLElement | null { @@ -137,7 +123,7 @@ export default class Screen { private getBoundingClientRect(): DOMRect { if (this.boundingRect === null) { // TODO: use this.screen instead in order to separate overlay functionality - return this.boundingRect = this.overlay.getBoundingClientRect() // expensive operation? + return this.boundingRect = this.screen.getBoundingClientRect() // expensive operation? } return this.boundingRect } @@ -145,7 +131,7 @@ export default class Screen { getInternalViewportCoordinates({ x, y }: Point): Point { const { x: overlayX, y: overlayY, width } = this.getBoundingClientRect(); - const screenWidth = this.overlay.offsetWidth; + const screenWidth = this.screen.offsetWidth; const scale = screenWidth / width; const screenX = (x - overlayX) * scale; @@ -246,7 +232,7 @@ export default class Screen { width: width + 'px', }) - this.boundingRect = this.overlay.getBoundingClientRect(); + this.boundingRect = this.screen.getBoundingClientRect(); this.onUpdateHook(width, height) } diff --git a/frontend/app/player/web/WebLivePlayer.ts b/frontend/app/player/web/WebLivePlayer.ts index 8753190b5..3f75e3e3f 100644 --- a/frontend/app/player/web/WebLivePlayer.ts +++ b/frontend/app/player/web/WebLivePlayer.ts @@ -1,4 +1,4 @@ -import type { Store } from '../common/types' +import type { Store, SessionFilesInfo } from 'Player' import type { Message } from './messages' import WebPlayer from './WebPlayer' @@ -7,9 +7,6 @@ import AssistManager from './assist/AssistManager' import MFileReader from './messages/MFileReader' import { requestEFSDom } from './network/loadFiles' -import { toast } from 'react-toastify'; // ** - - export default class WebLivePlayer extends WebPlayer { static readonly INITIAL_STATE = { ...WebPlayer.INITIAL_STATE, @@ -21,26 +18,31 @@ export default class WebLivePlayer extends WebPlayer { private readonly incomingMessages: Message[] = [] private historyFileIsLoading = false private lastMessageInFileTime = 0 - private lastMessageInFileIndex = 0 - constructor(wpState: Store, private session:any, config: RTCIceServer[] | null) { - super(wpState, session, true) + constructor( + wpState: Store, + private session: SessionFilesInfo, + config: RTCIceServer[] | null, + uiErrorHandler?: { error: (msg: string) => void } + ) { + super(wpState, session, true, false, uiErrorHandler) this.assistManager = new AssistManager( session, f => this.messageManager.setMessagesLoading(f), - (msg, idx) => { + (msg) => { this.incomingMessages.push(msg) if (!this.historyFileIsLoading) { // TODO: fix index-ing after historyFile-load - this.messageManager.distributeMessage(msg, idx) + this.messageManager.distributeMessage(msg) } }, this.screen, config, wpState, + uiErrorHandler, ) - this.assistManager.connect(session.agentToken) + this.assistManager.connect(session.agentToken!) } toggleTimetravel = async () => { @@ -64,14 +66,14 @@ export default class WebLivePlayer extends WebPlayer { result = true // here we need to update also lists state, if we gonna use them this.messageManager.onFileReadSuccess } catch(e) { - toast.error('Error requesting a session file') + this.uiErrorHandler?.error('Error requesting a session file') console.error("EFS file download error:", e) } // Append previously received messages this.incomingMessages .filter(msg => msg.time >= this.lastMessageInFileTime) - .forEach((msg, i) => this.messageManager.distributeMessage(msg, this.lastMessageInFileIndex + i)) + .forEach((msg) => this.messageManager.distributeMessage(msg)) this.incomingMessages.length = 0 this.historyFileIsLoading = false diff --git a/frontend/app/player/web/WebPlayer.ts b/frontend/app/player/web/WebPlayer.ts index af40a835f..407a69068 100644 --- a/frontend/app/player/web/WebPlayer.ts +++ b/frontend/app/player/web/WebPlayer.ts @@ -1,22 +1,21 @@ -import { Log, LogLevel } from './types/log' +import { Log, LogLevel, SessionFilesInfo } from 'App/player' import type { Store } from 'App/player' import Player from '../player/Player' import MessageManager from './MessageManager' +import MessageLoader from './MessageLoader' import InspectorController from './addons/InspectorController' import TargetMarker from './addons/TargetMarker' import Screen, { ScaleMode } from './Screen/Screen' import { Message } from "Player/web/messages"; - -// export type State = typeof WebPlayer.INITIAL_STATE - export default class WebPlayer extends Player { static readonly INITIAL_STATE = { ...Player.INITIAL_STATE, ...TargetMarker.INITIAL_STATE, ...MessageManager.INITIAL_STATE, + ...MessageLoader.INITIAL_STATE, inspectorMode: false, } @@ -24,10 +23,17 @@ export default class WebPlayer extends Player { private readonly inspectorController: InspectorController protected screen: Screen protected readonly messageManager: MessageManager + protected readonly messageLoader: MessageLoader private targetMarker: TargetMarker - constructor(protected wpState: Store, session: any, live: boolean, isClickMap = false) { + constructor( + protected wpState: Store, + session: SessionFilesInfo, + live: boolean, + isClickMap = false, + public readonly uiErrorHandler?: { error: (msg: string) => void } + ) { let initialLists = live ? {} : { event: session.events || [], stack: session.stackEvents || [], @@ -42,12 +48,19 @@ export default class WebPlayer extends Player { } const screen = new Screen(session.isMobile, isClickMap ? ScaleMode.AdjustParentHeight : ScaleMode.Embed) - const messageManager = new MessageManager(session, wpState, screen, initialLists) + const messageManager = new MessageManager(session, wpState, screen, initialLists, uiErrorHandler) + const messageLoader = new MessageLoader( + session, + wpState, + messageManager, + isClickMap + ) super(wpState, messageManager) this.screen = screen this.messageManager = messageManager + this.messageLoader = messageLoader if (!live) { // hack. TODO: split OfflinePlayer class - void messageManager.loadMessages(isClickMap) + void messageLoader.loadFiles() } this.targetMarker = new TargetMarker(this.screen, wpState) @@ -154,6 +167,7 @@ export default class WebPlayer extends Player { this.screen.clean() // @ts-ignore this.screen = undefined; + this.messageLoader.clean() // @ts-ignore this.messageManager = undefined; window.removeEventListener('resize', this.scale) diff --git a/frontend/app/player/web/addons/InspectorController.ts b/frontend/app/player/web/addons/InspectorController.ts index 987b9b0bc..7c4c48690 100644 --- a/frontend/app/player/web/addons/InspectorController.ts +++ b/frontend/app/player/web/addons/InspectorController.ts @@ -8,7 +8,20 @@ export default class InspectorController { private substitutor: Screen | null = null private inspector: Inspector | null = null marker: Marker | null = null - constructor(private screen: Screen) {} + constructor(private screen: Screen) { + screen.overlay.addEventListener('contextmenu', () => { + screen.overlay.style.display = 'none' + const doc = screen.document + if (!doc) { return } + const returnOverlay = () => { + screen.overlay.style.display = 'block' + doc.removeEventListener('mousemove', returnOverlay) + doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection + } + doc.addEventListener('mousemove', returnOverlay) + doc.addEventListener('mouseclick', returnOverlay) + }) + } scale(dims: Dimensions) { if (this.substitutor) { diff --git a/frontend/app/player/web/assist/AssistManager.ts b/frontend/app/player/web/assist/AssistManager.ts index 48b09c4f2..254fe2833 100644 --- a/frontend/app/player/web/assist/AssistManager.ts +++ b/frontend/app/player/web/assist/AssistManager.ts @@ -68,6 +68,7 @@ export default class AssistManager { private screen: Screen, private config: RTCIceServer[] | null, private store: Store, + public readonly uiErrorHandler?: { error: (msg: string) => void } ) {} private get borderStyle() { @@ -228,6 +229,7 @@ export default class AssistManager { socket, this.session.agentInfo, () => this.screen.setBorderStyle(this.borderStyle), + this.uiErrorHandler ) document.addEventListener('visibilitychange', this.onVisChange) diff --git a/frontend/app/player/web/assist/ScreenRecording.ts b/frontend/app/player/web/assist/ScreenRecording.ts index 064e2e61c..83b06b497 100644 --- a/frontend/app/player/web/assist/ScreenRecording.ts +++ b/frontend/app/player/web/assist/ScreenRecording.ts @@ -1,5 +1,3 @@ -import { toast } from 'react-toastify' - import type { Socket } from './types' import type { Store } from '../../common/types' @@ -24,6 +22,7 @@ export default class ScreenRecording { private socket: Socket, private agentInfo: Object, private onToggle: (active: boolean) => void, + public readonly uiErrorHandler: { error: (msg: string) => void } | undefined ) { socket.on('recording_accepted', () => { this.toggleRecording(true) @@ -38,7 +37,7 @@ export default class ScreenRecording { } private onRecordingBusy = () => { - toast.error("This session is already being recorded by another agent") + this.uiErrorHandler?.error("This session is already being recorded by another agent") } requestRecording = ({ onDeny }: { onDeny: () => void }) => { diff --git a/frontend/app/player/web/managers/DOM/VirtualDOM.ts b/frontend/app/player/web/managers/DOM/VirtualDOM.ts index 983a3b5f1..666b0e777 100644 --- a/frontend/app/player/web/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/web/managers/DOM/VirtualDOM.ts @@ -1,5 +1,5 @@ import { insertRule, deleteRule } from './safeCSSRules'; - +import { isRootNode } from 'App/player/guards' type Callback = (o: T) => void @@ -316,7 +316,7 @@ export class OnloadStyleSheet extends PromiseQueue { return new OnloadStyleSheet(new Promise((resolve, reject) => vRoot.onNode(node => { let context: typeof globalThis | null - if (node instanceof Document || node.nodeName === '#document') { + if (isRootNode(node)) { context = node.defaultView } else { context = node.ownerDocument.defaultView diff --git a/frontend/app/player/web/managers/PagesManager.ts b/frontend/app/player/web/managers/PagesManager.ts index 4ab78a0ac..1b5c7d4c2 100644 --- a/frontend/app/player/web/managers/PagesManager.ts +++ b/frontend/app/player/web/managers/PagesManager.ts @@ -20,7 +20,7 @@ export default class PagesManager extends ListWalker { constructor( private screen: Screen, private isMobile: boolean, - private setCssLoading: ConstructorParameters[3], + private setCssLoading: ConstructorParameters[4], ) { super() } /* diff --git a/frontend/app/player/web/network/crypto.ts b/frontend/app/player/web/network/crypto.ts index 2bc7f641f..565d076a6 100644 --- a/frontend/app/player/web/network/crypto.ts +++ b/frontend/app/player/web/network/crypto.ts @@ -27,7 +27,7 @@ export function decryptSessionBytes(cypher: Uint8Array, keyString: string): Prom const data = gunzipSync(u8Array) console.debug( "Decompression time", - performance.now() - now, + Math.floor(performance.now() - now) + 'ms', 'size', Math.floor(u8Array.byteLength/1024), '->', diff --git a/frontend/app/player/web/network/loadFiles.ts b/frontend/app/player/web/network/loadFiles.ts index adf5655d8..273c27ec6 100644 --- a/frontend/app/player/web/network/loadFiles.ts +++ b/frontend/app/player/web/network/loadFiles.ts @@ -15,18 +15,31 @@ export async function loadFiles( } try { for (let url of urls) { - const response = await window.fetch(url) - const data = await processAPIStreamResponse(response, urls.length > 1 ? url !== urls[0] : canSkip) - await onData(data) + await loadFile(url, onData, urls.length > 1 ? url !== urls[0] : canSkip) } - } catch(e) { - if (e === ALLOWED_404) { - return - } - throw e + return Promise.resolve() + } catch (e) { + return Promise.reject(e) } } +export async function loadFile( + url: string, + onData: (data: Uint8Array) => void, + canSkip: boolean = false, +): Promise { + return window.fetch(url) + .then(response => processAPIStreamResponse(response, canSkip)) + .then(data => onData(data)) + .catch(e => { + if (e === ALLOWED_404) { + return; + } else { + throw e + } + }) +} + export async function requestEFSDom(sessionId: string) { return await requestEFSMobFile(sessionId + "/dom.mob") }