From f7da54dded5265ab7fce360d8dc59e0505161f35 Mon Sep 17 00:00:00 2001 From: antelle Date: Fri, 28 May 2021 16:42:00 +0200 Subject: [PATCH] plugins --- app/scripts/bootstrap.ts | 18 +- app/scripts/comp/settings/config-loader.ts | 27 ++- app/scripts/const/timeouts.ts | 4 +- app/scripts/main.ts | 4 + app/scripts/plugins/plugin-manager.js | 234 --------------------- app/scripts/plugins/plugin-manager.ts | 189 +++++++++++++++++ app/scripts/plugins/plugin.ts | 5 +- app/scripts/plugins/types.ts | 13 ++ build/webpack.config.js | 2 +- 9 files changed, 239 insertions(+), 257 deletions(-) create mode 100644 app/scripts/main.ts delete mode 100644 app/scripts/plugins/plugin-manager.js create mode 100644 app/scripts/plugins/plugin-manager.ts diff --git a/app/scripts/bootstrap.ts b/app/scripts/bootstrap.ts index ecb5a07d..1ac5a1ee 100644 --- a/app/scripts/bootstrap.ts +++ b/app/scripts/bootstrap.ts @@ -1,7 +1,3 @@ -/* eslint-disable import/first */ -if (process.env.NODE_ENV === 'development') { - require('preact/debug'); -} import 'util/kdbxweb/protected-value'; import { h, render } from 'preact'; import { App } from 'ui/app'; @@ -30,7 +26,8 @@ import { ConfigLoader } from 'comp/settings/config-loader'; import { WindowClass } from 'comp/browser/window-class'; import { FileManager } from 'models/file-manager'; import { Updater } from './comp/app/updater'; -/* eslint-enable */ +import { Timeouts } from 'const/timeouts'; +import { PluginManager } from 'plugins/plugin-manager'; declare global { interface Window { @@ -51,7 +48,7 @@ async function bootstrap() { try { await loadConfigs(); - initModules(); + await initModules(); await loadRemoteConfig(); await ensureCanRun(); addWindowClasses(); @@ -95,7 +92,7 @@ async function bootstrap() { ); } - function initModules() { + async function initModules() { KeyHandler.init(); PopupNotifier.init(); KdbxwebInit.init(); @@ -103,7 +100,7 @@ async function bootstrap() { // AutoType.init(); // TODO ThemeWatcher.init(); SettingsManager.init(); - // await PluginManager.init() // TODO + await PluginManager.init(); StartProfiler.milestone('initializing modules'); } @@ -162,7 +159,10 @@ async function bootstrap() { AppRightsChecker.init().catch(noop); IdleTracker.init(); // BrowserExtensionConnector.init(appModel); // TODO - // PluginManager.runAutoUpdate(); // TODO: timeout + + setTimeout(() => { + PluginManager.runAutoUpdate().catch(noop); + }, Timeouts.PluginsAutoUpdateAfterStart); } function showView() { diff --git a/app/scripts/comp/settings/config-loader.ts b/app/scripts/comp/settings/config-loader.ts index 1d2cd7d4..7a2a7b89 100644 --- a/app/scripts/comp/settings/config-loader.ts +++ b/app/scripts/comp/settings/config-loader.ts @@ -6,6 +6,8 @@ import { Locale } from 'util/locale'; import { FileManager } from 'models/file-manager'; import { FileInfo, FileStorageExtraOptions } from 'models/file-info'; import { IdGenerator } from 'util/generators/id-generator'; +import { PluginManager } from 'plugins/plugin-manager'; +import { PluginManifest } from 'plugins/types'; const logger = new Logger('config-loader'); @@ -20,7 +22,7 @@ class ConfigLoader { throw new Error('Invalid app config, no settings section'); } - this.applyUserConfig(config); + await this.applyUserConfig(config); return true; } catch (e) { if (!AppSettings.cacheConfigSettings) { @@ -86,7 +88,7 @@ class ConfigLoader { } } - private applyUserConfig(config: Record): void { + private async applyUserConfig(config: Record): Promise { if (!config.settings) { logger.error('Invalid app config, no settings section', config); throw new Error('Invalid app config, no settings section'); @@ -141,14 +143,19 @@ class ConfigLoader { } } - if (config.plugins) { - // TODO: set plugins - // const pluginsPromises = config.plugins.map((plugin) => - // PluginManager.installIfNew(plugin.url, plugin.manifest, true) - // ); - // return Promise.all(pluginsPromises).then(() => { - // this.settings.set(config.settings); - // }); + if (Array.isArray(config.plugins)) { + const pluginPromises: Promise[] = []; + for (const plugin of config.plugins as Record[]) { + if (typeof plugin.url === 'string' && typeof plugin.manifest === 'object') { + const pluginPromise = PluginManager.installIfNew( + plugin.url, + plugin.manifest as PluginManifest, + true + ); + pluginPromises.push(pluginPromise); + } + } + await Promise.all(pluginPromises); } if (config.advancedSearch) { diff --git a/app/scripts/const/timeouts.ts b/app/scripts/const/timeouts.ts index b207ef02..11bc8c57 100644 --- a/app/scripts/const/timeouts.ts +++ b/app/scripts/const/timeouts.ts @@ -26,5 +26,7 @@ export const Timeouts = { UpdateInterval: 1000 * 60 * 60 * 24, MinUpdateTimeout: 500, InputShake: 1000, - WindowResizeUpdateDebounce: 200 + WindowResizeUpdateDebounce: 200, + PluginsAutoUpdateAfterStart: 2000, + PluginsUpdate: 1000 * 60 * 60 * 24 * 7 }; diff --git a/app/scripts/main.ts b/app/scripts/main.ts new file mode 100644 index 00000000..c58d1aca --- /dev/null +++ b/app/scripts/main.ts @@ -0,0 +1,4 @@ +if (process.env.NODE_ENV === 'development') { + require('preact/debug'); +} +require('./bootstrap'); diff --git a/app/scripts/plugins/plugin-manager.js b/app/scripts/plugins/plugin-manager.js deleted file mode 100644 index ba8cd87c..00000000 --- a/app/scripts/plugins/plugin-manager.js +++ /dev/null @@ -1,234 +0,0 @@ -import { Model } from 'framework/model'; -import { RuntimeInfo } from 'const/runtime-info'; -import { SettingsStore } from 'comp/settings/settings-store'; -import { Plugin, PluginStatus } from 'plugins/plugin'; -import { PluginCollection } from 'plugins/plugin-collection'; -import { PluginGallery } from 'plugins/plugin-gallery'; -import { SignatureVerifier } from 'util/data/signature-verifier'; -import { Logger } from 'util/logger'; -import { noop } from 'util/fn'; - -const logger = new Logger('plugin-mgr'); - -class PluginManager extends Model { - static UpdateInterval = 1000 * 60 * 60 * 24 * 7; - - constructor() { - super({ - plugins: new PluginCollection() - }); - } - - init() { - const ts = logger.ts(); - return SettingsStore.load('plugins').then((state) => { - if (!state) { - return; - } - this.set({ - autoUpdateAppVersion: state.autoUpdateAppVersion, - autoUpdateDate: state.autoUpdateDate - }); - if (!state || !state.plugins || !state.plugins.length) { - return; - } - return PluginGallery.getCachedGallery().then((gallery) => { - const promises = state.plugins.map((plugin) => this.loadPlugin(plugin, gallery)); - return Promise.all(promises).then((loadedPlugins) => { - this.plugins.push(...loadedPlugins.filter((plugin) => plugin)); - logger.info(`Loaded ${this.plugins.length} plugins`, logger.ts(ts)); - }); - }); - }); - } - - install(url, expectedManifest, skipSignatureValidation) { - this.emit('change'); - return Plugin.loadFromUrl(url, expectedManifest) - .then((plugin) => { - return this.uninstall(plugin.id).then(() => { - if (skipSignatureValidation) { - plugin.skipSignatureValidation = true; - } - return plugin.install(true, false).then(() => { - this.plugins.push(plugin); - this.emit('change'); - this.saveState(); - }); - }); - }) - .catch((e) => { - this.emit('change'); - throw e; - }); - } - - installIfNew(url, expectedManifest, skipSignatureValidation) { - const plugin = this.plugins.find((p) => p.url === url); - if (plugin && plugin.status !== 'invalid') { - return Promise.resolve(); - } - return this.install(url, expectedManifest, skipSignatureValidation); - } - - uninstall(id) { - const plugin = this.plugins.get(id); - if (!plugin) { - return Promise.resolve(); - } - this.emit('change'); - return plugin.uninstall().then(() => { - this.plugins.remove(id); - this.emit('change'); - this.saveState(); - }); - } - - disable(id) { - const plugin = this.plugins.get(id); - if (!plugin || plugin.status !== PluginStatus.STATUS_ACTIVE) { - return Promise.resolve(); - } - this.emit('change'); - return plugin.disable().then(() => { - this.emit('change'); - this.saveState(); - }); - } - - activate(id) { - const plugin = this.plugins.get(id); - if (!plugin || plugin.status === PluginStatus.STATUS_ACTIVE) { - return Promise.resolve(); - } - this.emit('change'); - return plugin.install(true, true).then(() => { - this.emit('change'); - this.saveState(); - }); - } - - update(id) { - const oldPlugin = this.plugins.get(id); - const validStatuses = [ - PluginStatus.STATUS_ACTIVE, - PluginStatus.STATUS_INACTIVE, - PluginStatus.STATUS_NONE, - PluginStatus.STATUS_ERROR, - PluginStatus.STATUS_INVALID - ]; - if (!oldPlugin || validStatuses.indexOf(oldPlugin.status) < 0) { - return Promise.reject(); - } - const url = oldPlugin.url; - this.emit('change'); - return Plugin.loadFromUrl(url) - .then((newPlugin) => { - return oldPlugin - .update(newPlugin) - .then(() => { - this.emit('change'); - this.saveState(); - }) - .catch((e) => { - this.emit('change'); - throw e; - }); - }) - .catch((e) => { - this.emit('change'); - throw e; - }); - } - - setAutoUpdate(id, enabled) { - const plugin = this.plugins.get(id); - if (!plugin || plugin.autoUpdate === enabled) { - return; - } - plugin.setAutoUpdate(enabled); - this.emit('change'); - this.saveState(); - } - - runAutoUpdate() { - const queue = this.plugins.filter((p) => p.autoUpdate).map((p) => p.id); - if (!queue.length) { - return Promise.resolve(); - } - const anotherVersion = this.autoUpdateAppVersion !== RuntimeInfo.version; - const wasLongAgo = - !this.autoUpdateDate || Date.now() - this.autoUpdateDate > PluginManager.UpdateInterval; - const autoUpdateRequired = anotherVersion || wasLongAgo; - if (!autoUpdateRequired) { - return; - } - logger.info('Auto-updating plugins', queue.join(', ')); - this.set({ - autoUpdateAppVersion: RuntimeInfo.version, - autoUpdateDate: Date.now() - }); - this.saveState(); - const updateNext = () => { - const pluginId = queue.shift(); - if (pluginId) { - return this.update(pluginId).catch(noop).then(updateNext); - } - }; - return updateNext(); - } - - loadPlugin(desc, gallery) { - const plugin = new Plugin({ - manifest: desc.manifest, - url: desc.url, - autoUpdate: desc.autoUpdate - }); - let enabled = desc.enabled; - if (enabled) { - const galleryPlugin = gallery - ? gallery.plugins.find((pl) => pl.manifest.name === desc.manifest.name) - : null; - const expectedPublicKeys = galleryPlugin - ? [galleryPlugin.manifest.publicKey] - : SignatureVerifier.getPublicKeys(); - enabled = expectedPublicKeys.includes(desc.manifest.publicKey); - } - return plugin - .install(enabled, true) - .then(() => plugin) - .catch(() => plugin); - } - - saveState() { - SettingsStore.save('plugins', { - autoUpdateAppVersion: this.autoUpdateAppVersion, - autoUpdateDate: this.autoUpdateDate, - plugins: this.plugins.map((plugin) => ({ - manifest: plugin.manifest, - url: plugin.url, - enabled: plugin.status === 'active', - autoUpdate: plugin.autoUpdate - })) - }); - } - - getStatus(id) { - const plugin = this.plugins.get(id); - return plugin ? plugin.status : ''; - } - - getPlugin(id) { - return this.plugins.get(id); - } -} - -PluginManager.defineModelProperties({ - plugins: null, - autoUpdateAppVersion: null, - autoUpdateDate: null -}); - -const instance = new PluginManager(); - -export { instance as PluginManager }; diff --git a/app/scripts/plugins/plugin-manager.ts b/app/scripts/plugins/plugin-manager.ts new file mode 100644 index 00000000..b0776fa6 --- /dev/null +++ b/app/scripts/plugins/plugin-manager.ts @@ -0,0 +1,189 @@ +import { Model } from 'util/model'; +import { RuntimeInfo } from 'const/runtime-info'; +import { SettingsStore } from 'comp/settings/settings-store'; +import { Plugin, PluginStatus } from 'plugins/plugin'; +import { PluginGallery } from 'plugins/plugin-gallery'; +import { SignatureVerifier } from 'util/data/signature-verifier'; +import { Logger } from 'util/logger'; +import { PluginGalleryData, PluginManifest, StoredPlugin, StoredPlugins } from 'plugins/types'; +import { Timeouts } from 'const/timeouts'; + +const logger = new Logger('plugin-mgr'); + +class PluginManager extends Model { + plugins: Plugin[] = []; + autoUpdateAppVersion?: string; + autoUpdateDate?: Date; + + async init() { + const ts = logger.ts(); + const storedPlugins = (await SettingsStore.load('plugins')) as StoredPlugins; + if (!storedPlugins) { + return; + } + this.batchSet(() => { + this.autoUpdateAppVersion = storedPlugins.autoUpdateAppVersion; + this.autoUpdateDate = storedPlugins.autoUpdateDate + ? new Date(storedPlugins.autoUpdateDate) + : undefined; + }); + + if (!storedPlugins.plugins?.length) { + return; + } + const gallery = await PluginGallery.getCachedGallery(); + const promises = storedPlugins.plugins.map((plugin) => this.loadPlugin(plugin, gallery)); + const loadedPlugins = await Promise.all(promises); + + this.plugins = this.plugins.concat(...loadedPlugins); + logger.info(`Loaded ${loadedPlugins.length} plugins`, logger.ts(ts)); + } + + async install( + url: string, + expectedManifest?: PluginManifest, + skipSignatureValidation?: boolean + ): Promise { + const plugin = await Plugin.loadFromUrl(url, expectedManifest); + await this.uninstall(plugin.id); + if (skipSignatureValidation) { + plugin.skipSignatureValidation = true; + } + await plugin.install(true, false); + this.plugins = this.plugins.concat(plugin); + await this.saveState(); + } + + installIfNew(url: string, expectedManifest: PluginManifest, skipSignatureValidation: boolean) { + const plugin = this.plugins.find((p) => p.url === url); + if (plugin && plugin.status !== 'invalid') { + return Promise.resolve(); + } + return this.install(url, expectedManifest, skipSignatureValidation); + } + + async uninstall(id: string): Promise { + const plugin = this.getPlugin(id); + if (!plugin) { + return Promise.resolve(); + } + await plugin.uninstall(); + this.plugins = this.plugins.filter((p) => p.id !== id); + await this.saveState(); + } + + async disable(id: string): Promise { + const plugin = this.getPlugin(id); + if (!plugin || plugin.status !== 'active') { + return Promise.resolve(); + } + await plugin.disable(); + await this.saveState(); + } + + async activate(id: string): Promise { + const plugin = this.getPlugin(id); + if (!plugin || plugin.status === 'active') { + return Promise.resolve(); + } + await plugin.install(true, true); + await this.saveState(); + } + + async update(id: string): Promise { + const oldPlugin = this.getPlugin(id); + const validStatuses: PluginStatus[] = ['active', 'inactive', 'error', 'invalid']; + if (!oldPlugin || (oldPlugin.status && !validStatuses.includes(oldPlugin.status))) { + return Promise.reject(); + } + const url = oldPlugin.url; + const newPlugin = await Plugin.loadFromUrl(url); + await oldPlugin.update(newPlugin); + await this.saveState(); + } + + async setAutoUpdate(id: string, enabled: boolean): Promise { + const plugin = this.getPlugin(id); + if (!plugin || plugin.autoUpdate === enabled) { + return; + } + plugin.autoUpdate = enabled; + await this.saveState(); + } + + async runAutoUpdate(): Promise { + const queue = this.plugins.filter((p) => p.autoUpdate).map((p) => p.id); + if (!queue.length) { + return Promise.resolve(); + } + const anotherVersion = this.autoUpdateAppVersion !== RuntimeInfo.version; + const wasLongAgo = + !this.autoUpdateDate || + Date.now() - this.autoUpdateDate.getTime() > Timeouts.PluginsUpdate; + const autoUpdateRequired = anotherVersion || wasLongAgo; + if (!autoUpdateRequired) { + return; + } + logger.info('Auto-updating plugins', queue.join(', ')); + this.batchSet(() => { + this.autoUpdateAppVersion = RuntimeInfo.version; + this.autoUpdateDate = new Date(); + }); + await this.saveState(); + + while (queue.length) { + const pluginId = queue.shift(); + if (!pluginId) { + break; + } + try { + await this.update(pluginId); + } catch (e) { + logger.error(`Error updating plugin`, pluginId); + } + } + } + + async loadPlugin(desc: StoredPlugin, gallery: PluginGalleryData | undefined): Promise { + const plugin = new Plugin(desc.url, desc.manifest, desc.autoUpdate); + let enabled = desc.enabled; + if (enabled) { + const galleryPlugin = gallery + ? gallery.plugins.find((pl) => pl.manifest.name === desc.manifest.name) + : null; + const expectedPublicKeys = galleryPlugin + ? [galleryPlugin.manifest.publicKey] + : SignatureVerifier.getPublicKeys(); + enabled = expectedPublicKeys.includes(desc.manifest.publicKey); + } + return plugin + .install(enabled, true) + .then(() => plugin) + .catch(() => plugin); + } + + async saveState() { + await SettingsStore.save('plugins', { + autoUpdateAppVersion: this.autoUpdateAppVersion, + autoUpdateDate: this.autoUpdateDate, + plugins: this.plugins.map((plugin) => ({ + manifest: plugin.manifest, + url: plugin.url, + enabled: plugin.status === 'active', + autoUpdate: plugin.autoUpdate + })) + }); + } + + getStatus(id: string): PluginStatus | undefined { + return this.getPlugin(id)?.status; + } + + getPlugin(id: string): Plugin | undefined { + return this.plugins.find((p) => p.id === id); + } +} + +const instance = new PluginManager(); + +export { instance as PluginManager }; diff --git a/app/scripts/plugins/plugin.ts b/app/scripts/plugins/plugin.ts index a14d1608..5ff4e40b 100644 --- a/app/scripts/plugins/plugin.ts +++ b/app/scripts/plugins/plugin.ts @@ -49,7 +49,7 @@ class Plugin extends Model { resources: Record; module?: { exports: Record }; - constructor(url: string, manifest: PluginManifest) { + constructor(url: string, manifest: PluginManifest, autoUpdate = false) { super(); const name = manifest.name; @@ -61,6 +61,7 @@ class Plugin extends Model { this.manifest = manifest; this.name = manifest.name; this.url = url; + this.autoUpdate = autoUpdate; this.logger = new Logger('plugin', name); this.resources = {}; } @@ -674,7 +675,7 @@ class Plugin extends Model { } } - static async loadFromUrl(url: string, expectedManifest: PluginManifest): Promise { + static async loadFromUrl(url: string, expectedManifest?: PluginManifest): Promise { if (url[url.length - 1] !== '/') { url += '/'; } diff --git a/app/scripts/plugins/types.ts b/app/scripts/plugins/types.ts index 1a85936b..bc5c823b 100644 --- a/app/scripts/plugins/types.ts +++ b/app/scripts/plugins/types.ts @@ -64,3 +64,16 @@ export interface PluginSetting { maxlength?: string; options?: PluginSettingOption[]; } + +export interface StoredPlugin { + manifest: PluginManifest; + url: string; + enabled: boolean; + autoUpdate: boolean; +} + +export interface StoredPlugins { + autoUpdateAppVersion?: string; + autoUpdateDate?: string | number; + plugins?: StoredPlugin[]; +} diff --git a/build/webpack.config.js b/build/webpack.config.js index c81d5bea..f886e015 100644 --- a/build/webpack.config.js +++ b/build/webpack.config.js @@ -23,7 +23,7 @@ function config(options) { return { mode, entry: { - app: ['babel-helpers', 'bootstrap', 'main.scss'] + app: ['babel-helpers', 'main', 'main.scss'] }, output: { path: path.resolve('.', 'tmp'),