diff --git a/CHANGELOG.md b/CHANGELOG.md index 8937e011d2..a9a7b915f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ _This release is scheduled to be released on 2024-01-01._ ### Added +- Added updatenotification Updater (for 3rd party modules) - Added node 21 to the test matrix - Added transform object to calendar:customEvents diff --git a/modules/default/updatenotification/node_helper.js b/modules/default/updatenotification/node_helper.js index f3b05fc84a..4061d2ad10 100644 --- a/modules/default/updatenotification/node_helper.js +++ b/modules/default/updatenotification/node_helper.js @@ -1,6 +1,7 @@ const NodeHelper = require("node_helper"); const defaultModules = require("../defaultmodules"); const GitHelper = require("./git_helper"); +const UpdateHelper = require("./update_helper"); const ONE_MINUTE = 60 * 1000; @@ -11,6 +12,7 @@ module.exports = NodeHelper.create({ updateProcessStarted: false, gitHelper: new GitHelper(), + updateHelper: null, async configureModules(modules) { for (const moduleName of modules) { @@ -28,6 +30,8 @@ module.exports = NodeHelper.create({ switch (notification) { case "CONFIG": this.config = payload; + this.updateHelper = new UpdateHelper(this.config); + await this.updateHelper.check_PM2_Process(); break; case "MODULES": // if this is the 1st time thru the update check process @@ -51,12 +55,22 @@ module.exports = NodeHelper.create({ const repos = await this.gitHelper.getRepos(); for (const repo of repos) { - this.sendSocketNotification("STATUS", repo); + this.sendSocketNotification("REPO_STATUS", repo); } - if (this.config.sendUpdatesNotifications) { - const updates = await this.gitHelper.checkUpdates(); - if (updates.length) this.sendSocketNotification("UPDATES", updates); + const updates = await this.gitHelper.checkUpdates(); + + if (this.config.sendUpdatesNotifications && updates.length) { + this.sendSocketNotification("UPDATES", updates); + } + + if (updates.length) { + const updateResult = await this.updateHelper.parse(updates); + for (const update of updateResult) { + if (update.inProgress) { + this.sendSocketNotification("UPDATE_STATUS", update); + } + } } this.scheduleNextFetch(this.config.updateInterval); diff --git a/modules/default/updatenotification/update_helper.js b/modules/default/updatenotification/update_helper.js new file mode 100644 index 0000000000..532472075c --- /dev/null +++ b/modules/default/updatenotification/update_helper.js @@ -0,0 +1,224 @@ +const Exec = require("child_process").exec; +const Spawn = require("child_process").spawn; +const commandExists = require("command-exists"); +const Log = require("logger"); + +/* class Updater + * Allow to self updating 3rd party modules from command defined in config + * + * [constructor] read value in config: + * updates: [ // array of modules update commands + * { + * : + * }, + * { + * ... + * } + * ], + * updateTimeout: 2 * 60 * 1000, // max update duration + * updateAutorestart: false // autoRestart MM when update done ? + * + * [main command]: parse(): + * parse if module update is needed + * --> Apply ONLY one update (first of the module list) + * --> auto-restart MagicMirror or wait manual restart by user + * return array with modules update state information for `updatenotification` module displayer information + * [ + * { + * name = , // name of the module + * updateCommand = , // update command (if found) + * inProgress = , // an update if in progress for this module + * error = , // an error if detected when updating + * updated = , // updated successfully + * needRestart = // manual restart of MagicMirror is required by user + * }, + * { + * ... + * } + * ] + */ + +class Updater { + constructor(config) { + this.updates = config.updates; + this.timeout = config.updateTimeout; + this.autoRestart = config.updateAutorestart; + this.moduleList = {}; + this.updating = false; + this.usePM2 = false; + this.PM2 = null; + this.version = global.version; + this.root_path = global.root_path; + Log.info("updatenotification: Updater Class Loaded!"); + } + + // [main command] parse if module update is needed + async parse(modules) { + var parser = modules.map(async (module) => { + if (this.moduleList[module.module] === undefined) { + this.moduleList[module.module] = {}; + this.moduleList[module.module].name = module.module; + this.moduleList[module.module].updateCommand = await this.applyCommand(module.module); + this.moduleList[module.module].inProgress = false; + this.moduleList[module.module].error = null; + this.moduleList[module.module].updated = false; + this.moduleList[module.module].needRestart = false; + } + if (!this.moduleList[module.module].inProgress) { + if (!this.updating) { + if (!this.moduleList[module.module].updateCommand) { + this.updating = false; + } else { + this.updating = true; + this.moduleList[module.module].inProgress = true; + Object.assign(this.moduleList[module.module], await this.updateProcess(this.moduleList[module.module])); + } + } + } + }); + + await Promise.all(parser); + let updater = Object.values(this.moduleList); + Log.debug("updatenotification Update Result:", updater); + return updater; + } + + // module updater with his proper command + // return object as result + //{ + // error: , // if error detected + // updated: , // if updated successfully + // needRestart: // if magicmirror restart required + //}; + updateProcess(module) { + let Result = { + error: false, + updated: false, + needRestart: false + }; + let Command = null; + const Path = `${this.root_path}/modules/`; + const modulePath = Path + module.name; + + if (module.updateCommand) { + Command = module.updateCommand; + } else { + Log.warn(`updatenotification: Update of ${module.name} is not supported.`); + return Result; + } + Log.info(`updatenotification: Updating ${module.name}...`); + + return new Promise((resolve) => { + Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout, stderr) => { + if (error) { + Log.error(`updatenotification: exec error: ${error}`); + Result.error = true; + } else { + Log.info(`updatenotification: Update logs of ${module.name}: ${stdout}`); + Result.updated = true; + if (this.autoRestart) { + Log.info("updatenotification: Update done"); + setTimeout(() => this.restart(), 3000); + } else { + Log.info("updatenotification: Update done, don't forget to restart MagicMirror!"); + Result.needRestart = true; + } + } + resolve(Result); + }); + }); + } + + // restart rules (pm2 or npm start) + restart() { + if (this.usePM2) this.pm2Restart(); + else this.npmRestart(); + } + + // restart MagicMiror with "pm2" + pm2Restart() { + Log.info("updatenotification: PM2 will restarting MagicMirror..."); + Exec(`pm2 restart ${this.PM2}`, (err, std, sde) => { + if (err) { + Log.error("updatenotification:[PM2] restart Error", err); + } + }); + } + + // restart MagicMiror with "npm start" + npmRestart() { + Log.info("updatenotification: Restarting MagicMirror..."); + const out = process.stdout; + const err = process.stderr; + const subprocess = Spawn("npm start", { cwd: this.root_path, shell: true, detached: true, stdio: ["ignore", out, err] }); + subprocess.unref(); + process.exit(); + } + + // Check using pm2 + check_PM2_Process() { + Log.info("updatenotification: Checking PM2 using..."); + return new Promise((resolve) => { + commandExists("pm2") + .then(async () => { + var PM2_List = await this.PM2_GetList(); + if (!PM2_List) { + Log.error("updatenotification: [PM2] Can't get process List!"); + this.usePM2 = false; + resolve(false); + return; + } + PM2_List.forEach((pm) => { + if (pm.pm2_env.version === this.version && pm.pm2_env.status === "online" && pm.pm2_env.PWD.includes(this.root_path)) { + this.PM2 = pm.name; + this.usePM2 = true; + Log.info("updatenotification: You are using pm2 with", this.PM2); + resolve(true); + } + }); + if (!this.PM2) { + Log.info("updatenotification: You are not using pm2"); + this.usePM2 = false; + resolve(false); + } + }) + .catch(() => { + Log.info("updatenotification: You are not using pm2"); + this.usePM2 = false; + resolve(false); + }); + }); + } + + // Get the list of pm2 process + PM2_GetList() { + return new Promise((resolve) => { + Exec("pm2 jlist", (err, std, sde) => { + if (err) { + resolve(null); + return; + } + let result = JSON.parse(std); + resolve(result); + }); + }); + } + + // check if module is MagicMirror + isMagicMirror(module) { + if (module === "MagicMirror") return true; + return false; + } + + // search update module command + applyCommand(module) { + if (this.isMagicMirror(module.module)) return null; + let command = null; + this.updates.forEach((updater) => { + if (updater[module]) command = updater[module]; + }); + return command; + } +} + +module.exports = Updater; diff --git a/modules/default/updatenotification/updatenotification.js b/modules/default/updatenotification/updatenotification.js index 73327ec844..7cfc1ceae8 100644 --- a/modules/default/updatenotification/updatenotification.js +++ b/modules/default/updatenotification/updatenotification.js @@ -9,11 +9,16 @@ Module.register("updatenotification", { updateInterval: 10 * 60 * 1000, // every 10 minutes refreshInterval: 24 * 60 * 60 * 1000, // one day ignoreModules: [], - sendUpdatesNotifications: false + sendUpdatesNotifications: false, + updates: [], + updateTimeout: 2 * 60 * 1000, // max update duration + updateAutorestart: false // autoRestart MM when update done ? }, suspended: false, moduleList: {}, + needRestart: false, + updates: {}, start() { Log.info(`Starting module: ${this.name}`); @@ -47,12 +52,15 @@ Module.register("updatenotification", { socketNotificationReceived(notification, payload) { switch (notification) { - case "STATUS": + case "REPO_STATUS": this.updateUI(payload); break; case "UPDATES": this.sendNotification("UPDATES", payload); break; + case "UPDATE_STATUS": + this.updatesNotifier(payload); + break; } }, @@ -65,7 +73,7 @@ Module.register("updatenotification", { }, getTemplateData() { - return { moduleList: this.moduleList, suspended: this.suspended }; + return { moduleList: this.moduleList, updatesList: this.updates, suspended: this.suspended, needRestart: this.needRestart }; }, updateUI(payload) { @@ -96,5 +104,29 @@ Module.register("updatenotification", { const remoteRef = status.tracking.replace(/.*\//, ""); return `${text}`; }); + }, + + updatesNotifier(payload, done = true) { + if (this.updates[payload.name] === undefined) { + this.updates[payload.name] = { + name: payload.name, + done: done + }; + + if (payload.error) { + this.sendSocketNotification("UPDATE_ERROR", payload.name); + this.updates[payload.name].done = false; + } else { + if (payload.updated) { + delete this.moduleList[payload.name]; + this.updates[payload.name].done = true; + } + if (payload.needRestart) { + this.needRestart = true; + } + } + + this.updateDom(2); + } } }); diff --git a/modules/default/updatenotification/updatenotification.njk b/modules/default/updatenotification/updatenotification.njk index 415688d6e1..fbc43f22fb 100644 --- a/modules/default/updatenotification/updatenotification.njk +++ b/modules/default/updatenotification/updatenotification.njk @@ -1,4 +1,13 @@ {% if not suspended %} + {% if needRestart %} +
+ + + {% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %} + {{ restartTextLabel | translate() | safe }} + +
+ {% endif %} {% for name, status in moduleList %}
@@ -12,4 +21,21 @@ {{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}
{% endfor %} + {% for name, status in updatesList %} +
+ {% if status.done %} + + + {% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %} + {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }} + + {% else %} + + + {% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %} + {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }} + + {% endif %} +
+ {% endfor %} {% endif %} diff --git a/package-lock.json b/package-lock.json index e9c93e65d3..9dd7980e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "colors": "^1.4.0", + "command-exists": "^1.2.9", "console-stamp": "^3.1.2", "envsub": "^4.1.0", "eslint": "^8.52.0", @@ -2860,6 +2861,11 @@ "node": ">= 0.8" } }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", diff --git a/package.json b/package.json index e3ed2312a3..473aa71cfc 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "dependencies": { "colors": "^1.4.0", + "command-exists": "^1.2.9", "console-stamp": "^3.1.2", "envsub": "^4.1.0", "eslint": "^8.52.0", diff --git a/translations/de.json b/translations/de.json index e0ea340e87..d0f5eed7cb 100644 --- a/translations/de.json +++ b/translations/de.json @@ -43,5 +43,8 @@ "UPDATE_NOTIFICATION": "Aktualisierung für MagicMirror² verfügbar.", "UPDATE_NOTIFICATION_MODULE": "Aktualisierung für das Modul „{MODULE_NAME}“ verfügbar.", "UPDATE_INFO_SINGLE": "Die aktuelle Installation ist ein Commit hinter dem {BRANCH_NAME}-Branch.", - "UPDATE_INFO_MULTIPLE": "Die aktuelle Installation ist {COMMIT_COUNT} Commits hinter dem {BRANCH_NAME}-Branch." + "UPDATE_INFO_MULTIPLE": "Die aktuelle Installation ist {COMMIT_COUNT} Commits hinter dem {BRANCH_NAME}-Branch.", + "UPDATE_NOTIFICATION_DONE": "Aktualisierung für das Modul {MODULE_NAME} abgeschlossen.", + "UPDATE_NOTIFICATION_ERROR": "Fehler bei der Aktualisierung für das Modul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror muss neu gestartet werden." } diff --git a/translations/en.json b/translations/en.json index eb9200c6c2..e10801e5b8 100644 --- a/translations/en.json +++ b/translations/en.json @@ -41,5 +41,8 @@ "UPDATE_NOTIFICATION": "MagicMirror² update available.", "UPDATE_NOTIFICATION_MODULE": "Update available for {MODULE_NAME} module.", "UPDATE_INFO_SINGLE": "The current installation is {COMMIT_COUNT} commit behind on the {BRANCH_NAME} branch.", - "UPDATE_INFO_MULTIPLE": "The current installation is {COMMIT_COUNT} commits behind on the {BRANCH_NAME} branch." + "UPDATE_INFO_MULTIPLE": "The current installation is {COMMIT_COUNT} commits behind on the {BRANCH_NAME} branch.", + "UPDATE_NOTIFICATION_DONE": "Update done for {MODULE_NAME} module", + "UPDATE_NOTIFICATION_ERROR": "Update error for {MODULE_NAME} module", + "UPDATE_NOTIFICATION_NEED-RESTART": "Restarting of MagicMirror is required." } diff --git a/translations/fr.json b/translations/fr.json index 319e0fda3d..b7f9d02746 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -43,5 +43,8 @@ "UPDATE_NOTIFICATION": "Une mise à jour de MagicMirror² est disponible", "UPDATE_NOTIFICATION_MODULE": "Une mise à jour est disponible pour le module {MODULE_NAME}.", "UPDATE_INFO_SINGLE": "L'installation actuelle est {COMMIT_COUNT} commit en retard sur la branche {BRANCH_NAME}.", - "UPDATE_INFO_MULTIPLE": "L'installation actuelle est {COMMIT_COUNT} commits en retard sur la branche {BRANCH_NAME}." + "UPDATE_INFO_MULTIPLE": "L'installation actuelle est {COMMIT_COUNT} commits en retard sur la branche {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Mise à jour effectuée pour le module {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Erreur lors de la mise à jour du module {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Le redémarrage de MagicMirror est nécessaire." }