Merge pull request #1420 from BetterDiscord/updater

Stop-gap Updater
This commit is contained in:
Zerebos 2022-09-29 19:15:21 -04:00 committed by GitHub
commit f41b071727
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 189 additions and 18 deletions

View File

@ -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);

View File

@ -0,0 +1,96 @@
import request from "request";
import fileSystem from "fs";
import {Config} from "data";
import path from "path";
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 getJSON = url => {
return new Promise(resolve => {
request(url, (error, _, 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 = [];
const pluginData = await getJSON(route("plugins"));
const themeData = await getJSON(route("themes"));
pluginData.reduce(reducer, this.cache);
themeData.reduce(reducer, this.cache);
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) {
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), (error, _, body) => {
if (error) {
Logger.stacktrace("AddonUpdater", `Failed to download body for ${info.id}:`, 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() {
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();
}
});
}
}

View File

@ -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();

View File

@ -155,7 +155,7 @@ BdApi.showToast = function(content, options = {}) {
* @param {object} options Options for the notice.
* @param {string} [options.type="info" | "error" | "warning" | "success"] Type for the notice. Will affect the color.
* @param {Array<{label: string, onClick: function}>} [options.buttons] Buttons that should be added next to the notice text.
* @param {number} [options.timeout=10000] Timeout until the notice is closed. Won't fire if it's set to 0;
* @param {number} [options.timeout=0] Timeout until the notice is closed. Won't fire if it's set to 0;
* @returns {function} A callback for closing the notice. Passing `true` as first parameter closes immediately without transitioning out.
*/
BdApi.showNotice = function (content, options = {}) {

View File

@ -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);}
}

View File

@ -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: 2px solid gray;
background: #26191E;
color: #ffffff;
font-weight: 700px;
border-radius: 5px;
font-size: 16px;
display: flex;
align-items: center;
}
.banner.banner-danger {
border-color: #F04747;
background: #473C41;
}
.banner .bd-icon {
fill: #ffffff;
margin-right: 5px;
height: 16px !important;
}
.banner-danger .bd-icon {
fill: #F04747;
}
.bd-addon-list .bd-description {
word-break: break-word;
margin-bottom: 5px;

View File

@ -108,3 +108,7 @@
opacity: 0.5;
filter: grayscale(1);
}
.bd-switch-disabled input {
cursor: not-allowed;
}

View File

@ -0,0 +1,12 @@
import {React} from "modules";
export default class Error extends React.Component {
render() {
const size = this.props.size || "24px";
return <svg viewBox="0 0 24 24" fill="#FFFFFF" style={{width: size, height: size}} onClick={this.props.onClick} className={this.props.className}>
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
</svg>;
}
}

View File

@ -1,7 +1,8 @@
import {WebpackModules} from "modules";
import {Utilities, WebpackModules} from "modules";
export default class Notices {
static get baseClass() {return this.__baseClass || (this.__baseClass = WebpackModules.getByProps("container", "base")?.base);}
static get baseClass() {return this.__baseClass ??= WebpackModules.getByProps("container", "base")?.base;}
static get errorPageClass() {return this.__errorPageClass ??= WebpackModules.getByProps("errorPage")?.errorPage;}
/** Shorthand for `type = "info"` for {@link module:Notices.show} */
static info(content, options = {}) {return this.show(content, Object.assign({}, options, {type: "info"}));}
@ -32,15 +33,16 @@ export default class Notices {
* @param {object} options Options for the notice.
* @param {string} [options.type="info" | "error" | "warning" | "success"] Type for the notice. Will affect the color.
* @param {Array<{label: string, onClick: (immediately?: boolean = false) => void}>} [options.buttons] Buttons that should be added next to the notice text.
* @param {number} [options.timeout=10000] Timeout until the toast is closed. Won't fire if it's set to 0;
* @param {number} [options.timeout=0] Timeout until the toast is closed. Won't fire if it's set to 0;
* @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();
@ -82,6 +84,14 @@ export default class Notices {
});
container.prepend(noticeContainer);
Utilities.onRemoved(container, async () => {
if (!this.errorPageClass) return;
const element = await new Promise(res => Utilities.onAdded(`.${this.errorPageClass}`, res));
element.prepend(noticeContainer);
});
return true;
}
}

View File

@ -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 {
<div className="bd-addon-header">
{this.props.type === "plugin" ? <ExtIcon size="18px" className="bd-icon" /> : <ThemeIcon size="18px" className="bd-icon" />}
<div className="bd-title">{this.buildTitle(name, version, {name: author, id: this.props.addon.authorId, link: this.props.addon.authorLink})}</div>
<Switch checked={this.props.enabled} onChange={this.onChange} />
<Switch disabled={this.props.disabled} checked={this.props.enabled} onChange={this.onChange} />
</div>
<div className="bd-description-wrap">
{this.props.disabled && <div className="banner banner-danger"><ErrorIcon className="bd-icon" />{`An error was encountered while trying to load this ${this.props.type}.`}</div>}
<div className="bd-description">{SimpleMarkdown.parseToReact(description)}</div>
</div>
<div className="bd-description-wrap"><div className="bd-description">{SimpleMarkdown.parseToReact(description)}</div></div>
{this.footer}
</div>;
}

View File

@ -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 <ErrorBoundary><AddonCard type={this.props.type} editAddon={this.editAddon.bind(this, addon.id)} deleteAddon={this.deleteAddon.bind(this, addon.id)} key={addon.id} enabled={addonState[addon.id]} addon={addon} onChange={onChange} reload={reload} hasSettings={hasSettings} getSettingsPanel={getSettings} /></ErrorBoundary>;
return <ErrorBoundary><AddonCard disabled={addon.partial} type={this.props.type} editAddon={this.editAddon.bind(this, addon.id)} deleteAddon={this.deleteAddon.bind(this, addon.id)} key={addon.id} enabled={addonState[addon.id]} addon={addon} onChange={onChange} reload={reload} hasSettings={hasSettings} getSettingsPanel={getSettings} /></ErrorBoundary>;
});
const hasAddonsInstalled = this.props.addonList.length !== 0;