(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);
}
}
})();