diff --git a/client/src/builtin/24Hour.js b/client/src/builtin/24Hour.js index 56fd33fc..e42fd45f 100644 --- a/client/src/builtin/24Hour.js +++ b/client/src/builtin/24Hour.js @@ -36,5 +36,5 @@ export default new class TwentyFourHour extends BuiltinModule { if (matched[3] === 'AM') return returnValue.replace(matched[0], `${matched[1] === '12' ? '00' : matched[1].padStart(2, '0')}:${matched[2]}`) return returnValue.replace(matched[0], `${parseInt(matched[1]) + 12}:${matched[2]}`) } - + } diff --git a/client/src/data/user.settings.default.json b/client/src/data/user.settings.default.json index 5c6388bf..7acf30aa 100644 --- a/client/src/data/user.settings.default.json +++ b/client/src/data/user.settings.default.json @@ -222,6 +222,13 @@ { "id": "default", "settings": [ + { + "id": "unsafe-content", + "type": "bool", + "text": "Allow unverified content", + "hint": "Allow loading unverified plugins/themes", + "value": "false" + }, { "id": "tracking-protection", "type": "bool", diff --git a/client/src/modules/contentmanager.js b/client/src/modules/contentmanager.js index da815d57..7a0076bb 100644 --- a/client/src/modules/contentmanager.js +++ b/client/src/modules/contentmanager.js @@ -8,14 +8,18 @@ * LICENSE file in the root directory of this source tree. */ +import asar from 'asar'; +import path, { dirname } from 'path'; +import rimraf from 'rimraf'; + import Content from './content'; import Globals from './globals'; import Database from './database'; import { Utils, FileUtils, ClientLogger as Logger } from 'common'; import { SettingsSet, ErrorEvent } from 'structs'; import { Modals } from 'ui'; -import path from 'path'; import Combokeys from 'combokeys'; +import Settings from './settings'; /** * Base class for managing external content @@ -77,12 +81,20 @@ export default class { const directories = await FileUtils.listDirectory(this.contentPath); for (const dir of directories) { - try { - await FileUtils.directoryExists(path.join(this.contentPath, dir)); - } catch (err) { continue; } + const packed = dir.endsWith('.bd'); + + if (!packed) { + try { + await FileUtils.directoryExists(path.join(this.contentPath, dir)); + } catch (err) { continue; } + } try { - await this.preloadContent(dir); + if (packed) { + await this.preloadPackedContent(dir); + } else { + await this.preloadContent(dir); + } } catch (err) { this.errors.push(new ErrorEvent({ module: this.moduleName, @@ -120,6 +132,8 @@ export default class { const directories = await FileUtils.listDirectory(this.contentPath); for (const dir of directories) { + const packed = dir.endsWith('.bd'); + // If content is already loaded this should resolve if (this.getContentByDirName(dir)) continue; @@ -173,6 +187,30 @@ export default class { } } + static async preloadPackedContent(pkg, reload = false, index) { + try { + const packagePath = path.join(this.contentPath, pkg); + const packageName = pkg.replace('.bd', ''); + await FileUtils.fileExists(packagePath); + + const config = JSON.parse(asar.extractFile(packagePath, 'config.json').toString()); + const unpackedPath = path.join(Globals.getPath('tmp'), packageName); + + asar.extractAll(packagePath, unpackedPath); + return this.preloadContent({ + config, + contentPath: unpackedPath, + packagePath: packagePath, + pkg, + packageName, + packed: true + }, reload, index); + + } catch (err) { + throw err; + } + } + /** * Common loading procedure for loading content before passing it to the actual loader * @param {any} dirName Base directory for content @@ -181,7 +219,15 @@ export default class { */ static async preloadContent(dirName, reload = false, index) { try { - const contentPath = path.join(this.contentPath, dirName); + const unsafeAllowed = Settings.getSetting('security', 'default', 'unsafe-content').value; + const packed = typeof dirName === 'object' && dirName.packed; + + // Block any unpacked content as they can't be verified + if (!packed && !unsafeAllowed) { + throw 'Blocked unsafe content'; + } + + const contentPath = packed ? dirName.contentPath : path.join(this.contentPath, dirName); await FileUtils.directoryExists(contentPath); @@ -189,7 +235,7 @@ export default class { throw { 'message': `Attempted to load already loaded user content: ${path}` }; const configPath = path.resolve(contentPath, 'config.json'); - const readConfig = await FileUtils.readJsonFromFile(configPath); + const readConfig = packed ? dirName.config : await FileUtils.readJsonFromFile(configPath); const mainPath = path.join(contentPath, readConfig.main || 'index.js'); const defaultConfig = new SettingsSet({ @@ -213,7 +259,7 @@ export default class { } } catch (err) { // We don't care if this fails it either means that user config doesn't exist or there's something wrong with it so we revert to default config - Logger.warn(this.moduleName, [`Failed reading config for ${this.contentType} ${readConfig.info.name} in ${dirName}`, err]); + Logger.warn(this.moduleName, [`Failed reading config for ${this.contentType} ${readConfig.info.name} in ${packed ? dirName.pkg : dirName}`, err]); } userConfig.config = defaultConfig.clone({ settings: userConfig.config }); @@ -243,16 +289,22 @@ export default class { mainPath }; - const content = await this.loadContent(paths, configs, readConfig.info, readConfig.main, readConfig.dependencies, readConfig.permissions, readConfig.mainExport); + const content = await this.loadContent(paths, configs, readConfig.info, readConfig.main, readConfig.dependencies, readConfig.permissions, readConfig.mainExport, packed ? dirName : false); if (!content) return undefined; if (!reload && this.getContentById(content.id)) - throw {message: `A ${this.contentType} with the ID ${content.id} already exists.`}; + throw { message: `A ${this.contentType} with the ID ${content.id} already exists.` }; if (reload) this.localContent.splice(index, 1, content); else this.localContent.push(content); return content; } catch (err) { throw err; + } finally { + if (typeof dirName === 'object' && dirName.packed) { + rimraf(dirName.contentPath, err => { + if (err) Logger.err(err); + }); + } } } @@ -309,14 +361,7 @@ export default class { if (this.unloadContentHook) this.unloadContentHook(content); - if (reload) { - const newcontent = await this.preloadContent(content.dirName, true, index); - if (newcontent.enabled) { - newcontent.userConfig.enabled = false; - newcontent.start(false); - } - return newcontent; - } + if (reload) return content.packed ? this.preloadPackedContent(content.packed.pkg, true, index) : this.preloadContent(content.dirName, true, index); this.localContent.splice(index, 1); } catch (err) { diff --git a/client/src/modules/globals.js b/client/src/modules/globals.js index 3b0b5b3b..7c40a19a 100644 --- a/client/src/modules/globals.js +++ b/client/src/modules/globals.js @@ -8,6 +8,7 @@ * LICENSE file in the root directory of this source tree. */ +import path from 'path'; import sparkplug from 'sparkplug'; import { ClientIPC } from 'common'; import Module from './module'; @@ -35,6 +36,10 @@ export default new class extends Module { async first() { const config = await ClientIPC.send('getConfig'); + config.paths.push({ + id: 'tmp', + path: path.join(config.paths.find(p => p.id === 'base').path, 'tmp') + }); this.setState({ config }); // This is for Discord to stop error reporting :3 diff --git a/client/src/modules/modules.js b/client/src/modules/modules.js index 8f39537f..239e89f8 100644 --- a/client/src/modules/modules.js +++ b/client/src/modules/modules.js @@ -27,3 +27,4 @@ export { default as Connectivity } from './connectivity'; export { default as Security } from './security'; export { default as Cache } from './cache'; export { default as Reflection } from './reflection/index'; +export { default as PackageInstaller } from './packageinstaller'; diff --git a/client/src/modules/packageinstaller.js b/client/src/modules/packageinstaller.js new file mode 100644 index 00000000..2267dec6 --- /dev/null +++ b/client/src/modules/packageinstaller.js @@ -0,0 +1,172 @@ +import EventListener from './eventlistener'; +import asar from 'asar'; +import fs from 'fs'; +import path from 'path'; +import rimraf from 'rimraf'; + +import { request } from 'vendor'; +import { Modals } from 'ui'; +import { Utils } from 'common'; +import PluginManager from './pluginmanager'; +import Globals from './globals'; +import Security from './security'; +import { ReactComponents } from './reactcomponents'; +import Reflection from './reflection'; +import DiscordApi from './discordapi'; + +export default class PackageInstaller { + + /** + * Handler for drag and drop package install + * @param {String} filePath Path to local file + * @param {String} channelId Current channel id + */ + static async dragAndDropHandler(filePath, channelId) { + try { + const config = JSON.parse(asar.extractFile(filePath, 'config.json').toString()); + const { info, main } = config; + + let icon = null; + if (info.icon && info.icon_type) { + const extractIcon = asar.extractFile(filePath, info.icon); + icon = `data:${info.icon_type};base64,${Utils.arrayBufferToBase64(extractIcon)}`; + } + + const isPlugin = info.type && info.type === 'plugin' || main.endsWith('.js'); + + // Show install modal + const modalResult = await Modals.installModal(isPlugin ? 'plugin' : 'theme', config, filePath, icon).promise; + + if (modalResult === 0) { + // Upload it instead + } + + } catch (err) { + console.log(err); + } + } + + /** + * Hash and verify a package + * @param {Byte[]|String} bytesOrPath byte array of binary or path to local file + * @param {String} id Package id + */ + static async verifyPackage(bytesOrPath, id) { + const bytes = typeof bytesOrPath === 'string' ? fs.readFileSync(bytesOrPath) : bytesOrPath; + // Temporary hash to simulate response from server + const tempVerified = ['2e3532ee366816adc37b0f478bfef35e03f96e7aeee9b115f5918ef6a4e94de8', '06a2eb4e37b926354ab80cd83207db67e544c932e9beddce545967a21f8db5aa']; + const hashBytes = Security.hash('sha256', bytes, 'hex'); + + return tempVerified.includes(hashBytes); + } + + // TODO lots of stuff + /** + * Installs or updates defined package + * @param {Byte[]|String} bytesOrPath byte array of binary or path to local file + * @param {String} name Package name + * @param {Boolean} update Does an older version already exist + */ + static async installPackage(bytesOrPath, id, update = false) { + let outputPath = null; + try { + const bytes = typeof bytesOrPath === 'string' ? fs.readFileSync(bytesOrPath) : bytesOrPath; + + const outputName = `${id}.bd`; + outputPath = path.join(Globals.getPath('plugins'), outputName); + fs.writeFileSync(outputPath, bytes); + + if (!update) return PluginManager.preloadPackedContent(outputName); + + const oldContent = PluginManager.getPluginById(id); + + if (update && oldContent.packed && oldContent.packed.packageName !== id) { + await oldContent.unload(true); + rimraf(oldContent.packed.packagePath, err => { + if(err) console.log(err); + }); + + return PluginManager.preloadPackedContent(outputName); + } + + if (update && !oldContent.packed) { + await oldContent.unload(true); + rimraf(oldContent.contentPath, err => { + if (err) console.log(err); + }); + + return PluginManager.preloadPackedContent(outputName); + } + + return PluginManager.reloadContent(oldContent); + } catch (err) { + if (outputPath) { + rimraf(outputPath, err => { + if (err) console.log(err); + }); + } + throw err; + } + } + + /** + * Install package from remote location. Only github/bdapi is supoorted. + * @param {String} remoteLocation Remote resource location + */ + static async installRemotePackage(remoteLocation) { + try { + const { hostname } = Object.assign(document.createElement('a'), { href: remoteLocation }); + if (hostname !== 'api.github.com' && hostname !== 'secretbdapi') throw 'Invalid host!'; + + const options = { + uri: remoteLocation, + headers: { + 'User-Agent': 'BetterDiscordClient', + 'Accept': 'application/octet-stream' + } + }; + + const response = await request.get(options); + const outputPath = path.join(Globals.getPath('tmp'), Security.hash('sha256', response, 'hex')); + fs.writeFileSync(outputPath, response); + + await this.dragAndDropHandler(outputPath); + + rimraf(outputPath, err => { + if (err) console.log(err); + }); + } catch (err) { + throw err; + } + } + + /** + * Patches Discord upload area for .bd files + */ + static async uploadAreaPatch() { + const { selector } = Reflection.resolve('uploadArea'); + this.UploadArea = await ReactComponents.getComponent('UploadArea', { selector }); + + const reflect = Reflection.DOM(selector); + const stateNode = reflect.getComponentStateNode(this.UploadArea); + const callback = function (e) { + if (!e.dataTransfer.files.length || !e.dataTransfer.files[0].name.endsWith('.bd')) return; + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + stateNode.clearDragging(); + + PackageInstaller.dragAndDropHandler(e.dataTransfer.files[0].path, DiscordApi.currentChannel.id); + }; + + // Remove their handler, add ours, then read theirs to give ours priority to stop theirs when we get a .bd file. + reflect.element.removeEventListener('drop', stateNode.handleDrop); + reflect.element.addEventListener('drop', callback); + reflect.element.addEventListener('drop', stateNode.handleDrop); + + this.unpatchUploadArea = function () { + reflect.element.removeEventListener('drop', callback); + }; + } + +} diff --git a/client/src/modules/pluginmanager.js b/client/src/modules/pluginmanager.js index e1c0d04b..3778412f 100644 --- a/client/src/modules/pluginmanager.js +++ b/client/src/modules/pluginmanager.js @@ -74,7 +74,7 @@ export default class extends ContentManager { static get refreshPlugins() { return this.refreshContent } static get loadContent() { return this.loadPlugin } - static async loadPlugin(paths, configs, info, main, dependencies, permissions, mainExport) { + static async loadPlugin(paths, configs, info, main, dependencies, permissions, mainExport, packed = false) { if (permissions && permissions.length > 0) { for (const perm of permissions) { Logger.log(this.moduleName, `Permission: ${Permissions.permissionText(perm).HEADER} - ${Permissions.permissionText(perm).BODY}`); @@ -112,11 +112,18 @@ export default class extends ContentManager { configs, info, main, paths: { contentPath: paths.contentPath, - dirName: paths.dirName, + dirName: packed ? packed.packageName : paths.dirName, mainPath: paths.mainPath } }); + if (packed) instance.packed = { + pkg: packed.pkg, + packageName: packed.packageName, + packagePath: packed.packagePath, + packed: true + }; else instance.packed = false; + if (instance.enabled && this.loaded) { instance.userConfig.enabled = false; instance.start(false); diff --git a/client/src/modules/reactcomponents.js b/client/src/modules/reactcomponents.js index c43849eb..8b71e6e9 100644 --- a/client/src/modules/reactcomponents.js +++ b/client/src/modules/reactcomponents.js @@ -15,6 +15,7 @@ import { Utils, Filters, ClientLogger as Logger } from 'common'; import { MonkeyPatch } from './patcher'; import Reflection from './reflection/index'; import DiscordApi from './discordapi'; +import PackageInstaller from './packageinstaller'; class Helpers { static get plannedActions() { @@ -501,28 +502,6 @@ export class ReactAutoPatcher { } static async patchUploadArea() { - const { selector } = Reflection.resolve('uploadArea'); - this.UploadArea = await ReactComponents.getComponent('UploadArea', {selector}); - - const reflect = Reflection.DOM(selector); - const stateNode = reflect.getComponentStateNode(this.UploadArea); - const callback = function(e) { - if (!e.dataTransfer.files.length || !e.dataTransfer.files[0].name.endsWith('.bd')) return; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - stateNode.clearDragging(); - Modals.confirm('Function not ready', `You tried to install "${e.dataTransfer.files[0].path}", but installing .bd files isn't ready yet.`) - // Possibly something like Events.emit('install-file', e.dataTransfer.files[0]); - }; - - // Remove their handler, add ours, then readd theirs to give ours priority to stop theirs when we get a .bd file. - reflect.element.removeEventListener('drop', stateNode.handleDrop); - reflect.element.addEventListener('drop', callback); - reflect.element.addEventListener('drop', stateNode.handleDrop); - - this.unpatchUploadArea = function() { - reflect.element.removeEventListener('drop', callback); - }; + PackageInstaller.uploadAreaPatch(); } } diff --git a/client/src/styles/partials/modals/index.scss b/client/src/styles/partials/modals/index.scss index 723202c8..51d3a225 100644 --- a/client/src/styles/partials/modals/index.scss +++ b/client/src/styles/partials/modals/index.scss @@ -8,3 +8,4 @@ @import './settings-modal'; @import './permission-modal'; @import './input-modal'; +@import './install-modal'; diff --git a/client/src/styles/partials/modals/install-modal.scss b/client/src/styles/partials/modals/install-modal.scss new file mode 100644 index 00000000..25267b3b --- /dev/null +++ b/client/src/styles/partials/modals/install-modal.scss @@ -0,0 +1,231 @@ +.bd-installModal { + + .bd-modalInner { + min-width: 520px; + min-height: 200px; + + @at-root .bd-err#{&} { + border: 2px solid $colerr; + } + + .bd-modalBody { + flex-grow: 1; + padding: 0; + + .bd-installModalBody { + display: flex; + flex-direction: column; + flex-grow: 1; + + .bd-installModalTop { + padding: 0 15px; + display: flex; + height: 100px; + + span { + font-weight: 700; + } + + .bd-installModalIcon { + background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTAwIDEzMCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTAwIDEzMDsiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+LnN0MHtmaWxsOiNGNEY2RkM7fS5zdDF7ZmlsbDpub25lO3N0cm9rZTojMzYzOTNFO3N0cm9rZS13aWR0aDoyO30uc3Qye29wYWNpdHk6MC42O2ZpbGw6bm9uZTtzdHJva2U6IzM2MzkzRTtzdHJva2Utd2lkdGg6MjtlbmFibGUtYmFja2dyb3VuZDpuZXcgICAgO308L3N0eWxlPjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xNSwxOWMwLTMuMywyLjctNiw2LTZoNDRjNC43LDAsMjAsMTUuMywyMCwyMHY3NGMwLDMuMy0yLjcsNi02LDZIMjFjLTMuMywwLTYtMi43LTYtNlYxOXoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNMTUsMTljMC0zLjMsMi43LTYsNi02aDQ0YzQuNywwLDIwLDE1LjMsMjAsMjB2NzRjMCwzLjMtMi43LDYtNiw2SDIxYy0zLjMsMC02LTIuNy02LTZWMTl6Ii8+PHBhdGggY2xhc3M9InN0MCIgZD0iTTY2LDE2YzAtMy4zLDEuOS00LjEsNC4yLTEuOGwxMy41LDEzLjVjMi4zLDIuMywxLjYsNC4yLTEuOCw0LjJINjljLTEuNywwLTMtMS4zLTMtM1YxNkw2NiwxNnoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNNjYsMTZjMC0zLjMsMS45LTQuMSw0LjItMS44bDEzLjUsMTMuNWMyLjMsMi4zLDEuNiw0LjItMS44LDQuMkg2OWMtMS43LDAtMy0xLjMtMy0zVjE2TDY2LDE2eiIvPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik02MSwxN0gyM2MtMi44LDAtNSwyLjItNSw1djciLz48cGF0aCBjbGFzcz0ic3QyIiBkPSJNNjksMTl2Ny41YzAsMS40LDEuMSwyLjUsMi41LDIuNUg3NCIvPjwvc3ZnPg=='); + background-size: 100% 100%; + flex-shrink: 0; + width: 100px; + height: 130px; + position: fixed; + border-radius: 3px; + margin-top: -30px; + display: flex; + align-content: center; + justify-content: center; + align-items: center; + justify-items: center; + + @at-root .bd-err#{&} { + background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTAwIDEzMCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTAwIDEzMDsiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+LnN0MHtmaWxsOiNGNEY2RkM7fS5zdDF7ZmlsbDpub25lO3N0cm9rZTojRDg0MDQwO3N0cm9rZS13aWR0aDoyO30uc3Qye29wYWNpdHk6MC42O2ZpbGw6bm9uZTtzdHJva2U6I0Q4NDA0MDtzdHJva2Utd2lkdGg6MjtlbmFibGUtYmFja2dyb3VuZDpuZXcgICAgO308L3N0eWxlPjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xNSwxOWMwLTMuMywyLjctNiw2LTZoNDRjNC43LDAsMjAsMTUuMywyMCwyMHY3NGMwLDMuMy0yLjcsNi02LDZIMjFjLTMuMywwLTYtMi43LTYtNlYxOXoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNMTUsMTljMC0zLjMsMi43LTYsNi02aDQ0YzQuNywwLDIwLDE1LjMsMjAsMjB2NzRjMCwzLjMtMi43LDYtNiw2SDIxYy0zLjMsMC02LTIuNy02LTZWMTl6Ii8+PHBhdGggY2xhc3M9InN0MCIgZD0iTTY2LDE2YzAtMy4zLDEuOS00LjEsNC4yLTEuOGwxMy41LDEzLjVjMi4zLDIuMywxLjYsNC4yLTEuOCw0LjJINjljLTEuNywwLTMtMS4zLTMtM1YxNkw2NiwxNnoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNNjYsMTZjMC0zLjMsMS45LTQuMSw0LjItMS44bDEzLjUsMTMuNWMyLjMsMi4zLDEuNiw0LjItMS44LDQuMkg2OWMtMS43LDAtMy0xLjMtMy0zVjE2TDY2LDE2eiIvPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik02MSwxN0gyM2MtMi44LDAtNSwyLjItNSw1djciLz48cGF0aCBjbGFzcz0ic3QyIiBkPSJNNjksMTl2Ny41YzAsMS40LDEuMSwyLjUsMi41LDIuNUg3NCIvPjwvc3ZnPg=='); + } + + .bd-installModalCi { + width: 40px; + height: 40px; + } + + svg { + fill: #36393e; + width: 40px; + height: 40px; + } + } + + .bd-installModalInfo { + display: flex; + flex-grow: 1; + flex-direction: column; + padding: 15px 10px; + margin-left: 100px; + } + + .bd-installModalDesc { + margin-top: 5px; + } + } + + + .bd-installModalBottom { + padding: 0 15px; + flex-grow: 1; + + .bd-permScope { + &:last-child { + .bd-permInner { + border: 0; + } + } + } + } + } + } + + .bd-installModalFooter { + display: flex; + justify-content: flex-end; + padding: 10px; + background: rgba(0, 0, 0, .1); + + .bd-installModalStatus { + height: 36px; + line-height: 36px; + flex-grow: 1; + padding: 0 25px; + font-weight: 600; + color: #fff; + + &.bd-ok { + color: $colok; + } + + &.bd-err { + color: $colerr; + } + } + + .bd-button { + font-size: 16px; + padding: 10px; + border-radius: 3px; + min-width: 100px; + transition: opacity .2s ease-in-out; + + &.bd-ok { + background: #3ecc9c; + } + + &.bd-err { + color: #fff; + } + + &:hover { + opacity: .8; + } + + &.bd-installModalUpload { + background: transparent; + transition: none; + + &:hover { + opacity: 1; + text-decoration: underline; + } + } + } + } + } + + &.bd-installModalDone { + .bd-modalInner { + min-width: 400px; + max-width: 400px; + height: 250px; + border: 2px solid $colok; + + .bd-installModalBody { + display: flex; + flex-grow: 1; + align-content: center; + justify-content: center; + align-items: center; + + h3 { + color: $colok; + font-size: 1.2em; + font-weight: 700; + text-align: center; + padding: 20px; + } + } + + .bd-materialDesignIcon { + svg { + fill: $colok; + width: 100px; + height: 100px; + } + } + + .bd-installModalFooter { + .bd-button { + margin-left: 5px; + } + } + } + } + + &.bd-installModalFail { + .bd-modalInner { + min-width: 400px; + max-width: 400px; + height: 250px; + border: 2px solid $colerr; + + .bd-installModalBody { + display: flex; + flex-grow: 1; + align-content: center; + justify-content: center; + align-items: center; + + h3 { + color: $colerr; + font-size: 1.2em; + font-weight: 700; + text-align: center; + padding: 20px; + } + } + + .bd-materialDesignIcon { + svg { + fill: $colerr; + width: 100px; + height: 100px; + } + } + + .bd-installModalFooter { + + &.bd-installModalErrMsg { + justify-content: flex-start; + padding: 10px; + background: rgba(0, 0, 0, .2); + font-weight: 700; + + span { + color: #fff; + flex-grow: 1; + text-align: right; + } + } + } + } + } +} diff --git a/client/src/ui/components/bd/PluginCard.vue b/client/src/ui/components/bd/PluginCard.vue index d36691db..f8cfe65d 100644 --- a/client/src/ui/components/bd/PluginCard.vue +++ b/client/src/ui/components/bd/PluginCard.vue @@ -12,6 +12,7 @@ + @@ -22,18 +23,47 @@ diff --git a/client/src/ui/components/common/MaterialIcon.js b/client/src/ui/components/common/MaterialIcon.js index 182b9826..bd5c2384 100644 --- a/client/src/ui/components/common/MaterialIcon.js +++ b/client/src/ui/components/common/MaterialIcon.js @@ -22,3 +22,5 @@ export { default as MiLock } from './materialicons/Lock.vue'; export { default as MiImagePlus } from './materialicons/ImagePlus.vue'; export { default as MiIcVpnKey } from './materialicons/IcVpnKey.vue'; export { default as MiArrowLeft } from './materialicons/ArrowLeft.vue'; +export { default as MiBoxDownload } from './materialicons/BoxDownload.vue'; +export { default as MiSuccessCircle } from './materialicons/SuccessCircle.vue'; diff --git a/client/src/ui/components/common/Modal.vue b/client/src/ui/components/common/Modal.vue index c06ab439..4fd0a688 100644 --- a/client/src/ui/components/common/Modal.vue +++ b/client/src/ui/components/common/Modal.vue @@ -11,7 +11,7 @@