Source: engine/ur/Connection.js

(function() {
    /** Connection handling for UR harmonization */
    Help4.engine.ur.Connection = class {
        /**
         * UI5 component name
         * @type {string}
         */
        static COMPONENT_NAME = 'sap.dfa.help.wpb.Help4';
        /**
         * Logging levels, matching those in UI5
         * @readonly
         * @enum {number}
         */
        static LOG_LEVEL = {
            WARNING: 2,
            DEBUG: 4
        };

        /**
         * Logging level to reset to when disabling logging
         * @type {?number}
         * @private
         */
        static #logLevel = null;

        /**
         * Which app is currently running in FLP
         * @readonly
         * @enum {string}
         */
        static APP_TYPE = {
            HTMLGUI: 'HTMLGUI',
            WDA: 'WDA',
            UI5: 'UI5',
            MOCK: 'MOCK'
        };

        /**
         * Connect; sending start message to frame
         * will repeat until update message is received and connection is confirmed
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         */
        static connect(engine) {
            const {_connected, _log, _started, _running} = engine;
            if (_connected || !_started || !_running) return;

            if (Help4.Feature.UrDebugging && !_log) {
                this.enableLogging(engine, this.connect);
                return;
            }

            engine._connections ||= _connectGenerator.call(this, engine, 250);
            const nextDelay = engine._connections.next().value;

            // repeat until we get at least one update over the API
            setTimeout(() => this.connect(engine), nextDelay);
        }

        /**
         * Disconnect; sending stop message to frame
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         */
        static disconnect(engine) {
            engine._connections = null;
            if (!engine._connected || !engine._started) return;

            const {MSG_TYPE: {v1, v2}} = engine.constructor;
            const {stop} = engine._apiVersion === 1 ? v1 : v2;  // default to v2
            engine._log?.('CMP: sending stop message', stop);
            this.messageAppFrame(engine, stop);
            this.disableLogging(engine);

            engine._connected = false;
            engine._apiVersion = null;
        }

        /**
         * try to limit the number of windows we send our messages to by finding the app frame origin
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @returns {string}
         */
        static getTargetOrigin(engine) {
            const {_appFrame} = engine;

            // cross-origin
            let src = _appFrame?.getAttribute?.('sap-orig-src') || _appFrame?.src || '';
            if (/^https?:\/\//.test(src)) {
                try {
                    return src && (new URL(src)).origin || '*';
                } catch(e) {
                }
            }

            // same-origin
            try {
                src = _appFrame?.contentWindow?.location;
                return src?.origin !== 'null' && (new URL(src)).origin || '*';
            } catch(e) {
            }

            return '*';
        }

        /**
         * Post a message to the app frame
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @param {string} service
         * @param {?boolean} [isAssignMode]
         * @param {?boolean} [startMessage]
         */
        static messageAppFrame(engine, service, isAssignMode, startMessage) {
            if (!startMessage && (!engine._connected || !engine._running)) return;

            if (typeof isAssignMode === 'boolean') engine._assignMode = isAssignMode;
            engine._appFrame?.contentWindow?.postMessage(Help4.JSON.stringify({
                type: 'request',
                service,
                body: {}
            }), this.getTargetOrigin(engine));
        }

        /**
         * determine the type of app currently running, this does not return UI5 for the main shell, when no app is open
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @returns {?string}
         */
        static getUI5AppType(engine) {
            const {HTMLGUI, WDA, UI5, MOCK} = this.APP_TYPE;
            if (engine._mock) return MOCK;

            const shell = Help4.getShell();
            const fioriApp = shell.getFioriApplication();
            switch (fioriApp) {
                case HTMLGUI:
                case WDA:
                    return fioriApp;
                default:
                    if (!engine._useUI5HelpTexts) break;

                    const service = shell.getUI5Service('AppLifeCycle');
                    const {applicationType, componentInstance, homePage} = service?.getCurrentApplication() || {};
                    // XRAY-6454: Check if the UI5 app has disabled backend help using the sap-disable-f1help parameter
                    const [disableHelp] = componentInstance?.getComponentData()?.startupParameters?.['sap-disable-f1help'] || [];
                    if (disableHelp === 'true' || disableHelp === '1') break;

                    if (!homePage && applicationType === UI5) {
                        return UI5;
                    }
            }
            return null;
        }

        /**
         * Handle UR message events
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @param {Object} event
         * @param {string} event.data - message data
         * @param {Window} event.source - of the event
         */
        static onMessage(engine, {data, source}) {
            // make sure we communicate with the current frame, e.g. we don't want to confirm a connection to a new frame by accepting updates from the previous frame
            const {
                _appFrame,
                _connected,
                _currentApp,
                _mock,
                _useABAPHelpTexts,
                _useUI5HelpTexts
            } = engine;
            if (typeof data !== 'string' || !(_mock || source === _appFrame?.contentWindow)) return;

            let msg;
            try {
                msg = Help4.JSON.parse(data);
            } catch(e) {
                msg = {};
            }

            const {body, service} = msg;
            if (!body || body.code === -1) return;  // ignore messages with error code, e.g. response of "Unknown service name"

            const {Connection, Migration, Tour, Update, WTA} = Help4.engine.ur;
            const {MSG_TYPE: {v1, v2}} = engine.constructor;

            switch (service) {
                case v1.update:
                case v2.update:
                    const nUpdates = (body?.elements || body?.hotspots)?.length || '-';
                    const isUI5 = _currentApp === this.APP_TYPE.UI5;
                    const logUpdate = () => isUI5 && body?.hotspots?.map?.(h => ({...h, element: document.getElementById(h.hotspotId)})) || body;
                    engine._log?.('CMP: received', '*️⃣ ' + service + ` (${nUpdates})`, logUpdate);

                    // set WTA for UI5 on first connection - UI5 doesn't send v2.app message
                    if (!_connected && isUI5 && _useUI5HelpTexts) {
                        WTA.handleUI5(engine);
                    }

                    // this confirms the connection has been established; we don't need to try to re-connect
                    engine._connected = true;  // stop _connect from sending more messages
                    engine._connections = null;  // reset connection generator

                    Update.handle(engine, body);
                    Migration.requestSelectorMigration(engine);

                    // storing the version of this connection, so we can send the correct stop message
                    engine._apiVersion = service === v1.update ? 1 : 2; // default to v2
                    break;

                case v2.fromSelectors:
                    if (Help4.Feature.UrDebugging) engine._log?.('CMP: received', '️#️⃣ ' + service, ()=>body);
                    Migration.handleMigration(engine, body);
                    break;

                case v1.app:
                case v2.app:
                    if (_useABAPHelpTexts) {
                        engine._log?.('CMP: received', 'ℹ️ ' + service, () => body);
                        WTA.handle(engine, body);
                    }
                    break;

                case v2.activateElement:
                case v2.hoverElement:
                case v2.interactOnElement:
                case v2.leaveElement:
                case v2.hotkeyPress:
                    engine._log?.('CMP: received', 'ℹ️ ' + service, ()=>body);
                    Tour.onEvent(engine, service, body);
                    break;
            }
        }

        /**
         * Enable UI5 logging
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @param {Function} callback - called after logging is enabled
         * @private
         */
        static enableLogging(engine, callback) {
            if (engine._log) return;

            const {COMPONENT_NAME} = this;
            window.sap?.ui?.require(["sap/base/Log"], Log => {
                "use strict";
                this.#logLevel = /** @type {number} */ Log.getLevel(COMPONENT_NAME);
                Log.setLevel(Log.Level.DEBUG, COMPONENT_NAME);
                /**
                 * Logging function only available when Help4.Feature.UrDebugging is enabled
                 * @type {?Function}
                 * @param {string} message - the message to log
                 * @param {?(string | Error)} [detail] - more detailed output or an Error
                 * @param {?Function} [fnSupportInfo] - only called if UI5 support info mode is turned on with logSupportInfo(true), fn should return a simple JSON object
                 * @param {?(Help4.engine.ur.Connection.LOG_LEVEL.WARNING|Help4.engine.ur.Connection.LOG_LEVEL.DEBUG)} [level] - declares this a warning or just debug info
                 * @private
                 */
                engine._log = (message, detail, fnSupportInfo, level) => {
                    const detailMsg = typeof detail === 'object'
                        ? Help4.JSON.stringify(detail, null, 2) // pretty-printed
                        : detail;
                    switch (level) {
                        case this.LOG_LEVEL.WARNING:
                            return Log.warning(message, detailMsg, COMPONENT_NAME, fnSupportInfo);
                        default:
                            return Log.debug(message, detailMsg, COMPONENT_NAME, fnSupportInfo);
                    }
                };
                Log.debug("CMP: UrDebugging enabled", 'use url parameter "sap-ui-support" for detailed output', COMPONENT_NAME);
                /**
                 * there doesn't seem to be a way to read the current setting,
                 * can't reset to original value in {@link Help4.engine.ur.Connection._disableLogging}
                 */
                Log.logSupportInfo(true);

                callback.call(this, engine);
            });

            if (engine._mock && typeof window.sap?.ui?.require !== 'function') engine._log = (message, detail, fnSupportInfo, level) => {
                console[level === this.LOG_LEVEL.WARNING ? 'warn' : 'log'](message, detail, fnSupportInfo?.());
            }
        }

        /**
         * Disable UI5 logging
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @private
         */
        static disableLogging(engine) {
            if (!engine._log) return;

            engine._log = null;
            window.sap?.ui?.require(["sap/base/Log"], Log => {
                "use strict";
                const {COMPONENT_NAME} = this;
                Log.debug("CMP: UrDebugging disabled", undefined, COMPONENT_NAME);
                Log.setLevel(this.#logLevel, COMPONENT_NAME);  // reset to original log level
                // Log.logSupportInfo(false ???);  // reset not possible
            });
        }
    };

    /**
     * Endless source of connections starting with version 2,
     * then falling back to version one
     * returning increased delay times
     * @memberof Help4.engine.ur.Connection
     * @private
     * @param {Help4.engine.ur.UrHarmonizationEngine} engine
     * @param {number} t - start delay in ms
     * @returns {Generator<number>}
     */
    function* _connectGenerator(engine, t) {
        const sendStartMsg = version => {
            const {MSG_TYPE} = engine.constructor;
            const {start} = MSG_TYPE[version];

            engine._log?.(`CMP: sending ${version} start message to ${engine._appFrame?.id}`, start);
            this.messageAppFrame(engine, start, undefined, true);
        };

        const {_mock, _useFixedVersion} = engine;
        if (_mock && _useFixedVersion) {
            const msg = _useFixedVersion === 2 ? 'v2' : 'v1';

            // we'll stay in this loop
            for (let i = 0; ; i++) {
                sendStartMsg(msg);
                yield t;
            }
        }

        // tweak the numbers?
        // should fall back to v1 early enough when page is loaded
        // should wait for page to load
        for (let i = 0; i < 5; i++) {
            sendStartMsg('v2');
            yield t;
        }

        const {MAX_DELAY} = engine.constructor;
        engine._log?.('CMP: Falling back to postMessage API v2 and v1', undefined, undefined, this.LOG_LEVEL.WARNING);
        // sending both v2 and v1 as a fallback
        for (let delay = t; ; delay *= 1.3) {
            sendStartMsg('v2');
            sendStartMsg('v1');
            yield Math.min(delay, MAX_DELAY);
        }
    }
})();