/** * @license * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ (function(window) { 'use strict'; // Import utils methods const { PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, getPluginNameFromUrl, getBaseUrl, } = window._apiUtils; /** * @enum {string} */ const PluginState = { /** * State that indicates the plugin is pending to be loaded. */ PENDING: 'PENDING', /** * State that indicates the plugin is already loaded. */ LOADED: 'LOADED', /** * State that indicates the plugin is already loaded. */ PRE_LOADED: 'PRE_LOADED', /** * State that indicates the plugin failed to load. */ LOAD_FAILED: 'LOAD_FAILED', }; // Prefix for any unrecognized plugin urls. // Url should match following patterns: // /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js) // /plugins/PLUGINNAME.(js|html) const UNKNOWN_PLUGIN_PREFIX = '__$$__'; // Current API version for Plugin, // plugins with incompatible version will not be laoded. const API_VERSION = '0.1'; /** * PluginLoader, responsible for: * * Loading all plugins and handling errors etc. * Recording plugin state. * Reporting on plugin loading status. * Retrieve plugin. * Check plugin status and if all plugins loaded. */ class PluginLoader { constructor() { this._pluginListLoaded = false; /** @type {Map} */ this._plugins = new Map(); this._reporting = null; // Promise that resolves when all plugins loaded this._loadingPromise = null; // Resolver to resolve _loadingPromise once all plugins loaded this._loadingResolver = null; } _getReporting() { if (!this._reporting) { this._reporting = document.createElement('gr-reporting'); } return this._reporting; } /** * Use the plugin name or use the full url if not recognized. * * @see gr-api-utils#getPluginNameFromUrl * @param {string|URL} url */ _getPluginKeyFromUrl(url) { return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`; } /** * Load multiple plugins with certain options. * * @param {Array} plugins * @param {Object} opts */ loadPlugins(plugins = [], opts = {}) { this._pluginListLoaded = true; plugins.forEach(path => { const url = this._urlFor(path, window.ASSETS_PATH); // Skip if preloaded, for bundling. if (this.isPluginPreloaded(url)) return; const pluginKey = this._getPluginKeyFromUrl(url); // Skip if already installed. if (this._plugins.has(pluginKey)) return; this._plugins.set(pluginKey, { name: pluginKey, url, state: PluginState.PENDING, plugin: null, }); if (this._isPathEndsWith(url, '.html')) { this._importHtmlPlugin(path, opts && opts[path]); } else if (this._isPathEndsWith(url, '.js')) { this._loadJsPlugin(path); } else { this._failToLoad(`Unrecognized plugin path ${path}`, path); } }); this.awaitPluginsLoaded().then(() => { console.info('Plugins loaded'); this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames()); }); } _isPathEndsWith(url, suffix) { if (!(url instanceof URL)) { try { url = new URL(url); } catch (e) { console.warn(e); return false; } } return url.pathname && url.pathname.endsWith(suffix); } _getAllInstalledPluginNames() { const installedPlugins = []; for (const plugin of this._plugins.values()) { if (plugin.state === PluginState.LOADED) { installedPlugins.push(plugin.name); } } return installedPlugins; } install(callback, opt_version, opt_src) { // HTML import polyfill adds __importElement pointing to the import tag. const script = document.currentScript && (document.currentScript.__importElement || document.currentScript); let src = opt_src || (script && script.src); if (!src || src.startsWith('data:')) { src = script && script.baseURI; } if (opt_version && opt_version !== API_VERSION) { this._failToLoad(`Plugin ${src} install error: only version ` + API_VERSION + ' is supported in PolyGerrit. ' + opt_version + ' was given.', src); return; } const url = this._urlFor(src); const pluginObject = this.getPlugin(url); let plugin = pluginObject && pluginObject.plugin; if (!plugin) { plugin = new Plugin(url); } try { callback(plugin); this._pluginInstalled(url, plugin); } catch (e) { this._failToLoad(`${e.name}: ${e.message}`, src); } } get arePluginsLoaded() { // As the size of plugins is relatively small, // so the performance of this check should be reasonable if (!this._pluginListLoaded) return false; for (const plugin of this._plugins.values()) { if (plugin.state === PluginState.PENDING) return false; } return true; } _checkIfCompleted() { if (this.arePluginsLoaded && this._loadingResolver) { this._loadingResolver(); this._loadingResolver = null; this._loadingPromise = null; } } _timeout() { const pendingPlugins = []; for (const plugin of this._plugins.values()) { if (plugin.state === PluginState.PENDING) { this._updatePluginState(plugin.url, PluginState.LOAD_FAILED); this._checkIfCompleted(); pendingPlugins.push(plugin.url); } } return `Timeout when loading plugins: ${pendingPlugins.join(',')}`; } _failToLoad(message, pluginUrl) { // Show an alert with the error document.dispatchEvent(new CustomEvent('show-alert', { detail: { message: `Plugin install error: ${message} from ${pluginUrl}`, }, })); this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED); this._checkIfCompleted(); } _updatePluginState(pluginUrl, state) { const key = this._getPluginKeyFromUrl(pluginUrl); if (this._plugins.has(key)) { this._plugins.get(key).state = state; } else { // Plugin is not recorded for some reason. console.warn(`Plugin loaded separately: ${pluginUrl}`); this._plugins.set(key, { name: key, url: pluginUrl, state, plugin: null, }); } return this._plugins.get(key); } _pluginInstalled(url, plugin) { const pluginObj = this._updatePluginState(url, PluginState.LOADED); pluginObj.plugin = plugin; this._getReporting().pluginLoaded(plugin.getPluginName() || url); console.log(`Plugin ${plugin.getPluginName() || url} installed.`); this._checkIfCompleted(); } installPreloadedPlugins() { if (!window.Gerrit || !window.Gerrit._preloadedPlugins) { return; } const Gerrit = window.Gerrit; for (const name in Gerrit._preloadedPlugins) { if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; } const callback = Gerrit._preloadedPlugins[name]; this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name); } } isPluginPreloaded(pathOrUrl) { const url = this._urlFor(pathOrUrl); const name = getPluginNameFromUrl(url); if (name && window.Gerrit._preloadedPlugins) { return window.Gerrit._preloadedPlugins.hasOwnProperty(name); } else { return false; } } /** * Checks if given plugin path/url is enabled or not. * * @param {string} pathOrUrl */ isPluginEnabled(pathOrUrl) { const url = this._urlFor(pathOrUrl); if (this.isPluginPreloaded(url)) return true; const key = this._getPluginKeyFromUrl(url); return this._plugins.has(key); } /** * Returns the plugin object with a given url. * * @param {string} pathOrUrl */ getPlugin(pathOrUrl) { const key = this._getPluginKeyFromUrl(this._urlFor(pathOrUrl)); return this._plugins.get(key); } /** * Checks if given plugin path/url is loaded or not. * * @param {string} pathOrUrl */ isPluginLoaded(pathOrUrl) { const url = this._urlFor(pathOrUrl); const key = this._getPluginKeyFromUrl(url); return this._plugins.has(key) ? this._plugins.get(key).state === PluginState.LOADED : false; } _importHtmlPlugin(pluginUrl, opts = {}) { const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH); const urlWithoutAP = this._urlFor(pluginUrl); let onerror = null; if (urlWithAP !== urlWithoutAP) { onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync); } this._loadHtmlPlugin(urlWithAP, opts.sync, onerror); } _loadHtmlPlugin(url, sync, onerror) { if (!onerror) { onerror = () => { this._failToLoad(`${url} import error`, url); }; } (Polymer.importHref || Polymer.Base.importHref)( url, () => {}, onerror, !sync); } _loadJsPlugin(pluginUrl) { const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH); const urlWithoutAP = this._urlFor(pluginUrl); let onerror = null; if (urlWithAP !== urlWithoutAP) { onerror = () => this._createScriptTag(urlWithoutAP); } this._createScriptTag(urlWithAP, onerror); } _createScriptTag(url, onerror) { if (!onerror) { onerror = () => this._failToLoad(`${url} load error`, url); } const el = document.createElement('script'); el.defer = true; el.setAttribute('src', url); el.onerror = onerror; return document.body.appendChild(el); } _urlFor(pathOrUrl, assetsPath) { if (!pathOrUrl) { return pathOrUrl; } // theme is per host, should always load from assetsPath const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html'); const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath; if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) || pathOrUrl.startsWith('http')) { // Plugins are loaded from another domain or preloaded. if (pathOrUrl.includes(location.host) && shouldTryLoadFromAssetsPathFirst) { // if is loading from host server, try replace with cdn when assetsPath provided return pathOrUrl .replace(location.origin, assetsPath); } return pathOrUrl; } if (!pathOrUrl.startsWith('/')) { pathOrUrl = '/' + pathOrUrl; } if (shouldTryLoadFromAssetsPathFirst) { return assetsPath + pathOrUrl; } return window.location.origin + getBaseUrl() + pathOrUrl; } awaitPluginsLoaded() { // Resolve if completed. this._checkIfCompleted(); if (this.arePluginsLoaded) { return Promise.resolve(); } if (!this._loadingPromise) { let timerId; this._loadingPromise = Promise.race([ new Promise(resolve => this._loadingResolver = resolve), new Promise((_, reject) => timerId = setTimeout( () => { reject(this._timeout()); }, PLUGIN_LOADING_TIMEOUT_MS)), ]).then(() => { if (timerId) clearTimeout(timerId); }); } return this._loadingPromise; } } /** * @typedef {{ * name:string, * url:string, * state:PluginState, * plugin:Object * }} */ PluginLoader.PluginObject; /** * @typedef {{ * sync:boolean, * }} */ PluginLoader.PluginOption; window.PluginLoader = PluginLoader; })(window);