| /** |
| * @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. |
| */ |
| import './gr-api-utils.js'; |
| |
| import { |
| PLUGIN_LOADING_TIMEOUT_MS, |
| PRELOADED_PROTOCOL, |
| getPluginNameFromUrl, |
| getBaseUrl, |
| } from './gr-api-utils.js'; |
| |
| /** |
| * @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. |
| */ |
| export class PluginLoader { |
| constructor() { |
| this._pluginListLoaded = false; |
| |
| /** @type {Map<string,PluginLoader.PluginObject>} */ |
| 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<string>} plugins |
| * @param {Object<string, PluginLoader.PluginOption>} 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); |
| } |
| } |
| |
| // The polygerrit uses version of sinon where you can't stub getter, |
| // declare it as a function here |
| 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); |
| // no credentials to send when fetch plugin js |
| // and this will help provide more meaningful error than |
| // 'Script error.' |
| el.setAttribute('crossorigin', 'anonymous'); |
| 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(new Error(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; |
| |
| // TODO(dmfilippov): Convert to service and add to appContext |
| export let pluginLoader = new PluginLoader(); |
| export function _testOnly_resetPluginLoader() { |
| pluginLoader = new PluginLoader(); |
| } |