import request from "request"; import fileSystem from "fs"; import path from "path"; import Logger from "@common/logger"; import Config from "@data/config"; import {comparator as semverComparator, regex as semverRegex} from "@structs/semver"; import Events from "./emitter"; import IPC from "./ipc"; import Strings from "./strings"; import DataStore from "./datastore"; import React from "./react"; import Settings from "./settingsmanager"; import PluginManager from "./pluginmanager"; import ThemeManager from "./thememanager"; import WebpackModules from "./webpackmodules"; import Toasts from "@ui/toasts"; import Notices from "@ui/notices"; import Modals from "@ui/modals"; import UpdaterPanel from "@ui/updater"; const UserSettingsWindow = WebpackModules.getByProps("updateAccount"); const base = "https://api.betterdiscord.app/v2/store/"; const route = r => `${base}${r}s`; 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}; return acc; }; export default class Updater { static initialize() { Settings.registerPanel("updates", Strings.Panels.updates, { order: 1, element: () => { return React.createElement(UpdaterPanel, { coreUpdater: CoreUpdater, pluginUpdater: PluginUpdater, themeUpdater: ThemeUpdater }); } }); CoreUpdater.initialize(); PluginUpdater.initialize(); ThemeUpdater.initialize(); } } export class CoreUpdater { static hasUpdate = false; static apiData = {}; static remoteVersion = ""; static async initialize() { this.checkForUpdate(); } static async checkForUpdate(showNotice = true) { const resp = await fetch(`https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest`,{ method: "GET", headers: { "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "BetterDiscord Updater" } }); const data = await resp.json(); this.apiData = data; const remoteVersion = data.tag_name.startsWith("v") ? data.tag_name.slice(1) : data.tag_name; this.hasUpdate = remoteVersion > Config.version; this.remoteVersion = remoteVersion; if (!this.hasUpdate || !showNotice) return; const close = Notices.info(Strings.Updater.updateAvailable.format({version: remoteVersion}), { buttons: [{ label: Strings.Notices.moreInfo, onClick: () => { close(); UserSettingsWindow?.open?.("updates"); } }] }); } static async update() { try { const asar = this.apiData.assets.find(a => a.name === "betterdiscord.asar"); const buff = await new Promise((resolve, reject) => request(asar.url, {headers: {"Content-Type": "application/octet-stream", "User-Agent": "BetterDiscord Updater", "Accept": "application/octet-stream"}}, (err, resp, body) => { if (err || resp.statusCode != 200) return reject(err || `${resp.statusCode} ${resp.statusMessage}`); return resolve(body); })); const asarPath = path.join(DataStore.baseFolder, "betterdiscord.asar"); const fs = require("original-fs"); fs.writeFileSync(asarPath, buff); this.hasUpdate = false; Config.version = this.remoteVersion; Modals.showConfirmationModal(Strings.Updater.updateSuccessful, Strings.Modals.restartPrompt, { confirmText: Strings.Modals.restartNow, cancelText: Strings.Modals.restartLater, danger: true, onConfirm: () => IPC.relaunch() }); } catch (err) { Logger.stacktrace("Updater", "Failed to update", err); Modals.showConfirmationModal(Strings.Updater.updateFailed, Strings.Updater.updateFailedMessage, { cancelText: null }); } } } class AddonUpdater { constructor(type) { this.manager = type === "plugin" ? PluginManager : ThemeManager; this.type = type; this.cache = {}; this.pending = []; } async initialize() { await this.updateCache(); this.checkAll(); Events.on(`${this.type}-loaded`, addon => { this.checkForUpdate(addon.filename, addon.version); }); Events.on(`${this.type}-unloaded`, addon => { const index = this.pending.indexOf(addon.filename); if (index >= 0) this.pending.splice(index, 1); }); } async updateCache() { this.cache = {}; const addonData = await getJSON(route(this.type)); addonData.reduce(reducer, this.cache); } clearPending() { this.pending.splice(0, this.pending.length); } checkAll(showNotice = true) { for (const addon of this.manager.addonList) this.checkForUpdate(addon.filename, addon.version); if (showNotice) this.showUpdateNotice(); } checkForUpdate(filename, currentVersion) { if (this.pending.includes(filename)) return; const info = this.cache[path.basename(filename)]; if (!info) return; let hasUpdate = info.version > currentVersion; if (semverRegex.test(info.version) && semverRegex.test(currentVersion)) { hasUpdate = semverComparator(currentVersion, info.version) > 0; } if (!hasUpdate) return; this.pending.push(filename); } async updateAddon(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(this.manager.addonFolder), filename); fileSystem.writeFile(file, body.toString(), () => { Toasts.success(Strings.Updater.addonUpdated.format({name: info.name, version: info.version})); this.pending.splice(this.pending.indexOf(filename), 1); }); }); } showUpdateNotice() { if (!this.pending.length) return; const close = Notices.info(Strings.Updater.addonUpdatesAvailable.format({count: this.pending.length, type: this.type}), { buttons: [{ label: Strings.Notices.moreInfo, onClick: () => { close(); UserSettingsWindow?.open?.("updates"); } }] }); } } export const PluginUpdater = new AddonUpdater("plugin"); export const ThemeUpdater = new AddonUpdater("theme");