Source: observer/WindowObserver.js

(function () {
    /**
     * options for WindowObserver.observe
     * @typedef {Object} Help4.observer.WindowObserver.Options
     * @property {Object} [mutationObserver] - configuration for the MutationObserver
     * @property {Object} [eventObserver] - configuration for the EventObserver
     * @property {Object} [focusObserver] - configuration for the FocusObserver
     */

    /**
     * options for WindowObserver.observeAll
     * @typedef {Help4.observer.WindowObserver.Options} Help4.observer.WindowObserver.OptionsAll
     * @property {boolean} [crossOrigin = false] - observe cross-origin windows with Agent
     */

    /**
     * observes windows using other observers: MutationObserver, EventObserver, FocusObserver
     * @augments Help4.observer.Observer
     */
    Help4.observer.WindowObserver = class extends Help4.observer.Observer {
        /**
         * @override
         * @param {Help4.observer.Callback} callback
         */
        constructor(callback) {
            const myCallback = event => {
                // the DOM and especially all included iframes might change
                // on certain DOM events; therefore refresh the target list
                _updateTargets.call(this);
                return callback(event);
            };

            super(callback, {
                statics: {
                    _mutationObserver: {init: null, destroy: false},
                    _eventObservers:   {init: [], destroy: false},
                    _focusObservers:   {init: [], destroy: false},
                    _toObserve:        {init: [], destroy: false},
                    _observeAll:       {init: null, destroy: false},
                    _coeId:            {init: null, destroy: false},
                    _cosIds:           {init: null, destroy: false},
                    _callback:         {init: myCallback, destroy: false}
                }
            });
        }

        /**
         * observes one window
         * @override
         * @param {Window} target - the to-be-observed window
         * @param {Help4.observer.WindowObserver.Options} options - configuration
         * @returns {Help4.observer.WindowObserver}
         */
        // target needs to be a window
        observe(target, options) {
            if (!target.document || !target.document.body) {
                // DOM not ready yet; try again later
                const {_toObserve} = this;
                _toObserve.push({target, options});

                if (_toObserve._timeout) clearTimeout(_toObserve._timeout);
                _toObserve._timeout = setTimeout(_retry.bind(this), 250);
                return this;
            }

            super.observe(target, options);

            const {MutationObserver, EventObserver, FocusObserver} = Help4.observer;
            const {mutationObserver, eventObserver, focusObserver} = options;

            if (mutationObserver && MutationObserver.isSupported()) {
                const o = this._mutationObserver ??= new MutationObserver(this._callback);
                o.observe(target.document.body, mutationObserver);
            }

            if (eventObserver) {
                const o = new EventObserver(this._callback, target)
                .observe(target, eventObserver);
                this._eventObservers.push(o);
            }

            if (focusObserver) {
                const o = new FocusObserver(this._callback, target).observe();
                this._focusObservers.push(o);
            }

            return this;
        }

        /**
         * observes all accessible windows: same window, same-origin windows, nested same-origin windows, cross-origin windows with Agent
         * @param {Help4.observer.WindowObserver.OptionsAll} options
         * @param {Window[]} [targets]
         * @returns {Help4.observer.WindowObserver}
         */
        observeAll(options, targets) {
            targets ||= _getTargets(window);

            this._observeAll = {targets, options};
            targets.forEach(target => this.observe(target, options));

            if (options.crossOrigin) {
                const controller = Help4.getController();
                controller?.getEngine('crossOrigin')?.windowObserver.observeAll(this._callback, options)
                .then(id => this._coeId = id);

                const cos = controller?.getService('crossOrigin');
                if (cos) {
                    const cosOptions = {};
                    if (options.mutationObserver) cosOptions.mutation = options.mutationObserver;
                    if (options.eventObserver) cosOptions.event = options.eventObserver;
                    if (options.focusObserver) cosOptions.focus = options.focusObserver;

                    cos.onWindowEvent.addListener(this._callback);

                    cos.sendCommand({
                        type: Help4.engine.crossorigin.AGENT_TYPE.recording,
                        command: 'observeWindowEvents',
                        params: cosOptions
                    }, true)
                    .then(ids => this._cosIds = ids)
                    .catch(() => this._cosIds = null);
                }
            }

            return this;
        }

        /**
         * @override
         * @returns {Help4.observer.WindowObserver}
         */
        disconnect() {
            _stopRetry.call(this);
            this._observeAll = null;

            const {_mutationObserver, _eventObservers, _focusObservers} = this;

            _mutationObserver?.destroy();
            this._mutationObserver = null;

            let o;
            while (o = _eventObservers?.shift()) o.destroy();
            while (o = _focusObservers?.shift()) o.destroy();

            const controller = Help4.getController();

            if (this._coeId) {
                controller?.getEngine('crossOrigin')?.windowObserver.disconnect(this._coeId);
                this._coeId = null;
            }

            if (this._cosIds) {
                const cos = controller?.getService('crossOrigin');
                if (cos) {
                    cos.onWindowEvent.removeListener(this._callback);

                    cos.sendCommand({
                        type: Help4.engine.crossorigin.AGENT_TYPE.recording,
                        command: 'disconnectWindowEvents',
                        params: this._cosIds
                    })
                    .catch(() => {});

                    this._cosIds = null;
                }
            }

            super.disconnect();
            return this;
        }
    }

    /**
     * in case a DOM was not ready on observe we retry later
     * @memberof Help4.observer.WindowObserver#
     * @private
     */
    function _retry() {
        const {_toObserve} = this;
        this._toObserve = [];  // reset
        _toObserve.forEach(({target, options}) => this.observe(target, options));
    }

    /**
     * stop to retry
     * @memberof Help4.observer.WindowObserver#
     * @private
     */
    function _stopRetry() {
        const {_timeout} = this._toObserve || {};
        if (_timeout) {
            clearTimeout(_timeout);
            this._toObserve = [];
        }
    }

    /**
     * checks if
     * - the observed windows are still available
     * - new windows have been added
     * will adopt
     * - stop observing gone windows
     * - observe added windows
     * @memberof Help4.observer.WindowObserver#
     * @private
     */
    function _updateTargets() {
        const {_observeAll} = this;
        if (!_observeAll) return;

        const {options, targets} = _observeAll;
        const newTargets = _getTargets(window);

        const needsUpdate = targets.length === newTargets.length
            ? !targets.every((target, index) => newTargets[index] === target)
            : true;

        if (needsUpdate) {
            this.disconnect();
            this.observeAll(options, newTargets);
        }
    }

    /**
     * @memberof Help4.observer.WindowObserver#
     * @param {Window} window
     * @returns {Window[]} a list of all accessible windows (same, same-origin, nested same-origin)
     * @private
     */
    function _getTargets(window) {
        let targets = [window];

        const {IFrameService} = Help4.service?.recording || {};
        if (!IFrameService) return targets;

        const coe = Help4.getController()?.getEngine('crossOrigin');
        const iframes = window.document.getElementsByTagName('IFRAME');

        for (const iframe of iframes) {
            if (Help4.isHelp4Iframe(iframe)) continue;  // ignore our own iframes

            const {contentWindow} = iframe;
            if (coe?.hasAgent({window: contentWindow, type: coe?.AGENT_TYPE.recording})) continue;  // skip IFRAMES that are handled by an agent

            if (IFrameService.isSameOriginWindow(contentWindow)) {
                targets = targets.concat(_getTargets(contentWindow));
            }
        }

        return targets;
    }
})();