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
This commit is contained in:
Delirium 2023-05-12 15:38:43 +02:00 committed by GitHub
parent 55040b2c76
commit a24d99f75c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 330 additions and 219 deletions

View file

@ -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)))
)

View file

@ -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<IPlayerContext>(defaultContextValue);
useEffect(() => {
const [WebPlayerInst, PlayerStore] = createClickMapPlayer(customSession, (state) =>
makeAutoObservable(state)
const [WebPlayerInst, PlayerStore] = createClickMapPlayer(
customSession,
(state) => makeAutoObservable(state),
toast,
);
setContextValue({ player: WebPlayerInst, store: PlayerStore });

View file

@ -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;

View file

@ -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}
<div className={stl.timeline} ref={timelineRef}>
{devtoolsLoading || !ready ? <div className={stl.stripes} /> : null}
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
</div>
{events.map((e) => (

View file

@ -25,3 +25,21 @@ export interface Store<G extends Object, S extends Object = G> {
update(state: Partial<S>): 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<string, any>[]
stackEvents: Record<string, any>[]
frustrations: Record<string, any>[]
errors: Record<string, any>[]
agentInfo?: { email: string, name: string }
}

View file

@ -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<WebLiveState>
export type IWebLivePlayer = WebLivePlayer
export type IWebLivePlayerStore = WebLivePlayerStore
export function createWebPlayer(session: Record<string, any>, 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<WebState>({
...WebPlayer.INITIAL_STATE,
})
@ -22,12 +26,16 @@ export function createWebPlayer(session: Record<string, any>, 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<string, any>, 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<WebState>({
...WebPlayer.INITIAL_STATE,
})
@ -35,11 +43,16 @@ export function createClickMapPlayer(session: Record<string, any>, 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<string, any>, 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<WebLiveState>({
...WebLivePlayer.INITIAL_STATE,
})
@ -47,6 +60,6 @@ export function createLiveWebPlayer(session: Record<string, any>, config: RTCIce
store = wrapStore(store)
}
const player = new WebLivePlayer(store, session, config)
const player = new WebLivePlayer(store, session, config, uiErrorHandler)
return [player, store]
}

View file

@ -0,0 +1,3 @@
export function isRootNode(node: Node): node is Document {
return node.nodeType === Node.DOCUMENT_NODE || node instanceof Document
}

View file

@ -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 =

View file

@ -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<State & AnimatorGetState>, private manager: Moveable & Cleanable) {
constructor(private pState: Store<State & AnimatorGetState>, private manager: MessageManager) {
super(pState, manager)
// Autoplay

View file

@ -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<State>,
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<Message & { _index?: number }> = []
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<void>) {
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<any> = domData.status === 'fulfilled'
? domParser(domData.value) : Promise.reject('No dom file in EFS')
const parseDevtoolsPromise: Promise<any> = 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);
}
}

View file

@ -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<any>/*<LocationEvent>*/ = 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<State>,
private readonly screen: Screen,
initialLists?: Partial<InitialLists>
initialLists?: Partial<InitialLists>,
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<string, string>) => { // TODO: to one of "Moveable" module
initialLists?.event?.forEach((e: Record<string, string>) => { // 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<InitialLists>) {
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<State>= {
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<Message> = []
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<void>) {
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<string>) {
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 });
}

View file

@ -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)
}

View file

@ -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<typeof WebLivePlayer.INITIAL_STATE>, private session:any, config: RTCIceServer[] | null) {
super(wpState, session, true)
constructor(
wpState: Store<typeof WebLivePlayer.INITIAL_STATE>,
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

View file

@ -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<typeof WebPlayer.INITIAL_STATE>, session: any, live: boolean, isClickMap = false) {
constructor(
protected wpState: Store<typeof WebPlayer.INITIAL_STATE>,
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)

View file

@ -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) {

View file

@ -68,6 +68,7 @@ export default class AssistManager {
private screen: Screen,
private config: RTCIceServer[] | null,
private store: Store<typeof AssistManager.INITIAL_STATE>,
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)

View file

@ -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 }) => {

View file

@ -1,5 +1,5 @@
import { insertRule, deleteRule } from './safeCSSRules';
import { isRootNode } from 'App/player/guards'
type Callback<T> = (o: T) => void
@ -316,7 +316,7 @@ export class OnloadStyleSheet extends PromiseQueue<CSSStyleSheet> {
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

View file

@ -20,7 +20,7 @@ export default class PagesManager extends ListWalker<DOMManager> {
constructor(
private screen: Screen,
private isMobile: boolean,
private setCssLoading: ConstructorParameters<typeof DOMManager>[3],
private setCssLoading: ConstructorParameters<typeof DOMManager>[4],
) { super() }
/*

View file

@ -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),
'->',

View file

@ -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<void> {
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")
}