From ecf4e0e8a21bef6523d301c27025adc699a68a86 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Fri, 21 Apr 2023 18:08:07 +0200 Subject: [PATCH] whatewer --- .../Player/LivePlayer/Overlay/LiveOverlay.tsx | 8 +- .../Session_/Player/Controls/Controls.tsx | 6 +- .../components/Session_/Player/Overlay.tsx | 8 +- frontend/app/player/common/StoreSubscriber.ts | 23 +++ frontend/app/player/player/Animator.ts | 2 +- frontend/app/player/player/Player.ts | 39 ++-- frontend/app/player/web/MessageLoader.ts | 92 +++++++++ frontend/app/player/web/MessageManager.ts | 182 +++++------------- frontend/app/player/web/Screen/Screen.ts | 12 +- frontend/app/player/web/WebFilePlayer.ts | 1 + frontend/app/player/web/WebLivePlayer.ts | 32 ++- frontend/app/player/web/WebPlayer.ts | 38 +++- frontend/app/player/web/managers/types.ts | 3 + .../player/web/messageLoader/MessageLoader.ts | 163 ++++++++++++++++ .../app/player/web/messageLoader/loadFiles.ts | 60 ++++++ 15 files changed, 469 insertions(+), 200 deletions(-) create mode 100644 frontend/app/player/common/StoreSubscriber.ts create mode 100644 frontend/app/player/web/MessageLoader.ts create mode 100644 frontend/app/player/web/WebFilePlayer.ts create mode 100644 frontend/app/player/web/managers/types.ts create mode 100644 frontend/app/player/web/messageLoader/MessageLoader.ts create mode 100644 frontend/app/player/web/messageLoader/loadFiles.ts diff --git a/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx b/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx index 38eef2ba1..c71caf895 100644 --- a/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx +++ b/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx @@ -24,19 +24,17 @@ function Overlay({ const { store } = React.useContext(PlayerContext) const { - messagesLoading, - cssLoading, + ready, peerConnectionStatus, livePlay, calling, remoteControl, recordingState, } = store.get() - const loading = messagesLoading || cssLoading const liveStatusText = getStatusText(peerConnectionStatus) const connectionStatus = peerConnectionStatus - const showLiveStatusText = livePlay && liveStatusText && !loading; + const showLiveStatusText = livePlay && liveStatusText && ready; const showRequestWindow = (calling === CallingState.Connecting || @@ -66,7 +64,7 @@ function Overlay({ connectionStatus={closedLive ? ConnectionStatus.Closed : connectionStatus} /> )} - {loading ? : null} + {!ready ? : null} ); } diff --git a/frontend/app/components/Session_/Player/Controls/Controls.tsx b/frontend/app/components/Session_/Player/Controls/Controls.tsx index 37d8bdbef..603bece8c 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.tsx +++ b/frontend/app/components/Session_/Player/Controls/Controls.tsx @@ -66,8 +66,7 @@ function Controls(props: any) { completed, skip, speed, - cssLoading, - messagesLoading, + ready, inspectorMode, markedTargets, exceptionsList, @@ -88,7 +87,7 @@ function Controls(props: any) { } = props; const storageType = selectStorageType(store.get()); - const disabled = disabledRedux || cssLoading || messagesLoading || inspectorMode || markedTargets; + const disabled = !ready || disabledRedux || inspectorMode || markedTargets; const profilesCount = profilesList.length; const graphqlCount = graphqlList.length; const showGraphql = graphqlCount > 0; @@ -326,7 +325,6 @@ export default connect( // nextProps.showFetch !== props.showFetch || // nextProps.fetchCount !== props.fetchCount || // nextProps.graphqlCount !== props.graphqlCount || -// nextProps.liveTimeTravel !== props.liveTimeTravel || // nextProps.skipInterval !== props.skipInterval // ) // return true; diff --git a/frontend/app/components/Session_/Player/Overlay.tsx b/frontend/app/components/Session_/Player/Overlay.tsx index ff0344757..bf53ab97f 100644 --- a/frontend/app/components/Session_/Player/Overlay.tsx +++ b/frontend/app/components/Session_/Player/Overlay.tsx @@ -21,23 +21,21 @@ function Overlay({ const togglePlay = () => player.togglePlay() const { playing, - messagesLoading, - cssLoading, + ready, completed, autoplay, inspectorMode, markedTargets, activeTargetIndex, } = store.get() - const loading = messagesLoading || cssLoading const showAutoplayTimer = completed && autoplay && nextId - const showPlayIconLayer = !isClickmap && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer; + const showPlayIconLayer = !isClickmap && !markedTargets && !inspectorMode && ready && !showAutoplayTimer; return ( <> {showAutoplayTimer && } - {loading ? : null} + {!ready ? : null} {showPlayIconLayer && } {markedTargets && } diff --git a/frontend/app/player/common/StoreSubscriber.ts b/frontend/app/player/common/StoreSubscriber.ts new file mode 100644 index 000000000..5866c2c72 --- /dev/null +++ b/frontend/app/player/common/StoreSubscriber.ts @@ -0,0 +1,23 @@ +import { Store } from './types' + + +export default class StoreSubscriber implements Store { + constructor(private store: Store) {} + get() { return this.store.get() } + update(newState: Partial) { + this.store.update(newState) + this.subscriptions.forEach(sb => sb()) + } + private subscriptions: Function[] = [] + subscribe(selector: (g: G) => T, cb: (val: T) => void) { + let prevVal = selector(this.get()) + const checkSubscription = () => { + const newVal = selector(this.get()) + if (newVal !== prevVal) { + prevVal = newVal + cb(newVal) + } + } + this.subscriptions.push(checkSubscription) + } +} \ No newline at end of file diff --git a/frontend/app/player/player/Animator.ts b/frontend/app/player/player/Animator.ts index e60b28d78..1ff7be466 100644 --- a/frontend/app/player/player/Animator.ts +++ b/frontend/app/player/player/Animator.ts @@ -80,7 +80,7 @@ export default class Animator { endTime, live, livePlay, - ready, // = messagesLoading || cssLoading || disconnected + ready, lastMessageTime, } = this.store.get() diff --git a/frontend/app/player/player/Player.ts b/frontend/app/player/player/Player.ts index 6f6adb1e2..f9e4ae95e 100644 --- a/frontend/app/player/player/Player.ts +++ b/frontend/app/player/player/Player.ts @@ -25,10 +25,10 @@ export type State = typeof Player.INITIAL_STATE export default class Player extends Animator { static INITIAL_STATE = { ...Animator.INITIAL_STATE, - skipToIssue: initialSkipToIssue, - showEvents: initialShowEvents, + showEvents: initialShowEvents, autoplay: initialAutoplay, + skip: initialSkip, speed: initialSpeed, } as const @@ -36,29 +36,27 @@ export default class Player extends Animator { constructor(private pState: Store, private manager: Moveable) { super(pState, manager) - // Autoplay - if (pState.get().autoplay) { - let autoPlay = true; - document.addEventListener("visibilitychange", () => { - if (document.hidden) { - const { playing } = pState.get(); - autoPlay = playing - if (playing) { - this.pause(); - } - } else if (autoPlay) { - this.play(); + // Autostart + let autostart = true // TODO: configurable + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + const { playing } = pState.get(); + autostart = playing + if (playing) { + this.pause(); } - }) - - if (!document.hidden) { + } else if (autostart) { this.play(); } + }) + if (!document.hidden) { + this.play(); } } /* === TODO: incapsulate in LSCache === */ + //TODO: move to react part ("autoplay" responsible for auto-playing-next) toggleAutoplay() { const autoplay = !this.pState.get().autoplay localStorage.setItem(AUTOPLAY_STORAGE_KEY, `${autoplay}`); @@ -72,13 +70,6 @@ export default class Player extends Animator { this.pState.update({ showEvents }) } - // TODO: move to React part - toggleSkipToIssue() { - const skipToIssue = !this.pState.get().skipToIssue - localStorage.setItem(SKIP_TO_ISSUE_STORAGE_KEY, `${skipToIssue}`); - this.pState.update({ skipToIssue }) - } - toggleSkip() { const skip = !this.pState.get().skip localStorage.setItem(SKIP_STORAGE_KEY, `${skip}`); diff --git a/frontend/app/player/web/MessageLoader.ts b/frontend/app/player/web/MessageLoader.ts new file mode 100644 index 000000000..98c10f473 --- /dev/null +++ b/frontend/app/player/web/MessageLoader.ts @@ -0,0 +1,92 @@ +import logger from 'App/logger'; + +import { decryptSessionBytes } from './network/crypto'; +import MFileReader from './messages/MFileReader'; +import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles'; +import type { + Message, +} from './messages'; + +import type { Store } from '../common/types'; + + +interface SessionFilesInfo { + startedAt: number + sessionId: string + + domURL: string[] + devtoolsURL: string[] + mobsUrl: string[] // back-compatibility. TODO: Remove in the 1.11.0 + fileKey: string | null +} + +type State = typeof MessageLoader.INITIAL_STATE + +export default class MessageLoader { + static INITIAL_STATE = { + firstFileLoading: false, + domLoading: false, + devtoolsLoading: false, + } + + private lastMessageInFileTime: number = 0; + + constructor( + private readonly session: SessionFilesInfo, + private store: Store, + private distributeMessage: (msg: Message, index: number) => void, + ) {} + + private createNewParser(shouldDecrypt=true) { + const fKey = this.session.fileKey + const decrypt = shouldDecrypt && fKey + ? (b: Uint8Array) => decryptSessionBytes(b, fKey) + : (b: Uint8Array) => Promise.resolve(b) + // Each time called - new fileReader created. TODO: reuseable decryptor instance + const fileReader = new MFileReader(new Uint8Array(), this.session.startedAt) + return (b: Uint8Array) => decrypt(b).then(b => { + fileReader.append(b) + const msgs: Array = [] + for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) { + this.distributeMessage(msg, msg._index) + msgs.push(msg) + } + + logger.info("Messages loaded: ", msgs.length, msgs) + + //this._sortMessagesHack(fileReader) // TODO + this.store.update({ firstFileLoading: false }) // How to do it more explicit: on the first file loading? + }) + } + + requestFallbackDOM = () => + requestEFSDom(this.session.sessionId) + .then(this.createNewParser(false)) + + loadDOM() { + this.store.update({ + domLoading: true, + firstFileLoading: true, + }) + + const loadMethod = this.session.domURL && this.session.domURL.length > 0 + ? { url: this.session.domURL, needsDecryption: true } + : { url: this.session.mobsUrl, needsDecryption: false } + + return loadFiles(loadMethod.url, this.createNewParser(loadMethod.needsDecryption)) + // EFS fallback + .catch((e) => this.requestFallbackDOM()) + .finally(() => this.store.update({ domLoading: false })) + } + + loadDevtools() { + this.store.update({ devtoolsLoading: true }) + return loadFiles(this.session.devtoolsURL, this.createNewParser()) + // EFS fallback + .catch(() => + requestEFSDevtools(this.session.sessionId) + .then(this.createNewParser(false)) + ) + .finally(() => this.store.update({ devtoolsLoading: false })) + } +} diff --git a/frontend/app/player/web/MessageManager.ts b/frontend/app/player/web/MessageManager.ts index 72c07c445..6c90f6c3f 100644 --- a/frontend/app/player/web/MessageManager.ts +++ b/frontend/app/player/web/MessageManager.ts @@ -6,8 +6,6 @@ import { TYPES as EVENT_TYPES } from 'Types/session/event'; import { Log } from './types/log'; import { Resource, ResourceType, getResourceFromResourceTiming, getResourceFromNetworkRequest } from './types/resource' -import { toast } from 'react-toastify'; - import type { Store, Timed } from '../common/types'; import ListWalker from '../common/ListWalker'; @@ -36,7 +34,6 @@ import { decryptSessionBytes } from './network/crypto'; import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from './Lists'; import Screen, { - INITIAL_STATE as SCREEN_INITIAL_STATE, State as ScreenState, } from './Screen/Screen'; @@ -57,13 +54,9 @@ export interface State extends ScreenState, ListsState { domContentLoadedTime?: { time: number, value: number }, domBuildingTime?: number, loadTime?: { time: number, value: number }, - error: boolean, - devtoolsLoading: boolean, - messagesLoading: boolean, cssLoading: boolean, - ready: boolean, lastMessageTime: number, } @@ -80,16 +73,12 @@ const visualChanges = [ export default class MessageManager { static INITIAL_STATE: State = { - ...SCREEN_INITIAL_STATE, + ...Screen.INITIAL_STATE, ...LISTS_INITIAL_STATE, performanceChartData: [], skipIntervals: [], - error: false, - devtoolsLoading: false, - messagesLoading: false, cssLoading: false, - ready: false, lastMessageTime: 0, } @@ -137,117 +126,6 @@ export default class MessageManager { this.activityManager = new ActivityManager(this.session.duration.milliseconds) // only if not-live } - private setCSSLoading = (cssLoading: boolean) => { - this.screen.displayFrame(!cssLoading) - this.state.update({ cssLoading, ready: !this.state.get().messagesLoading && !cssLoading }) - } - - private _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) => { - if (m1.time === m2.time) { - if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) { - if (headChildrenIds.includes(m1.id)) { - return -1; - } - } else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) { - if (headChildrenIds.includes(m2.id)) { - return 1; - } - } else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) { - const m1FromHead = headChildrenIds.includes(m1.id); - const m2FromHead = headChildrenIds.includes(m2.id); - if (m1FromHead && !m2FromHead) { - return -1; - } else if (m2FromHead && !m1FromHead) { - return 1; - } - } - } - return 0; - }) - } - - private waitingForFiles: boolean = false - private onFileReadSuccess = () => { - const stateToUpdate : Partial= { - performanceChartData: this.performanceTrackManager.chartData, - performanceAvaliability: this.performanceTrackManager.avaliability, - ...this.lists.getFullListsState(), - } - if (this.activityManager) { - this.activityManager.end() - stateToUpdate.skipIntervals = this.activityManager.list - } - this.state.update(stateToUpdate) - } - private onFileReadFailed = (e: any) => { - logger.error(e) - this.state.update({ error: true }) - toast.error('Error requesting a session file') - } - private onFileReadFinally = () => { - this.waitingForFiles = false - // this.setMessagesLoading(false) - // this.state.update({ filesLoaded: true }) - } - - async loadMessages(isClickmap: boolean = false) { - this.setMessagesLoading(true) - // TODO: reusable decryptor instance - const createNewParser = (shouldDecrypt = true) => { - 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) - const msgs: Array = [] - for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) { - this.distributeMessage(msg, msg._index) - msgs.push(msg) - } - - logger.info("Messages count: ", msgs.length, msgs) - this._sortMessagesHack(msgs) - this.setMessagesLoading(false) - }) - } - - this.waitingForFiles = true - - const loadMethod = this.session.domURL && this.session.domURL.length > 0 - ? { url: this.session.domURL, parser: createNewParser } - : { url: this.session.mobsUrl, parser: () => createNewParser(false)} - - loadFiles(loadMethod.url, loadMethod.parser()) - // EFS fallback - .catch((e) => - requestEFSDom(this.session.sessionId) - .then(createNewParser(false)) - ) - .then(this.onFileReadSuccess) - .catch(this.onFileReadFailed) - .finally(this.onFileReadFinally); - - // load devtools (TODO: start after the first DOM file download) - if (isClickmap) return; - this.state.update({ devtoolsLoading: true }) - loadFiles(this.session.devtoolsURL, createNewParser()) - // EFS fallback - .catch(() => - requestEFSDevtools(this.session.sessionId) - .then(createNewParser(false)) - ) - .then(() => { - this.state.update(this.lists.getFullListsState()) // TODO: also in case of dynamic update through assist - }) - .catch(e => logger.error("Can not download the devtools file", e)) - .finally(() => this.state.update({ devtoolsLoading: false })) - } - resetMessageManagers() { this.locationEventManager = new ListWalker(); this.locationManager = new ListWalker(); @@ -327,10 +205,6 @@ export default class MessageManager { this.screen.cursor.click(); } }) - - if (this.waitingForFiles && this.lastMessageTime <= t && t !== this.session.duration.milliseconds) { - this.setMessagesLoading(true) - } } private decodeStateMessage(msg: any, keys: Array) { @@ -347,7 +221,8 @@ export default class MessageManager { return { ...msg, ...decoded }; } - distributeMessage(msg: Message, index: number): void { + distributeMessage = (msg: Message, index: number): void => { + this._handeleForSortHack(msg) const lastMessageTime = Math.max(msg.time, this.lastMessageTime) this.lastMessageTime = lastMessageTime this.state.update({ lastMessageTime }) @@ -471,9 +346,54 @@ export default class MessageManager { } } - setMessagesLoading(messagesLoading: boolean) { - this.screen.display(!messagesLoading); - this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading }); + private _heeadChildrenID: number[] = [] + private _handeleForSortHack(m: Message) { + // @ts-ignore + m.parentID === 1 && this._heeadChildrenID.push(m.id) + } + // Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) + private _sortMessagesHack() { + const headChildrenIds = this._heeadChildrenID + this.pagesManager.sortPages((m1, m2) => { + if (m1.time === m2.time) { + if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) { + if (headChildrenIds.includes(m1.id)) { + return -1; + } + } else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) { + if (headChildrenIds.includes(m2.id)) { + return 1; + } + } else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) { + const m1FromHead = headChildrenIds.includes(m1.id); + const m2FromHead = headChildrenIds.includes(m2.id); + if (m1FromHead && !m2FromHead) { + return -1; + } else if (m2FromHead && !m1FromHead) { + return 1; + } + } + } + return 0; + }) + } + + onMessagesLoaded = () => { + const stateToUpdate : Partial= { + performanceChartData: this.performanceTrackManager.chartData, + performanceAvaliability: this.performanceTrackManager.avaliability, + ...this.lists.getFullListsState(), + } + if (this.activityManager) { + this.activityManager.end() + stateToUpdate.skipIntervals = this.activityManager.list + } + this.state.update(stateToUpdate) + this._sortMessagesHack() + } + + private setCSSLoading = (cssLoading: boolean) => { + this.state.update({ cssLoading }) } private setSize({ height, width }: { height: number, width: number }) { diff --git a/frontend/app/player/web/Screen/Screen.ts b/frontend/app/player/web/Screen/Screen.ts index f639f4ce3..d48731e92 100644 --- a/frontend/app/player/web/Screen/Screen.ts +++ b/frontend/app/player/web/Screen/Screen.ts @@ -6,19 +6,12 @@ import type { Point, Dimensions } from './types'; export type State = Dimensions -export const INITIAL_STATE: State = { - width: 0, - height: 0, -} - - export enum ScaleMode { Embed, //AdjustParentWidth AdjustParentHeight, } - function getElementsFromInternalPoint(doc: Document, { x, y }: Point): Element[] { // @ts-ignore (IE, Edge) if (typeof doc.msElementsFromRect === 'function') { @@ -57,6 +50,11 @@ function isIframe(el: Element): el is HTMLIFrameElement { } export default class Screen { + static INITIAL_STATE: State = { + width: 0, + height: 0, + } + readonly overlay: HTMLDivElement readonly cursor: Cursor diff --git a/frontend/app/player/web/WebFilePlayer.ts b/frontend/app/player/web/WebFilePlayer.ts new file mode 100644 index 000000000..5c34158f9 --- /dev/null +++ b/frontend/app/player/web/WebFilePlayer.ts @@ -0,0 +1 @@ +// separate Player from File & WebLive player \ No newline at end of file diff --git a/frontend/app/player/web/WebLivePlayer.ts b/frontend/app/player/web/WebLivePlayer.ts index 709692d20..9e6da7c8a 100644 --- a/frontend/app/player/web/WebLivePlayer.ts +++ b/frontend/app/player/web/WebLivePlayer.ts @@ -15,6 +15,10 @@ export default class WebLivePlayer extends WebPlayer { ...WebPlayer.INITIAL_STATE, ...AssistManager.INITIAL_STATE, liveTimeTravel: false, + assistLoading: false, + // get ready() { // TODO TODO TODO how to extend state here? + // return this.assistLoading && super.ready + // } } assistManager: AssistManager // public so far @@ -23,12 +27,12 @@ export default class WebLivePlayer extends WebPlayer { private lastMessageInFileTime = 0 private lastMessageInFileIndex = 0 - constructor(wpState: Store, private session:any, config: RTCIceServer[]) { - super(wpState, session, true) + constructor(wpStore: Store, private session:any, config: RTCIceServer[]) { + super(wpStore, session, true) this.assistManager = new AssistManager( session, - f => this.messageManager.setMessagesLoading(f), + assistLoading => wpStore.update({ assistLoading }), (msg, idx) => { this.incomingMessages.push(msg) if (!this.historyFileIsLoading) { @@ -38,31 +42,27 @@ export default class WebLivePlayer extends WebPlayer { }, this.screen, config, - wpState, + wpStore, ) this.assistManager.connect(session.agentToken) } toggleTimetravel = async () => { - if (this.wpState.get().liveTimeTravel) { + // TODO: implement via jump() API rewritten instead + if (this.wpStore.get().liveTimeTravel) { return } let result = false; this.historyFileIsLoading = true - this.messageManager.setMessagesLoading(true) // do it in one place. update unique loading states each time instead this.messageManager.resetMessageManagers() try { - const bytes = await requestEFSDom(this.session.sessionId) - const fileReader = new MFileReader(bytes, this.session.startedAt) - for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) { - this.messageManager.distributeMessage(msg, msg._index) - } - this.wpState.update({ + await this.messageLoader.requestFallbackDOM() + this.wpStore.update({ liveTimeTravel: true, }) + this.messageManager.onMessagesLoaded() 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') console.error("EFS file download error:", e) @@ -75,16 +75,14 @@ export default class WebLivePlayer extends WebPlayer { this.incomingMessages.length = 0 this.historyFileIsLoading = false - this.messageManager.setMessagesLoading(false) return result; } jumpToLive = () => { - this.wpState.update({ - live: true, + this.wpStore.update({ livePlay: true, }) - this.jump(this.wpState.get().lastMessageTime) + this.jump(this.wpStore.get().lastMessageTime) } clean = () => { diff --git a/frontend/app/player/web/WebPlayer.ts b/frontend/app/player/web/WebPlayer.ts index e2134adf1..cc67c41fa 100644 --- a/frontend/app/player/web/WebPlayer.ts +++ b/frontend/app/player/web/WebPlayer.ts @@ -1,31 +1,38 @@ import { Log, LogLevel } from './types/log' +import { toast } from 'react-toastify'; +import logger from 'App/logger'; -import type { Store } from 'App/player' +import type { Store } from '../common/types' +import StorSubscriber from '../common/StoreSubscriber' import Player from '../player/Player' import MessageManager from './MessageManager' import InspectorController from './addons/InspectorController' import TargetMarker from './addons/TargetMarker' import Screen, { ScaleMode } from './Screen/Screen' +import MessageLoader from './MessageLoader' -// 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, + + ready: true, inspectorMode: false, } private readonly inspectorController: InspectorController protected readonly screen: Screen + protected readonly messageLoader: MessageLoader protected readonly messageManager: MessageManager private targetMarker: TargetMarker + constructor(protected wpState: Store, session: any, live: boolean, isClickMap = false) { let initialLists = live ? {} : { event: session.events || [], @@ -39,13 +46,32 @@ export default class WebPlayer extends Player { ) || [], } + const store = new StorSubscriber(wpState) const screen = new Screen(session.isMobile, isClickMap ? ScaleMode.AdjustParentHeight : ScaleMode.Embed) - const messageManager = new MessageManager(session, wpState, screen, initialLists) - super(wpState, messageManager) + const messageManager = new MessageManager(session, store, screen, initialLists) + //TODO: same for scaling + store.subscribe(state => state.cssLoading, cssLoading => this.screen.displayFrame(!cssLoading)) + store.subscribe(state => state.domLoading, domLoading => this.screen.display(!domLoading)) + store.subscribe(state => { + const notReady = state.cssLoading || (state.domLoading && state.time >= state.lastMessageTime) + return !notReady + }, ready => store.update({ ready })) + + super(store, messageManager) this.screen = screen this.messageManager = messageManager + this.messageLoader = new MessageLoader(session, wpState, messageManager.distributeMessage) + if (!live) { // hack. TODO: split OfflinePlayer class - void messageManager.loadMessages(isClickMap) + this.messageLoader.loadDOM() + .then(() => messageManager.onMessagesLoaded()) + .catch((e: any) => { + logger.error(e) + toast.error('Error requesting a session file') // TODO: outside of the player lib + }) + this.messageLoader.loadDevtools() + .then(() => messageManager.onMessagesLoaded()) + .catch(e => logger.error("Can not download the devtools file", e)) } this.targetMarker = new TargetMarker(this.screen, wpState) diff --git a/frontend/app/player/web/managers/types.ts b/frontend/app/player/web/managers/types.ts new file mode 100644 index 000000000..77c266436 --- /dev/null +++ b/frontend/app/player/web/managers/types.ts @@ -0,0 +1,3 @@ +export interface MessageManager { + handleMessage(msg: Message): void +} diff --git a/frontend/app/player/web/messageLoader/MessageLoader.ts b/frontend/app/player/web/messageLoader/MessageLoader.ts new file mode 100644 index 000000000..08231efec --- /dev/null +++ b/frontend/app/player/web/messageLoader/MessageLoader.ts @@ -0,0 +1,163 @@ +import { toast } from 'react-toastify'; +import logger from 'App/logger'; + +import { decryptSessionBytes } from '../network/crypto'; +import MFileReader from '../messages/MFileReader'; +import { loadFiles, requestEFSDom, requestEFSDevtools } from './loadFiles'; +import type { + Message, +} from '../messages'; + + + +export default class MessageLoader { + private lastMessageInFileTime: number = 0; + + constructor( + private readonly session: any /*Session*/, + private setMessagesLoading: (flag: boolean) => void, + private distributeMessage: (msg: Message, index: number) => void, + ) {} + + requestEFSFile() { + this.setMessagesLoading(true) + this.waitingForFiles = true + const onData = (byteArray: Uint8Array) => { + const onMessage = (msg: Message) => { this.lastMessageInFileTime = msg.time } + this.parseAndDistributeMessages(new MFileReader(byteArray, this.session.startedAt), onMessage) + } + + // assist will pause and skip messages to prevent timestamp related errors + // ----> this.reloadMessageManagers() + // ---> this.windowNodeCounter.reset() + + + + return requestEFSDom(this.session.sessionId) + .then(onData) + // --->.then(this.onFileReadSuccess) + // --->.catch(this.onFileReadFailed) + .finally(this.onFileReadFinally) + } + + private parseAndDistributeMessages(fileReader: MFileReader, onMessage?: (msg: Message) => void) { + const msgs: Array = [] + let next: ReturnType + while (next = fileReader.next()) { + const [msg, index] = next + this.distributeMessage(msg, index) + msgs.push(msg) + onMessage?.(msg) + } + + + logger.info("Messages loaded: ", msgs.length, msgs) + + // @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) => { + // if (m1.time === m2.time) { + // if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) { + // if (headChildrenIds.includes(m1.id)) { + // return -1; + // } + // } else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) { + // if (headChildrenIds.includes(m2.id)) { + // return 1; + // } + // } else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) { + // const m1FromHead = headChildrenIds.includes(m1.id); + // const m2FromHead = headChildrenIds.includes(m2.id); + // if (m1FromHead && !m2FromHead) { + // return -1; + // } else if (m2FromHead && !m1FromHead) { + // return 1; + // } + // } + // } + // return 0; + // }) + } + + private waitingForFiles: boolean = false + //--->// private onFileReadSuccess = () => { + // const stateToUpdate : Partial= { + // performanceChartData: this.performanceTrackManager.chartData, + // performanceAvaliability: this.performanceTrackManager.avaliability, + // ...this.lists.getFullListsState(), + // } + // if (this.activityManager) { + // this.activityManager.end() + // stateToUpdate.skipIntervals = this.activityManager.list + // } + // this.state.update(stateToUpdate) + // } + //---> private onFileReadFailed = (e: any) => { + // this.state.update({ error: true }) + // logger.error(e) + // toast.error('Error requesting a session file') + // } + private onFileReadFinally = () => { + //--->// this.incomingMessages + // .filter(msg => msg.time >= this.lastMessageInFileTime) + // .forEach(msg => this.distributeMessage(msg, 0)) + + this.waitingForFiles = false + this.setMessagesLoading(false) + } + + + private createNewParser(shouldDecrypt=true) { + const decrypt = shouldDecrypt && this.session.fileKey + ? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey) + : (b: Uint8Array) => Promise.resolve(b) + // Each time called - new fileReader created. TODO: reuseable decryptor instance + const fileReader = new MFileReader(new Uint8Array(), this.session.startedAt) + return (b: Uint8Array) => decrypt(b).then(b => { + fileReader.append(b) + this.parseAndDistributeMessages(fileReader) + this.setMessagesLoading(false) + }) + } + + loadDOM() { + this.setMessagesLoading(true) + this.waitingForFiles = true + + let fileReadPromise = this.session.domURL && this.session.domURL.length > 0 + ? loadFiles(this.session.domURL, this.createNewParser()) + : Promise.reject() + + return fileReadPromise + // EFS fallback + .catch(() => requestEFSDom(this.session.sessionId).then(this.createNewParser(false))) + // old url fallback + .catch(e => { + logger.error('Can not get normal session replay file:', e) + // back compat fallback to an old mobsUrl + return loadFiles(this.session.mobsUrl, this.createNewParser(false)) + }) + // --->.then(this.onFileReadSuccess) + // --->.catch(this.onFileReadFailed) + .finally(this.onFileReadFinally) + } + + loadDevtools() { + // load devtools + if (this.session.devtoolsURL.length) { + // ---> this.state.update({ devtoolsLoading: true }) + return loadFiles(this.session.devtoolsURL, this.createNewParser()) + .catch(() => + requestEFSDevtools(this.session.sessionId) + .then(this.createNewParser(false)) + ) + //--->// .then(() => { + // this.state.update(this.lists.getFullListsState()) + // }) + // --->.catch(e => logger.error("Can not download the devtools file", e)) + // --->//.finally(() => this.state.update({ devtoolsLoading: false })) + } + return Promise.resolve() + } + +} \ No newline at end of file diff --git a/frontend/app/player/web/messageLoader/loadFiles.ts b/frontend/app/player/web/messageLoader/loadFiles.ts new file mode 100644 index 000000000..be3adc436 --- /dev/null +++ b/frontend/app/player/web/messageLoader/loadFiles.ts @@ -0,0 +1,60 @@ +import APIClient from 'App/api_client'; + +const NO_NTH_FILE = "nnf" +const NO_UNPROCESSED_FILES = "nuf" + +export async function* loadFiles( + urls: string[], +){ + const firstFileURL = urls[0] + urls = urls.slice(1) + if (!firstFileURL) { + throw "No urls provided" + } + try { + yield await window.fetch(firstFileURL) + .then(r => processAPIStreamResponse(r, true)) + for(const url in urls) { + yield await window.fetch(url) + .then(r => processAPIStreamResponse(r, false)) + } + } catch(e) { + if (e === NO_NTH_FILE) { + return + } + throw e + } +} + + +export async function requestEFSDom(sessionId: string) { + return await requestEFSMobFile(sessionId + "/dom.mob") +} + +export async function requestEFSDevtools(sessionId: string) { + return await requestEFSMobFile(sessionId + "/devtools.mob") +} + +async function requestEFSMobFile(filename: string) { + const api = new APIClient() + const res = await api.fetch('/unprocessed/' + filename) + if (res.status >= 400) { + throw NO_UNPROCESSED_FILES + } + return await processAPIStreamResponse(res, false) +} + +const processAPIStreamResponse = (response: Response, isFirstFile: boolean) => { + return new Promise((res, rej) => { + if (response.status === 404 && !isFirstFile) { + return rej(NO_NTH_FILE) + } + if (response.status >= 400) { + return rej( + isFirstFile ? `no start file. status code ${ response.status }` + : `Bad endfile status code ${response.status}` + ) + } + res(response.arrayBuffer()) + }).then(buffer => new Uint8Array(buffer)) +}