port tracker-14 fixes to latest

This commit is contained in:
nick-delirium 2024-10-10 13:47:46 +02:00
parent d26e6ec098
commit 87d45714b1
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
10 changed files with 189 additions and 68 deletions

View file

@ -6,9 +6,10 @@ import {
} from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { Slider } from 'antd';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React, { useContext, useEffect, useRef, useState } from 'react';
import cn from 'classnames';
import { PlayerContext } from 'App/components/Session/playerContext';
function DropdownAudioPlayer({
@ -27,15 +28,24 @@ function DropdownAudioPlayer({
const fileLengths = useRef<Record<string, number>>({});
const { time = 0, speed = 1, playing, sessionStart } = store?.get() ?? {};
const files = React.useMemo(() => audioEvents.map((pa) => {
const data = pa.payload;
const nativeTs = data.timestamp
return {
url: data.url,
timestamp: data.timestamp,
start: nativeTs ? nativeTs : pa.timestamp - sessionStart,
};
}), [audioEvents.length, sessionStart])
const files = React.useMemo(
() =>
audioEvents.map((pa) => {
const data = pa.payload;
const nativeTs = data.timestamp;
const startTs = nativeTs
? nativeTs > sessionStart
? nativeTs - sessionStart
: nativeTs
: pa.timestamp - sessionStart;
return {
url: data.url,
timestamp: data.timestamp,
start: startTs,
};
}),
[audioEvents.length, sessionStart]
);
React.useEffect(() => {
Object.entries(audioRefs.current).forEach(([url, audio]) => {
@ -43,10 +53,10 @@ function DropdownAudioPlayer({
audio.loop = false;
audio.addEventListener('loadedmetadata', () => {
fileLengths.current[url] = audio.duration;
})
});
}
})
}, [audioRefs.current])
});
}, [audioRefs.current]);
const toggleMute = () => {
Object.values(audioRefs.current).forEach((audio) => {
@ -125,7 +135,7 @@ function DropdownAudioPlayer({
useEffect(() => {
const deltaMs = delta * 1000;
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs)
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs);
if (deltaTime >= 250) {
handleSeek(time);
}
@ -134,7 +144,7 @@ function DropdownAudioPlayer({
const file = files.find((f) => f.url === url);
const fileLength = fileLengths.current[url];
if (file) {
if (fileLength && (fileLength*1000)+file.start < time) {
if (fileLength && fileLength * 1000 + file.start < time) {
return;
}
if (time >= file.start) {
@ -155,8 +165,8 @@ function DropdownAudioPlayer({
if (audio) {
audio.muted = isMuted;
}
})
}, [isMuted])
});
}, [isMuted]);
useEffect(() => {
changePlaybackSpeed(speed);
@ -168,7 +178,7 @@ function DropdownAudioPlayer({
const file = files.find((f) => f.url === url);
const fileLength = fileLengths.current[url];
if (file) {
if (fileLength && (fileLength*1000)+file.start < time) {
if (fileLength && fileLength * 1000 + file.start < time) {
audio.pause();
return;
}
@ -183,7 +193,8 @@ function DropdownAudioPlayer({
setVolume(isMuted ? 0 : volume);
}, [playing]);
const buttonIcon = 'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit'
const buttonIcon =
'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit';
return (
<div className={'relative'}>
<div className={'flex items-center'} style={{ height: 24 }}>
@ -205,20 +216,14 @@ function DropdownAudioPlayer({
</div>
}
>
<div
className={
cn(buttonIcon, 'rounded-l')
}
>
<div className={cn(buttonIcon, 'rounded-l')}>
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
</div>
</Popover>
<div
onClick={toggleVisible}
style={{ marginLeft: -1 }}
className={
cn(buttonIcon, 'rounded-r')
}
className={cn(buttonIcon, 'rounded-r')}
>
<CaretDownOutlined />
</div>

View file

@ -1,5 +1,4 @@
import logger from 'App/logger';
import { resolveURL } from "../../messages/rewriter/urlResolve";
import type Screen from '../../Screen/Screen';
import type { Message, SetNodeScroll } from '../../messages';
@ -32,6 +31,8 @@ export default class DOMManager extends ListWalker<Message> {
private readonly vTexts: Map<number, VText> = new Map() // map vs object here?
private readonly vElements: Map<number, VElement> = new Map()
private readonly olVRoots: Map<number, OnloadVRoot> = new Map()
/** required to keep track of iframes, frameId : vnodeId */
private readonly iframeRoots: Record<number, number> = {}
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
* as well as <style> tag owned StyleSheets
*/
@ -219,6 +220,10 @@ export default class DOMManager extends ListWalker<Message> {
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) {
vElem.prioritized = true
}
if (this.vElements.has(msg.id)) {
logger.error("CreateElementNode: Node already exists", msg)
return
}
this.vElements.set(msg.id, vElem)
this.insertNode(msg)
this.removeBodyScroll(msg.id, vElem)
@ -316,6 +321,10 @@ export default class DOMManager extends ListWalker<Message> {
case MType.CreateIFrameDocument: {
const vElem = this.vElements.get(msg.frameID)
if (!vElem) { logger.error("CreateIFrameDocument: Node not found", msg); return }
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
this.olVRoots.delete(this.iframeRoots[msg.frameID])
}
this.iframeRoots[msg.frameID] = msg.id
const vRoot = OnloadVRoot.fromVElement(vElem)
vRoot.catch(e => logger.warn(e, msg))
this.olVRoots.set(msg.id, vRoot)

View file

@ -167,6 +167,12 @@ type AppOptions = {
}
network?: NetworkOptions
/**
* use this flag if you're using Angular
* basically goes around window.Zone api changes to mutation observer
* and event listeners
* */
angularMode?: boolean
} & WebworkerOptions &
SessOptions
@ -312,6 +318,7 @@ export default class App {
__save_canvas_locally: false,
useAnimationFrame: false,
},
angularMode: false,
}
this.options = simpleMerge(defaultOptions, options)
@ -329,7 +336,7 @@ export default class App {
this.localStorage = this.options.localStorage ?? window.localStorage
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage
this.sanitizer = new Sanitizer(this, options)
this.nodes = new Nodes(this.options.node_id)
this.nodes = new Nodes(this.options.node_id, Boolean(options.angularMode))
this.observer = new Observer(this, options)
this.ticker = new Ticker(this)
this.ticker.attach(() => this.commit())
@ -366,6 +373,7 @@ export default class App {
window.parent.postMessage(
{
line: proto.polling,
context: this.contextId,
},
'*',
)
@ -420,6 +428,7 @@ export default class App {
}
}
/** used by child iframes for crossdomain only */
/** used by child iframes for crossdomain only */
parentActive = false
checkStatus = () => {
@ -447,7 +456,6 @@ export default class App {
this.frameOderNumber = data.frameOrderNumber
this.debug.log('starting iframe tracking', data)
this.allowAppStart()
this.delay = data.frameTimeOffset
}
if (data.line === proto.killIframe) {
if (this.active()) {
@ -456,7 +464,11 @@ export default class App {
}
}
trackedFrames: number[] = []
/**
* context ids for iframes,
* order is not so important as long as its consistent
* */
trackedFrames: string[] = []
crossDomainIframeListener = (event: MessageEvent) => {
if (!this.active() || event.source === window) return
const { data } = event
@ -471,24 +483,34 @@ export default class App {
return console.error('Couldnt connect to event.source for child iframe tracking')
}
const id = await this.checkNodeId(pageIframes, event.source)
if (id && !this.trackedFrames.includes(id)) {
if (id && !this.trackedFrames.includes(data.context)) {
try {
this.trackedFrames.push(id)
this.trackedFrames.push(data.context)
await this.waitStarted()
const token = this.session.getSessionToken()
const order = this.trackedFrames.findIndex((f) => f === data.context) + 1
if (order === 0) {
this.debug.error(
'Couldnt get order number for iframe',
data.context,
this.trackedFrames,
)
}
const iframeData = {
line: proto.iframeId,
context: this.contextId,
id,
token,
frameOrderNumber: this.trackedFrames.length,
frameTimeOffset: this.timestamp(),
// since indexes go from 0 we +1
frameOrderNumber: order,
}
this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData)
// @ts-ignore
event.source?.postMessage(iframeData, '*')
} catch (e) {
console.error(e)
}
} else {
this.debug.log('Couldnt get node id for iframe', event.source, pageIframes)
}
}
void signalId()
@ -544,25 +566,42 @@ export default class App {
this.messages.push(...mappedMessages)
}
if (data.line === proto.polling) {
if (!this.pollingQueue.length) {
if (!this.pollingQueue.order.length) {
return
}
while (this.pollingQueue.length) {
const msg = this.pollingQueue.shift()
const nextCommand = this.pollingQueue.order[0]
if (this.pollingQueue[nextCommand].includes(data.context)) {
this.pollingQueue[nextCommand] = this.pollingQueue[nextCommand].filter(
(c: string) => c !== data.context,
)
// @ts-ignore
event.source?.postMessage({ line: msg }, '*')
event.source?.postMessage({ line: nextCommand }, '*')
if (this.pollingQueue[nextCommand].length === 0) {
this.pollingQueue.order.shift()
}
}
}
}
pollingQueue: string[] = []
/**
* { command : [remaining iframes] }
* + order of commands
**/
pollingQueue: Record<string, any> = {
order: [],
}
private readonly addCommand = (cmd: string) => {
this.pollingQueue.order.push(cmd)
this.pollingQueue[cmd] = [...this.trackedFrames]
}
public bootChildrenFrames = async () => {
await this.waitStarted()
this.pollingQueue.push(proto.startIframe)
this.addCommand(proto.startIframe)
}
public killChildrenFrames = () => {
this.pollingQueue.push(proto.killIframe)
this.addCommand(proto.killIframe)
}
signalIframeTracker = () => {
@ -753,6 +792,7 @@ export default class App {
if (this.worker === undefined || !this.messages.length) {
return
}
try {
requestIdleCb(() => {
this.messages.unshift(TabData(this.session.getTabId()))
@ -854,9 +894,13 @@ export default class App {
}
const createListener = () =>
target ? createEventListener(target, type, listener, useCapture) : null
target
? createEventListener(target, type, listener, useCapture, this.options.angularMode)
: null
const deleteListener = () =>
target ? deleteEventListener(target, type, listener, useCapture) : null
target
? deleteEventListener(target, type, listener, useCapture, this.options.angularMode)
: null
this.attachStartCallback(createListener, useSafe)
this.attachStopCallback(deleteListener, useSafe)
@ -1640,7 +1684,6 @@ export default class App {
stop(stopWorker = true): void {
if (this.activityState !== ActivityState.NotActive) {
console.trace('stopped')
try {
if (!this.insideIframe && this.options.crossdomain?.enabled) {
this.killChildrenFrames()
@ -1660,6 +1703,7 @@ export default class App {
this.trackedFrames = []
this.parentActive = false
this.canStart = false
this.pollingQueue = { order: [] }
} finally {
this.activityState = ActivityState.NotActive
this.debug.log('OpenReplay tracking stopped.')

View file

@ -10,10 +10,13 @@ export default class Nodes {
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
private nextNodeId = 0
constructor(private readonly node_id: string) {}
constructor(
private readonly node_id: string,
private readonly angularMode: boolean,
) {}
syntheticMode(frameOrder: number) {
const maxSafeNumber = 9007199254740900
const maxSafeNumber = Number.MAX_SAFE_INTEGER
const placeholderSize = 99999999
const nextFrameId = placeholderSize * frameOrder
// I highly doubt that this will ever happen,
@ -25,7 +28,7 @@ export default class Nodes {
}
// Attached once per Tracker instance
attachNodeCallback(nodeCallback: NodeCallback): void {
attachNodeCallback = (nodeCallback: NodeCallback): void => {
this.nodeCallbacks.push(nodeCallback)
}
@ -33,12 +36,12 @@ export default class Nodes {
this.nodes.forEach((node) => cb(node))
}
attachNodeListener(node: Node, type: string, listener: EventListener, useCapture = true): void {
attachNodeListener = (node: Node, type: string, listener: EventListener, useCapture = true): void => {
const id = this.getID(node)
if (id === undefined) {
return
}
createEventListener(node, type, listener, useCapture)
createEventListener(node, type, listener, useCapture, this.angularMode)
let listeners = this.elementListeners.get(id)
if (listeners === undefined) {
listeners = []
@ -70,7 +73,7 @@ export default class Nodes {
if (listeners !== undefined) {
this.elementListeners.delete(id)
listeners.forEach((listener) =>
deleteEventListener(node, listener[0], listener[1], listener[2]),
deleteEventListener(node, listener[0], listener[1], listener[2], this.angularMode),
)
}
this.totalNodeAmount--

View file

@ -19,13 +19,13 @@ export default class IFrameObserver extends Observer {
})
}
syntheticObserve(selfId: number, doc: Document) {
syntheticObserve(rootNodeId: number, doc: Document) {
this.observeRoot(doc, (docID) => {
if (docID === undefined) {
this.app.debug.log('OpenReplay: Iframe document not bound')
return
}
this.app.send(CreateIFrameDocument(selfId, docID))
this.app.send(CreateIFrameDocument(rootNodeId, docID))
})
}
}

View file

@ -1,4 +1,4 @@
import { createMutationObserver, ngSafeBrowserMethod } from '../../utils.js'
import { createMutationObserver } from '../../utils.js'
import {
RemoveNodeAttribute,
SetNodeAttributeURLBased,
@ -105,6 +105,9 @@ export default abstract class Observer {
if (name === null) {
continue
}
if (target instanceof HTMLIFrameElement && name === 'src') {
this.handleIframeSrcChange(target)
}
let attr = this.attributesMap.get(id)
if (attr === undefined) {
this.attributesMap.set(id, (attr = new Set()))
@ -119,6 +122,7 @@ export default abstract class Observer {
}
this.commitNodes()
}) as MutationCallback,
this.app.options.angularMode,
)
}
private clear(): void {
@ -129,10 +133,49 @@ export default abstract class Observer {
this.textSet.clear()
}
/**
* Unbinds the removed nodes in case of iframe src change.
*/
private handleIframeSrcChange(iframe: HTMLIFrameElement): void {
const oldContentDocument = iframe.contentDocument
if (oldContentDocument) {
const id = this.app.nodes.getID(oldContentDocument)
if (id !== undefined) {
const walker = document.createTreeWalker(
oldContentDocument,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
isIgnored(node) || this.app.nodes.getID(node) === undefined
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
},
// @ts-ignore
false,
)
let removed = 0
const totalBeforeRemove = this.app.nodes.getNodeCount()
while (walker.nextNode()) {
if (!iframe.contentDocument.contains(walker.currentNode)) {
removed += 1
this.app.nodes.unregisterNode(walker.currentNode)
}
}
const removedPercent = Math.floor((removed / totalBeforeRemove) * 100)
if (removedPercent > 30) {
this.app.send(UnbindNodes(removedPercent))
}
}
}
}
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
if (isSVGElement(node)) {
if (name.substr(0, 6) === 'xlink:') {
name = name.substr(6)
if (name.substring(0, 6) === 'xlink:') {
name = name.substring(6)
}
if (value === null) {
this.app.send(RemoveNodeAttribute(id, name))
@ -152,7 +195,7 @@ export default abstract class Observer {
name === 'integrity' ||
name === 'crossorigin' ||
name === 'autocomplete' ||
name.substr(0, 2) === 'on'
name.substring(0, 2) === 'on'
) {
return
}

View file

@ -140,7 +140,7 @@ export default class TopObserver extends Observer {
)
}
crossdomainObserve(selfId: number, frameOder: number) {
crossdomainObserve(rootNodeId: number, frameOder: number) {
const observer = this
Element.prototype.attachShadow = function () {
// eslint-disable-next-line
@ -152,7 +152,7 @@ export default class TopObserver extends Observer {
this.app.nodes.syntheticMode(frameOder)
const iframeObserver = new IFrameObserver(this.app)
this.iframeObservers.push(iframeObserver)
iframeObserver.syntheticObserve(selfId, window.document)
iframeObserver.syntheticObserve(rootNodeId, window.document)
}
disconnect() {

View file

@ -99,6 +99,7 @@ export default function (app: App): void {
}
}
}) as MutationCallback,
app.options.angularMode,
)
app.attachStopCallback(() => {

View file

@ -132,9 +132,13 @@ export function ngSafeBrowserMethod(method: string): string {
: method
}
export function createMutationObserver(cb: MutationCallback) {
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
return new window[mObserver](cb)
export function createMutationObserver(cb: MutationCallback, angularMode?: boolean) {
if (angularMode) {
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
return new window[mObserver](cb)
} else {
return new MutationObserver(cb)
}
}
export function createEventListener(
@ -142,8 +146,14 @@ export function createEventListener(
event: string,
cb: EventListenerOrEventListenerObject,
capture?: boolean,
angularMode?: boolean,
) {
const safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
let safeAddEventListener: 'addEventListener'
if (angularMode) {
safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
} else {
safeAddEventListener = 'addEventListener'
}
try {
target[safeAddEventListener](event, cb, capture)
} catch (e) {
@ -152,6 +162,7 @@ export function createEventListener(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
target,
)
}
}
@ -161,10 +172,14 @@ export function deleteEventListener(
event: string,
cb: EventListenerOrEventListenerObject,
capture?: boolean,
angularMode?: boolean,
) {
const safeRemoveEventListener = ngSafeBrowserMethod(
'removeEventListener',
) as 'removeEventListener'
let safeRemoveEventListener: 'removeEventListener'
if (angularMode) {
safeRemoveEventListener = ngSafeBrowserMethod('removeEventListener') as 'removeEventListener'
} else {
safeRemoveEventListener = 'removeEventListener'
}
try {
target[safeRemoveEventListener](event, cb, capture)
} catch (e) {
@ -173,6 +188,7 @@ export function deleteEventListener(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
target,
)
}
}

View file

@ -7,7 +7,7 @@ describe('Nodes', () => {
const mockCallback = jest.fn()
beforeEach(() => {
nodes = new Nodes(nodeId)
nodes = new Nodes(nodeId, false)
mockCallback.mockClear()
})