From 07046cc2fbdc32ef67369a0074bc216ce05d99bc Mon Sep 17 00:00:00 2001 From: Delirium Date: Tue, 21 Nov 2023 11:22:54 +0100 Subject: [PATCH] feat: canvas support [assist] (#1641) * feat(tracker/ui): start canvas support * feat(tracker): slpeer -> peerjs for canvas streams * fix(ui): fix agent canvas peer id * fix(ui): fix agent canvas peer id * fix(ui): fix peer removal * feat(tracker): canvas recorder * feat(tracker): canvas recorder * feat(tracker): canvas recorder * feat(tracker): canvas recorder * feat(ui): canvas support for ui * fix(tracker): fix falling tests * feat(ui): replay canvas in video * feat(ui): refactor video streaming to draw on canvas * feat(ui): 10hz check for canvas replay * feat(ui): fix for tests * feat(ui): fix for tests * feat(ui): fix for tests * feat(ui): fix for tests cov * feat(ui): mroe test coverage * fix(ui): styling * fix(tracker): support backend settings for canvas --- .github/workflows/tracker-tests.yaml | 8 +- backend/pkg/messages/filters.go | 2 +- backend/pkg/messages/messages.go | 24 ++ backend/pkg/messages/read-message.go | 14 + codecov.yml | 10 + ee/connectors/msgcodec/messages.py | 8 + ee/connectors/msgcodec/messages.pyx | 11 + ee/connectors/msgcodec/msgcodec.py | 6 + ee/connectors/msgcodec/msgcodec.pyx | 6 + frontend/.babelrc | 6 +- frontend/app/player/web/MessageManager.ts | 4 + frontend/app/player/web/Screen/Screen.ts | 2 +- frontend/app/player/web/TabManager.ts | 58 ++- frontend/app/player/web/WebLivePlayer.ts | 1 + .../app/player/web/assist/AssistManager.ts | 337 +++++++++--------- frontend/app/player/web/assist/Call.ts | 4 +- .../app/player/web/assist/CanvasReceiver.ts | 162 +++++++++ .../app/player/web/managers/CanvasManager.ts | 53 +++ .../app/player/web/managers/DOM/DOMManager.ts | 7 +- .../app/player/web/managers/DOM/VirtualDOM.ts | 2 +- .../app/player/web/managers/PagesManager.ts | 4 + .../web/messages/RawMessageReader.gen.ts | 10 + .../app/player/web/messages/filters.gen.ts | 2 +- .../app/player/web/messages/message.gen.ts | 3 + frontend/app/player/web/messages/raw.gen.ts | 9 +- .../player/web/messages/tracker-legacy.gen.ts | 1 + .../app/player/web/messages/tracker.gen.ts | 16 +- frontend/app/types/session/session.ts | 5 + frontend/app/window.d.ts | 14 + frontend/package.json | 3 +- frontend/yarn.lock | 27 +- mobs/messages.rb | 5 + tracker/.husky/pre-commit | 4 +- tracker/tracker-assist/bun.lockb | Bin 245037 -> 238231 bytes tracker/tracker-assist/package.json | 4 +- tracker/tracker-assist/src/Assist.ts | 73 +++- tracker/tracker-assist/src/Canvas.ts | 61 ++++ tracker/tracker-assist/src/version.ts | 2 +- tracker/tracker/bun.lockb | Bin 219847 -> 212166 bytes tracker/tracker/package.json | 5 +- tracker/tracker/src/common/messages.gen.ts | 9 +- tracker/tracker/src/main/app/canvas.ts | 130 +++++++ tracker/tracker/src/main/app/guards.ts | 1 + tracker/tracker/src/main/app/index.ts | 19 +- tracker/tracker/src/main/app/messages.gen.ts | 11 + tracker/tracker/src/tests/guards.test.ts | 71 ++++ .../{main/app => tests}/nodes.unit.test.ts | 26 +- .../src/webworker/MessageEncoder.gen.ts | 4 + 48 files changed, 1028 insertions(+), 216 deletions(-) create mode 100644 codecov.yml create mode 100644 frontend/app/player/web/assist/CanvasReceiver.ts create mode 100644 frontend/app/player/web/managers/CanvasManager.ts create mode 100644 frontend/app/window.d.ts create mode 100644 tracker/tracker-assist/src/Canvas.ts create mode 100644 tracker/tracker/src/main/app/canvas.ts create mode 100644 tracker/tracker/src/tests/guards.test.ts rename tracker/tracker/src/{main/app => tests}/nodes.unit.test.ts (81%) diff --git a/.github/workflows/tracker-tests.yaml b/.github/workflows/tracker-tests.yaml index d5c4caeaf..fa73f34a6 100644 --- a/.github/workflows/tracker-tests.yaml +++ b/.github/workflows/tracker-tests.yaml @@ -47,10 +47,6 @@ jobs: run: | cd tracker/tracker bun install - - name: (TA) Setup Testing packages - run: | - cd tracker/tracker-assist - bun install - name: Jest tests run: | cd tracker/tracker @@ -59,6 +55,10 @@ jobs: run: | cd tracker/tracker bun run build + - name: (TA) Setup Testing packages + run: | + cd tracker/tracker-assist + bun install - name: (TA) Jest tests run: | cd tracker/tracker-assist diff --git a/backend/pkg/messages/filters.go b/backend/pkg/messages/filters.go index fca1a2065..bd7716f7b 100644 --- a/backend/pkg/messages/filters.go +++ b/backend/pkg/messages/filters.go @@ -10,5 +10,5 @@ func IsIOSType(id int) bool { } func IsDOMType(id int) bool { - return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id + return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 119 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id } diff --git a/backend/pkg/messages/messages.go b/backend/pkg/messages/messages.go index 720670c71..3b9f4ef0d 100644 --- a/backend/pkg/messages/messages.go +++ b/backend/pkg/messages/messages.go @@ -84,6 +84,7 @@ const ( MsgResourceTiming = 116 MsgTabChange = 117 MsgTabData = 118 + MsgCanvasNode = 119 MsgIssueEvent = 125 MsgSessionEnd = 126 MsgSessionSearch = 127 @@ -2245,6 +2246,29 @@ func (msg *TabData) TypeID() int { return 118 } +type CanvasNode struct { + message + NodeId string + Timestamp uint64 +} + +func (msg *CanvasNode) Encode() []byte { + buf := make([]byte, 21+len(msg.NodeId)) + buf[0] = 119 + p := 1 + p = WriteString(msg.NodeId, buf, p) + p = WriteUint(msg.Timestamp, buf, p) + return buf[:p] +} + +func (msg *CanvasNode) Decode() Message { + return msg +} + +func (msg *CanvasNode) TypeID() int { + return 119 +} + type IssueEvent struct { message MessageID uint64 diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index 3260c0fed..910922b8a 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -1365,6 +1365,18 @@ func DecodeTabData(reader BytesReader) (Message, error) { return msg, err } +func DecodeCanvasNode(reader BytesReader) (Message, error) { + var err error = nil + msg := &CanvasNode{} + if msg.NodeId, err = reader.ReadString(); err != nil { + return nil, err + } + if msg.Timestamp, err = reader.ReadUint(); err != nil { + return nil, err + } + return msg, err +} + func DecodeIssueEvent(reader BytesReader) (Message, error) { var err error = nil msg := &IssueEvent{} @@ -1993,6 +2005,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) { return DecodeTabChange(reader) case 118: return DecodeTabData(reader) + case 119: + return DecodeCanvasNode(reader) case 125: return DecodeIssueEvent(reader) case 126: diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..3d0feafea --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +ignore: + - "**/*/*.gen.ts" + - "**/*/coverage.xml" + - "**/*/coverage-final.json" + - "**/*/coverage/**" + - "**/*/node_modules/**" + - "**/*/dist/**" + - "**/*/build/**" + - "**/*/.test.*" + - "**/*/version.ts" \ No newline at end of file diff --git a/ee/connectors/msgcodec/messages.py b/ee/connectors/msgcodec/messages.py index 9e3091142..d5348a9e5 100644 --- a/ee/connectors/msgcodec/messages.py +++ b/ee/connectors/msgcodec/messages.py @@ -788,6 +788,14 @@ class TabData(Message): self.tab_id = tab_id +class CanvasNode(Message): + __id__ = 119 + + def __init__(self, node_id, timestamp): + self.node_id = node_id + self.timestamp = timestamp + + class IssueEvent(Message): __id__ = 125 diff --git a/ee/connectors/msgcodec/messages.pyx b/ee/connectors/msgcodec/messages.pyx index 8c1b6206a..af0816b95 100644 --- a/ee/connectors/msgcodec/messages.pyx +++ b/ee/connectors/msgcodec/messages.pyx @@ -1164,6 +1164,17 @@ cdef class TabData(PyMessage): self.tab_id = tab_id +cdef class CanvasNode(PyMessage): + cdef public int __id__ + cdef public str node_id + cdef public unsigned long timestamp + + def __init__(self, str node_id, unsigned long timestamp): + self.__id__ = 119 + self.node_id = node_id + self.timestamp = timestamp + + cdef class IssueEvent(PyMessage): cdef public int __id__ cdef public unsigned long message_id diff --git a/ee/connectors/msgcodec/msgcodec.py b/ee/connectors/msgcodec/msgcodec.py index 0040064c8..4aba0f775 100644 --- a/ee/connectors/msgcodec/msgcodec.py +++ b/ee/connectors/msgcodec/msgcodec.py @@ -711,6 +711,12 @@ class MessageCodec(Codec): tab_id=self.read_string(reader) ) + if message_id == 119: + return CanvasNode( + node_id=self.read_string(reader), + timestamp=self.read_uint(reader) + ) + if message_id == 125: return IssueEvent( message_id=self.read_uint(reader), diff --git a/ee/connectors/msgcodec/msgcodec.pyx b/ee/connectors/msgcodec/msgcodec.pyx index 6a6faf871..c918be6ea 100644 --- a/ee/connectors/msgcodec/msgcodec.pyx +++ b/ee/connectors/msgcodec/msgcodec.pyx @@ -809,6 +809,12 @@ cdef class MessageCodec: tab_id=self.read_string(reader) ) + if message_id == 119: + return CanvasNode( + node_id=self.read_string(reader), + timestamp=self.read_uint(reader) + ) + if message_id == 125: return IssueEvent( message_id=self.read_uint(reader), diff --git a/frontend/.babelrc b/frontend/.babelrc index 631979df1..9816ce275 100644 --- a/frontend/.babelrc +++ b/frontend/.babelrc @@ -6,10 +6,10 @@ ], "plugins": [ "babel-plugin-react-require", - [ "@babel/plugin-proposal-private-property-in-object", { "loose": true } ], + ["@babel/plugin-transform-private-property-in-object", { "loose":true } ], [ "@babel/plugin-transform-runtime", { "regenerator": true } ], [ "@babel/plugin-proposal-decorators", { "legacy":true } ], - [ "@babel/plugin-proposal-class-properties", { "loose":true } ], - [ "@babel/plugin-proposal-private-methods", { "loose": true }] + [ "@babel/plugin-transform-class-properties", { "loose":true } ], + [ "@babel/plugin-transform-private-methods", { "loose": true }] ] } \ No newline at end of file diff --git a/frontend/app/player/web/MessageManager.ts b/frontend/app/player/web/MessageManager.ts index dc1441e69..f7ea33245 100644 --- a/frontend/app/player/web/MessageManager.ts +++ b/frontend/app/player/web/MessageManager.ts @@ -236,6 +236,10 @@ export default class MessageManager { } } + public getNode(id: number) { + return this.tabs[this.activeTab]?.getNode(id); + } + public changeTab(tabId: string) { this.activeTab = tabId; this.state.update({ currentTab: tabId }); diff --git a/frontend/app/player/web/Screen/Screen.ts b/frontend/app/player/web/Screen/Screen.ts index 2956940e3..1db5da88c 100644 --- a/frontend/app/player/web/Screen/Screen.ts +++ b/frontend/app/player/web/Screen/Screen.ts @@ -120,7 +120,7 @@ export default class Screen { return this.parentElement } - setBorderStyle(style: { border: string }) { + setBorderStyle(style: { outline: string }) { return Object.assign(this.screen.style, style) } diff --git a/frontend/app/player/web/TabManager.ts b/frontend/app/player/web/TabManager.ts index defde829b..2dc7c3554 100644 --- a/frontend/app/player/web/TabManager.ts +++ b/frontend/app/player/web/TabManager.ts @@ -1,23 +1,28 @@ +import type { Store } from "Player"; +import { getResourceFromNetworkRequest, getResourceFromResourceTiming, Log, ResourceType } from "Player"; import ListWalker from "Player/common/ListWalker"; +import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, InitialLists, State as ListsState } from "Player/web/Lists"; +import CanvasManager from "Player/web/managers/CanvasManager"; +import { VElement } from "Player/web/managers/DOM/VirtualDOM"; +import PagesManager from "Player/web/managers/PagesManager"; +import PerformanceTrackManager from "Player/web/managers/PerformanceTrackManager"; +import WindowNodeCounter from "Player/web/managers/WindowNodeCounter"; import { + CanvasNode, ConnectionInformation, - Message, MType, ResourceTiming, + Message, + MType, + ResourceTiming, SetPageLocation, SetViewportScroll, SetViewportSize } from "Player/web/messages"; -import PerformanceTrackManager from "Player/web/managers/PerformanceTrackManager"; -import WindowNodeCounter from "Player/web/managers/WindowNodeCounter"; -import PagesManager from "Player/web/managers/PagesManager"; +import { isDOMType } from "Player/web/messages/filters.gen"; +import Screen from "Player/web/Screen/Screen"; // @ts-ignore import { Decoder } from "syncod"; -import Lists, { InitialLists, INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from "Player/web/Lists"; -import type { Store } from 'Player'; -import Screen from "Player/web/Screen/Screen"; import { TYPES as EVENT_TYPES } from "Types/session/event"; -import type { PerformanceChartPoint } from './managers/PerformanceTrackManager'; -import { getResourceFromNetworkRequest, getResourceFromResourceTiming, Log, ResourceType } from "Player"; -import { isDOMType } from "Player/web/messages/filters.gen"; +import type { PerformanceChartPoint } from "./managers/PerformanceTrackManager"; export interface TabState extends ListsState { performanceAvailability?: PerformanceTrackManager['availability'] @@ -57,6 +62,8 @@ export default class TabSessionManager { public readonly decoder = new Decoder(); private lists: Lists; private navigationStartOffset = 0 + private canvasManagers: { [key: string]: { manager: CanvasManager, start: number, running: boolean } } = {} + private canvasReplayWalker: ListWalker = new ListWalker(); constructor( private readonly session: any, @@ -76,6 +83,10 @@ export default class TabSessionManager { }) } + public getNode = (id: number) => { + return this.pagesManager.getNode(id) + } + public updateLists(lists: Partial) { Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => { const currentList = this.lists.lists[key] @@ -141,6 +152,23 @@ export default class TabSessionManager { distributeMessage(msg: Message): void { switch (msg.tp) { + case MType.CanvasNode: + const managerId = `${msg.timestamp}_${msg.nodeId}`; + if (!this.canvasManagers[managerId]) { + const filename = `${managerId}.mp4`; + const delta = msg.timestamp - this.sessionStart; + const fileUrl = this.session.canvasURL.find((url: string) => url.includes(filename)); + const manager = new CanvasManager( + msg.nodeId, + delta, + fileUrl, + this.getNode as (id: number) => VElement | undefined + ); + this.canvasManagers[managerId] = { manager, start: msg.timestamp, running: false }; + this.canvasReplayWalker.append(msg); + + } + break; case MType.SetPageLocation: this.locationManager.append(msg); if (msg.navigationStart > 0) { @@ -289,6 +317,16 @@ export default class TabSessionManager { if (!!lastScroll && this.screen.window) { this.screen.window.scrollTo(lastScroll.x, lastScroll.y); } + const canvasMsg = this.canvasReplayWalker.moveGetLast(t) + if (canvasMsg) { + this.canvasManagers[`${canvasMsg.timestamp}_${canvasMsg.nodeId}`].manager.startVideo(); + this.canvasManagers[`${canvasMsg.timestamp}_${canvasMsg.nodeId}`].running = true; + } + const runningManagers = Object.keys(this.canvasManagers).filter((key) => this.canvasManagers[key].running); + runningManagers.forEach((key) => { + const manager = this.canvasManagers[key].manager; + manager.move(t); + }) }) } diff --git a/frontend/app/player/web/WebLivePlayer.ts b/frontend/app/player/web/WebLivePlayer.ts index e7ec09d2c..a163eb83e 100644 --- a/frontend/app/player/web/WebLivePlayer.ts +++ b/frontend/app/player/web/WebLivePlayer.ts @@ -42,6 +42,7 @@ export default class WebLivePlayer extends WebPlayer { this.screen, config, wpState, + (id) => this.messageManager.getNode(id), uiErrorHandler, ) this.assistManager.connect(session.agentToken!, agentId, projectId) diff --git a/frontend/app/player/web/assist/AssistManager.ts b/frontend/app/player/web/assist/AssistManager.ts index ddae2a5ef..5fccb2e3d 100644 --- a/frontend/app/player/web/assist/AssistManager.ts +++ b/frontend/app/player/web/assist/AssistManager.ts @@ -1,19 +1,16 @@ +import MessageManager from 'Player/web/MessageManager'; import type { Socket } from 'socket.io-client'; import type Screen from '../Screen/Screen'; -import type { Store } from '../../common/types' +import type { Store } from '../../common/types'; import type { Message } from '../messages'; import MStreamReader from '../messages/MStreamReader'; -import JSONRawMessageReader from '../messages/JSONRawMessageReader' +import JSONRawMessageReader from '../messages/JSONRawMessageReader'; import Call, { CallingState } from './Call'; -import RemoteControl, { RemoteControlStatus } from './RemoteControl' -import ScreenRecording, { SessionRecordingStatus } from './ScreenRecording' +import RemoteControl, { RemoteControlStatus } from './RemoteControl'; +import ScreenRecording, { SessionRecordingStatus } from './ScreenRecording'; +import CanvasReceiver from 'Player/web/assist/CanvasReceiver'; - -export { - RemoteControlStatus, - SessionRecordingStatus, - CallingState, -} +export { RemoteControlStatus, SessionRecordingStatus, CallingState }; export enum ConnectionStatus { Connecting, @@ -25,29 +22,30 @@ export enum ConnectionStatus { Closed, } -type StatsEvent = 's_call_started' +type StatsEvent = + | 's_call_started' | 's_call_ended' | 's_control_started' | 's_control_ended' | 's_recording_started' - | 's_recording_ended' + | 's_recording_ended'; export function getStatusText(status: ConnectionStatus): string { - switch(status) { + switch (status) { case ConnectionStatus.Closed: return 'Closed...'; case ConnectionStatus.Connecting: - return "Connecting..."; + return 'Connecting...'; case ConnectionStatus.Connected: - return ""; + return ''; case ConnectionStatus.Inactive: - return "Client tab is inactive"; + return 'Client tab is inactive'; case ConnectionStatus.Disconnected: - return "Disconnected"; + return 'Disconnected'; case ConnectionStatus.Error: - return "Something went wrong. Try to reload the page."; + return 'Something went wrong. Try to reload the page.'; case ConnectionStatus.WaitingMessages: - return "Connected. Waiting for the data... (The tab might be inactive)" + return 'Connected. Waiting for the data... (The tab might be inactive)'; } } @@ -59,14 +57,16 @@ export function getStatusText(status: ConnectionStatus): string { const MAX_RECONNECTION_COUNT = 4; export default class AssistManager { - assistVersion = 1 + assistVersion = 1; + private canvasReceiver: CanvasReceiver; static readonly INITIAL_STATE = { peerConnectionStatus: ConnectionStatus.Connecting, assistStart: 0, ...Call.INITIAL_STATE, ...RemoteControl.INITIAL_STATE, ...ScreenRecording.INITIAL_STATE, - } + }; + // TODO: Session type constructor( private session: any, @@ -75,26 +75,31 @@ export default class AssistManager { private screen: Screen, private config: RTCIceServer[] | null, private store: Store, - public readonly uiErrorHandler?: { error: (msg: string) => void } + private getNode: MessageManager['getNode'], + public readonly uiErrorHandler?: { + error: (msg: string) => void; + } ) {} - public getAssistVersion = () => this.assistVersion + public getAssistVersion = () => this.assistVersion; private get borderStyle() { - const { recordingState, remoteControl } = this.store.get() + const { recordingState, remoteControl } = this.store.get(); - const isRecordingActive = recordingState === SessionRecordingStatus.Recording - const isControlActive = remoteControl === RemoteControlStatus.Enabled + const isRecordingActive = recordingState === SessionRecordingStatus.Recording; + const isControlActive = remoteControl === RemoteControlStatus.Enabled; // recording gets priority here - if (isRecordingActive) return { outline: '2px dashed red' } - if (isControlActive) return { outline: '2px dashed blue' } - return { outline: 'unset' } + if (isRecordingActive) return { outline: '2px dashed red' }; + if (isControlActive) return { outline: '2px dashed blue' }; + return { outline: 'unset' }; } private setStatus(status: ConnectionStatus) { - if (this.store.get().peerConnectionStatus === ConnectionStatus.Disconnected && - status !== ConnectionStatus.Connected) { - return + if ( + this.store.get().peerConnectionStatus === ConnectionStatus.Disconnected && + status !== ConnectionStatus.Connected + ) { + return; } if (status === ConnectionStatus.Connecting) { @@ -111,145 +116,154 @@ export default class AssistManager { } private get peerID(): string { - return `${this.session.projectKey}-${this.session.sessionId}` + return `${this.session.projectKey}-${this.session.sessionId}`; } - private socketCloseTimeout: ReturnType | undefined + private socketCloseTimeout: ReturnType | undefined; private onVisChange = () => { - this.socketCloseTimeout && clearTimeout(this.socketCloseTimeout) + this.socketCloseTimeout && clearTimeout(this.socketCloseTimeout); if (document.hidden) { this.socketCloseTimeout = setTimeout(() => { - const state = this.store.get() - if (document.hidden && + const state = this.store.get(); + if ( + document.hidden && // TODO: should it be RemoteControlStatus.Disabled? (check) - (state.calling === CallingState.NoCall && state.remoteControl === RemoteControlStatus.Enabled)) { - this.socket?.close() + state.calling === CallingState.NoCall && + state.remoteControl === RemoteControlStatus.Enabled + ) { + this.socket?.close(); } - }, 30000) + }, 30000); } else { - this.socket?.open() + this.socket?.open(); } - } + }; + + private socket: Socket | null = null; + private disconnectTimeout: ReturnType | undefined; + private inactiveTimeout: ReturnType | undefined; + private inactiveTabs: string[] = []; - private socket: Socket | null = null - private disconnectTimeout: ReturnType | undefined - private inactiveTimeout: ReturnType | undefined - private inactiveTabs: string[] = [] private clearDisconnectTimeout() { - this.disconnectTimeout && clearTimeout(this.disconnectTimeout) - this.disconnectTimeout = undefined + this.disconnectTimeout && clearTimeout(this.disconnectTimeout); + this.disconnectTimeout = undefined; } - private clearInactiveTimeout() { - this.inactiveTimeout && clearTimeout(this.inactiveTimeout) - this.inactiveTimeout = undefined - } - connect(agentToken: string, agentId: number, projectId: number) { - const jmr = new JSONRawMessageReader() - const reader = new MStreamReader(jmr, this.session.startedAt) - let waitingForMessages = true - const now = +new Date() - this.store.update({ assistStart: now }) + private clearInactiveTimeout() { + this.inactiveTimeout && clearTimeout(this.inactiveTimeout); + this.inactiveTimeout = undefined; + } + + connect(agentToken: string, agentId: number, projectId: number) { + const jmr = new JSONRawMessageReader(); + const reader = new MStreamReader(jmr, this.session.startedAt); + let waitingForMessages = true; + + const now = +new Date(); + this.store.update({ assistStart: now }); // @ts-ignore import('socket.io-client').then(({ default: io }) => { - if (this.socket != null || this.cleaned) { return } + if (this.socket != null || this.cleaned) { + return; + } // @ts-ignore - const urlObject = new URL(window.env.API_EDP || window.location.origin) // does it handle ssl automatically? + const urlObject = new URL(window.env.API_EDP || window.location.origin); // does it handle ssl automatically? - const socket: Socket = this.socket = io(urlObject.origin, { + const socket: Socket = (this.socket = io(urlObject.origin, { withCredentials: true, multiplex: true, transports: ['websocket'], path: '/ws-assist/socket', auth: { - token: agentToken + token: agentToken, }, query: { peerId: this.peerID, projectId, - identity: "agent", + identity: 'agent', agentInfo: JSON.stringify({ ...this.session.agentInfo, id: agentId, + peerId: this.peerID, query: document.location.search, - }) - } - }) - socket.on("connect", () => { - waitingForMessages = true - this.setStatus(ConnectionStatus.WaitingMessages) // TODO: reconnect happens frequently on bad network - }) + }), + }, + })); + socket.on('connect', () => { + waitingForMessages = true; + this.setStatus(ConnectionStatus.WaitingMessages); // TODO: reconnect happens frequently on bad network + }); - socket.on('messages', messages => { - const isOldVersion = messages.meta.version === 1 - this.assistVersion = isOldVersion ? 1 : 2 + socket.on('messages', (messages) => { + const isOldVersion = messages.meta.version === 1; + this.assistVersion = isOldVersion ? 1 : 2; - const data = messages.data || messages - jmr.append(data) // as RawMessage[] + const data = messages.data || messages; + jmr.append(data); // as RawMessage[] if (waitingForMessages) { - waitingForMessages = false // TODO: more explicit - this.setStatus(ConnectionStatus.Connected) + waitingForMessages = false; // TODO: more explicit + this.setStatus(ConnectionStatus.Connected); } if (messages.meta.tabId !== this.store.get().currentTab) { - this.clearDisconnectTimeout() + this.clearDisconnectTimeout(); if (isOldVersion) { - reader.currentTab = messages.meta.tabId - this.store.update({ currentTab: messages.meta.tabId }) + reader.currentTab = messages.meta.tabId; + this.store.update({ currentTab: messages.meta.tabId }); } } - for (let msg = reader.readNext();msg !== null;msg = reader.readNext()) { - this.handleMessage(msg, msg._index) + for (let msg = reader.readNext(); msg !== null; msg = reader.readNext()) { + this.handleMessage(msg, msg._index); } - }) + }); socket.on('SESSION_RECONNECTED', () => { - this.clearDisconnectTimeout() - this.clearInactiveTimeout() - this.setStatus(ConnectionStatus.Connected) - }) + this.clearDisconnectTimeout(); + this.clearInactiveTimeout(); + this.setStatus(ConnectionStatus.Connected); + }); socket.on('UPDATE_SESSION', (evData) => { - const { meta = {}, data = {} } = evData - const { tabId } = meta - const usedData = this.assistVersion === 1 ? evData : data - const { active } = usedData - const currentTab = this.store.get().currentTab - this.clearDisconnectTimeout() - !this.inactiveTimeout && this.setStatus(ConnectionStatus.Connected) - if (typeof active === "boolean") { - this.clearInactiveTimeout() + const { meta = {}, data = {} } = evData; + const { tabId } = meta; + const usedData = this.assistVersion === 1 ? evData : data; + const { active } = usedData; + const currentTab = this.store.get().currentTab; + this.clearDisconnectTimeout(); + !this.inactiveTimeout && this.setStatus(ConnectionStatus.Connected); + if (typeof active === 'boolean') { + this.clearInactiveTimeout(); if (active) { - this.setStatus(ConnectionStatus.Connected) - this.inactiveTabs = this.inactiveTabs.filter(t => t !== tabId) + this.setStatus(ConnectionStatus.Connected); + this.inactiveTabs = this.inactiveTabs.filter((t) => t !== tabId); } else { if (!this.inactiveTabs.includes(tabId)) { - this.inactiveTabs.push(tabId) + this.inactiveTabs.push(tabId); } if (tabId === undefined || tabId === currentTab) { this.inactiveTimeout = setTimeout(() => { // @ts-ignore - const tabs = this.store.get().tabs + const tabs = this.store.get().tabs; if (this.inactiveTabs.length === tabs.size) { - this.setStatus(ConnectionStatus.Inactive) + this.setStatus(ConnectionStatus.Inactive); } - }, 10000) + }, 10000); } } } - }) - socket.on('SESSION_DISCONNECTED', e => { - waitingForMessages = true - this.clearDisconnectTimeout() + }); + socket.on('SESSION_DISCONNECTED', (e) => { + waitingForMessages = true; + this.clearDisconnectTimeout(); this.disconnectTimeout = setTimeout(() => { - this.setStatus(ConnectionStatus.Disconnected) - }, 30000) - }) - socket.on('error', e => { - console.warn("Socket error: ", e ) + this.setStatus(ConnectionStatus.Disconnected); + }, 30000); + }); + socket.on('error', (e) => { + console.warn('Socket error: ', e); this.setStatus(ConnectionStatus.Error); - }) + }); // Maybe do lazy initialization for all? // TODO: socket proxy (depend on interfaces) @@ -259,88 +273,93 @@ export default class AssistManager { this.config, this.peerID, this.getAssistVersion - ) + ); this.remoteControl = new RemoteControl( this.store, socket, this.screen, this.session.agentInfo, () => this.screen.setBorderStyle(this.borderStyle), - this.getAssistVersion, - ) + this.getAssistVersion + ); this.screenRecording = new ScreenRecording( this.store, socket, this.session.agentInfo, () => this.screen.setBorderStyle(this.borderStyle), this.uiErrorHandler, - this.getAssistVersion, - ) + this.getAssistVersion + ); + this.canvasReceiver = new CanvasReceiver(this.peerID, this.config, this.getNode, { + ...this.session.agentInfo, + id: agentId, + }); - document.addEventListener('visibilitychange', this.onVisChange) - }) + document.addEventListener('visibilitychange', this.onVisChange); + }); } public ping(event: StatsEvent, id: number) { - this.socket?.emit(event, id) + this.socket?.emit(event, id); } - /* ==== ScreenRecording ==== */ - private screenRecording: ScreenRecording | null = null + private screenRecording: ScreenRecording | null = null; requestRecording = (...args: Parameters) => { - return this.screenRecording?.requestRecording(...args) - } + return this.screenRecording?.requestRecording(...args); + }; stopRecording = (...args: Parameters) => { - return this.screenRecording?.stopRecording(...args) - } - + return this.screenRecording?.stopRecording(...args); + }; /* ==== RemoteControl ==== */ - private remoteControl: RemoteControl | null = null - requestReleaseRemoteControl = (...args: Parameters) => { - return this.remoteControl?.requestReleaseRemoteControl(...args) - } + private remoteControl: RemoteControl | null = null; + requestReleaseRemoteControl = ( + ...args: Parameters + ) => { + return this.remoteControl?.requestReleaseRemoteControl(...args); + }; setRemoteControlCallbacks = (...args: Parameters) => { - return this.remoteControl?.setCallbacks(...args) - } + return this.remoteControl?.setCallbacks(...args); + }; releaseRemoteControl = (...args: Parameters) => { - return this.remoteControl?.releaseRemoteControl(...args) - } + return this.remoteControl?.releaseRemoteControl(...args); + }; toggleAnnotation = (...args: Parameters) => { - return this.remoteControl?.toggleAnnotation(...args) - } + return this.remoteControl?.toggleAnnotation(...args); + }; /* ==== Call ==== */ - private callManager: Call | null = null + private callManager: Call | null = null; initiateCallEnd = async (...args: Parameters) => { - return this.callManager?.initiateCallEnd(...args) - } + return this.callManager?.initiateCallEnd(...args); + }; setCallArgs = (...args: Parameters) => { - return this.callManager?.setCallArgs(...args) - } + return this.callManager?.setCallArgs(...args); + }; call = (...args: Parameters) => { - return this.callManager?.call(...args) - } + return this.callManager?.call(...args); + }; toggleVideoLocalStream = (...args: Parameters) => { - return this.callManager?.toggleVideoLocalStream(...args) - } + return this.callManager?.toggleVideoLocalStream(...args); + }; addPeerCall = (...args: Parameters) => { - return this.callManager?.addPeerCall(...args) - } - + return this.callManager?.addPeerCall(...args); + }; /* ==== Cleaning ==== */ - private cleaned = false + private cleaned = false; + clean() { - this.cleaned = true // sometimes cleaned before modules loaded - this.remoteControl?.clean() - this.callManager?.clean() - this.socket?.close() - this.socket = null - this.clearDisconnectTimeout() - this.clearInactiveTimeout() - this.socketCloseTimeout && clearTimeout(this.socketCloseTimeout) - document.removeEventListener('visibilitychange', this.onVisChange) + this.cleaned = true; // sometimes cleaned before modules loaded + this.remoteControl?.clean(); + this.callManager?.clean(); + this.canvasReceiver?.clear(); + this.socket?.close(); + this.socket = null; + this.clearDisconnectTimeout(); + this.clearInactiveTimeout(); + this.socketCloseTimeout && clearTimeout(this.socketCloseTimeout); + document.removeEventListener('visibilitychange', this.onVisChange); } } diff --git a/frontend/app/player/web/assist/Call.ts b/frontend/app/player/web/assist/Call.ts index 03f4c0134..1ad66dc15 100644 --- a/frontend/app/player/web/assist/Call.ts +++ b/frontend/app/player/web/assist/Call.ts @@ -32,7 +32,7 @@ export default class Call { private videoStreams: Record = {}; constructor( - private store: Store, + private store: Store }>, private socket: Socket, private config: RTCIceServer[] | null, private peerID: string, @@ -253,7 +253,7 @@ export default class Call { this.getAssistVersion() === 1 ? this.peerID : `${this.peerID}-${tab || Object.keys(this.store.get().tabs)[0]}`; - console.log(peerId, this.getAssistVersion()); + void this._peerConnection(peerId); this.emitData('_agent_name', appStore.getState().getIn(['user', 'account', 'name'])); } diff --git a/frontend/app/player/web/assist/CanvasReceiver.ts b/frontend/app/player/web/assist/CanvasReceiver.ts new file mode 100644 index 000000000..325860c37 --- /dev/null +++ b/frontend/app/player/web/assist/CanvasReceiver.ts @@ -0,0 +1,162 @@ +import Peer from 'peerjs'; +import { VElement } from 'Player/web/managers/DOM/VirtualDOM'; +import MessageManager from 'Player/web/MessageManager'; + +let frameCounter = 0; + +function draw( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, + canvasCtx: CanvasRenderingContext2D +) { + if (frameCounter % 4 === 0) { + canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height); + } + frameCounter++; + requestAnimationFrame(() => draw(video, canvas, canvasCtx)); +} + +export default class CanvasReceiver { + private streams: Map = new Map(); + private peer: Peer | null = null; + + constructor( + private readonly peerIdPrefix: string, + private readonly config: RTCIceServer[] | null, + private readonly getNode: MessageManager['getNode'], + private readonly agentInfo: Record + ) { + // @ts-ignore + const urlObject = new URL(window.env.API_EDP || window.location.origin); + const peerOpts: Peer.PeerJSOption = { + host: urlObject.hostname, + path: '/assist', + port: + urlObject.port === '' + ? location.protocol === 'https:' + ? 443 + : 80 + : parseInt(urlObject.port), + }; + if (this.config) { + peerOpts['config'] = { + iceServers: this.config, + //@ts-ignore + sdpSemantics: 'unified-plan', + iceTransportPolicy: 'all', + }; + } + const id = `${this.peerIdPrefix}-${this.agentInfo.id}-canvas`; + const canvasPeer = new Peer(id, peerOpts); + this.peer = canvasPeer; + canvasPeer.on('error', (err) => console.error('canvas peer error', err)); + canvasPeer.on('call', (call) => { + call.answer(); + const canvasId = call.peer.split('-')[2]; + call.on('stream', (stream) => { + this.streams.set(canvasId, stream); + setTimeout(() => { + const node = this.getNode(parseInt(canvasId, 10)); + const videoEl = spawnVideo( + this.streams.get(canvasId)?.clone() as MediaStream, + node as VElement + ); + if (node) { + draw( + videoEl, + node.node as HTMLCanvasElement, + (node.node as HTMLCanvasElement).getContext('2d') as CanvasRenderingContext2D + ); + } + }, 500); + }); + call.on('error', (err) => console.error('canvas call error', err)); + }); + } + + clear() { + if (this.peer) { + // otherwise it calls reconnection on data chan close + const peer = this.peer; + this.peer = null; + peer.disconnect(); + peer.destroy(); + } + } +} + +function spawnVideo(stream: MediaStream, node: VElement) { + const videoEl = document.createElement('video'); + + videoEl.srcObject = stream + videoEl.setAttribute('autoplay', 'true'); + videoEl.setAttribute('muted', 'true'); + videoEl.setAttribute('playsinline', 'true'); + videoEl.setAttribute('crossorigin', 'anonymous'); + void videoEl.play(); + + return videoEl; +} + +function spawnDebugVideo(stream: MediaStream, node: VElement) { + const video = document.createElement('video'); + video.id = 'canvas-or-testing'; + video.style.border = '1px solid red'; + video.setAttribute('autoplay', 'true'); + video.setAttribute('muted', 'true'); + video.setAttribute('playsinline', 'true'); + video.setAttribute('crossorigin', 'anonymous'); + + const coords = node.node.getBoundingClientRect(); + + Object.assign(video.style, { + position: 'absolute', + left: `${coords.left}px`, + top: `${coords.top}px`, + width: `${coords.width}px`, + height: `${coords.height}px`, + }); + video.width = coords.width; + video.height = coords.height; + video.srcObject = stream; + + document.body.appendChild(video); + video + .play() + .then(() => { + console.log('started streaming canvas'); + }) + .catch((e) => { + console.error(e); + const waiter = () => { + void video.play(); + document.removeEventListener('click', waiter); + }; + document.addEventListener('click', waiter); + }); +} + +/** simple peer example + * // @ts-ignore + * const peer = new SLPeer({ initiator: false }) + * socket.on('c_signal', ({ data }) => { + * console.log('got signal', data) + * peer.signal(data.data); + * peer.canvasId = data.id; + * }); + * + * peer.on('signal', (data: any) => { + * socket.emit('c_signal', data); + * }); + * peer.on('stream', (stream: MediaStream) => { + * console.log('stream ready', stream, peer.canvasId); + * this.streams.set(peer.canvasId, stream) + * setTimeout(() => { + * const node = this.getNode(peer.canvasId) + * console.log(peer.canvasId, this.streams, node) + * spawnVideo(this.streams.get(peer.canvasId)?.clone(), node, this.screen) + * }, 500) + * }) + * peer.on('error', console.error) + * + * */ diff --git a/frontend/app/player/web/managers/CanvasManager.ts b/frontend/app/player/web/managers/CanvasManager.ts new file mode 100644 index 000000000..051e5e044 --- /dev/null +++ b/frontend/app/player/web/managers/CanvasManager.ts @@ -0,0 +1,53 @@ +import { VElement } from "Player/web/managers/DOM/VirtualDOM"; + +export default class CanvasManager { + private fileData: string | undefined; + private canvasEl: HTMLVideoElement + private canvasCtx: CanvasRenderingContext2D | null = null; + private videoTag = document.createElement('video') + private lastTs = 0; + + constructor( + private readonly nodeId: string, + private readonly delta: number, + private readonly filename: string, + private readonly getNode: (id: number) => VElement | undefined) { + // getting mp4 file composed from canvas snapshot images + fetch(this.filename).then((r) => { + if (r.status === 200) { + r.blob().then((blob) => { + this.fileData = URL.createObjectURL(blob); + }) + } else { + return Promise.reject(`File ${this.filename} not found`) + } + }).catch(console.error) + } + + startVideo = () => { + if (!this.fileData) return; + this.videoTag.setAttribute('autoplay', 'true'); + this.videoTag.setAttribute('muted', 'true'); + this.videoTag.setAttribute('playsinline', 'true'); + this.videoTag.setAttribute('crossorigin', 'anonymous'); + this.videoTag.src = this.fileData; + this.videoTag.currentTime = 0; + + const node = this.getNode(parseInt(this.nodeId, 10)) as unknown as VElement + this.canvasCtx = (node.node as HTMLCanvasElement).getContext('2d'); + this.canvasEl = node.node as HTMLVideoElement; + } + + move(t: number) { + if (t - this.lastTs < 100) return; + this.lastTs = t; + const playTime = t - this.delta + if (playTime > 0) { + if (!this.videoTag.paused) { + void this.videoTag.pause() + } + this.videoTag.currentTime = playTime/1000; + this.canvasCtx?.drawImage(this.videoTag, 0, 0, this.canvasEl.width, this.canvasEl.height); + } + } +} \ No newline at end of file diff --git a/frontend/app/player/web/managers/DOM/DOMManager.ts b/frontend/app/player/web/managers/DOM/DOMManager.ts index 18700fd50..baa30d300 100644 --- a/frontend/app/player/web/managers/DOM/DOMManager.ts +++ b/frontend/app/player/web/managers/DOM/DOMManager.ts @@ -113,6 +113,10 @@ export default class DOMManager extends ListWalker { return false; } + public getNode(id: number) { + return this.vElements.get(id) || this.vTexts.get(id) + } + private insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void { const child = this.vElements.get(id) || this.vTexts.get(id) if (!child) { @@ -208,7 +212,8 @@ export default class DOMManager extends ListWalker { return } case MType.CreateElementNode: { - const vElem = new VElement(msg.tag, msg.svg) + // if (msg.tag.toLowerCase() === 'canvas') msg.tag = 'video' + const vElem = new VElement(msg.tag, msg.svg, msg.index) if (['STYLE', 'style', 'LINK'].includes(msg.tag)) { vElem.prioritized = true } diff --git a/frontend/app/player/web/managers/DOM/VirtualDOM.ts b/frontend/app/player/web/managers/DOM/VirtualDOM.ts index 071b89a8f..fccb5816d 100644 --- a/frontend/app/player/web/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/web/managers/DOM/VirtualDOM.ts @@ -144,7 +144,7 @@ export class VElement extends VParent { parentNode: VParent | null = null /** Should be modified only by he parent itself */ private newAttributes: Map = new Map() - constructor(readonly tagName: string, readonly isSVG = false) { super() } + constructor(readonly tagName: string, readonly isSVG = false, public readonly index: number) { super() } protected createNode() { try { return this.isSVG diff --git a/frontend/app/player/web/managers/PagesManager.ts b/frontend/app/player/web/managers/PagesManager.ts index 18722f962..4569c1648 100644 --- a/frontend/app/player/web/managers/PagesManager.ts +++ b/frontend/app/player/web/managers/PagesManager.ts @@ -56,6 +56,10 @@ export default class PagesManager extends ListWalker { this.forEach(page => page.sort(comparator)) } + public getNode(id: number) { + return this.currentPage?.getNode(id) + } + moveReady(t: number): Promise { const requiredPage = this.moveGetLast(t) if (requiredPage != null) { diff --git a/frontend/app/player/web/messages/RawMessageReader.gen.ts b/frontend/app/player/web/messages/RawMessageReader.gen.ts index 6edd13fce..793d3967e 100644 --- a/frontend/app/player/web/messages/RawMessageReader.gen.ts +++ b/frontend/app/player/web/messages/RawMessageReader.gen.ts @@ -713,6 +713,16 @@ export default class RawMessageReader extends PrimitiveReader { }; } + case 119: { + const nodeId = this.readString(); if (nodeId === null) { return resetPointer() } + const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } + return { + tp: MType.CanvasNode, + nodeId, + timestamp, + }; + } + case 93: { const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const length = this.readUint(); if (length === null) { return resetPointer() } diff --git a/frontend/app/player/web/messages/filters.gen.ts b/frontend/app/player/web/messages/filters.gen.ts index 1c8a091c6..eca3afb64 100644 --- a/frontend/app/player/web/messages/filters.gen.ts +++ b/frontend/app/player/web/messages/filters.gen.ts @@ -4,7 +4,7 @@ import { MType } from './raw.gen' const IOS_TYPES = [90,91,92,93,94,95,96,97,98,100,101,102,103,104,105,106,107,110,111] -const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118] +const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118,119] export function isDOMType(t: MType) { return DOM_TYPES.includes(t) } \ No newline at end of file diff --git a/frontend/app/player/web/messages/message.gen.ts b/frontend/app/player/web/messages/message.gen.ts index ef7922b71..efc5d6cd6 100644 --- a/frontend/app/player/web/messages/message.gen.ts +++ b/frontend/app/player/web/messages/message.gen.ts @@ -61,6 +61,7 @@ import type { RawResourceTiming, RawTabChange, RawTabData, + RawCanvasNode, RawIosEvent, RawIosScreenChanges, RawIosClickEvent, @@ -190,6 +191,8 @@ export type TabChange = RawTabChange & Timed export type TabData = RawTabData & Timed +export type CanvasNode = RawCanvasNode & Timed + export type IosEvent = RawIosEvent & Timed export type IosScreenChanges = RawIosScreenChanges & Timed diff --git a/frontend/app/player/web/messages/raw.gen.ts b/frontend/app/player/web/messages/raw.gen.ts index d910ee949..f808a7713 100644 --- a/frontend/app/player/web/messages/raw.gen.ts +++ b/frontend/app/player/web/messages/raw.gen.ts @@ -59,6 +59,7 @@ export const enum MType { ResourceTiming = 116, TabChange = 117, TabData = 118, + CanvasNode = 119, IosEvent = 93, IosScreenChanges = 96, IosClickEvent = 100, @@ -476,6 +477,12 @@ export interface RawTabData { tabId: string, } +export interface RawCanvasNode { + tp: MType.CanvasNode, + nodeId: string, + timestamp: number, +} + export interface RawIosEvent { tp: MType.IosEvent, timestamp: number, @@ -568,4 +575,4 @@ export interface RawIosIssueEvent { } -export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosInternalError | RawIosNetworkCall | RawIosSwipeEvent | RawIosIssueEvent; +export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawCanvasNode | RawIosEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosInternalError | RawIosNetworkCall | RawIosSwipeEvent | RawIosIssueEvent; diff --git a/frontend/app/player/web/messages/tracker-legacy.gen.ts b/frontend/app/player/web/messages/tracker-legacy.gen.ts index 10eae3770..5ee4cf6a0 100644 --- a/frontend/app/player/web/messages/tracker-legacy.gen.ts +++ b/frontend/app/player/web/messages/tracker-legacy.gen.ts @@ -60,6 +60,7 @@ export const TP_MAP = { 116: MType.ResourceTiming, 117: MType.TabChange, 118: MType.TabData, + 119: MType.CanvasNode, 93: MType.IosEvent, 96: MType.IosScreenChanges, 100: MType.IosClickEvent, diff --git a/frontend/app/player/web/messages/tracker.gen.ts b/frontend/app/player/web/messages/tracker.gen.ts index f389e4af2..d7042a355 100644 --- a/frontend/app/player/web/messages/tracker.gen.ts +++ b/frontend/app/player/web/messages/tracker.gen.ts @@ -493,8 +493,14 @@ type TrTabData = [ tabId: string, ] +type TrCanvasNode = [ + type: 119, + nodeId: string, + timestamp: number, +] -export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData + +export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData | TrCanvasNode export default function translate(tMsg: TrackerMessage): RawMessage | null { switch(tMsg[0]) { @@ -992,6 +998,14 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null { } } + case 119: { + return { + tp: MType.CanvasNode, + nodeId: tMsg[1], + timestamp: tMsg[2], + } + } + default: return null } diff --git a/frontend/app/types/session/session.ts b/frontend/app/types/session/session.ts index 67eff4f8f..8700876ea 100644 --- a/frontend/app/types/session/session.ts +++ b/frontend/app/types/session/session.ts @@ -79,6 +79,7 @@ export interface ISession { metadata: []; favorite: boolean; filterId?: string; + canvasURL: string[]; domURL: string[]; devtoolsURL: string[]; /** @@ -148,6 +149,7 @@ const emptyValues = { devtoolsURL: [], mobsUrl: [], notes: [], + canvasURL: [], metadata: {}, startedAt: 0, platform: 'web', @@ -160,6 +162,7 @@ export default class Session { siteId: ISession['siteId']; projectKey: ISession['projectKey']; peerId: ISession['peerId']; + canvasURL: ISession['canvasURL']; live: ISession['live']; startedAt: ISession['startedAt']; duration: ISession['duration']; @@ -234,6 +237,7 @@ export default class Session { mobsUrl = [], crashes = [], notes = [], + canvasURL = [], ...session } = sessionData; const duration = Duration.fromMillis(session.duration < 1000 ? 1000 : session.duration); @@ -325,6 +329,7 @@ export default class Session { domURL, devtoolsURL, notes, + canvasURL, notesWithEvents: mixedEventsWithIssues, frustrations: frustrationList, }); diff --git a/frontend/app/window.d.ts b/frontend/app/window.d.ts new file mode 100644 index 000000000..26cd12261 --- /dev/null +++ b/frontend/app/window.d.ts @@ -0,0 +1,14 @@ +declare global { + interface Window { + env: { + NODE_ENV: string + ORIGIN: string + ASSETS_HOST: string + API_EDP: string + VERSION: string + TRACKER_VERSION: string + TRACKER_HOST: string + SOURCEMAP: boolean + } + } +} diff --git a/frontend/package.json b/frontend/package.json index e6a29f7f4..0b1c79fca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@ant-design/icons": "^5.2.5", + "@babel/plugin-transform-private-methods": "^7.23.3", "@floating-ui/react-dom-interactions": "^0.10.3", "@sentry/browser": "^5.21.1", "@svg-maps/world": "^1.0.1", @@ -89,7 +90,7 @@ "@babel/plugin-proposal-decorators": "^7.23.2", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-transform-class-properties": "^7.22.5", + "@babel/plugin-transform-class-properties": "^7.23.3", "@babel/plugin-transform-private-property-in-object": "^7.22.11", "@babel/plugin-transform-runtime": "^7.17.12", "@babel/preset-env": "^7.23.2", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b4cc35011..9290418de 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1626,6 +1626,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-class-properties@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-transform-class-properties@npm:7.23.3" + dependencies: + "@babel/helper-create-class-features-plugin": ^7.22.15 + "@babel/helper-plugin-utils": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: bca30d576f539eef216494b56d610f1a64aa9375de4134bc021d9660f1fa735b1d7cc413029f22abc0b7cb737e3a57935c8ae9d8bd1730921ccb1deebce51bfd + languageName: node + linkType: hard + "@babel/plugin-transform-class-static-block@npm:^7.22.11": version: 7.22.11 resolution: "@babel/plugin-transform-class-static-block@npm:7.22.11" @@ -2227,6 +2239,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-private-methods@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-transform-private-methods@npm:7.23.3" + dependencies: + "@babel/helper-create-class-features-plugin": ^7.22.15 + "@babel/helper-plugin-utils": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 745a655edcd111b7f91882b921671ca0613079760d8c9befe336b8a9bc4ce6bb49c0c08941831c950afb1b225b4b2d3eaac8842e732db095b04db38efd8c34f4 + languageName: node + linkType: hard + "@babel/plugin-transform-private-property-in-object@npm:^7.22.11": version: 7.22.11 resolution: "@babel/plugin-transform-private-property-in-object@npm:7.22.11" @@ -19630,7 +19654,8 @@ __metadata: "@babel/plugin-proposal-decorators": ^7.23.2 "@babel/plugin-proposal-private-methods": ^7.18.6 "@babel/plugin-syntax-bigint": ^7.8.3 - "@babel/plugin-transform-class-properties": ^7.22.5 + "@babel/plugin-transform-class-properties": ^7.23.3 + "@babel/plugin-transform-private-methods": ^7.23.3 "@babel/plugin-transform-private-property-in-object": ^7.22.11 "@babel/plugin-transform-runtime": ^7.17.12 "@babel/preset-env": ^7.23.2 diff --git a/mobs/messages.rb b/mobs/messages.rb index 4682538f5..538f1eeb4 100644 --- a/mobs/messages.rb +++ b/mobs/messages.rb @@ -504,6 +504,11 @@ message 118, 'TabData' do string 'TabId' end +message 119, 'CanvasNode' do + string 'NodeId' + uint 'Timestamp' +end + ## Backend-only message 125, 'IssueEvent', :replayer => false, :tracker => false do uint 'MessageID' diff --git a/tracker/.husky/pre-commit b/tracker/.husky/pre-commit index 0200dbbae..ae22cb74b 100755 --- a/tracker/.husky/pre-commit +++ b/tracker/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -if git diff --cached --name-only | grep --quiet '^tracker/tracker/' +if git diff --cached --name-only | grep -v '\.gen\.ts$' | grep --quiet '^tracker/tracker/' then echo "tracker" pwd @@ -12,7 +12,7 @@ then cd ../../ fi -if git diff --cached --name-only | grep --quiet '^tracker/tracker-assist/' +if git diff --cached --name-only | grep -v '\.gen\.ts$' | grep --quiet '^tracker/tracker-assist/' then echo "tracker-assist" cd tracker/tracker-assist diff --git a/tracker/tracker-assist/bun.lockb b/tracker/tracker-assist/bun.lockb index 06287d996e625d42ffa1a911792f07ebdead5c0b..2eb6b31b25a2ffb72ad67d9d988a377e4fcc7186 100755 GIT binary patch delta 29291 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.0.0" + "@openreplay/tracker": ">=10.0.3" }, "devDependencies": { "@openreplay/tracker": "file:../tracker", diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index a52bad921..b70e5ad1e 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import {hasTag,} from '@openreplay/tracker/lib/app/guards' import type { Socket, } from 'socket.io-client' import { connect, } from 'socket.io-client' import Peer, { MediaConnection, } from 'peerjs' @@ -14,6 +15,7 @@ import { callConfirmDefault, } from './ConfirmWindow/defaults.js' import type { Options as ConfirmOptions, } from './ConfirmWindow/defaults.js' import ScreenRecordingState from './ScreenRecordingState.js' import { pkgVersion, } from './version.js' +import Canvas from './Canvas.js' // TODO: fully specified strict check with no-any (everywhere) // @ts-ignore @@ -21,6 +23,14 @@ const safeCastedPeer = Peer.default || Peer type StartEndCallback = (agentInfo?: Record) => ((() => any) | void) +interface AgentInfo { + email: string; + id: number + name: string + peerId: string + query: string +} + export interface Options { onAgentConnect: StartEndCallback; onCallStart: StartEndCallback; @@ -58,7 +68,7 @@ type OptionalCallback = (()=>Record) | void type Agent = { onDisconnect?: OptionalCallback, onControlReleased?: OptionalCallback, - agentInfo: Record | undefined + agentInfo: AgentInfo | undefined // } @@ -68,12 +78,15 @@ export default class Assist { private socket: Socket | null = null private peer: Peer | null = null + private canvasPeer: Peer | null = null private assistDemandedRestart = false private callingState: CallingState = CallingState.False private remoteControl: RemoteControl | null = null; private agents: Record = {} private readonly options: Options + private readonly canvasMap: Map = new Map() + constructor( private readonly app: App, options?: Partial, @@ -151,13 +164,14 @@ export default class Assist { } return '' } + private onStart() { const app = this.app const sessionId = app.getSessionID() // Common for all incoming call requests let callUI: CallWindow | null = null let annot: AnnotationCanvas | null = null - // TODO: incapsulate + // TODO: encapsulate let callConfirmWindow: ConfirmWindow | null = null let callConfirmAnswer: Promise | null = null let callEndCallback: ReturnType | null = null @@ -190,7 +204,7 @@ export default class Assist { app.debug.log('Socket:', ...args) }) - const onGrand = (id) => { + const onGrand = (id: string) => { if (!callUI) { callUI = new CallWindow(app.debug.error, this.options.callUITemplate) } @@ -203,7 +217,7 @@ export default class Assist { annot.mount() return callingAgents.get(id) } - const onRelease = (id, isDenied) => { + const onRelease = (id?: string | null, isDenied?: boolean) => { { if (id) { const cb = this.agents[id].onControlReleased @@ -237,7 +251,7 @@ export default class Assist { const onAcceptRecording = () => { socket.emit('recording_accepted') } - const onRejectRecording = (agentData) => { + const onRejectRecording = (agentData: AgentInfo) => { socket.emit('recording_rejected') this.options.onRecordingDeny?.(agentData || {}) @@ -276,7 +290,7 @@ export default class Assist { socket.on('startAnnotation', (id, event) => processEvent(id, event, (_, d) => annot?.start(d))) socket.on('stopAnnotation', (id, event) => processEvent(id, event, annot?.stop)) - socket.on('NEW_AGENT', (id: string, info) => { + socket.on('NEW_AGENT', (id: string, info: AgentInfo) => { this.agents[id] = { onDisconnect: this.options.onAgentConnect?.(info), agentInfo: info, // TODO ? @@ -386,7 +400,7 @@ export default class Assist { host: this.getHost(), path: this.getBasePrefixUrl()+'/assist', port: location.protocol === 'http:' && this.noSecureMode ? 80 : 443, - //debug: appOptions.__debug_log ? 2 : 0, // 0 Print nothing //1 Prints only errors. / 2 Prints errors and warnings. / 3 Prints all logs. + debug: 2, //appOptions.__debug_log ? 2 : 0, // 0 Print nothing //1 Prints only errors. / 2 Prints errors and warnings. / 3 Prints all logs. } if (this.options.config) { peerOptions['config'] = this.options.config @@ -547,6 +561,38 @@ export default class Assist { app.debug.log(reason) }) }) + + app.nodes.attachNodeCallback((node) => { + const id = app.nodes.getID(node) + if (id && hasTag(node, 'canvas')) { + const canvasPId = `${app.getProjectKey()}-${sessionId}-${id}` + if (!this.canvasPeer) this.canvasPeer = new safeCastedPeer(canvasPId, peerOptions) as Peer + const canvasHandler = new Canvas( + node as unknown as HTMLCanvasElement, + id, + 30, + (stream: MediaStream) => { + Object.values(this.agents).forEach(agent => { + if (agent.agentInfo) { + const target = `${agent.agentInfo.peerId}-${agent.agentInfo.id}-canvas` + const connection = this.canvasPeer?.connect(target) + connection?.on('open', () => { + if (agent.agentInfo) { + const pCall = this.canvasPeer?.call(target, stream) + pCall?.on('error', app.debug.error) + } + }) + connection?.on('error', app.debug.error) + this.canvasPeer?.on('error', app.debug.error) + } else { + app.debug.error('Assist: cant establish canvas peer to agent, no agent info') + } + }) + }, + ) + this.canvasMap.set(id, canvasHandler) + } + }) } private playNotificationSound() { @@ -572,3 +618,16 @@ export default class Assist { } } } + +/** simple peers impl + * const slPeer = new SLPeer({ initiator: true, stream: stream, }) + * // slPeer.on('signal', (data: any) => { + * // this.emit('c_signal', { data, id, }) + * // }) + * // this.socket?.on('c_signal', (tab: string, data: any) => { + * // console.log(data) + * // slPeer.signal(data) + * // }) + * // slPeer.on('error', console.error) + * // this.emit('canvas_stream', { canvasId, }) + * */ \ No newline at end of file diff --git a/tracker/tracker-assist/src/Canvas.ts b/tracker/tracker-assist/src/Canvas.ts new file mode 100644 index 000000000..2faba0a09 --- /dev/null +++ b/tracker/tracker-assist/src/Canvas.ts @@ -0,0 +1,61 @@ + +export default class CanvasRecorder { + stream: MediaStream | null; + + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly canvasId: number, + private readonly fps: number, + private readonly onStream: (stream: MediaStream) => void + ) { + this.canvas.getContext('2d', { alpha: true, }) + const stream = this.canvas.captureStream(this.fps) + this.emitStream(stream) + } + + restart() { + // this.stop() + const stream = this.canvas.captureStream(this.fps) + this.stream = stream + this.emitStream(stream) + } + + toggleLocal(stream: MediaStream) { + const possibleVideoEl = document.getElementById('canvas-or-testing') + if (possibleVideoEl) { + document.body.removeChild(possibleVideoEl) + } + const video = document.createElement('video') + video.width = 520 + video.height = 400 + video.id = 'canvas-or-testing' + video.setAttribute('autoplay', 'true') + video.setAttribute('muted', 'true') + video.setAttribute('playsinline', 'true') + video.crossOrigin = 'anonymous' + document.body.appendChild(video) + + video.srcObject = stream + + void video.play() + video.addEventListener('error', (e) => { + console.error('Video error:', e) + }) + } + + emitStream(stream?: MediaStream) { + if (stream) { + return this.onStream(stream) + } + if (this.stream) { + this.onStream(this.stream) + } else { + console.error('no stream for canvas', this.canvasId) + } + } + + stop() { + this.stream?.getTracks().forEach((track) => track.stop()) + this.stream = null + } +} diff --git a/tracker/tracker-assist/src/version.ts b/tracker/tracker-assist/src/version.ts index 819bfe0a6..cfebd79bf 100644 --- a/tracker/tracker-assist/src/version.ts +++ b/tracker/tracker-assist/src/version.ts @@ -1 +1 @@ -export const pkgVersion = '6.0.3' +export const pkgVersion = '6.0.4-57' diff --git a/tracker/tracker/bun.lockb b/tracker/tracker/bun.lockb index b4c48d4f86e47718e7f2b400d9bc6ba4b8125a57..8e730cd9eaab84c4ff81eb16997ecd77f9f1d69c 100755 GIT binary patch delta 24068 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 delta 28045 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" + "Aleksandr K ", + "Nikita D " ], "license": "MIT", "type": "module", diff --git a/tracker/tracker/src/common/messages.gen.ts b/tracker/tracker/src/common/messages.gen.ts index 792b27888..46eda477e 100644 --- a/tracker/tracker/src/common/messages.gen.ts +++ b/tracker/tracker/src/common/messages.gen.ts @@ -71,6 +71,7 @@ export declare const enum Type { ResourceTiming = 116, TabChange = 117, TabData = 118, + CanvasNode = 119, } @@ -562,6 +563,12 @@ export type TabData = [ /*tabId:*/ string, ] +export type CanvasNode = [ + /*type:*/ Type.CanvasNode, + /*nodeId:*/ string, + /*timestamp:*/ number, +] -type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData + +type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode export default Message diff --git a/tracker/tracker/src/main/app/canvas.ts b/tracker/tracker/src/main/app/canvas.ts new file mode 100644 index 000000000..3163ea097 --- /dev/null +++ b/tracker/tracker/src/main/app/canvas.ts @@ -0,0 +1,130 @@ +import App from '../app/index.js' +import { hasTag } from './guards.js' +import Message, { CanvasNode } from './messages.gen.js' + +interface CanvasSnapshot { + images: { data: string; id: number }[] + createdAt: number +} + +interface Options { + fps: number + quality: 'low' | 'medium' | 'high' +} + +class CanvasRecorder { + private snapshots: Record = {} + private readonly intervals: NodeJS.Timeout[] = [] + private readonly interval: number + + constructor( + private readonly app: App, + private readonly options: Options, + ) { + this.interval = 1000 / options.fps + } + + startTracking() { + this.app.nodes.attachNodeCallback((node: Node): void => { + const id = this.app.nodes.getID(node) + if (!id || !hasTag(node, 'canvas') || this.snapshots[id]) { + return + } + const ts = this.app.timestamp() + this.snapshots[id] = { + images: [], + createdAt: ts, + } + const canvasMsg = CanvasNode(id.toString(), ts) + this.app.send(canvasMsg as Message) + const int = setInterval(() => { + const cid = this.app.nodes.getID(node) + const canvas = cid ? this.app.nodes.getNode(cid) : undefined + if (!canvas || !hasTag(canvas, 'canvas') || canvas !== node) { + console.log('Canvas element not in sync') + clearInterval(int) + } else { + const snapshot = captureSnapshot(canvas, this.options.quality) + this.snapshots[id].images.push({ id: this.app.timestamp(), data: snapshot }) + if (this.snapshots[id].images.length > 9) { + this.sendSnaps(this.snapshots[id].images, id, this.snapshots[id].createdAt) + this.snapshots[id].images = [] + } + } + }, this.interval) + this.intervals.push(int) + }) + } + + sendSnaps(images: { data: string; id: number }[], canvasId: number, createdAt: number) { + if (Object.keys(this.snapshots).length === 0) { + console.log(this.snapshots) + return + } + const formData = new FormData() + images.forEach((snapshot) => { + const blob = dataUrlToBlob(snapshot.data)[0] + formData.append('snapshot', blob, `${createdAt}_${canvasId}_${snapshot.id}.jpeg`) + // saveImageData(snapshot.data, `${createdAt}_${canvasId}_${snapshot.id}.jpeg`) + }) + + fetch(this.app.options.ingestPoint + '/v1/web/images', { + method: 'POST', + headers: { + Authorization: `Bearer ${this.app.session.getSessionToken() ?? ''}`, + }, + body: formData, + }) + .then((r) => { + console.log('done', r) + }) + .catch((e) => { + console.error('error saving canvas', e) + }) + } + + clear() { + console.log('cleaning up') + this.intervals.forEach((int) => clearInterval(int)) + this.snapshots = {} + } +} + +const qualityInt = { + low: 0.33, + medium: 0.55, + high: 0.8, +} + +function captureSnapshot(canvas: HTMLCanvasElement, quality: 'low' | 'medium' | 'high' = 'medium') { + const imageFormat = 'image/jpeg' // or /png' + return canvas.toDataURL(imageFormat, qualityInt[quality]) +} + +function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] { + const [header, base64] = dataUrl.split(',') + // @ts-ignore + const mime = header.match(/:(.*?);/)[1] + const blobStr = atob(base64) + let n = blobStr.length + const u8arr = new Uint8Array(n) + + while (n--) { + u8arr[n] = blobStr.charCodeAt(n) + } + + return [new Blob([u8arr], { type: mime }), u8arr] +} + +function saveImageData(imageDataUrl: string, name: string) { + const link = document.createElement('a') + link.href = imageDataUrl + link.download = name + link.style.display = 'none' + + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +export default CanvasRecorder diff --git a/tracker/tracker/src/main/app/guards.ts b/tracker/tracker/src/main/app/guards.ts index 5379f9387..11edb1dd1 100644 --- a/tracker/tracker/src/main/app/guards.ts +++ b/tracker/tracker/src/main/app/guards.ts @@ -38,6 +38,7 @@ type TagTypeMap = { iframe: HTMLIFrameElement style: HTMLStyleElement | SVGStyleElement link: HTMLLinkElement + canvas: HTMLCanvasElement } export function hasTag( el: Node, diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index 13499a669..068e1dabf 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -23,6 +23,7 @@ import type { Options as SanitizerOptions } from './sanitizer.js' import type { Options as LoggerOptions } from './logger.js' import type { Options as SessOptions } from './session.js' import type { Options as NetworkOptions } from '../modules/network.js' +import CanvasRecorder from './canvas.js' import type { Options as WebworkerOptions, @@ -142,6 +143,12 @@ export default class App { private readonly bc: BroadcastChannel | null = null private readonly contextId public attributeSender: AttributeSender + private canvasRecorder: CanvasRecorder | null = null + private canvasOptions = { + canvasEnabled: false, + canvasQuality: 'medium', + canvasFPS: 1, + } constructor(projectKey: string, sessionToken: string | undefined, options: Partial) { // if (options.onStart !== undefined) { @@ -631,6 +638,9 @@ export default class App { userDevice, userOS, userState, + canvasEnabled, + canvasQuality, + canvasFPS, } = r if ( typeof token !== 'string' || @@ -675,13 +685,19 @@ export default class App { }) this.compressionThreshold = compressionThreshold - const onStartInfo = { sessionToken: token, userUUID, sessionID } // TODO: start as early as possible (before receiving the token) this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed) this.observer.observe() this.ticker.start() + + if (canvasEnabled) { + this.canvasRecorder = + this.canvasRecorder ?? + new CanvasRecorder(this, { fps: canvasFPS, quality: canvasQuality }) + this.canvasRecorder.startTracking() + } this.activityState = ActivityState.Active this.notify.log('OpenReplay tracking started.') @@ -752,6 +768,7 @@ export default class App { if (this.worker && stopWorker) { this.worker.postMessage('stop') } + this.canvasRecorder?.clear() } finally { this.activityState = ActivityState.NotActive } diff --git a/tracker/tracker/src/main/app/messages.gen.ts b/tracker/tracker/src/main/app/messages.gen.ts index 2e3199248..f186a5f27 100644 --- a/tracker/tracker/src/main/app/messages.gen.ts +++ b/tracker/tracker/src/main/app/messages.gen.ts @@ -912,3 +912,14 @@ export function TabData( ] } +export function CanvasNode( + nodeId: string, + timestamp: number, +): Messages.CanvasNode { + return [ + Messages.Type.CanvasNode, + nodeId, + timestamp, + ] +} + diff --git a/tracker/tracker/src/tests/guards.test.ts b/tracker/tracker/src/tests/guards.test.ts new file mode 100644 index 000000000..c34775db1 --- /dev/null +++ b/tracker/tracker/src/tests/guards.test.ts @@ -0,0 +1,71 @@ +import { describe, beforeEach, expect, test } from '@jest/globals' +import { + isNode, + isSVGElement, + isElementNode, + isCommentNode, + isTextNode, + isDocument, + isRootNode, + hasTag, +} from '../main/app/guards' + +describe('DOM utility functions', () => { + let elementNode: Element + let commentNode: Comment + let textNode: Text + let documentNode: Document + let fragmentNode: DocumentFragment + let svgElement: SVGElement + + beforeEach(() => { + elementNode = document.createElement('div') + commentNode = document.createComment('This is a comment') + textNode = document.createTextNode('This is text') + documentNode = document + fragmentNode = document.createDocumentFragment() + svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + }) + + test('isNode', () => { + expect(isNode(elementNode)).toBeTruthy() + expect(isNode(null)).toBeFalsy() + }) + + test('isSVGElement', () => { + expect(isSVGElement(svgElement)).toBeTruthy() + expect(isSVGElement(elementNode)).toBeFalsy() + }) + + test('isElementNode', () => { + expect(isElementNode(elementNode)).toBeTruthy() + expect(isElementNode(textNode)).toBeFalsy() + }) + + test('isCommentNode', () => { + expect(isCommentNode(commentNode)).toBeTruthy() + expect(isCommentNode(elementNode)).toBeFalsy() + }) + + test('isTextNode', () => { + expect(isTextNode(textNode)).toBeTruthy() + expect(isTextNode(elementNode)).toBeFalsy() + }) + + test('isDocument', () => { + expect(isDocument(documentNode)).toBeTruthy() + expect(isDocument(elementNode)).toBeFalsy() + }) + + test('isRootNode', () => { + expect(isRootNode(documentNode)).toBeTruthy() + expect(isRootNode(fragmentNode)).toBeTruthy() + expect(isRootNode(elementNode)).toBeFalsy() + }) + + test('hasTag', () => { + const imgElement = document.createElement('img') + expect(hasTag(imgElement, 'img')).toBeTruthy() + expect(hasTag(elementNode, 'img')).toBeFalsy() + }) +}) diff --git a/tracker/tracker/src/main/app/nodes.unit.test.ts b/tracker/tracker/src/tests/nodes.unit.test.ts similarity index 81% rename from tracker/tracker/src/main/app/nodes.unit.test.ts rename to tracker/tracker/src/tests/nodes.unit.test.ts index 8b32f7916..213b83b6b 100644 --- a/tracker/tracker/src/main/app/nodes.unit.test.ts +++ b/tracker/tracker/src/tests/nodes.unit.test.ts @@ -1,5 +1,5 @@ -import Nodes from './nodes' -import { describe, beforeEach, expect, it, jest } from '@jest/globals' +import Nodes from '../main/app/nodes' +import { describe, beforeEach, expect, test, jest } from '@jest/globals' describe('Nodes', () => { let nodes: Nodes @@ -11,13 +11,13 @@ describe('Nodes', () => { mockCallback.mockClear() }) - it('attachNodeCallback', () => { + test('attachNodeCallback', () => { nodes.attachNodeCallback(mockCallback) nodes.callNodeCallbacks(document.createElement('div'), true) expect(mockCallback).toHaveBeenCalled() }) - it('attachNodeListener is listening to events', () => { + test('attachNodeListener is listening to events', () => { const node = document.createElement('div') const mockListener = jest.fn() document.body.appendChild(node) @@ -26,7 +26,7 @@ describe('Nodes', () => { node.dispatchEvent(new Event('click')) expect(mockListener).toHaveBeenCalled() }) - it('attachNodeListener is calling native method', () => { + test('attachNodeListener is calling native method', () => { const node = document.createElement('div') const mockListener = jest.fn() const addEventListenerSpy = jest.spyOn(node, 'addEventListener') @@ -36,55 +36,55 @@ describe('Nodes', () => { expect(addEventListenerSpy).toHaveBeenCalledWith('click', mockListener, true) }) - it('registerNode', () => { + test('registerNode', () => { const node = document.createElement('div') const [id, isNew] = nodes.registerNode(node) expect(id).toBeDefined() expect(isNew).toBe(true) }) - it('unregisterNode', () => { + test('unregisterNode', () => { const node = document.createElement('div') const [id] = nodes.registerNode(node) const unregisteredId = nodes.unregisterNode(node) expect(unregisteredId).toBe(id) }) - it('cleanTree', () => { + test('cleanTree', () => { const node = document.createElement('div') nodes.registerNode(node) nodes.cleanTree() expect(nodes.getNodeCount()).toBe(0) }) - it('callNodeCallbacks', () => { + test('callNodeCallbacks', () => { nodes.attachNodeCallback(mockCallback) const node = document.createElement('div') nodes.callNodeCallbacks(node, true) expect(mockCallback).toHaveBeenCalledWith(node, true) }) - it('getID', () => { + test('getID', () => { const node = document.createElement('div') const [id] = nodes.registerNode(node) const fetchedId = nodes.getID(node) expect(fetchedId).toBe(id) }) - it('getNode', () => { + test('getNode', () => { const node = document.createElement('div') const [id] = nodes.registerNode(node) const fetchedNode = nodes.getNode(id) expect(fetchedNode).toBe(node) }) - it('getNodeCount', () => { + test('getNodeCount', () => { expect(nodes.getNodeCount()).toBe(0) nodes.registerNode(document.createElement('div')) expect(nodes.getNodeCount()).toBe(1) }) - it('clear', () => { + test('clear', () => { nodes.registerNode(document.createElement('div')) nodes.clear() expect(nodes.getNodeCount()).toBe(0) diff --git a/tracker/tracker/src/webworker/MessageEncoder.gen.ts b/tracker/tracker/src/webworker/MessageEncoder.gen.ts index a66283322..4ae5cfbd3 100644 --- a/tracker/tracker/src/webworker/MessageEncoder.gen.ts +++ b/tracker/tracker/src/webworker/MessageEncoder.gen.ts @@ -286,6 +286,10 @@ export default class MessageEncoder extends PrimitiveEncoder { return this.string(msg[1]) break + case Messages.Type.CanvasNode: + return this.string(msg[1]) && this.uint(msg[2]) + break + } }