diff --git a/renderer/src/modules/addonmanager.js b/renderer/src/modules/addonmanager.js index e9a45eff..7eed3965 100644 --- a/renderer/src/modules/addonmanager.js +++ b/renderer/src/modules/addonmanager.js @@ -185,6 +185,8 @@ export default class AddonManager { addon.modified = stats.mtimeMs; addon.size = stats.size; addon.fileContent = fileContent; + if (this.addonList.find(c => c.id == addon.id)) throw new AddonError(addon.name, filename, Strings.Addons.alreadyExists.format({type: this.prefix, name: addon.name}), this.prefix); + this.addonList.push(addon); return addon; } @@ -196,14 +198,22 @@ export default class AddonManager { addon = this.requireAddon(path.resolve(this.addonFolder, filename)); } catch (e) { + const partialAddon = this.addonList.find(c => c.filename == filename); + if (partialAddon) { + partialAddon.partial = true; + this.state[partialAddon.id] = false; + } return e; } - if (this.addonList.find(c => c.id == addon.id)) return new AddonError(addon.name, filename, Strings.Addons.alreadyExists.format({type: this.prefix, name: addon.name}), this.prefix); + const error = this.initializeAddon(addon); - if (error) return error; + if (error) { + this.state[addon.id] = false; + addon.partial = true; + return error; + } - this.addonList.push(addon); if (shouldToast) Toasts.success(`${addon.name} v${addon.version} was loaded.`); this.emit("loaded", addon.id); @@ -248,7 +258,7 @@ export default class AddonManager { enableAddon(idOrAddon) { const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon; - if (!addon) return; + if (!addon || addon.partial) return; if (this.state[addon.id]) return; this.state[addon.id] = true; this.startAddon(addon); @@ -257,7 +267,7 @@ export default class AddonManager { disableAddon(idOrAddon) { const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon; - if (!addon) return; + if (!addon || addon.partial) return; if (!this.state[addon.id]) return; this.state[addon.id] = false; this.stopAddon(addon); diff --git a/renderer/src/modules/addonupdater.js b/renderer/src/modules/addonupdater.js new file mode 100644 index 00000000..147e6130 --- /dev/null +++ b/renderer/src/modules/addonupdater.js @@ -0,0 +1,109 @@ +import {Config} from "data"; +import fileSystem from "fs"; +import path from "path"; +import request from "request"; + +import PluginManager from "./pluginmanager"; +import ThemeManager from "./thememanager"; + +import Toasts from "../ui/toasts"; +import Notices from "../ui/notices"; +import Logger from "../../../common/logger"; + + +const base = "https://api.betterdiscord.app/v2/store/"; +const route = r => `${base}${r}`; +const redirect = addonId => `https://betterdiscord.app/gh-redirect?id=${addonId}`; + +// const raceTimeout = (delay, reason) => new Promise((_, rej) => setTimeout(() => rej(reason), delay)); +// const timeout = (promise, delay, reason) => Promise.race([promise, raceTimeout(delay, reason)]); + +const getJSON = url => { + return new Promise(resolve => { + request(url, (error, response, body) => { + if (error) return resolve([]); + resolve(JSON.parse(body)); + }); + }); +}; + +const reducer = (acc, addon) => { + if (addon.version === "Unknown") return acc; + acc[addon.file_name] = {name: addon.name, version: addon.version, id: addon.id, type: addon.type}; + return acc; +}; + +export default class AddonUpdater { + + static async initialize() { + this.cache = {}; + this.shown = false; + this.pending = []; + + Logger.info("AddonUpdater", "Before get api"); + const pluginData = await getJSON(route("plugins")); + const themeData = await getJSON(route("themes")); + Logger.info("AddonUpdater", "After get api"); + + this.temp = {pluginData, themeData}; + + pluginData.reduce(reducer, this.cache); + themeData.reduce(reducer, this.cache); + + Logger.info("AddonUpdater", "going to check lists"); + for (const addon of PluginManager.addonList) this.checkForUpdate(addon.filename, addon.version); + for (const addon of ThemeManager.addonList) this.checkForUpdate(addon.filename, addon.version); + this.showUpdateNotice(); + } + + static clearPending() { + this.pending.splice(0, this.pending.length); + } + + static async checkForUpdate(filename, currentVersion) { + Logger.info("AddonUpdater", "checkForUpdate", filename, currentVersion); + const info = this.cache[path.basename(filename)]; + if (!info) return; + const hasUpdate = info.version > currentVersion; + if (!hasUpdate) return; + this.pending.push(filename); + } + + static async updatePlugin(filename) { + const info = this.cache[filename]; + request(redirect(info.id), (err, response) => { + if (err) return; + if (!response.headers.location) return; // expected redirect + request(response.headers.location, (error, _, body) => { + if (error) return; + const file = path.join(path.resolve(Config.dataPath, info.type + "s"), filename); + fileSystem.writeFile(file, body.toString(), () => { + Toasts.success(`${info.name} has been updated to version ${info.version}!`); + }); + }); + + }); + } + + static showUpdateNotice() { + Logger.info("AddonUpdater", "showUpdateNotice", this.shown, this.pending); + if (this.shown || !this.pending.length) return; + this.shown = true; + const close = Notices.info(`BetterDiscord has found updates for ${this.pending.length} of your plugins and themes!`, { + timeout: 0, + buttons: [{ + label: "Update Now", + onClick: async () => { + for (const name of this.pending) await this.updatePlugin(name); + close(); + } + }], + onClose: () => { + this.shown = false; + this.clearPending(); + } + }); + } +} + +window.updater = AddonUpdater; \ No newline at end of file diff --git a/renderer/src/modules/core.js b/renderer/src/modules/core.js index 98889a35..b64018f1 100644 --- a/renderer/src/modules/core.js +++ b/renderer/src/modules/core.js @@ -16,6 +16,7 @@ import IPC from "./ipc"; import LoadingIcon from "../loadingicon"; import Styles from "../styles/index.css"; import Editor from "./editor"; +import AddonUpdater from "./addonupdater"; export default new class Core { async startup() { @@ -64,6 +65,9 @@ export default new class Core { // const themeErrors = []; const themeErrors = ThemeManager.initialize(); + Logger.log("Startup", "Initializing AddonUpdater"); + AddonUpdater.initialize(); + Logger.log("Startup", "Removing Loading Icon"); LoadingIcon.hide(); diff --git a/renderer/src/modules/pluginmanager.js b/renderer/src/modules/pluginmanager.js index 451938f4..441f939a 100644 --- a/renderer/src/modules/pluginmanager.js +++ b/renderer/src/modules/pluginmanager.js @@ -187,7 +187,7 @@ export default new class PluginManager extends AddonManager { for (let i = 0; i < this.addonList.length; i++) { const plugin = this.addonList[i].instance; if (!this.state[this.addonList[i].id]) continue; - if (typeof(plugin.onSwitch) === "function") { + if (typeof(plugin?.onSwitch) === "function") { try {plugin.onSwitch();} catch (err) {Logger.stacktrace(this.name, `Unable to fire onSwitch for ${this.addonList[i].name} v${this.addonList[i].version}`, err);} } @@ -198,7 +198,7 @@ export default new class PluginManager extends AddonManager { for (let i = 0; i < this.addonList.length; i++) { const plugin = this.addonList[i].instance; if (!this.state[this.addonList[i].id]) continue; - if (typeof plugin.observer === "function") { + if (typeof plugin?.observer === "function") { try {plugin.observer(mutation);} catch (err) {Logger.stacktrace(this.name, `Unable to fire observer for ${this.addonList[i].name} v${this.addonList[i].version}`, err);} } diff --git a/renderer/src/styles/ui/addonlist.css b/renderer/src/styles/ui/addonlist.css index 3ded72ad..8dfeb747 100644 --- a/renderer/src/styles/ui/addonlist.css +++ b/renderer/src/styles/ui/addonlist.css @@ -63,6 +63,10 @@ fill: var(--header-primary); } +.disabled .bd-addon-header .bd-icon { + fill: red; +} + .bd-title, .bd-name, .bd-meta { @@ -106,6 +110,33 @@ padding: 8px 16px 0 16px; } +.bd-description-wrap .banner { + padding: 5px; + border-left: 5px solid gray; + background: #26191E; + color: #C13A3A; + border-radius: 5px; + font-size: 16px; + display: flex; + align-items: center; +} + +.banner.banner-danger { + border-left-color: #C13A3A; + background: #26191E; + color: #C13A3A; +} + +.banner .bd-icon { + fill: red; + margin-right: 5px; + height: 16px !important; +} + +.banner-danger .bd-icon { + fill: red; +} + .bd-addon-list .bd-description { word-break: break-word; margin-bottom: 5px; diff --git a/renderer/src/styles/ui/switch.css b/renderer/src/styles/ui/switch.css index a94e4e51..9ad196c0 100644 --- a/renderer/src/styles/ui/switch.css +++ b/renderer/src/styles/ui/switch.css @@ -107,4 +107,8 @@ .bd-switch-disabled { opacity: 0.5; filter: grayscale(1); +} + +.bd-switch-disabled input { + cursor: not-allowed; } \ No newline at end of file diff --git a/renderer/src/ui/icons/error.jsx b/renderer/src/ui/icons/error.jsx new file mode 100644 index 00000000..eb1bf767 --- /dev/null +++ b/renderer/src/ui/icons/error.jsx @@ -0,0 +1,12 @@ +import {React} from "modules"; + +export default class Error extends React.Component { + render() { + const size = this.props.size || "24px"; + return + + + ; + } +} + diff --git a/renderer/src/ui/notices.js b/renderer/src/ui/notices.js index 0fe6941e..3585d274 100644 --- a/renderer/src/ui/notices.js +++ b/renderer/src/ui/notices.js @@ -36,11 +36,12 @@ export default class Notices { * @returns {(immediately?: boolean = false) => void} */ static show(content, options = {}) { - const {type, buttons = [], timeout = 0} = options; + const {type, buttons = [], timeout = 0, onClose = () => {}} = options; const haveContainer = this.ensureContainer(); if (!haveContainer) return; const closeNotification = function (immediately = false) { + onClose?.(); // Immediately remove the notice without adding the closing class. if (immediately) return noticeElement.remove(); diff --git a/renderer/src/ui/settings/addoncard.jsx b/renderer/src/ui/settings/addoncard.jsx index e7f35d04..98d14150 100644 --- a/renderer/src/ui/settings/addoncard.jsx +++ b/renderer/src/ui/settings/addoncard.jsx @@ -12,6 +12,7 @@ import WebIcon from "../icons/globe"; import PatreonIcon from "../icons/patreon"; import SupportIcon from "../icons/support"; import ExtIcon from "../icons/extension"; +import ErrorIcon from "../icons/error"; import ThemeIcon from "../icons/theme"; import Modals from "../modals"; import Toasts from "../toasts"; @@ -174,9 +175,12 @@ export default class AddonCard extends React.Component {
{this.props.type === "plugin" ? : }
{this.buildTitle(name, version, {name: author, id: this.props.addon.authorId, link: this.props.addon.authorLink})}
- + +
+
+ {this.props.disabled &&
{`An error was encountered while trying to load this ${this.props.type}.`}
} +
{SimpleMarkdown.parseToReact(description)}
-
{SimpleMarkdown.parseToReact(description)}
{this.footer} ; } diff --git a/renderer/src/ui/settings/addonlist.jsx b/renderer/src/ui/settings/addonlist.jsx index eecc93bc..213c7275 100644 --- a/renderer/src/ui/settings/addonlist.jsx +++ b/renderer/src/ui/settings/addonlist.jsx @@ -150,7 +150,7 @@ export default class AddonList extends React.Component { const renderedCards = sortedAddons.map(addon => { const hasSettings = addon.instance && typeof(addon.instance.getSettingsPanel) === "function"; const getSettings = hasSettings && addon.instance.getSettingsPanel.bind(addon.instance); - return ; + return ; }); const hasAddonsInstalled = this.props.addonList.length !== 0;