Path: blob/main/replay/backend/models/ReplayView.ts
1030 views
import { v1 as uuidv1 } from 'uuid';1import Window from './Window';2import ReplayApi from '~backend/api';3import ViewBackend from './ViewBackend';4import ReplayTabState from '~backend/api/ReplayTabState';5import PlaybarView from '~backend/models/PlaybarView';6import Application from '~backend/Application';7import { TOOLBAR_HEIGHT } from '~shared/constants/design';8import IRectangle from '~shared/interfaces/IRectangle';9import { DomActionType, IFrontendDomChangeEvent } from '~shared/interfaces/IDomChangeEvent';10import { IFrontendMouseEvent, IScrollRecord } from '~shared/interfaces/ISaSession';11import OutputView from '~backend/models/OutputView';1213const domReplayerScript = require.resolve('../../injected-scripts/domReplayerSubscribe');1415export default class ReplayView extends ViewBackend {16public static MESSAGE_HANG_ID = 'script-hang';17public replayApi: ReplayApi;18public tabState: ReplayTabState;19public readonly playbarView: PlaybarView;20public readonly outputView: OutputView;2122public readonly toolbarHeight = TOOLBAR_HEIGHT;23public outputWidth = 300;2425private previousWidth = 300;2627private isTabLoaded = false;28private lastInactivityMillis = 0;2930private checkResponsiveInterval: NodeJS.Timeout;3132public constructor(window: Window) {33super(window, {34preload: domReplayerScript,35nodeIntegrationInSubFrames: true,36enableRemoteModule: false,37partition: uuidv1(),38contextIsolation: true,39webSecurity: false,40javascript: false,41});4243this.playbarView = new PlaybarView(window);44this.outputView = new OutputView(window, this);45this.checkResponsive = this.checkResponsive.bind(this);46this.checkResponsiveInterval = setInterval(this.timerCheckResponsive.bind(this), 500).unref();4748let resizeTimeout;49this.window.browserWindow.on('resize', () => {50if (resizeTimeout) clearTimeout(resizeTimeout);51resizeTimeout = setTimeout(() => this.sizeWebContentsToFit(), 20);52});53}5455public async load(replayApi: ReplayApi) {56const isFirstLoad = !this.replayApi;57this.clearReplayApi();5859this.replayApi = replayApi;6061const session = this.webContents.session;62const scriptUrl = await replayApi.getReplayScript();63session.setPreloads([scriptUrl]);64if (isFirstLoad) this.detach(false);65this.replayApi.onTabChange = this.onTabChange.bind(this);66await replayApi.isReady;67await this.loadTab();68}6970public start() {71this.playbarView.play();72}7374public async loadTab(id?: number) {75this.isTabLoaded = false;76await this.clearTabState();77this.attach();7879this.window.setAddressBarUrl('Loading session...');8081this.tabState = id ? this.replayApi.getTab(id) : this.replayApi.startTab;82this.tabState.on('tick:changes', this.checkResponsive);83await this.playbarView.load(this.tabState);8485console.log('Loaded tab state', this.tabState.startOrigin);86this.window.setActiveTabId(this.tabState.tabId);87this.window.setAddressBarUrl(this.tabState.startOrigin);8889this.browserView.setBackgroundColor('#ffffff');9091await Promise.race([92this.webContents.loadURL(this.tabState.startOrigin),93new Promise(resolve => setTimeout(resolve, 500)),94]);9596this.isTabLoaded = true;97this.sizeWebContentsToFit();9899if (this.tabState.currentPlaybarOffsetPct > 0) {100console.log('Resetting playbar offset to %s%', this.tabState.currentPlaybarOffsetPct);101const events = this.tabState.setTickValue(this.tabState.currentPlaybarOffsetPct, true);102await this.publishTickChanges(events);103}104}105106public onTickHover(rect: IRectangle, tickValue: number) {107this.playbarView.onTickHover(rect, tickValue);108}109110public toggleOutputView(show: boolean): void {111const bounds = this.bounds;112if (show === false) {113this.previousWidth = this.outputWidth;114this.outputWidth = 0;115} else {116this.outputWidth = this.previousWidth;117}118this.fixBounds(bounds);119}120121public growOutputView(diffX: number): void {122const bounds = this.bounds;123this.outputWidth += diffX;124this.fixBounds(bounds);125}126127public fixBounds(newBounds: { x: number; width: number; y: any; height: number }) {128this.playbarView.fixBounds({129x: 0,130y: newBounds.height + newBounds.y,131width: newBounds.width,132height: this.toolbarHeight,133});134this.outputView.fixBounds({135x: newBounds.width - this.outputWidth,136y: newBounds.y,137width: this.outputWidth,138height: newBounds.height,139});140newBounds.width -= this.outputWidth;141super.fixBounds(newBounds);142this.bounds.width += this.outputWidth;143this.sizeWebContentsToFit();144this.window.browserWindow.addBrowserView(this.outputView.browserView);145this.window.browserWindow.addBrowserView(this.playbarView.browserView);146}147148public attach() {149if (this.isAttached) return;150super.attach();151this.playbarView.attach();152this.outputView.attach();153this.interceptHttpRequests();154this.webContents.openDevTools({ mode: 'detach', activate: false });155}156157public detach(detachPlaybar = true) {158this.webContents.closeDevTools();159super.detach();160// clear out everytime we detach161this._browserView = null;162if (detachPlaybar) {163this.outputView.detach();164this.playbarView.detach();165}166}167168public async onTickDrag(tickValue: number) {169if (!this.isAttached || !this.tabState) return;170const events = this.tabState.setTickValue(tickValue);171await this.publishTickChanges(events);172}173174public async gotoNextTick() {175const nextTick = await this.nextTick();176this.playbarView.changeTickOffset(nextTick?.playbarOffset);177}178179public async gotoPreviousTick() {180const state = this.tabState.transitionToPreviousTick();181await this.publishTickChanges(state);182this.playbarView.changeTickOffset(this.tabState.currentPlaybarOffsetPct);183}184185public async nextTick(startMillisDeficit = 0) {186try {187if (!this.tabState) return { playbarOffset: 0, millisToNextTick: 100 };188const startTime = new Date();189// calculate when client should request the next tick190let millisToNextTick = 50;191192const events = this.tabState.transitionToNextTick();193await this.publishTickChanges(events);194setImmediate(() => this.checkResponsive());195196if (this.tabState.currentTick && this.tabState.nextTick) {197const nextTickTime = new Date(this.tabState.nextTick.timestamp);198const currentTickTime = new Date(this.tabState.currentTick.timestamp);199const diff = nextTickTime.getTime() - currentTickTime.getTime();200const fnDuration = new Date().getTime() - startTime.getTime();201millisToNextTick = diff - fnDuration + startMillisDeficit;202}203204return { playbarOffset: this.tabState.currentPlaybarOffsetPct, millisToNextTick };205} catch (err) {206console.log('ERROR getting next tick', err);207return { playbarOffset: this.tabState.currentPlaybarOffsetPct, millisToNextTick: 100 };208}209}210211public destroy() {212super.destroy();213this.clearReplayApi();214this.clearTabState();215}216217public sizeWebContentsToFit() {218if (!this.tabState || !this.isTabLoaded) return;219const screenSize = this.browserView.getBounds();220221const viewSize = {222height: Math.min(this.tabState.viewportHeight, screenSize.height),223width: this.tabState.viewportWidth,224};225226// NOTE: This isn't working in electron, so setting scale instead227const viewPosition = {228x: screenSize.width > viewSize.width ? (screenSize.width - viewSize.width) / 2 : 0,229y: screenSize.height > viewSize.height ? (screenSize.height - viewSize.height) / 2 : 0,230};231232const scale = screenSize.width / viewSize.width;233234if (viewSize.height * scale > viewSize.height) {235viewSize.height *= scale;236}237238this.browserView.webContents.enableDeviceEmulation({239deviceScaleFactor: 1,240screenPosition: 'mobile',241viewSize,242scale,243viewPosition,244screenSize,245});246}247248private async publishTickChanges(249events: [250IFrontendDomChangeEvent[],251{ frameIdPath: string; nodeIds: number[] },252IFrontendMouseEvent,253IScrollRecord,254],255) {256if (!events || !events.length) return;257const [domChanges] = events;258259if (domChanges?.length) {260const [{ action, frameIdPath }] = domChanges;261const hasNewUrlToLoad = action === DomActionType.newDocument && frameIdPath === 'main';262if (hasNewUrlToLoad) {263const nav = domChanges.shift();264await Promise.race([265this.webContents.loadURL(nav.textContent),266new Promise(resolve => setTimeout(resolve, 500)),267]);268}269}270271const columns = [272'action',273'nodeId',274'nodeType',275'textContent',276'tagName',277'namespaceUri',278'parentNodeId',279'previousSiblingId',280'attributeNamespaces',281'attributes',282'properties',283'frameIdPath',284];285const compressedChanges = domChanges286? domChanges.map(x => columns.map(col => x[col]))287: undefined;288this.webContents.send('dom:apply', columns, compressedChanges, ...events.slice(1));289this.outputView.setCommandId(this.tabState.currentTick.commandId);290this.window.setAddressBarUrl(this.tabState.urlOrigin);291}292293private async onTabChange(tab: ReplayTabState) {294await tab.isReady.promise;295this.window.onReplayTabChange({296tabId: tab.tabId,297detachedFromTabId: tab.detachedFromTabId,298startOrigin: tab.startOrigin,299createdTime: tab.tabCreatedTime,300width: tab.viewportWidth,301height: tab.viewportHeight,302});303}304305private checkResponsive() {306const lastActivityMillis =307new Date().getTime() - (this.replayApi.lastActivityDate ?? new Date()).getTime();308309if (lastActivityMillis < this.lastInactivityMillis) {310this.lastInactivityMillis = lastActivityMillis;311return;312}313if (lastActivityMillis - this.lastInactivityMillis < 500) return;314this.lastInactivityMillis = lastActivityMillis;315316if (317!this.tabState.replayTime.close &&318lastActivityMillis >= 5e3 &&319this.replayApi.lastCommandName !== 'waitForMillis' &&320this.replayApi.showUnresponsiveMessage321) {322const lastActivitySecs = Math.floor(lastActivityMillis / 1e3);323Application.instance.overlayManager.show(324'message-overlay',325this.window.browserWindow,326this.window.browserWindow.getContentBounds(),327{328title: 'Did your script hang?',329message: `The last update was ${lastActivitySecs} seconds ago.`,330id: ReplayView.MESSAGE_HANG_ID,331},332);333} else {334Application.instance.overlayManager.getByName('message-overlay').hide();335}336}337338private timerCheckResponsive(): void {339if (this.replayApi && this.tabState && this.isTabLoaded && !this.tabState.replayTime.close) {340this.checkResponsive();341}342}343344private clearReplayApi() {345if (this.replayApi) {346this.outputView.clear();347this.webContents.session.setPreloads([]);348this.replayApi.onTabChange = null;349this.replayApi.close();350this.replayApi = null;351}352}353354private clearTabState() {355if (this.tabState) {356this.tabState.off('tick:changes', this.checkResponsive);357this.tabState = null;358this.outputView.clear();359this.detach(false);360}361}362363private interceptHttpRequests() {364const session = this.webContents.session;365session.protocol.interceptStreamProtocol('http', async (request, callback) => {366console.log('intercepting http stream', request.url);367const result = await this.replayApi.getResource(request.url);368callback(result);369});370session.protocol.interceptStreamProtocol('https', async (request, callback) => {371console.log('intercepting https stream', request.url);372const result = await this.replayApi.getResource(request.url);373callback(result);374});375}376}377378379